34 KiB
PR #1764: check for updates
- URL: https://github.com/openai/codex/pull/1764
- Author: nornagon-openai
- Created: 2025-07-31 19:34:01 UTC
- Updated: 2025-08-02 00:31:46 UTC
- Changes: +181/-0, Files changed: 5, Commits: 11
Description
- Ping https://api.github.com/repos/openai/codex/releases/latest (at most once every 20 hrs)
- Store the result in ~/.codex/version.jsonl
- If CARGO_PKG_VERSION < latest_version, print a message at boot.
Full Diff
diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js
index ae1fb9593c..df06dd36a7 100755
--- a/codex-cli/bin/codex.js
+++ b/codex-cli/bin/codex.js
@@ -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) => {
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 460a440e63..d71553cf4a 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -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",
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 468f2f3be6..09b537c6c3 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -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"
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index f0a0e9d833..0ec9be6153 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -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()
diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs
new file mode 100644
index 0000000000..c7f7afd2a5
--- /dev/null
+++ b/codex-rs/tui/src/updates.rs
@@ -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
+ // 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| {
+ 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));
+ }
+}
Review Comments
codex-rs/tui/Cargo.toml
- Created: 2025-08-01 23:24:38 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249006404
@@ -42,6 +42,9 @@ ratatui = { version = "0.29.0", features = [
ratatui-image = "8.0.0"
regex-lite = "0.1"
serde_json = { version = "1", features = ["preserve_order"] }
+serde = { version = "1", features = ["derive"] }
alpha-sort?
codex-rs/tui/src/lib.rs
- Created: 2025-07-31 22:39:55 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2246519183
@@ -139,6 +144,26 @@ 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 update_command = if cfg!(target_os = "macos")
+ && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+ {
+ "brew upgrade codex"
+ } else {
+ "npm install -g @openai/codex@latest"
+ };
To recommend
npm install, I think we should add the check in here:https://github.com/openai/codex/blob/main/codex-cli/bin/codex.js
I think we should have a special build-time env var when building for Homebrew:
https://github.com/bolinfest/homebrew-core/blob/main/Formula/c/codex.rb
And check it with
env!()and set theupdate_command, as appropriate.If there is no valid
update_command, we should point the user to https://github.com/openai/codex/releases/latest (maybe even the artifact within latest, though we have to commit to the artifact names to future-proof it)
- Created: 2025-08-01 00:22:47 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2246618345
@@ -139,6 +144,26 @@ 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 update_command = if cfg!(target_os = "macos")
+ && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+ {
+ "brew upgrade codex"
+ } else {
+ "npm install -g @openai/codex@latest"
+ };
I generally agree that seems better, though I've been trying to think through that could be abused by a bad actor in some way.
- Created: 2025-08-01 22:09:59 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2248942242
@@ -139,6 +144,26 @@ 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 update_command = if cfg!(target_os = "macos")
+ && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+ {
+ "brew upgrade codex"
+ } else {
+ "npm install -g @openai/codex@latest"
+ };
Sure, though neither
npm install -gnorbrew installshould be run asroot, so they can't write/etceither?
- Created: 2025-08-01 22:22:45 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2248953134
@@ -139,6 +144,26 @@ 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 update_command = if cfg!(target_os = "macos")
+ && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
Why don't we drop
exe.starts_with("/usr/local"))for now? There's a lot of ways one can end up with/usr/local/bin/codexand I'm not sure how often I believe it isbrew install.
- Created: 2025-08-01 23:26:34 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249007650
@@ -139,6 +144,38 @@ pub async fn run_main(
.with(tui_layer)
.try_init();
+ #[allow(clippy::print_stderr)]
Low pri, but I would consider moving this to
updates.rsso you can have a smaller thing attached to#[cfg(not(debug_assertions))].
codex-rs/tui/src/updates.rs
- Created: 2025-08-01 22:20:41 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2248950891
@@ -0,0 +1,110 @@
+#![cfg(not(debug_assertions))]
+
+use chrono::Duration;
+use chrono::Utc;
+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),
+ } {
+ tokio::spawn(async move {
+ update_version(&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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
+}
+
+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 update_version(version_file: &Path) -> anyhow::Result<()> {
Can you add a docstring and/or rename? This doesn't update the version of the CLI, but the version file with metadata, is that right?
- Created: 2025-08-01 22:21:50 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2248952479
@@ -0,0 +1,110 @@
+#![cfg(not(debug_assertions))]
+
+use chrono::Duration;
+use chrono::Utc;
+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),
+ } {
+ tokio::spawn(async move {
+ update_version(&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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
+}
+
+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 update_version(version_file: &Path) -> anyhow::Result<()> {
+ #[derive(serde::Deserialize, Debug, Clone)]
+ struct ReleaseInfo {
+ tag_name: String,
+ }
+
+ let resp = 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 latest_tag_name = resp.tag_name;
+
+ 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: chrono::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.ok();
+ }
+ tokio::fs::write(version_file, json_line).await.ok();
+ Ok(())
+}
+
+fn is_newer(latest: &str, current: &str) -> Option<bool> {
Can you please add some unit tests so we can be sure that something like
0.11.0-beta.1always fails theis_newer()check? I believe that is the case right now, but I want to be sure we don't regress that.
- Created: 2025-08-01 22:23:30 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2248953659
@@ -0,0 +1,110 @@
+#![cfg(not(debug_assertions))]
+
+use chrono::Duration;
+use chrono::Utc;
+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),
+ } {
+ tokio::spawn(async move {
So we spawn this, but nothing waits for it? I'm a bit confused what the expectation here is.
- Created: 2025-08-01 23:28:32 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249008616
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
I use
use serde::DeserializeandDeserializeat the top, FYI.
- Created: 2025-08-01 23:29:04 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249008924
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
Not just
.json?
- Created: 2025-08-01 23:30:07 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249009498
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
It should be possible to do something like this?
config.codex_home.join(VERSION_FILENAME).to_path_buf()
- Created: 2025-08-01 23:30:46 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249009831
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
+}
+
+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<()> {
+ #[derive(serde::Deserialize, Debug, Clone)]
I would just make this a top level struct, but up to you.
- Created: 2025-08-01 23:31:35 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249010295
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
+}
+
+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<()> {
+ #[derive(serde::Deserialize, Debug, Clone)]
+ struct ReleaseInfo {
+ tag_name: String,
+ }
+
+ let resp = 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 latest_tag_name = resp.tag_name;
+
+ 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)
anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")
- Created: 2025-08-01 23:32:32 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249010797
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
+}
+
+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<()> {
+ #[derive(serde::Deserialize, Debug, Clone)]
+ struct ReleaseInfo {
+ tag_name: String,
+ }
+
+ let resp = 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 latest_tag_name = resp.tag_name;
+
+ 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: chrono::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.ok();
Why
ok()instead of?(and two lines down)
- Created: 2025-08-01 23:34:00 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249011549
@@ -0,0 +1,138 @@
+#![cfg(any(not(debug_assertions), test))]
+
+use chrono::Duration;
+use chrono::Utc;
+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
+ // 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| {
+ 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
+struct VersionInfo {
+ latest_version: String,
+ // ISO-8601 timestamp (RFC3339)
+ last_checked_at: chrono::DateTime<chrono::Utc>,
+}
+
+const VERSION_FILENAME: &str = "version.jsonl";
+const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
+
+fn version_filepath(config: &Config) -> PathBuf {
+ let mut path = config.codex_home.clone();
+ path.push(VERSION_FILENAME);
+ path
+}
+
+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<()> {
+ #[derive(serde::Deserialize, Debug, Clone)]
+ struct ReleaseInfo {
+ tag_name: String,
+ }
+
+ let resp = reqwest::Client::new()
Feels like a good candidate for destructuring:
let ReleaseInfo { tag_name: latest_tag_name} = reqwest::Client::new()and then you can remove:
let latest_tag_name = resp.tag_name;