2025-08-01 17:31:38 -07:00
|
|
|
|
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;
|
2025-09-03 10:11:02 -07:00
|
|
|
|
use codex_core::default_client::create_client;
|
2025-08-01 17:31:38 -07:00
|
|
|
|
|
2025-09-05 16:27:31 -07:00
|
|
|
|
use crate::version::CODEX_CLI_VERSION;
|
|
|
|
|
|
|
2025-08-01 17:31:38 -07:00
|
|
|
|
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 {
|
2025-09-09 14:23:23 -07:00
|
|
|
|
check_for_update(&version_file)
|
2025-08-01 17:31:38 -07:00
|
|
|
|
.await
|
|
|
|
|
|
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
info.and_then(|info| {
|
2025-09-05 16:27:31 -07:00
|
|
|
|
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
|
2025-08-01 17:31:38 -07:00
|
|
|
|
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>,
|
2025-10-15 16:11:20 -07:00
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
dismissed_version: Option<String>,
|
2025-08-01 17:31:38 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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)?)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-09 14:23:23 -07:00
|
|
|
|
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
2025-08-01 17:31:38 -07:00
|
|
|
|
let ReleaseInfo {
|
|
|
|
|
|
tag_name: latest_tag_name,
|
2025-09-09 14:23:23 -07:00
|
|
|
|
} = create_client()
|
2025-08-01 17:31:38 -07:00
|
|
|
|
.get(LATEST_RELEASE_URL)
|
|
|
|
|
|
.send()
|
|
|
|
|
|
.await?
|
|
|
|
|
|
.error_for_status()?
|
|
|
|
|
|
.json::<ReleaseInfo>()
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
2025-10-15 16:11:20 -07:00
|
|
|
|
// Preserve any previously dismissed version if present.
|
|
|
|
|
|
let prev_info = read_version_info(version_file).ok();
|
2025-08-01 17:31:38 -07:00
|
|
|
|
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(),
|
2025-10-15 16:11:20 -07:00
|
|
|
|
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
|
2025-08-01 17:31:38 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 16:11:20 -07:00
|
|
|
|
/// Returns the latest version to show in a popup, if it should be shown.
|
|
|
|
|
|
/// This respects the user's dismissal choice for the current latest version.
|
|
|
|
|
|
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
|
|
|
|
|
|
let version_file = version_filepath(config);
|
|
|
|
|
|
let latest = get_upgrade_version(config)?;
|
|
|
|
|
|
// If the user dismissed this exact version previously, do not show the popup.
|
|
|
|
|
|
if let Ok(info) = read_version_info(&version_file)
|
|
|
|
|
|
&& info.dismissed_version.as_deref() == Some(latest.as_str())
|
|
|
|
|
|
{
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(latest)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Persist a dismissal for the current latest version so we don't show
|
|
|
|
|
|
/// the update popup again for this version.
|
|
|
|
|
|
pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> {
|
|
|
|
|
|
let version_file = version_filepath(config);
|
|
|
|
|
|
let mut info = match read_version_info(&version_file) {
|
|
|
|
|
|
Ok(info) => info,
|
|
|
|
|
|
Err(_) => return Ok(()),
|
|
|
|
|
|
};
|
|
|
|
|
|
info.dismissed_version = Some(version.to_string());
|
|
|
|
|
|
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(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-01 17:31:38 -07:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 14:40:14 -07:00
|
|
|
|
/// Update action the CLI should perform after the TUI exits.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
|
pub enum UpdateAction {
|
|
|
|
|
|
/// Update via `npm install -g @openai/codex@latest`.
|
|
|
|
|
|
NpmGlobalLatest,
|
|
|
|
|
|
/// Update via `bun install -g @openai/codex@latest`.
|
|
|
|
|
|
BunGlobalLatest,
|
|
|
|
|
|
/// Update via `brew upgrade codex`.
|
|
|
|
|
|
BrewUpgrade,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(any(not(debug_assertions), test))]
|
|
|
|
|
|
pub(crate) fn get_update_action() -> Option<UpdateAction> {
|
|
|
|
|
|
let exe = std::env::current_exe().unwrap_or_default();
|
|
|
|
|
|
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
|
|
|
|
|
|
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
|
|
|
|
|
|
if managed_by_npm {
|
|
|
|
|
|
Some(UpdateAction::NpmGlobalLatest)
|
|
|
|
|
|
} else if managed_by_bun {
|
|
|
|
|
|
Some(UpdateAction::BunGlobalLatest)
|
|
|
|
|
|
} else if cfg!(target_os = "macos")
|
|
|
|
|
|
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
|
|
|
|
|
|
{
|
|
|
|
|
|
Some(UpdateAction::BrewUpgrade)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl UpdateAction {
|
|
|
|
|
|
/// Returns the list of command-line arguments for invoking the update.
|
|
|
|
|
|
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
|
|
|
|
|
|
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
|
2025-10-22 11:10:30 +09:00
|
|
|
|
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]),
|
2025-10-20 14:40:14 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns string representation of the command-line arguments for invoking the update.
|
|
|
|
|
|
pub fn command_str(self) -> String {
|
|
|
|
|
|
let (command, args) = self.command_args();
|
|
|
|
|
|
let args_str = args.join(" ");
|
|
|
|
|
|
format!("{command} {args_str}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-01 17:31:38 -07:00
|
|
|
|
#[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));
|
|
|
|
|
|
}
|
2025-10-20 14:40:14 -07:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_get_update_action() {
|
|
|
|
|
|
let prev = std::env::var_os("CODEX_MANAGED_BY_NPM");
|
|
|
|
|
|
|
|
|
|
|
|
// First: no npm var -> expect None (we do not run from brew in CI)
|
|
|
|
|
|
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
|
|
|
|
|
|
assert_eq!(get_update_action(), None);
|
|
|
|
|
|
|
|
|
|
|
|
// Then: with npm var -> expect NpmGlobalLatest
|
|
|
|
|
|
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") };
|
|
|
|
|
|
assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest));
|
|
|
|
|
|
|
|
|
|
|
|
// Restore prior value to avoid leaking state
|
|
|
|
|
|
if let Some(v) = prev {
|
|
|
|
|
|
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-01 17:31:38 -07:00
|
|
|
|
}
|