Files
llmx/codex-rs/tui/src/updates.rs
Naoya Yasuda 53cadb4df6 docs: Add --cask option to brew command to suggest (#5432)
## What
- Add the `--cask` flag to the Homebrew update command for Codex.

## Why
- `brew upgrade codex` alone does not update the cask, so users were not
getting the right upgrade instructions.

## How
- Update `UpdateAction::BrewUpgrade` in `codex-rs/tui/src/updates.rs` to
use `upgrade --cask codex`.

## Testing
- [x] cargo test -p codex-tui

Co-authored-by: Thibault Sottiaux <tibo@openai.com>
2025-10-21 19:10:30 -07:00

234 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> {
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
// isnt 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<Utc>,
#[serde(default)]
dismissed_version: Option<String>,
}
#[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<VersionInfo> {
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::<ReleaseInfo>()
.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<bool> {
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<String> {
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::<u64>().ok()?;
let min = iter.next()?.parse::<u64>().ok()?;
let pat = iter.next()?.parse::<u64>().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<UpdateAction> {
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") };
}
}
}