mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(updater): upgrade Codex standalone installs in place instead of via npm
Updating a CLI installed outside npm fell back to a bare `npm i -g`, which silently installed a second, npm-managed copy instead of upgrading the one the command line actually runs. - Codex standalone installer (install.sh / install.ps1): rerun the official installer with CODEX_INSTALL_DIR / CODEX_HOME pinned to the detected install. `codex update` is not used: it pipes `curl | sh` without pipefail and reports success even when the download fails. - Keep the npm self-heal for broken Codex installs away from standalone installs whose entry lives in a Homebrew/nvm bin directory. - When the default install is a native binary outside node_modules and its channel is unknown (winget, Scoop, Nix, manual download), stop falling back to npm: the probe reports it as unmanaged, the About page skips it with a localized hint, and the backend refuses to run it. - Add a Claude Code cask regression test on top of the Homebrew cask support from #6572. Fixes #7650
This commit is contained in:
1 parent
bc4cce047b
commit
8ab8eebdd0
7 files changed
+554
-18
No files matched your search
+512
-14
@@ -454,6 +454,14 @@ const GROK_INSTALL_UNIX: &str =
|
||||
/// 可改),系统 Node 不达标时自带隔离 Node;重跑即升级(已是最新则跳过),全程无交互提示。
|
||||
const MCODE_INSTALL_UNIX: &str =
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://filecdn.minimax.chat/public/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
/// Codex 官方独立安装器(POSIX sh 脚本)。独立安装版的 `codex update` 内部跑的正是
|
||||
/// `curl ... | sh` 且没开 pipefail——实测断网时 curl 失败、sh 读到空脚本 exit 0,
|
||||
/// `codex update` 仍打印 "Update ran successfully" 并 exit 0。所以不走 `codex update`,
|
||||
/// 由这里下载到临时文件再执行,curl 失败会如实变成整条命令失败。
|
||||
/// 只用于锚定升级(Windows 用 `CODEX_INSTALL_WINDOWS_SCRIPT`;WSL 不锚定),故按平台 gate。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const CODEX_INSTALL_UNIX: &str =
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o $tmp && sh $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
|
||||
/// Hermes 官方安装器会自带/选择合适的 Python 运行时。不要再用
|
||||
/// `python3 -m pip ... || python -m pip ...`:Hermes PyPI 包要求 Python >=3.11,
|
||||
@@ -472,6 +480,8 @@ const GROK_INSTALL_WINDOWS_SCRIPT: &str = "irm https://x.ai/cli/install.ps1 | ie
|
||||
#[cfg(target_os = "windows")]
|
||||
const MCODE_INSTALL_WINDOWS_SCRIPT: &str =
|
||||
"irm https://filecdn.minimax.chat/public/install.ps1 | iex";
|
||||
#[cfg(target_os = "windows")]
|
||||
const CODEX_INSTALL_WINDOWS_SCRIPT: &str = "irm https://chatgpt.com/codex/install.ps1 | iex";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn powershell_encoded_command(script: &str) -> String {
|
||||
@@ -531,6 +541,89 @@ fn mcode_installer_update_command(install_root: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Codex 官方独立安装器(`install.sh` / `install.ps1`)装出的一处安装。
|
||||
///
|
||||
/// 布局(2026-09 实测 install.sh、读 install.ps1):发布包在
|
||||
/// `<CODEX_HOME>/packages/standalone/releases/<ver>-<target>/`,`current` 指向当前版本;
|
||||
/// PATH 上的入口放在 `CODEX_INSTALL_DIR`(POSIX 默认 `~/.local/bin` 下的软链,Windows 默认
|
||||
/// `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin` 这个 junction),真身都解析到发布包里。
|
||||
/// 它既不归 npm 管,也不在 `prefers_official_update` 里,此前锚定落空、退回裸
|
||||
/// `npm i -g` 另装一份 npm 版(#7650)。
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct CodexStandaloneInstall {
|
||||
/// 入口所在目录,重跑 installer 时作为 `CODEX_INSTALL_DIR` 写回同一处。
|
||||
install_dir: String,
|
||||
/// 从真身路径截出的 `CODEX_HOME`;GUI 进程拿不到用户 shell 里设的 `CODEX_HOME`,
|
||||
/// 不显式传会装进默认的 `~/.codex`,入口指向的那份原地不动。
|
||||
codex_home: Option<String>,
|
||||
}
|
||||
|
||||
/// 纯字符串判定、不碰 fs,POSIX / Windows 路径都能在任一平台单测。
|
||||
fn codex_standalone_install(bin_path: &str, real_target: &str) -> Option<CodexStandaloneInstall> {
|
||||
let install_dir = parent_dir(bin_path);
|
||||
if install_dir.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let normalized_real = real_target.replace('\\', "/").to_ascii_lowercase();
|
||||
if let Some(idx) = normalized_real.find("/packages/standalone/") {
|
||||
// `replace` 与 `to_ascii_lowercase` 都不改字节长度,下标可直接用于原串。
|
||||
// Windows 的 real 是 canonicalize 出的 `\\?\` verbatim 路径,交给 installer 前还原。
|
||||
let home = &real_target[..idx];
|
||||
let home = match home.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => home.strip_prefix(r"\\?\").unwrap_or(home).to_string(),
|
||||
};
|
||||
if !home.is_empty() {
|
||||
return Some(CodexStandaloneInstall {
|
||||
install_dir,
|
||||
codex_home: Some(home),
|
||||
});
|
||||
}
|
||||
}
|
||||
// canonicalize 失败时 real 就是入口本身;Windows 默认入口目录仍能认出来,
|
||||
// 只是拿不到 CODEX_HOME,交给 installer 用默认值。
|
||||
let normalized_bin = bin_path.replace('\\', "/").to_ascii_lowercase();
|
||||
normalized_bin
|
||||
.contains("/programs/openai/codex/bin/")
|
||||
.then_some(CodexStandaloneInstall {
|
||||
install_dir,
|
||||
codex_home: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 重跑官方 installer 升级独立安装那一处。显式传 `CODEX_INSTALL_DIR` / `CODEX_HOME`
|
||||
/// 写回探测到的位置(实测自定义目录时会原地升级、不在默认目录多装一份);
|
||||
/// `CODEX_NON_INTERACTIVE=1` 与 `codex update` 自己重跑 installer 时的做法一致。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn codex_installer_update_command(install: &CodexStandaloneInstall) -> String {
|
||||
let mut env = format!(
|
||||
"CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR={}",
|
||||
shell_single_quote(&install.install_dir)
|
||||
);
|
||||
if let Some(home) = &install.codex_home {
|
||||
env.push_str(&format!(" CODEX_HOME={}", shell_single_quote(home)));
|
||||
}
|
||||
format!("{env} {CODEX_INSTALL_UNIX}")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn codex_installer_update_command(install: &CodexStandaloneInstall) -> String {
|
||||
// PowerShell 单引号字面量里 `'` 写作 `''`;整段经 EncodedCommand 传入,不经 cmd 解析。
|
||||
let quote = |value: &str| value.replace('\'', "''");
|
||||
let mut script = format!(
|
||||
"$env:CODEX_NON_INTERACTIVE = '1'; $env:CODEX_INSTALL_DIR = '{}'; ",
|
||||
quote(&install.install_dir)
|
||||
);
|
||||
if let Some(home) = &install.codex_home {
|
||||
script.push_str(&format!("$env:CODEX_HOME = '{}'; ", quote(home)));
|
||||
}
|
||||
script.push_str(CODEX_INSTALL_WINDOWS_SCRIPT);
|
||||
format!(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {}",
|
||||
powershell_encoded_command(&script)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn hermes_update_windows_command() -> String {
|
||||
// fallback 是 powershell.exe,不是 .cmd/.bat;这里不需要 `call`。PowerShell 的
|
||||
@@ -726,8 +819,12 @@ fn build_tool_action_line(
|
||||
let command = match action {
|
||||
ToolLifecycleAction::Update => {
|
||||
let installs = enumerate_tool_installations(tool);
|
||||
installs_anchored_command(tool, &installs)
|
||||
.unwrap_or_else(|| static_fallback_command(tool))
|
||||
match resolve_update_command(tool, &installs) {
|
||||
UpdateCommand::Anchored(command) | UpdateCommand::Static(command) => command,
|
||||
UpdateCommand::Unmanaged => {
|
||||
return Err(unmanaged_update_error(tool, &installs));
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolLifecycleAction::Install => {
|
||||
static_fallback_command_for(tool, ToolLifecycleAction::Install)
|
||||
@@ -753,8 +850,12 @@ fn build_tool_action_line(
|
||||
let command = match action {
|
||||
ToolLifecycleAction::Update => {
|
||||
let installs = enumerate_tool_installations(tool);
|
||||
installs_anchored_command(tool, &installs)
|
||||
.unwrap_or_else(|| static_fallback_command(tool))
|
||||
match resolve_update_command(tool, &installs) {
|
||||
UpdateCommand::Anchored(command) | UpdateCommand::Static(command) => command,
|
||||
UpdateCommand::Unmanaged => {
|
||||
return Err(unmanaged_update_error(tool, &installs));
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolLifecycleAction::Install => install_command_for(tool),
|
||||
};
|
||||
@@ -3105,7 +3206,9 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
|
||||
// brew formula / cask(real 在 Cellar 或 Caskroom)→ 不归 npm 管,交回 anchored 走 brew upgrade。
|
||||
if is_brew_managed_path(real) {
|
||||
// 官方独立安装同理:`CODEX_INSTALL_DIR` 可以指到 Homebrew / nvm 的 bin 目录,入口来源
|
||||
// 会被判成 homebrew / nvm,但真身在 standalone 发布包里,交回 anchored 重跑 installer。
|
||||
if is_brew_managed_path(real) || codex_standalone_install(bin_path, real).is_some() {
|
||||
return None;
|
||||
}
|
||||
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
|
||||
@@ -3197,6 +3300,8 @@ fn package_manager_anchored_command_from_paths(
|
||||
/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
|
||||
/// MiniMax Code 的脚本安装同理不归全局 npm 管,但 `mcode update` 非 TTY 下不安装
|
||||
/// (见 `official_update_args` 上方注释),改为带 `MCODE_INSTALL_DIR` 重跑官方 installer。
|
||||
/// Codex 独立安装器同理:`codex update` 断网时假成功(见 `CODEX_INSTALL_UNIX`),
|
||||
/// 改为带 `CODEX_INSTALL_DIR` / `CODEX_HOME` 重跑官方 installer。
|
||||
/// ③ Homebrew formula / cask(真身在 `Cellar/<formula>/` 或 `Caskroom/<token>/`)
|
||||
/// → `<bin_path 同目录>/brew upgrade [--cask] <name>`;由 Homebrew 拥有,避免
|
||||
/// self-update 或 sibling npm 改动包管理器管理的安装。
|
||||
@@ -3227,6 +3332,11 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return Some(mcode_installer_update_command(&root));
|
||||
}
|
||||
}
|
||||
if tool == "codex" {
|
||||
if let Some(install) = codex_standalone_install(bin_path, real_target) {
|
||||
return Some(codex_installer_update_command(&install));
|
||||
}
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
|
||||
if is_brew_managed_path(real_target) {
|
||||
return package_command;
|
||||
@@ -3294,6 +3404,7 @@ fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Op
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① hermes / Grok native → `<bin_path> update`;CLI 自己处理安装环境。
|
||||
/// MiniMax Code 脚本安装 → 带 `MCODE_INSTALL_DIR` 重跑官方 PowerShell installer。
|
||||
/// Codex 独立安装 → 带 `CODEX_INSTALL_DIR` / `CODEX_HOME` 重跑官方 PowerShell installer。
|
||||
/// ② 支持官方自升级且 Windows 可安全静默执行的工具 → `<bin_path> update/upgrade || call <包管理器 fallback>`。
|
||||
/// ③ 其余 npm 工具 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest。
|
||||
///
|
||||
@@ -3315,6 +3426,11 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return Some(mcode_installer_update_command(&root));
|
||||
}
|
||||
}
|
||||
if tool == "codex" {
|
||||
if let Some(install) = codex_standalone_install(bin_path, real_target) {
|
||||
return Some(codex_installer_update_command(&install));
|
||||
}
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
|
||||
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
|
||||
let update = anchored_official_update_command(tool, bin_path)?;
|
||||
@@ -3865,6 +3981,77 @@ fn static_fallback_command(tool: &str) -> String {
|
||||
static_fallback_command_for(tool, ToolLifecycleAction::Update)
|
||||
}
|
||||
|
||||
/// 升级命令的解析结果。plan(前端确认 / 展示)与 execute(真正执行)共用
|
||||
/// `resolve_update_command`,保证两边判定一致。
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum UpdateCommand {
|
||||
/// 锚定到命令行实际命中的那处。
|
||||
Anchored(String),
|
||||
/// 定位不到默认那处,或那处认不出渠道但可能归 npm 管(asdf/nodenv shim 等脚本)
|
||||
/// → 静态命令,保持旧行为。
|
||||
Static(String),
|
||||
/// 默认那处是原生可执行文件且不在 `node_modules` 里:不归 npm 管,也认不出是哪个
|
||||
/// 安装器装的(winget、Scoop、Nix、手动下载的二进制……)。退回裸 `npm i -g` 只会
|
||||
/// 另装一份 npm 版——要么被原生那份遮蔽、版本号不动,要么反过来顶替它(#7650),
|
||||
/// 所以不执行,由前端提示用户用原来的安装方式升级。
|
||||
Unmanaged,
|
||||
}
|
||||
|
||||
fn resolve_update_command(tool: &str, installs: &[ToolInstallation]) -> UpdateCommand {
|
||||
if let Some(command) = installs_anchored_command(tool, installs) {
|
||||
return UpdateCommand::Anchored(command);
|
||||
}
|
||||
if default_install(installs).is_some_and(is_unmanaged_native_install) {
|
||||
return UpdateCommand::Unmanaged;
|
||||
}
|
||||
UpdateCommand::Static(static_fallback_command(tool))
|
||||
}
|
||||
|
||||
/// 只认「原生可执行文件 + 真身不在 `node_modules` 里」。npm 全局包的真身都在
|
||||
/// `node_modules/<pkg>/` 下(多为 JS 入口,个别包把原生二进制放在包内);asdf / nodenv
|
||||
/// 的 shim、Windows npm 的 `.cmd` 是文本脚本,都不算——它们背后可能正是 npm 全局包,
|
||||
/// 拒绝会误伤,保持旧的静态命令。
|
||||
fn is_unmanaged_native_install(inst: &ToolInstallation) -> bool {
|
||||
let real = inst
|
||||
.real
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.to_ascii_lowercase();
|
||||
!real.contains("/node_modules/") && is_native_executable(&inst.real)
|
||||
}
|
||||
|
||||
/// 按文件头识别原生可执行文件:ELF、Mach-O(单架构 / universal)、Windows PE。
|
||||
fn is_native_executable(path: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
|
||||
let mut magic = [0u8; 4];
|
||||
let read = std::fs::File::open(path).and_then(|mut file| file.read_exact(&mut magic));
|
||||
if read.is_err() {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
magic,
|
||||
[0x7f, b'E', b'L', b'F']
|
||||
| [0xcf, 0xfa, 0xed, 0xfe]
|
||||
| [0xce, 0xfa, 0xed, 0xfe]
|
||||
| [0xfe, 0xed, 0xfa, 0xcf]
|
||||
| [0xfe, 0xed, 0xfa, 0xce]
|
||||
| [0xca, 0xfe, 0xba, 0xbe]
|
||||
) || magic.starts_with(b"MZ")
|
||||
}
|
||||
|
||||
/// 执行路径的兜底报错。正常流程里前端已按 probe 的 `unmanaged` 跳过该工具并给出
|
||||
/// 本地化提示;只有 probe 失败、前端直接执行时才会走到这里。
|
||||
fn unmanaged_update_error(tool: &str, installs: &[ToolInstallation]) -> String {
|
||||
let path = default_install(installs)
|
||||
.map(|inst| inst.path.as_str())
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{} at {path} was not installed by npm or a recognized installer; update it the way it was installed",
|
||||
tool_display_name(tool)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新装(install)的命令:对有官方 installer 的工具走「上游推荐 || npm 兜底」短路链,
|
||||
/// 其余工具透传到 install 静态命令。update fallback 会在平台可安全静默执行时
|
||||
/// 优先跑官方 CLI 自升级,但 install 端不能先跑 `tool update`,
|
||||
@@ -3925,22 +4112,20 @@ fn install_command_for(tool: &str) -> String {
|
||||
/// ——后者读 `tool_action_shell_command`,Windows target 给 hermes 返回 PowerShell
|
||||
/// installer,跨 wsl.exe 后不适用;`build_tool_action_line` 的 WSL 分支也用同一 wrapper,
|
||||
/// 保证 plan 展示给前端的命令与实际执行落 .bat 的命令一致。
|
||||
/// - 其他平台与 Windows 原生工具走 `installs_anchored_command`:命中 → 锚定;
|
||||
/// - 其他平台与 Windows 原生工具走 `resolve_update_command`:命中 → 锚定;
|
||||
/// None(无默认 / sibling 不存在等)→ 静态兜底、`anchored=false`,
|
||||
/// 前端据此给"默认入口无法确定"诚实文案。
|
||||
fn plan_command_for(tool: &str, installs: &[ToolInstallation]) -> (String, bool, bool) {
|
||||
/// 前端据此给"默认入口无法确定"诚实文案;默认那处是认不出渠道的原生可执行文件
|
||||
/// → `Unmanaged`,前端跳过并提示用原安装方式升级。
|
||||
fn plan_command_for(tool: &str, installs: &[ToolInstallation]) -> (UpdateCommand, bool) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if wsl_distro_for_tool(tool).is_some() {
|
||||
let cmd = wsl_tool_action_shell_command(tool, ToolLifecycleAction::Update)
|
||||
.unwrap_or_default();
|
||||
return (cmd, false, false);
|
||||
return (UpdateCommand::Static(cmd), false);
|
||||
}
|
||||
}
|
||||
match installs_anchored_command(tool, installs) {
|
||||
Some(command) => (command, installs.len() >= 2, true),
|
||||
None => (static_fallback_command(tool), installs.len() >= 2, false),
|
||||
}
|
||||
(resolve_update_command(tool, installs), installs.len() >= 2)
|
||||
}
|
||||
|
||||
/// 多处安装是否构成"真冲突":≥2 处,且(版本分歧 或 有的能跑有的跑不起来)。
|
||||
@@ -3973,6 +4158,9 @@ pub struct ToolInstallationReport {
|
||||
/// 是否成功锚定到某处具体安装。false = 退到裸 fallback 命令(无法确定命令行实际
|
||||
/// 命中哪处,或该处无同级 npm);前端据此给出"默认入口无法确定"的诚实文案。
|
||||
anchored: bool,
|
||||
/// 默认那处是认不出安装渠道的原生可执行文件(见 `UpdateCommand::Unmanaged`):
|
||||
/// 不会执行升级,此时 `command` 为空;前端跳过该工具并提示用原安装方式升级。
|
||||
unmanaged: bool,
|
||||
}
|
||||
|
||||
/// 探测各工具的安装分布:枚举所有安装、标记冲突、生成锚定升级命令。只读、无副作用。
|
||||
@@ -3991,7 +4179,12 @@ pub async fn probe_tool_installations(
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let installs = enumerate_tool_installations(tool);
|
||||
let (command, needs_confirmation, anchored) = plan_command_for(tool, &installs);
|
||||
let (update, needs_confirmation) = plan_command_for(tool, &installs);
|
||||
let (command, anchored, unmanaged) = match update {
|
||||
UpdateCommand::Anchored(command) => (command, true, false),
|
||||
UpdateCommand::Static(command) => (command, false, false),
|
||||
UpdateCommand::Unmanaged => (String::new(), false, true),
|
||||
};
|
||||
let is_conflict = is_conflicting(&installs);
|
||||
ToolInstallationReport {
|
||||
tool: tool.to_string(),
|
||||
@@ -4000,6 +4193,7 @@ pub async fn probe_tool_installations(
|
||||
needs_confirmation,
|
||||
command,
|
||||
anchored,
|
||||
unmanaged,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -5787,6 +5981,191 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 官方独立安装器的识别是纯字符串判定,POSIX / Windows 布局都在这里固化。
|
||||
mod codex_standalone_detection {
|
||||
use super::super::*;
|
||||
|
||||
#[test]
|
||||
fn posix_default_layout() {
|
||||
assert_eq!(
|
||||
codex_standalone_install(
|
||||
"/Users/me/.local/bin/codex",
|
||||
"/Users/me/.codex/packages/standalone/releases/0.157.0-aarch64-apple-darwin/bin/codex",
|
||||
),
|
||||
Some(CodexStandaloneInstall {
|
||||
install_dir: "/Users/me/.local/bin".to_string(),
|
||||
codex_home: Some("/Users/me/.codex".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_verbatim_real_is_restored() {
|
||||
// canonicalize 出的 `\\?\` 前缀要剥掉再交给 installer;大小写保持原样。
|
||||
assert_eq!(
|
||||
codex_standalone_install(
|
||||
r"C:\Users\Me\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe",
|
||||
r"\\?\C:\Users\Me\.Codex\Packages\Standalone\releases\0.157.0-x86_64-pc-windows-msvc\bin\codex.exe",
|
||||
),
|
||||
Some(CodexStandaloneInstall {
|
||||
install_dir: r"C:\Users\Me\AppData\Local\Programs\OpenAI\Codex\bin".to_string(),
|
||||
codex_home: Some(r"C:\Users\Me\.Codex".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
codex_standalone_install(
|
||||
r"\\server\share\bin\codex.exe",
|
||||
r"\\?\UNC\server\share\cx\packages\standalone\current\bin\codex.exe",
|
||||
)
|
||||
.and_then(|install| install.codex_home),
|
||||
Some(r"\\server\share\cx".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_entry_without_resolved_real() {
|
||||
// canonicalize 失败时 real 就是入口本身:仍按默认入口目录认出,只是不传 CODEX_HOME。
|
||||
let bin = r"C:\Users\me\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe";
|
||||
assert_eq!(
|
||||
codex_standalone_install(bin, bin),
|
||||
Some(CodexStandaloneInstall {
|
||||
install_dir: r"C:\Users\me\AppData\Local\Programs\OpenAI\Codex\bin".to_string(),
|
||||
codex_home: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_install_channels_are_not_standalone() {
|
||||
for (bin, real) in [
|
||||
(
|
||||
"/Users/me/.nvm/versions/node/v22.19.0/bin/codex",
|
||||
"/Users/me/.nvm/versions/node/v22.19.0/lib/node_modules/@openai/codex/bin/codex.js",
|
||||
),
|
||||
(
|
||||
"/opt/homebrew/bin/codex",
|
||||
"/opt/homebrew/Caskroom/codex/0.157.0/bin/codex",
|
||||
),
|
||||
// app-server 守护进程的发布包不是 PATH 上的 CLI。
|
||||
(
|
||||
"/Users/me/.local/bin/codex",
|
||||
"/Users/me/.codex/packages/app-server-daemon/current/bin/codex",
|
||||
),
|
||||
(r"C:\Users\me\AppData\Roaming\npm\codex.cmd", r"C:\Users\me\AppData\Roaming\npm\codex.cmd"),
|
||||
] {
|
||||
assert_eq!(codex_standalone_install(bin, real), None, "{bin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 锚定落空后的兜底:认不出渠道的原生可执行文件不再退回裸 `npm i -g`(#7650),
|
||||
/// 脚本 shim / node_modules 里的入口 / 定位不到默认那处仍保持旧的静态命令。
|
||||
mod unmanaged_native_update {
|
||||
use super::super::*;
|
||||
|
||||
const MACH_O_64: [u8; 4] = [0xcf, 0xfa, 0xed, 0xfe];
|
||||
|
||||
/// 在 tempdir 的 `rel` 位置写入 `bytes`,返回指向它的安装记录。TempDir 须保活。
|
||||
fn installed(
|
||||
dir: &tempfile::TempDir,
|
||||
rel: &str,
|
||||
bytes: &[u8],
|
||||
is_default: bool,
|
||||
) -> ToolInstallation {
|
||||
let path = dir.path().join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
ToolInstallation {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
version: Some("1.0.0".to_string()),
|
||||
runnable: true,
|
||||
error: None,
|
||||
source: infer_install_source(&path).to_string(),
|
||||
is_path_default: is_default,
|
||||
real: path,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_binary_outside_node_modules_is_unmanaged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let gemini = installed(&dir, "nix-profile/bin/gemini", &MACH_O_64, true);
|
||||
assert_eq!(
|
||||
resolve_update_command("gemini", &[gemini]),
|
||||
UpdateCommand::Unmanaged
|
||||
);
|
||||
let pi = installed(&dir, "bin/pi", b"\x7fELF\x02\x01\x01", true);
|
||||
assert_eq!(
|
||||
resolve_update_command("pi", &[pi]),
|
||||
UpdateCommand::Unmanaged
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_shim_keeps_static_npm_command() {
|
||||
// asdf / nodenv 的 shim 是脚本,背后可能正是 npm 全局包,不能拒绝。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shim = installed(
|
||||
&dir,
|
||||
".asdf/shims/gemini",
|
||||
b"#!/usr/bin/env bash\nexec asdf exec gemini \"$@\"\n",
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_update_command("gemini", &[shim]),
|
||||
UpdateCommand::Static(static_fallback_command("gemini"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_binary_inside_node_modules_keeps_static_npm_command() {
|
||||
// 有的 npm 包把原生二进制放在包内,真身仍在 node_modules 下,归 npm 管。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bundled = installed(
|
||||
&dir,
|
||||
"prefix/lib/node_modules/@google/gemini-cli/bin/gemini",
|
||||
&MACH_O_64,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_update_command("gemini", &[bundled]),
|
||||
UpdateCommand::Static(static_fallback_command("gemini"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_default_keeps_static_command() {
|
||||
// 多处安装又定位不到默认那处:不知道命令行用的是哪份,维持旧行为(前端会弹确认)。
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = installed(&dir, "a/bin/gemini", &MACH_O_64, false);
|
||||
let b = installed(&dir, "b/bin/gemini", &MACH_O_64, false);
|
||||
assert_eq!(
|
||||
resolve_update_command("gemini", &[a, b]),
|
||||
UpdateCommand::Static(static_fallback_command("gemini"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_executable_magic() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cases: [(&str, &[u8], bool); 7] = [
|
||||
("elf", b"\x7fELF\x02", true),
|
||||
("macho", &MACH_O_64, true),
|
||||
("fat", &[0xca, 0xfe, 0xba, 0xbe], true),
|
||||
("pe.exe", b"MZ\x90\x00", true),
|
||||
("script", b"#!/bin/sh\n", false),
|
||||
("cmd-shim.cmd", b"@ECHO off\r\n", false),
|
||||
("short", b"MZ", false),
|
||||
];
|
||||
for (name, bytes, expected) in cases {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
assert_eq!(is_native_executable(&path), expected, "{name}");
|
||||
}
|
||||
assert!(!is_native_executable(&dir.path().join("missing")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-only 锚定升级回归(等价类压缩到 3 种 idiom:volta/pnpm/npm)。整块通过
|
||||
/// `cfg(target_os = "windows")` gate,在 macOS/Linux 上不参与 cargo test;Windows
|
||||
/// CI 跑全套验证。tempdir 模拟 sibling 入口存在/不存在,锁定"扩展名顺序优先级 +
|
||||
@@ -5959,6 +6338,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_standalone_windows_reruns_installer_in_place() {
|
||||
// #7650:独立安装的 codex.exe 旁边没有 npm.cmd,此前退回 `call npm i -g`
|
||||
// 另装一份 npm 版。
|
||||
let bin = r"C:\Users\o'brien\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe";
|
||||
let real = r"\\?\C:\Users\o'brien\.codex\packages\standalone\releases\0.157.0-x86_64-pc-windows-msvc\bin\codex.exe";
|
||||
let cmd = anchored_command_from_paths("codex", bin, real).unwrap();
|
||||
let expected = powershell_encoded_command(
|
||||
"$env:CODEX_NON_INTERACTIVE = '1'; $env:CODEX_INSTALL_DIR = 'C:\\Users\\o''brien\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin'; $env:CODEX_HOME = 'C:\\Users\\o''brien\\.codex'; irm https://chatgpt.com/codex/install.ps1 | iex",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.split_once("-EncodedCommand ")
|
||||
.map(|(_, encoded)| encoded),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
assert!(!cmd.contains("npm"), "npm must not be used: {cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcode_windows_npm_install_keeps_allow_scripts() {
|
||||
let (_dir, sub, bin_path) = setup_sibling("v22.19.0", "mcode.cmd", &["npm.cmd"]);
|
||||
@@ -7117,6 +7514,107 @@ mod tests {
|
||||
assert!(!cmd.unwrap().contains("npm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_standalone_reruns_official_installer_in_place() {
|
||||
// #7650:官方独立安装器装的 codex 此前被判 system 来源、锚定落空,退回裸
|
||||
// `npm i -g` 另装一份 npm 版。现在带 CODEX_INSTALL_DIR / CODEX_HOME 重跑官方
|
||||
// installer;不用 `codex update`——它断网时 exit 0 假成功。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/Users/me/.local/bin/codex",
|
||||
"/Users/me/.codex/packages/standalone/releases/0.157.0-aarch64-apple-darwin/bin/codex",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
format!(
|
||||
"CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR='/Users/me/.local/bin' CODEX_HOME='/Users/me/.codex' {CODEX_INSTALL_UNIX}"
|
||||
)
|
||||
);
|
||||
assert!(!cmd.contains("npm"), "npm must not be used: {cmd}");
|
||||
assert!(
|
||||
!cmd.contains("codex update"),
|
||||
"codex update fakes success: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_standalone_custom_dirs_are_written_back() {
|
||||
// 自定义 CODEX_INSTALL_DIR / CODEX_HOME 装的一处:不显式传回去,installer 会装进
|
||||
// 默认的 ~/.local/bin 与 ~/.codex,命令行实际在用的那份原地不动。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/opt/tools/codex bin/codex",
|
||||
"/data/codex-home/packages/standalone/releases/0.157.0-x86_64-unknown-linux-musl/bin/codex",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(format!(
|
||||
"CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR='/opt/tools/codex bin' CODEX_HOME='/data/codex-home' {CODEX_INSTALL_UNIX}"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_standalone_reinstalls_via_installer_not_npm_repair() {
|
||||
// 跑不起来的独立安装版:npm 自愈门控只放行 nvm/fnm/mise/homebrew,这里交回锚定,
|
||||
// 由官方 installer 重装同一处。
|
||||
let broken = ToolInstallation {
|
||||
path: "/Users/me/.local/bin/codex".to_string(),
|
||||
version: None,
|
||||
runnable: false,
|
||||
error: None,
|
||||
source: "system".to_string(),
|
||||
is_path_default: true,
|
||||
real: std::path::PathBuf::from(
|
||||
"/Users/me/.codex/packages/standalone/releases/0.157.0-aarch64-apple-darwin/bin/codex",
|
||||
),
|
||||
};
|
||||
let cmd = installs_anchored_command("codex", &[broken]).unwrap();
|
||||
assert!(cmd.ends_with(CODEX_INSTALL_UNIX), "{cmd}");
|
||||
assert!(!cmd.contains("npm"), "{cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_standalone_in_node_manager_dir_skips_npm_repair() {
|
||||
// CODEX_INSTALL_DIR 指到 Homebrew / nvm 的 bin 目录时,入口路径会被判成
|
||||
// homebrew / nvm 来源;npm 自愈门控只看入口来源的话会抢先返回 npm 重装,
|
||||
// 把独立安装版换成 npm 版。真身仍在 standalone 发布包里,必须交回 installer。
|
||||
for bin in [
|
||||
"/opt/homebrew/bin/codex",
|
||||
"/Users/me/.nvm/versions/node/v22.19.0/bin/codex",
|
||||
] {
|
||||
let broken = ToolInstallation {
|
||||
path: bin.to_string(),
|
||||
version: None,
|
||||
runnable: false,
|
||||
error: None,
|
||||
source: infer_install_source(Path::new(bin)).to_string(),
|
||||
is_path_default: true,
|
||||
real: std::path::PathBuf::from(
|
||||
"/Users/me/.codex/packages/standalone/releases/0.157.0-aarch64-apple-darwin/bin/codex",
|
||||
),
|
||||
};
|
||||
let cmd = installs_anchored_command("codex", &[broken]).unwrap();
|
||||
assert!(cmd.ends_with(CODEX_INSTALL_UNIX), "{bin}: {cmd}");
|
||||
assert!(!cmd.contains("npm"), "{bin}: {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_homebrew_cask_uses_brew_not_self_update_or_npm() {
|
||||
// cask 归 brew 管:与 formula 一样不先跑 `claude update`,也不挂 npm fallback。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/opt/homebrew/Caskroom/claude-code/2.1.274/claude",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd, "/opt/homebrew/bin/brew upgrade --cask claude-code");
|
||||
assert!(!cmd.contains("claude update"));
|
||||
assert!(!cmd.contains("npm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_abs_path_line_skips_shell_noise() {
|
||||
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
|
||||
|
||||
@@ -821,12 +821,40 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
await executeRun(toolNames, action);
|
||||
return;
|
||||
}
|
||||
const needConfirm = reports.filter((r) => r.needs_confirmation);
|
||||
// 认不出安装渠道的原生安装(winget / Scoop / 手动下载的二进制等)不执行升级:
|
||||
// 退回 npm 只会另装一份 npm 版(#7650)。跳过并提示用原安装方式升级,其余照常。
|
||||
const unmanaged = reports.filter((r) => r.unmanaged);
|
||||
if (unmanaged.length > 0) {
|
||||
toast.warning(t("settings.toolUpgradeUnmanagedTitle"), {
|
||||
description: unmanaged
|
||||
.map((r) =>
|
||||
t("settings.toolUpgradeUnmanagedDetail", {
|
||||
tool: toolDisplayName(r.tool),
|
||||
path:
|
||||
(r.installs.find((i) => i.is_path_default) ?? r.installs[0])
|
||||
?.path ?? "",
|
||||
}),
|
||||
)
|
||||
.join("\n"),
|
||||
closeButton: true,
|
||||
});
|
||||
}
|
||||
const runnableTools = toolNames.filter(
|
||||
(name) => !unmanaged.some((r) => r.tool === name),
|
||||
);
|
||||
if (runnableTools.length === 0) return;
|
||||
const needConfirm = reports.filter(
|
||||
(r) => r.needs_confirmation && !r.unmanaged,
|
||||
);
|
||||
if (needConfirm.length === 0) {
|
||||
await executeRun(toolNames, action);
|
||||
await executeRun(runnableTools, action);
|
||||
return;
|
||||
}
|
||||
setPendingUpgrade({ toolNames, plans: needConfirm, fromBatchEntry });
|
||||
setPendingUpgrade({
|
||||
toolNames: runnableTools,
|
||||
plans: needConfirm,
|
||||
fromBatchEntry,
|
||||
});
|
||||
} finally {
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction(null);
|
||||
@@ -838,7 +866,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[executeRun, preflightTools, toolActions],
|
||||
[executeRun, preflightTools, toolActions, t],
|
||||
);
|
||||
|
||||
const handleConfirmUpgrade = useCallback(() => {
|
||||
|
||||
@@ -855,6 +855,8 @@
|
||||
"toolUpgradeWillRun": "Will run:",
|
||||
"toolUpgradeConfirmBtn": "Confirm upgrade",
|
||||
"toolUpgradeUnanchoredHint": "Can't determine which install your command line uses; will run the default upgrade command (may install to the first npm on PATH).",
|
||||
"toolUpgradeUnmanagedTitle": "Skipped upgrading these tools",
|
||||
"toolUpgradeUnmanagedDetail": "{{tool}} ({{path}}) wasn't installed by npm or a recognized installer, so CC Switch can't tell how to upgrade it. Upgrade it the way you installed it (e.g. winget, Scoop, or re-running the official install script).",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
|
||||
@@ -855,6 +855,8 @@
|
||||
"toolUpgradeWillRun": "実行内容:",
|
||||
"toolUpgradeConfirmBtn": "アップグレードを実行",
|
||||
"toolUpgradeUnanchoredHint": "コマンドラインが実際に使用する箇所を特定できません。既定のアップグレードコマンドを実行します(PATH 上の最初の npm にインストールされる場合があります)。",
|
||||
"toolUpgradeUnmanagedTitle": "次のツールのアップグレードをスキップしました",
|
||||
"toolUpgradeUnmanagedDetail": "{{tool}}({{path}})は npm または認識できるインストーラーでインストールされていないため、CC Switch ではアップグレード方法を判断できません。元のインストール方法でアップグレードしてください(例:winget、Scoop、公式インストールスクリプトの再実行)。",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
|
||||
@@ -856,6 +856,8 @@
|
||||
"toolUpgradeWillRun": "將執行:",
|
||||
"toolUpgradeConfirmBtn": "確認更新",
|
||||
"toolUpgradeUnanchoredHint": "無法判定命令列實際使用的安裝位置;將執行預設更新指令(可能安裝至 PATH 中第一個 npm)。",
|
||||
"toolUpgradeUnmanagedTitle": "以下工具已略過更新",
|
||||
"toolUpgradeUnmanagedDetail": "{{tool}}({{path}})不是透過 npm 或可辨識的安裝程式安裝的,CC Switch 無法判斷如何更新它。請使用你原本的安裝方式更新(例如 winget、Scoop 或重新執行官方安裝指令碼)。",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
|
||||
@@ -855,6 +855,8 @@
|
||||
"toolUpgradeWillRun": "将执行:",
|
||||
"toolUpgradeConfirmBtn": "确认升级",
|
||||
"toolUpgradeUnanchoredHint": "无法确定命令行实际使用哪处,将执行默认升级命令(可能装到 PATH 上第一个 npm)。",
|
||||
"toolUpgradeUnmanagedTitle": "以下工具已跳过升级",
|
||||
"toolUpgradeUnmanagedDetail": "{{tool}}({{path}})不是通过 npm 或可识别的安装器安装的,CC Switch 无法判断如何升级它。请用你原来的安装方式升级(例如 winget、Scoop 或重新运行官方安装脚本)。",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
|
||||
@@ -319,6 +319,8 @@ export interface ToolInstallationReport {
|
||||
needs_confirmation: boolean;
|
||||
command: string;
|
||||
anchored: boolean;
|
||||
/** 默认那处是认不出安装渠道的原生可执行文件:不执行升级,command 为空。 */
|
||||
unmanaged: boolean;
|
||||
}
|
||||
|
||||
export interface RectifierConfig {
|
||||
|
||||
Reference in new issue
Block a user