check for updates (#1764)

1. Ping https://api.github.com/repos/openai/codex/releases/latest (at
most once every 20 hrs)
2. Store the result in ~/.codex/version.jsonl
3. If CARGO_PKG_VERSION < latest_version, print a message at boot.

---------

Co-authored-by: easong-openai <easong@openai.com>
This commit is contained in:
Jeremy Rose
2025-08-01 17:31:38 -07:00
committed by GitHub
parent 929ba50adc
commit 7e0f506da2
5 changed files with 181 additions and 0 deletions

View File

@@ -83,6 +83,7 @@ if (wantsNative && process.platform !== 'win32') {
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
env: { ...process.env, CODEX_MANAGED_BY_NPM: "1" },
});
child.on("error", (err) => {

3
codex-rs/Cargo.lock generated
View File

@@ -843,6 +843,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"clap",
"codex-ansi-escape",
"codex-arg0",
@@ -861,6 +862,8 @@ dependencies = [
"ratatui",
"ratatui-image",
"regex-lite",
"reqwest",
"serde",
"serde_json",
"shlex",
"strum 0.27.2",

View File

@@ -17,6 +17,7 @@ workspace = true
[dependencies]
anyhow = "1"
base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
codex-ansi-escape = { path = "../ansi-escape" }
codex-arg0 = { path = "../arg0" }
@@ -41,6 +42,8 @@ ratatui = { version = "0.29.0", features = [
] }
ratatui-image = "8.0.0"
regex-lite = "0.1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0"
strum = "0.27.2"

View File

@@ -41,6 +41,11 @@ mod text_formatting;
mod tui;
mod user_approval_widget;
#[cfg(not(debug_assertions))]
mod updates;
#[cfg(not(debug_assertions))]
use color_eyre::owo_colors::OwoColorize;
pub use cli::Cli;
pub async fn run_main(
@@ -139,6 +144,38 @@ pub async fn run_main(
.with(tui_layer)
.try_init();
#[allow(clippy::print_stderr)]
#[cfg(not(debug_assertions))]
if let Some(latest_version) = updates::get_upgrade_version(&config) {
let current_version = env!("CARGO_PKG_VERSION");
let exe = std::env::current_exe()?;
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
eprintln!(
"{} {current_version} -> {latest_version}.",
"✨⬆️ Update available!".bold().cyan()
);
if managed_by_npm {
let npm_cmd = "npm install -g @openai/codex@latest";
eprintln!("Run {} to update.", npm_cmd.cyan().on_black());
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
{
let brew_cmd = "brew upgrade codex";
eprintln!("Run {} to update.", brew_cmd.cyan().on_black());
} else {
eprintln!(
"See {} for the latest releases and installation options.",
"https://github.com/openai/codex/releases/latest"
.cyan()
.on_black()
);
}
eprintln!("");
}
let show_login_screen = should_show_login_screen(&config);
if show_login_screen {
std::io::stdout()

137
codex-rs/tui/src/updates.rs Normal file
View File

@@ -0,0 +1,137 @@
#![cfg(any(not(debug_assertions), test))]
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;
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| {
let current_version = env!("CARGO_PKG_VERSION");
if is_newer(&info.latest_version, current_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>,
}
#[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,
} = reqwest::Client::new()
.get(LATEST_RELEASE_URL)
.header(
"User-Agent",
format!(
"codex/{} (+https://github.com/openai/codex)",
env!("CARGO_PKG_VERSION")
),
)
.send()
.await?
.error_for_status()?
.json::<ReleaseInfo>()
.await?;
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(),
};
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,
}
}
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))
}
#[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));
}
}