Files
codex/prs/bolinfest/PR-1764.md
2025-09-02 15:17:45 -07:00

1106 lines
34 KiB
Markdown
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.
# 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
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.
## Full Diff
```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
+ // 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));
+ }
+}
```
## 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
```diff
@@ -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
```diff
@@ -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 the `update_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
```diff
@@ -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
```diff
@@ -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 -g` nor `brew install` should be run as `root`, so they can't write `/etc` either?
- Created: 2025-08-01 22:22:45 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2248953134
```diff
@@ -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/codex` and I'm not sure how often I believe it is `brew install`.
- Created: 2025-08-01 23:26:34 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249007650
```diff
@@ -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.rs` so 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
```diff
@@ -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
```diff
@@ -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.1` always fails the `is_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
```diff
@@ -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
```diff
@@ -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
+ // 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(serde::Serialize, serde::Deserialize, Debug, Clone)]
```
> I use `use serde::Deserialize` and `Deserialize` at the top, FYI.
- Created: 2025-08-01 23:29:04 UTC | Link: https://github.com/openai/codex/pull/1764#discussion_r2249008924
```diff
@@ -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
+ // 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(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
```diff
@@ -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
+ // 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(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?
>
> ```suggestion
> 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
```diff
@@ -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
+ // 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(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
```diff
@@ -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
+ // 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(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)
```
> ```suggestion
> 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
```diff
@@ -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
+ // 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(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
```diff
@@ -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
+ // 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(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:
>
> ```suggestion
> let ReleaseInfo { tag_name: latest_tag_name} = reqwest::Client::new()
> ```
>
>
> and then you can remove:
>
> ```
> let latest_tag_name = resp.tag_name;
> ```