The previous config approach had a few issues: 1. It is part of the config but not designed to be used externally 2. It had to be wired through many places (look at the +/- on this PR 3. It wasn't guaranteed to be set consistently everywhere because we don't have a super well defined way that configs stack. For example, the extension would configure during newConversation but anything that happened outside of that (like login) wouldn't get it. This env var approach is cleaner and also creates one less thing we have to deal with when coming up with a better holistic story around configs. One downside is that I removed the unit test testing for the override because I don't want to deal with setting the global env or spawning child processes and figuring out how to introspect their originator header. The new code is sufficiently simple and I tested it e2e that I feel as if this is still worth it.
133 lines
3.8 KiB
Rust
133 lines
3.8 KiB
Rust
#![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;
|
||
use codex_core::default_client::create_client;
|
||
|
||
use crate::version::CODEX_CLI_VERSION;
|
||
|
||
pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||
let version_file = version_filepath(config);
|
||
let info = read_version_info(&version_file).ok();
|
||
|
||
if match &info {
|
||
None => true,
|
||
Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20),
|
||
} {
|
||
// Refresh the cached latest version in the background so TUI startup
|
||
// 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| {
|
||
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
|
||
Some(info.latest_version)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
struct VersionInfo {
|
||
latest_version: String,
|
||
// ISO-8601 timestamp (RFC3339)
|
||
last_checked_at: DateTime<Utc>,
|
||
}
|
||
|
||
#[derive(Deserialize, Debug, Clone)]
|
||
struct ReleaseInfo {
|
||
tag_name: String,
|
||
}
|
||
|
||
const VERSION_FILENAME: &str = "version.json";
|
||
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
|
||
|
||
fn version_filepath(config: &Config) -> PathBuf {
|
||
config.codex_home.join(VERSION_FILENAME)
|
||
}
|
||
|
||
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
|
||
let contents = std::fs::read_to_string(version_file)?;
|
||
Ok(serde_json::from_str(&contents)?)
|
||
}
|
||
|
||
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
||
let ReleaseInfo {
|
||
tag_name: latest_tag_name,
|
||
} = create_client()
|
||
.get(LATEST_RELEASE_URL)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?
|
||
.json::<ReleaseInfo>()
|
||
.await?;
|
||
|
||
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));
|
||
}
|
||
}
|