use chrono::DateTime; use chrono::Duration; use chrono::Utc; 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 { let version_file = version_filepath(config); let info = read_version_info(&version_file).ok(); if match &info { None => true, Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20), } { // Refresh the cached latest version in the background so TUI startup // isn’t blocked by a network call. The UI reads the previously cached // value (if any) for this run; the next run shows the banner if needed. tokio::spawn(async move { check_for_update(&version_file) .await .inspect_err(|e| tracing::error!("Failed to update version: {e}")) }); } info.and_then(|info| { if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) { Some(info.latest_version) } else { None } }) } #[derive(Serialize, Deserialize, Debug, Clone)] struct VersionInfo { latest_version: String, // ISO-8601 timestamp (RFC3339) last_checked_at: DateTime, #[serde(default)] dismissed_version: Option, } #[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) } fn read_version_info(version_file: &Path) -> anyhow::Result { let contents = std::fs::read_to_string(version_file)?; Ok(serde_json::from_str(&contents)?) } 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?; // 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(), last_checked_at: Utc::now(), dismissed_version: prev_info.and_then(|p| p.dismissed_version), }; let json_line = format!("{}\n", serde_json::to_string(&info)?); if let Some(parent) = version_file.parent() { tokio::fs::create_dir_all(parent).await?; } tokio::fs::write(version_file, json_line).await?; Ok(()) } fn is_newer(latest: &str, current: &str) -> Option { match (parse_version(latest), parse_version(current)) { (Some(l), Some(c)) => Some(l > c), _ => None, } } /// 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 { let version_file = version_filepath(config); let latest = get_upgrade_version(config)?; // If the user dismissed this exact version previously, do not show the popup. if let Ok(info) = read_version_info(&version_file) && info.dismissed_version.as_deref() == Some(latest.as_str()) { return None; } Some(latest) } /// Persist a dismissal for the current latest version so we don't show /// the update popup again for this version. pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> { let version_file = version_filepath(config); let mut info = match read_version_info(&version_file) { Ok(info) => info, Err(_) => return Ok(()), }; info.dismissed_version = Some(version.to_string()); let json_line = format!("{}\n", serde_json::to_string(&info)?); if let Some(parent) = version_file.parent() { tokio::fs::create_dir_all(parent).await?; } tokio::fs::write(version_file, json_line).await?; Ok(()) } fn parse_version(v: &str) -> Option<(u64, u64, u64)> { let mut iter = v.trim().split('.'); let maj = iter.next()?.parse::().ok()?; let min = iter.next()?.parse::().ok()?; let pat = iter.next()?.parse::().ok()?; 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 prerelease_version_is_not_considered_newer() { assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None); } #[test] fn plain_semver_comparisons_work() { assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true)); assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false)); assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true)); assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false)); } #[test] fn whitespace_is_ignored() { 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") }; } } }