diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 17297091..75a7cb8e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -21,7 +21,7 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; -use codex_tui::updates::UpdateAction; +use codex_tui::update_action::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 06c13ff1..188e8137 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -13,7 +13,7 @@ use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; -use crate::updates::UpdateAction; +use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -203,7 +203,7 @@ impl App { tui, AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( latest_version, - crate::updates::get_update_action(), + crate::update_action::get_update_action(), ))), ) .await?; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 875bb9e2..a9abd42f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -16,7 +16,7 @@ use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::updates::UpdateAction; +use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9efa5eac..12fc1f81 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -71,8 +71,9 @@ mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; +pub mod update_action; mod update_prompt; -pub mod updates; +mod updates; mod version; mod wrapping; diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs new file mode 100644 index 00000000..b5cf56a6 --- /dev/null +++ b/codex-rs/tui/src/update_action.rs @@ -0,0 +1,101 @@ +/// Update action the CLI should perform after the TUI exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + shlex::try_join(std::iter::once(command).chain(args.iter().copied())) + .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) + } +} + +#[cfg(not(debug_assertions))] +pub(crate) fn get_update_action() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + + detect_update_action( + cfg!(target_os = "macos"), + &exe, + managed_by_npm, + managed_by_bun, + ) +} + +#[cfg(any(not(debug_assertions), test))] +fn detect_update_action( + is_macos: bool, + current_exe: &std::path::Path, + managed_by_npm: bool, + managed_by_bun: bool, +) -> Option { + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else if managed_by_bun { + Some(UpdateAction::BunGlobalLatest) + } else if is_macos + && (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local")) + { + Some(UpdateAction::BrewUpgrade) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_update_action_without_env_mutation() { + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), false, false), + None + ); + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), true, false), + Some(UpdateAction::NpmGlobalLatest) + ); + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), false, true), + Some(UpdateAction::BunGlobalLatest) + ); + assert_eq!( + detect_update_action( + true, + std::path::Path::new("/opt/homebrew/bin/codex"), + false, + false + ), + Some(UpdateAction::BrewUpgrade) + ); + assert_eq!( + detect_update_action( + true, + std::path::Path::new("/usr/local/bin/codex"), + false, + false + ), + Some(UpdateAction::BrewUpgrade) + ); + } +} diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index d505385b..43ee0dbd 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -10,8 +10,8 @@ use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use crate::update_action::UpdateAction; use crate::updates; -use crate::updates::UpdateAction; use codex_core::config::Config; use color_eyre::Result; use crossterm::event::KeyCode; @@ -39,7 +39,7 @@ pub(crate) async fn run_update_prompt_if_needed( let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { return Ok(UpdatePromptOutcome::Continue); }; - let Some(update_action) = crate::updates::get_update_action() else { + let Some(update_action) = crate::update_action::get_update_action() else { return Ok(UpdatePromptOutcome::Continue); }; diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 8b92af4f..0c3f2044 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -1,14 +1,17 @@ +#![cfg(not(debug_assertions))] + +use crate::update_action; +use crate::update_action::UpdateAction; use chrono::DateTime; use chrono::Duration; use chrono::Utc; +use codex_core::config::Config; +use codex_core::default_client::create_client; use serde::Deserialize; use serde::Serialize; use std::path::Path; use std::path::PathBuf; -use codex_core::config::Config; -use codex_core::default_client::create_client; - use crate::version::CODEX_CLI_VERSION; pub fn get_upgrade_version(config: &Config) -> Option { @@ -47,14 +50,17 @@ struct VersionInfo { dismissed_version: Option, } +const VERSION_FILENAME: &str = "version.json"; +// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind. +const HOMEBREW_CASK_URL: &str = + "https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb"; +const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; + #[derive(Deserialize, Debug, Clone)] struct ReleaseInfo { tag_name: String, } -const VERSION_FILENAME: &str = "version.json"; -const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; - fn version_filepath(config: &Config) -> PathBuf { config.codex_home.join(VERSION_FILENAME) } @@ -65,23 +71,35 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { } async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { - let ReleaseInfo { - tag_name: latest_tag_name, - } = create_client() - .get(LATEST_RELEASE_URL) - .send() - .await? - .error_for_status()? - .json::() - .await?; + let latest_version = match update_action::get_update_action() { + Some(UpdateAction::BrewUpgrade) => { + let cask_contents = create_client() + .get(HOMEBREW_CASK_URL) + .send() + .await? + .error_for_status()? + .text() + .await?; + extract_version_from_cask(&cask_contents)? + } + _ => { + let ReleaseInfo { + tag_name: latest_tag_name, + } = create_client() + .get(LATEST_RELEASE_URL) + .send() + .await? + .error_for_status()? + .json::() + .await?; + extract_version_from_latest_tag(&latest_tag_name)? + } + }; // Preserve any previously dismissed version if present. let prev_info = read_version_info(version_file).ok(); let info = VersionInfo { - latest_version: latest_tag_name - .strip_prefix("rust-v") - .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))? - .into(), + latest_version, last_checked_at: Utc::now(), dismissed_version: prev_info.and_then(|p| p.dismissed_version), }; @@ -101,6 +119,25 @@ fn is_newer(latest: &str, current: &str) -> Option { } } +fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result { + cask_contents + .lines() + .find_map(|line| { + let line = line.trim(); + line.strip_prefix("version \"") + .and_then(|rest| rest.strip_suffix('"')) + .map(ToString::to_string) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file")) +} + +fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result { + latest_tag_name + .strip_prefix("rust-v") + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")) +} + /// Returns the latest version to show in a popup, if it should be shown. /// This respects the user's dismissal choice for the current latest version. pub fn get_upgrade_version_for_popup(config: &Config) -> Option { @@ -140,57 +177,36 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> { Some((maj, min, pat)) } -/// Update action the CLI should perform after the TUI exits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAction { - /// Update via `npm install -g @openai/codex@latest`. - NpmGlobalLatest, - /// Update via `bun install -g @openai/codex@latest`. - BunGlobalLatest, - /// Update via `brew upgrade codex`. - BrewUpgrade, -} - -#[cfg(any(not(debug_assertions), test))] -pub(crate) fn get_update_action() -> Option { - let exe = std::env::current_exe().unwrap_or_default(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - if managed_by_npm { - Some(UpdateAction::NpmGlobalLatest) - } else if managed_by_bun { - Some(UpdateAction::BunGlobalLatest) - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - Some(UpdateAction::BrewUpgrade) - } else { - None - } -} - -impl UpdateAction { - /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(self) -> (&'static str, &'static [&'static str]) { - match self { - UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), - } - } - - /// Returns string representation of the command-line arguments for invoking the update. - pub fn command_str(self) -> String { - let (command, args) = self.command_args(); - let args_str = args.join(" "); - format!("{command} {args_str}") - } -} - #[cfg(test)] mod tests { use super::*; + #[test] + fn parses_version_from_cask_contents() { + let cask = r#" + cask "codex" do + version "0.55.0" + end + "#; + assert_eq!( + extract_version_from_cask(cask).expect("failed to parse version"), + "0.55.0" + ); + } + + #[test] + fn extracts_version_from_latest_tag() { + assert_eq!( + extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"), + "1.5.0" + ); + } + + #[test] + fn latest_tag_without_prefix_is_invalid() { + assert!(extract_version_from_latest_tag("v1.5.0").is_err()); + } + #[test] fn prerelease_version_is_not_considered_newer() { assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); @@ -210,24 +226,4 @@ mod tests { assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); } - - #[test] - fn test_get_update_action() { - let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); - - // First: no npm var -> expect None (we do not run from brew in CI) - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - assert_eq!(get_update_action(), None); - - // Then: with npm var -> expect NpmGlobalLatest - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; - assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); - - // Restore prior value to avoid leaking state - if let Some(v) = prev { - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; - } else { - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - } - } }