mirror of
https://github.com/openai/codex.git
synced 2026-04-28 18:32:04 +03:00
1106 lines
34 KiB
Markdown
1106 lines
34 KiB
Markdown
# 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
|
||
+ // 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
|
||
|
||
```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
|
||
+ // 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::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
|
||
+ // 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
|
||
|
||
```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
|
||
+ // 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?
|
||
>
|
||
> ```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
|
||
+ // 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
|
||
|
||
```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
|
||
+ // 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)
|
||
```
|
||
|
||
> ```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
|
||
+ // 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
|
||
|
||
```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
|
||
+ // 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:
|
||
>
|
||
> ```suggestion
|
||
> let ReleaseInfo { tag_name: latest_tag_name} = reqwest::Client::new()
|
||
> ```
|
||
>
|
||
>
|
||
> and then you can remove:
|
||
>
|
||
> ```
|
||
> let latest_tag_name = resp.tag_name;
|
||
> ``` |