diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ff7bc42626..da19f30103 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -919,6 +919,23 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "codex-auto-updater" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-internal-storage", + "pretty_assertions", + "semver", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.16", + "tokio", + "tracing", + "which", +] + [[package]] name = "codex-backend-client" version = "0.0.0" @@ -1224,6 +1241,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-internal-storage" +version = "0.0.0" +dependencies = [ + "serde_json", + "thiserror 2.0.16", + "tokio", +] + [[package]] name = "codex-keyring-store" version = "0.0.0" @@ -1445,6 +1471,7 @@ dependencies = [ "codex-ansi-escape", "codex-app-server-protocol", "codex-arg0", + "codex-auto-updater", "codex-common", "codex-core", "codex-feedback", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f9d0865f32..8738e43739 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -40,6 +40,8 @@ members = [ "utils/readiness", "utils/string", "utils/tokenizer", + "auto-updater", + "internal-storage", ] resolver = "2" @@ -59,6 +61,7 @@ codex-app-server = { path = "app-server" } codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } +codex-auto-updater = { path = "auto-updater" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } @@ -81,6 +84,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } +codex-internal-storage = { path = "internal-storage" } codex-utils-cache = { path = "utils/cache" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } @@ -168,6 +172,7 @@ sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" +semver = "1" serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" diff --git a/codex-rs/auto-updater/Cargo.toml b/codex-rs/auto-updater/Cargo.toml new file mode 100644 index 0000000000..f647fddb81 --- /dev/null +++ b/codex-rs/auto-updater/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "codex-auto-updater" +version.workspace = true +edition.workspace = true + +[dependencies] +async-trait = { workspace = true } +semver = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread"] } +which = { workspace = true } +tracing = { workspace = true } +codex-internal-storage = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/codex-rs/auto-updater/src/brew.rs b/codex-rs/auto-updater/src/brew.rs new file mode 100644 index 0000000000..16de2674e8 --- /dev/null +++ b/codex-rs/auto-updater/src/brew.rs @@ -0,0 +1,940 @@ +use crate::Installer; +use crate::UpdateStatus; +use crate::errors::Error; +use async_trait::async_trait; +use semver::Version; +use serde::Deserialize; +use std::cmp::Ordering; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::time::Instant; + +const CODENAME: &str = "codex"; + +#[derive(Clone, Debug)] +pub(crate) struct BrewInstaller { + path: PathBuf, +} + +impl BrewInstaller { + pub(crate) fn detect() -> Result, Error> { + let path = match which::which("brew") { + Ok(path) => path, + Err(which::Error::CannotFindBinaryPath) => return Err(Error::BrewMissing), + Err(err) => return Err(Error::Io(err.to_string())), + }; + + let installer = Self { path }; + match installer.install_status() { + Ok(_) => Ok(Some(installer)), + Err(Error::Unsupported) => Ok(None), + Err(err) => Err(err), + } + } + + fn update_repository(&self) -> Result<(), Error> { + run_command_sync_with_env(&self.path, &["update"], &[])?; + Ok(()) + } + + fn install_status(&self) -> Result { + if let Some(info) = self.formula_info()? { + return Ok(InstallStatus { + method: InstallMethod::Formula, + current_version: info.current_version, + latest_version: info.latest_version, + }); + } + if let Some(info) = self.cask_info()? { + return Ok(InstallStatus { + method: InstallMethod::Cask, + current_version: info.current_version, + latest_version: info.latest_version, + }); + } + Err(Error::Unsupported) + } + + async fn upgrade(&self, method: InstallMethod) -> Result<(), Error> { + let args: &[&str] = match method { + InstallMethod::Formula => &["upgrade", CODENAME], + InstallMethod::Cask => &["upgrade", "--cask", CODENAME], + }; + run_command_async(&self.path, args, &[("HOMEBREW_NO_AUTO_UPDATE", "1")]).await?; + Ok(()) + } + + fn current_version(&self, method: InstallMethod) -> Result { + match method { + InstallMethod::Formula => self.formula_current_version(), + InstallMethod::Cask => self.cask_current_version(), + } + } + + fn formula_info(&self) -> Result, Error> { + let output = match run_command_sync(&self.path, &["info", "--json=v2", CODENAME]) { + Ok(output) => output, + Err(Error::Command { .. }) => return Ok(None), + Err(err) => return Err(err), + }; + let parsed: BrewFormulaInfoResponse = + serde_json::from_str(&output.stdout).map_err(|err| Error::Json(err.to_string()))?; + let formula = match parsed.formulae.into_iter().next() { + Some(value) => value, + None => return Ok(None), + }; + let installed_versions: Vec = formula + .installed + .into_iter() + .filter_map(|entry| entry.version) + .collect(); + let current_version = if installed_versions.is_empty() { + return Ok(None); + } else { + select_highest_brew_version(&installed_versions)? + }; + let latest_version = formula + .versions + .stable + .ok_or_else(|| Error::Version("missing stable formula version".into()))?; + Ok(Some(BrewFormulaInfo { + current_version, + latest_version, + })) + } + + fn cask_info(&self) -> Result, Error> { + let output = match run_command_sync(&self.path, &["info", "--cask", "--json=v2", CODENAME]) + { + Ok(output) => output, + Err(Error::Command { .. }) => return Ok(None), + Err(err) => return Err(err), + }; + let parsed: BrewCaskInfoResponse = + serde_json::from_str(&output.stdout).map_err(|err| Error::Json(err.to_string()))?; + let cask = match parsed.casks.into_iter().next() { + Some(value) => value, + None => return Ok(None), + }; + let installed_versions: Vec = cask + .installed + .into_iter() + .filter_map(|entry| entry.version) + .collect(); + let current_version = if installed_versions.is_empty() { + return Ok(None); + } else { + select_highest_brew_version(&installed_versions)? + }; + let latest_version = cask + .version + .ok_or_else(|| Error::Version("missing cask version".into()))?; + Ok(Some(BrewCaskInfo { + current_version, + latest_version, + })) + } + + fn formula_current_version(&self) -> Result { + self.parse_current_version(&["list", "--formula", "--versions", CODENAME]) + } + + fn cask_current_version(&self) -> Result { + self.parse_current_version(&["list", "--cask", "--versions", CODENAME]) + } + + fn parse_current_version(&self, args: &[&str]) -> Result { + let output = run_command_sync(&self.path, args)?; + parse_brew_list_version(&output.stdout) + } +} + +#[async_trait] +impl Installer for BrewInstaller { + fn version_status(&self) -> Result { + let started = Instant::now(); + let outcome = (|| { + self.update_repository()?; + let status = self.install_status()?; + let update_available = status.needs_update()?; + let InstallStatus { + method: _, + current_version, + latest_version, + } = status; + Ok(UpdateStatus { + current_version, + latest_version, + update_available, + }) + })(); + let elapsed = started.elapsed(); + match &outcome { + Ok(_) => tracing::info!( + elapsed_ms = elapsed.as_millis(), + "brew version status completed" + ), + Err(err) => tracing::info!( + elapsed_ms = elapsed.as_millis(), + error = %err, + "brew version status failed" + ), + } + outcome + } + + async fn update(&self) -> Result { + let initial_status = run_blocking({ + let brew = self.clone(); + move || brew.install_status() + }) + .await?; + + let needs_update = initial_status.needs_update()?; + let method = initial_status.method; + if !needs_update { + return Ok(initial_status.current_version); + } + + self.upgrade(method).await?; + + run_blocking({ + let brew = self.clone(); + move || brew.current_version(method) + }) + .await + } +} + +#[derive(Clone, Copy, Debug)] +enum InstallMethod { + Formula, + Cask, +} + +#[derive(Debug)] +struct InstallStatus { + method: InstallMethod, + current_version: String, + latest_version: String, +} + +impl InstallStatus { + fn needs_update(&self) -> Result { + compare_versions(&self.current_version, &self.latest_version) + } +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaInfoResponse { + formulae: Vec, +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaEntry { + installed: Vec, + versions: BrewFormulaVersions, +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaInstalledEntry { + version: Option, +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaVersions { + stable: Option, +} + +#[derive(Debug)] +struct BrewFormulaInfo { + current_version: String, + latest_version: String, +} + +#[derive(Debug, Deserialize)] +struct BrewCaskInfoResponse { + casks: Vec, +} + +#[derive(Debug, Deserialize)] +struct BrewCaskEntry { + installed: Vec, + version: Option, +} + +#[derive(Debug, Deserialize)] +struct BrewCaskInstalledEntry { + version: Option, +} + +#[derive(Debug)] +struct BrewCaskInfo { + current_version: String, + latest_version: String, +} + +struct CommandOutput { + stdout: String, +} + +fn run_command_sync(path: &Path, args: &[&str]) -> Result { + run_command_sync_with_env(path, args, &[("HOMEBREW_NO_AUTO_UPDATE", "1")]) +} + +fn run_command_sync_with_env( + path: &Path, + args: &[&str], + env: &[(&str, &str)], +) -> Result { + let mut command = Command::new(path); + command.args(args); + for (key, value) in env { + command.env(key, value); + } + let output = command.output().map_err(|err| Error::Io(err.to_string()))?; + handle_command_output(path, args, output) +} + +async fn run_command_async( + path: &Path, + args: &[&str], + env: &[(&str, &str)], +) -> Result { + let mut command = tokio::process::Command::new(path); + command.args(args); + for (key, value) in env { + command.env(key, value); + } + let output = command + .output() + .await + .map_err(|err| Error::Io(err.to_string()))?; + handle_command_output(path, args, output) +} + +fn handle_command_output( + path: &Path, + args: &[&str], + output: std::process::Output, +) -> Result { + if output.status.success() { + Ok(CommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + }) + } else { + Err(Error::Command { + command: format_command(path, args), + status: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } +} + +fn format_command(path: &Path, args: &[&str]) -> String { + let mut display = path.display().to_string(); + for arg in args { + display.push(' '); + display.push_str(arg); + } + display +} + +fn parse_brew_list_version(stdout: &str) -> Result { + let line = stdout + .lines() + .find(|candidate| !candidate.trim().is_empty()) + .ok_or_else(|| Error::Version("missing version output".into()))?; + let mut parts = line.split_whitespace(); + let name = parts + .next() + .ok_or_else(|| Error::Version("unexpected brew list output".into()))?; + if name != CODENAME { + return Err(Error::Version( + "brew list returned unexpected formula".into(), + )); + } + let version = parts + .last() + .ok_or_else(|| Error::Version("brew list did not include version".into()))?; + Ok(version.to_string()) +} + +fn select_highest_brew_version(versions: &[String]) -> Result { + let mut best: Option = None; + for version in versions { + let trimmed = version.trim(); + if trimmed.is_empty() { + continue; + } + let candidate = trimmed.to_string(); + match &best { + Some(current_best) => { + if version_ordering(current_best, &candidate) == Ordering::Less { + best = Some(candidate); + } + } + None => best = Some(candidate), + } + } + match best { + Some(value) => Ok(value), + None => Err(Error::Version("missing installed brew version".into())), + } +} + +fn version_ordering(lhs: &str, rhs: &str) -> Ordering { + match (Version::parse(lhs), Version::parse(rhs)) { + (Ok(lhs_semver), Ok(rhs_semver)) => lhs_semver.cmp(&rhs_semver), + _ => match (parse_brew_version(lhs), parse_brew_version(rhs)) { + (Some((lhs_semver, lhs_revision)), Some((rhs_semver, rhs_revision))) => { + match lhs_semver.cmp(&rhs_semver) { + Ordering::Equal => lhs_revision.cmp(&rhs_revision), + other => other, + } + } + _ => lhs.cmp(rhs), + }, + } +} + +fn compare_versions(current: &str, latest: &str) -> Result { + match (Version::parse(current), Version::parse(latest)) { + (Ok(current_semver), Ok(latest_semver)) => Ok(latest_semver > current_semver), + (Err(_), Err(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { + if let Some(result) = compare_brew_versions(current, latest) { + return Ok(result); + } + Ok(latest > current) + } + } +} + +fn compare_brew_versions(current: &str, latest: &str) -> Option { + let (current_semver, current_revision) = parse_brew_version(current)?; + let (latest_semver, latest_revision) = parse_brew_version(latest)?; + if current_semver != latest_semver { + return Some(latest_semver > current_semver); + } + Some(latest_revision > current_revision) +} + +fn parse_brew_version(version: &str) -> Option<(Version, i64)> { + let (core, revision) = version + .split_once('_') + .map_or((version, "0"), |(base, revision)| (base, revision)); + let semver = normalize_semver(core)?; + let revision_value = if revision.is_empty() { + 0 + } else { + revision.parse::().ok()? + }; + Some((semver, revision_value)) +} + +fn normalize_semver(version: &str) -> Option { + if let Ok(parsed) = Version::parse(version) { + return Some(parsed); + } + + let (without_build, build) = version + .split_once('+') + .map_or((version, None), |(core, build)| (core, Some(build))); + let (numeric, suffix) = without_build + .split_once('-') + .map_or((without_build, None), |(core, suffix)| (core, Some(suffix))); + + if numeric.is_empty() { + return None; + } + + let mut components: Vec<&str> = numeric.split('.').collect(); + if components.is_empty() || components.iter().any(|component| component.is_empty()) { + return None; + } + if components.len() > 3 { + return None; + } + while components.len() < 3 { + components.push("0"); + } + + let mut normalized = components.join("."); + if let Some(suffix) = suffix { + if suffix.is_empty() { + return None; + } + normalized.push('-'); + normalized.push_str(suffix); + } + if let Some(build) = build { + if build.is_empty() { + return None; + } + normalized.push('+'); + normalized.push_str(build); + } + + Version::parse(&normalized).ok() +} + +async fn run_blocking(func: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(func) + .await + .map_err(|err| Error::Io(err.to_string()))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compare_versions_prefers_semver() -> Result<(), Error> { + pretty_assertions::assert_eq!(compare_versions("0.8.0", "0.9.0")?, true); + pretty_assertions::assert_eq!(compare_versions("0.9.0", "0.9.0")?, false); + pretty_assertions::assert_eq!(compare_versions("0.10.0", "0.9.0")?, false); + Ok(()) + } + + #[test] + fn compare_versions_falls_back_to_string_compare() -> Result<(), Error> { + pretty_assertions::assert_eq!(compare_versions("0.9.0_1", "0.9.1")?, true); + pretty_assertions::assert_eq!(compare_versions("1.0-nightly", "1.0-nightly")?, false); + Ok(()) + } + + #[test] + fn compare_versions_handles_brew_revision_suffix() -> Result<(), Error> { + pretty_assertions::assert_eq!(compare_versions("0.9.0_1", "0.10.0_1")?, true); + pretty_assertions::assert_eq!(compare_versions("0.10.0_1", "0.10.0_2")?, true); + pretty_assertions::assert_eq!(compare_versions("0.10.0_2", "0.10.0_1")?, false); + pretty_assertions::assert_eq!(compare_versions("0.10.0", "0.10.0_1")?, true); + Ok(()) + } + + #[cfg(unix)] + mod unix { + use super::*; + use crate::update; + use crate::update_available; + use serde_json::json; + use std::env; + use std::error::Error as StdError; + use std::ffi::OsStr; + use std::ffi::OsString; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use std::path::PathBuf; + use std::sync::Mutex; + use std::sync::OnceLock; + use tempfile::TempDir; + + const BREW_SCRIPT: &str = r#"#!/bin/sh +set -eu + +command="$1" +case "$command" in + info) + if [ "${2:-}" = "--cask" ]; then + cat "$BREW_CASK_INFO" + else + cat "$BREW_FORMULA_INFO" + fi + ;; + list) + if [ "${2:-}" = "--cask" ]; then + cat "$BREW_CASK_LIST" + else + cat "$BREW_FORMULA_LIST" + fi + ;; + update) + printf '%s\n' 'update' >> "$BREW_UPDATE_LOG" + ;; + upgrade) + if [ "${HOMEBREW_NO_AUTO_UPDATE:-}" != "1" ]; then + echo "missing HOMEBREW_NO_AUTO_UPDATE" >&2 + exit 7 + fi + if [ "${2:-}" = "--cask" ]; then + printf '%s\n' 'upgrade --cask codex' >> "$BREW_UPGRADE_LOG" + cp "$BREW_CASK_UPDATED_LIST" "$BREW_CASK_LIST" + else + printf '%s\n' 'upgrade codex' >> "$BREW_UPGRADE_LOG" + cp "$BREW_FORMULA_UPDATED_LIST" "$BREW_FORMULA_LIST" + fi + ;; + *) + echo "unsupported command: $command" >&2 + exit 8 + ;; +esac +"#; + + #[tokio::test] + async fn update_available_reports_formula_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + + pretty_assertions::assert_eq!(available, true); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_available_reports_formula_up_to_date() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.9.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + + pretty_assertions::assert_eq!(available, false); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_status_reports_versions() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let status = crate::update_status()?; + + pretty_assertions::assert_eq!(status.update_available, true); + pretty_assertions::assert_eq!(status.current_version, "0.8.0".to_string()); + pretty_assertions::assert_eq!(status.latest_version, "0.9.0".to_string()); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn version_status_runs_brew_update() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let status = crate::update_status()?; + + pretty_assertions::assert_eq!(status.latest_version, "0.9.0".to_string()); + pretty_assertions::assert_eq!(fake_brew.update_log_contents()?, "update\n".to_string()); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_executes_formula_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let version = update().await?; + + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + pretty_assertions::assert_eq!( + fake_brew.upgrade_log_contents()?, + "upgrade codex\n".to_string() + ); + pretty_assertions::assert_eq!( + fake_brew.current_list_contents(InstallMethod::Formula)?, + "codex 0.9.0\n".to_string() + ); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_skips_formula_when_up_to_date() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.9.0", "0.9.0", "0.9.0")?; + + let version = update().await?; + + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + pretty_assertions::assert_eq!(fake_brew.upgrade_log_contents()?, String::new()); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_available_reports_cask_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::cask("0.8.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + + pretty_assertions::assert_eq!(available, true); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_executes_cask_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::cask("0.8.0", "0.9.0", "0.9.0")?; + + let version = update().await?; + + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + pretty_assertions::assert_eq!( + fake_brew.upgrade_log_contents()?, + "upgrade --cask codex\n".to_string() + ); + pretty_assertions::assert_eq!( + fake_brew.current_list_contents(InstallMethod::Cask)?, + "codex 0.9.0\n".to_string() + ); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn detects_cask_when_formula_entry_missing() -> Result<(), Box> { + let fake_brew = FakeBrew::cask_without_formula_entry("0.8.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + pretty_assertions::assert_eq!(available, true); + + let version = update().await?; + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + + drop(fake_brew); + Ok(()) + } + + struct FakeBrew { + _tempdir: TempDir, + _env: EnvironmentGuard, + _vars: Vec, + update_log: PathBuf, + upgrade_log: PathBuf, + formula_list: PathBuf, + cask_list: PathBuf, + } + + impl FakeBrew { + fn formula( + current: &str, + latest: &str, + updated: &str, + ) -> Result> { + Self::new( + build_formula_info(&[current], latest), + build_cask_info(&[], latest), + format!("codex {current}\n"), + "codex 0.0.0\n".to_string(), + format!("codex {updated}\n"), + "codex 0.0.0\n".to_string(), + ) + } + + fn cask(current: &str, latest: &str, updated: &str) -> Result> { + Self::new( + build_formula_info(&[], latest), + build_cask_info(&[current], latest), + "codex 0.0.0\n".to_string(), + format!("codex {current}\n"), + "codex 0.0.0\n".to_string(), + format!("codex {updated}\n"), + ) + } + + fn cask_without_formula_entry( + current: &str, + latest: &str, + updated: &str, + ) -> Result> { + Self::new( + build_empty_formula_info(), + build_cask_info(&[current], latest), + "codex 0.0.0\n".to_string(), + format!("codex {current}\n"), + "codex 0.0.0\n".to_string(), + format!("codex {updated}\n"), + ) + } + + fn new( + formula_info: serde_json::Value, + cask_info: serde_json::Value, + formula_list: String, + cask_list: String, + formula_updated_list: String, + cask_updated_list: String, + ) -> Result> { + let tempdir = TempDir::new()?; + let brew_path = tempdir.path().join("brew"); + fs::write(&brew_path, BREW_SCRIPT)?; + let mut permissions = fs::metadata(&brew_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&brew_path, permissions)?; + + let formula_info_string = serde_json::to_string(&formula_info)?; + let formula_info_path = tempdir.path().join("formula_info.json"); + fs::write(&formula_info_path, formula_info_string.as_bytes())?; + let cask_info_string = serde_json::to_string(&cask_info)?; + let cask_info_path = tempdir.path().join("cask_info.json"); + fs::write(&cask_info_path, cask_info_string.as_bytes())?; + + let formula_list_path = tempdir.path().join("formula_list.txt"); + fs::write(&formula_list_path, formula_list.as_bytes())?; + let cask_list_path = tempdir.path().join("cask_list.txt"); + fs::write(&cask_list_path, cask_list.as_bytes())?; + + let formula_updated_path = tempdir.path().join("formula_list_updated.txt"); + fs::write(&formula_updated_path, formula_updated_list.as_bytes())?; + let cask_updated_path = tempdir.path().join("cask_list_updated.txt"); + fs::write(&cask_updated_path, cask_updated_list.as_bytes())?; + + let update_log = tempdir.path().join("update.log"); + fs::write(&update_log, Vec::new())?; + let upgrade_log = tempdir.path().join("upgrade.log"); + fs::write(&upgrade_log, Vec::new())?; + + let env = EnvironmentGuard::new(tempdir.path()); + let vars = vec![ + VarGuard::new("BREW_FORMULA_INFO", &formula_info_path), + VarGuard::new("BREW_CASK_INFO", &cask_info_path), + VarGuard::new("BREW_FORMULA_LIST", &formula_list_path), + VarGuard::new("BREW_CASK_LIST", &cask_list_path), + VarGuard::new("BREW_FORMULA_UPDATED_LIST", &formula_updated_path), + VarGuard::new("BREW_CASK_UPDATED_LIST", &cask_updated_path), + VarGuard::new("BREW_UPDATE_LOG", &update_log), + VarGuard::new("BREW_UPGRADE_LOG", &upgrade_log), + ]; + + Ok(Self { + _tempdir: tempdir, + _env: env, + _vars: vars, + update_log, + upgrade_log, + formula_list: formula_list_path, + cask_list: cask_list_path, + }) + } + + fn update_log_contents(&self) -> Result> { + Ok(fs::read_to_string(&self.update_log)?) + } + + fn upgrade_log_contents(&self) -> Result> { + Ok(fs::read_to_string(&self.upgrade_log)?) + } + + fn current_list_contents( + &self, + method: InstallMethod, + ) -> Result> { + let path = match method { + InstallMethod::Formula => &self.formula_list, + InstallMethod::Cask => &self.cask_list, + }; + Ok(fs::read_to_string(path)?) + } + } + + fn build_formula_info(installed: &[&str], latest: &str) -> serde_json::Value { + json!({ + "formulae": [{ + "installed": installed + .iter() + .map(|version| json!({"version": version})) + .collect::>(), + "versions": {"stable": latest} + }] + }) + } + + fn build_cask_info(installed: &[&str], latest: &str) -> serde_json::Value { + json!({ + "casks": [{ + "installed": installed + .iter() + .map(|version| json!({"version": version})) + .collect::>(), + "version": latest + }] + }) + } + + fn build_empty_formula_info() -> serde_json::Value { + json!({ + "formulae": [] + }) + } + + struct EnvironmentGuard { + _lock: std::sync::MutexGuard<'static, ()>, + _path_guard: PathGuard, + } + + impl EnvironmentGuard { + fn new(new_dir: &Path) -> Self { + let lock = acquire_environment_lock(); + let path_guard = PathGuard::set(new_dir); + Self { + _lock: lock, + _path_guard: path_guard, + } + } + } + + struct PathGuard { + original: Option, + } + + impl PathGuard { + fn set(new_dir: &Path) -> Self { + let original = env::var_os("PATH"); + let mut joined = OsString::new(); + joined.push(new_dir.as_os_str()); + if let Some(current) = original.as_ref() { + joined.push(OsStr::new(":")); + joined.push(current); + } + // SAFETY: environment access is guarded by acquire_environment_lock(). + unsafe { env::set_var("PATH", &joined) }; + Self { original } + } + } + + impl Drop for PathGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { env::set_var("PATH", value) }, + None => unsafe { env::remove_var("PATH") }, + } + } + } + + struct VarGuard { + key: &'static str, + original: Option, + } + + impl VarGuard { + fn new(key: &'static str, value: &Path) -> Self { + let original = env::var_os(key); + // SAFETY: environment access is guarded by acquire_environment_lock(). + unsafe { env::set_var(key, value) }; + Self { key, original } + } + } + + impl Drop for VarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { env::set_var(self.key, value) }, + None => unsafe { env::remove_var(self.key) }, + } + } + } + + fn acquire_environment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + match LOCK.get_or_init(|| Mutex::new(())).lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + } +} diff --git a/codex-rs/auto-updater/src/errors.rs b/codex-rs/auto-updater/src/errors.rs new file mode 100644 index 0000000000..f52d172ba6 --- /dev/null +++ b/codex-rs/auto-updater/src/errors.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("unsupported install method")] + Unsupported, + #[error("brew not found in PATH")] + BrewMissing, + #[error("command failed: {command}")] + Command { + command: String, + status: Option, + stdout: String, + stderr: String, + }, + #[error("json parse error: {0}")] + Json(String), + #[error("version parse error: {0}")] + Version(String), + #[error("io error: {0}")] + Io(String), +} diff --git a/codex-rs/auto-updater/src/lib.rs b/codex-rs/auto-updater/src/lib.rs new file mode 100644 index 0000000000..6930d59010 --- /dev/null +++ b/codex-rs/auto-updater/src/lib.rs @@ -0,0 +1,88 @@ +mod brew; +mod errors; + +use async_trait::async_trait; +pub use errors::Error; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; + +use crate::brew::BrewInstaller; + +const AUTO_UPDATER_STATUS_KEY: &str = "auto_updater.status"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateStatus { + pub current_version: String, + pub latest_version: String, + pub update_available: bool, +} + +#[async_trait] +pub trait Installer: Send + Sync { + fn version_status(&self) -> Result; + + fn update_available(&self) -> Result { + self.version_status().map(|status| status.update_available) + } + + async fn update(&self) -> Result; +} + +pub fn installer() -> Result, Error> { + if let Some(installer) = BrewInstaller::detect()? { + return Ok(Box::new(installer)); + } + Err(Error::Unsupported) +} + +fn compute_update_status() -> Result { + installer()?.version_status() +} + +pub fn update_status() -> Result { + compute_update_status() +} + +pub fn update_available() -> Result { + installer()?.update_available() +} + +pub async fn update() -> Result { + installer()?.update().await +} + +pub fn initialize_storage(codex_home: &Path) -> Result<(), Error> { + codex_internal_storage::initialize(codex_home.to_path_buf()); + Ok(()) +} + +pub fn read_cached_status() -> Result, Error> { + match codex_internal_storage::read(AUTO_UPDATER_STATUS_KEY) { + Ok(Some(value)) => { + let status = + serde_json::from_str(&value).map_err(|err| Error::Json(err.to_string()))?; + Ok(Some(status)) + } + Ok(None) => Ok(None), + Err(err) => Err(map_storage_error(err)), + } +} + +pub async fn refresh_status() -> Result { + let status = compute_update_status()?; + let serialized = serde_json::to_string(&status).map_err(|err| Error::Json(err.to_string()))?; + codex_internal_storage::write(AUTO_UPDATER_STATUS_KEY, &serialized) + .map_err(map_storage_error)?; + Ok(status) +} + +fn map_storage_error(err: codex_internal_storage::InternalStorageError) -> Error { + match err { + codex_internal_storage::InternalStorageError::Io(err) => Error::Io(err.to_string()), + codex_internal_storage::InternalStorageError::Json(err) => Error::Json(err.to_string()), + codex_internal_storage::InternalStorageError::Uninitialized => { + Error::Io("internal storage not initialized".into()) + } + } +} diff --git a/codex-rs/internal-storage/Cargo.toml b/codex-rs/internal-storage/Cargo.toml new file mode 100644 index 0000000000..ccc30337b9 --- /dev/null +++ b/codex-rs/internal-storage/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "codex-internal-storage" +version.workspace = true +edition.workspace = true + +[dependencies] +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync"] } + +[lints] +workspace = true diff --git a/codex-rs/internal-storage/src/lib.rs b/codex-rs/internal-storage/src/lib.rs new file mode 100644 index 0000000000..5504626368 --- /dev/null +++ b/codex-rs/internal-storage/src/lib.rs @@ -0,0 +1,136 @@ +use serde_json::Map as JsonMap; +use serde_json::Value; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; +use thiserror::Error; +use tokio::runtime::Builder as RuntimeBuilder; +use tokio::runtime::Handle as RuntimeHandle; +use tokio::sync::Mutex; + +const INTERNAL_STORAGE_FILENAME: &str = "internal_storage.json"; + +#[derive(Debug, Error)] +pub enum InternalStorageError { + #[error("{0}")] + Io(#[from] io::Error), + #[error("{0}")] + Json(#[from] serde_json::Error), + #[error("internal storage has not been initialized")] + Uninitialized, +} + +#[derive(Debug)] +struct Storage { + path: PathBuf, + lock: Mutex<()>, +} + +impl Storage { + fn new(path: PathBuf) -> Self { + Self { + path, + lock: Mutex::new(()), + } + } + + fn read_map(&self) -> Result, InternalStorageError> { + match fs::read_to_string(&self.path) { + Ok(contents) => { + let value: Value = serde_json::from_str(&contents)?; + match value { + Value::Object(map) => Ok(map), + Value::Null => Ok(JsonMap::new()), + _ => Ok(JsonMap::new()), + } + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(JsonMap::new()), + Err(err) => Err(err.into()), + } + } + + fn write_map(&self, map: &JsonMap) -> Result<(), InternalStorageError> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + let payload = serde_json::to_string_pretty(&Value::Object(map.clone()))?; + fs::write(&self.path, payload)?; + Ok(()) + } + + async fn read_async(&self, key: &str) -> Result, InternalStorageError> { + let _guard = self.lock.lock().await; + let map = self.read_map()?; + Ok(map.get(key).map(value_to_string)) + } + + async fn write_async(&self, key: &str, value: &str) -> Result<(), InternalStorageError> { + let _guard = self.lock.lock().await; + let mut map = self.read_map()?; + map.insert(key.to_string(), Value::String(value.to_string())); + self.write_map(&map) + } +} + +static STORAGE: OnceLock = OnceLock::new(); + +pub fn initialize(codex_home: PathBuf) { + let path = build_storage_path(&codex_home); + let storage = Storage::new(path.clone()); + match STORAGE.get() { + Some(existing) if existing.path != path => {} + Some(_) => {} + None => { + let _ = STORAGE.set(storage); + } + } +} + +pub fn read(key: &str) -> Result, InternalStorageError> { + let fut = storage()?.read_async(key); + match block_on(fut) { + Ok(res) => res, + Err(err) => Err(err.into()), + } +} + +pub fn write(key: &str, value: &str) -> Result<(), InternalStorageError> { + let fut = storage()?.write_async(key, value); + match block_on(fut) { + Ok(res) => res, + Err(err) => Err(err.into()), + } +} + +fn storage() -> Result<&'static Storage, InternalStorageError> { + STORAGE.get().ok_or(InternalStorageError::Uninitialized) +} + +fn build_storage_path(codex_home: &Path) -> PathBuf { + codex_home.join(INTERNAL_STORAGE_FILENAME) +} + +fn value_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + _ => value.to_string(), + } +} + +fn block_on(fut: F) -> Result +where + F: std::future::Future, +{ + match RuntimeHandle::try_current() { + Ok(handle) => Ok(handle.block_on(fut)), + Err(_) => { + let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; + Ok(rt.block_on(fut)) + } + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f087202c22..9d711c3f25 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -33,6 +33,7 @@ codex-common = { workspace = true, features = [ "elapsed", "sandbox_summary", ] } +codex-auto-updater = { workspace = true } codex-core = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8fc18a1d9b..df04b7da06 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -40,9 +40,6 @@ use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; -#[cfg(not(debug_assertions))] -use crate::history_cell::UpdateAvailableHistoryCell; - #[derive(Debug, Clone)] pub struct AppExitInfo { pub token_usage: TokenUsage, @@ -92,6 +89,7 @@ impl App { initial_images: Vec, resume_selection: ResumeSelection, feedback: codex_feedback::CodexFeedback, + initial_events: Vec, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -148,9 +146,6 @@ impl App { }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - #[cfg(not(debug_assertions))] - let upgrade_version = crate::updates::get_upgrade_version(&config); - let mut app = Self { server: conversation_manager, app_event_tx, @@ -170,16 +165,8 @@ impl App { pending_update_action: None, }; - #[cfg(not(debug_assertions))] - if let Some(latest_version) = upgrade_version { - app.handle_event( - tui, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - latest_version, - crate::updates::get_update_action(), - ))), - ) - .await?; + for event in initial_events { + app.handle_event(tui, event).await?; } let tui_events = tui.event_stream(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 0cbaf12037..29d05c2c5a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -16,7 +16,6 @@ use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::updates::UpdateAction; -use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; @@ -265,15 +264,21 @@ impl HistoryCell for PlainHistoryCell { #[cfg_attr(debug_assertions, allow(dead_code))] #[derive(Debug)] pub(crate) struct UpdateAvailableHistoryCell { + current_version: String, latest_version: String, update_action: Option, } #[cfg_attr(debug_assertions, allow(dead_code))] impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { + pub(crate) fn new( + current_version: impl Into, + latest_version: impl Into, + update_action: Option, + ) -> Self { Self { - latest_version, + current_version: current_version.into(), + latest_version: latest_version.into(), update_action, } } @@ -298,7 +303,7 @@ impl HistoryCell for UpdateAvailableHistoryCell { padded_emoji("✨").bold().cyan(), "Update available!".bold().cyan(), " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), + format!("{} -> {}", self.current_version, self.latest_version).bold(), ], update_instruction, "", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87..a0a2683bb4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,6 +7,8 @@ use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; +use codex_auto_updater::Error as AutoUpdateError; +use codex_auto_updater::UpdateStatus; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -79,6 +81,8 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +use crate::app_event::AppEvent; +use crate::history_cell::UpdateAvailableHistoryCell; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; @@ -256,6 +260,36 @@ pub async fn run_main( .try_init(); }; + let mut cached_update_status: Option = None; + + match codex_auto_updater::initialize_storage(&config.codex_home) { + Ok(_) => { + match codex_auto_updater::read_cached_status() { + Ok(Some(status)) => { + if status.update_available { + cached_update_status = Some(status); + } + } + Ok(None) | Err(AutoUpdateError::Unsupported) => {} + Err(err) => { + error!(error = ?err, "Failed to read cached Codex update status"); + } + } + + tokio::spawn(async move { + if let Err(err) = codex_auto_updater::refresh_status().await { + error!(error = ?err, "Failed to refresh Codex update status"); + } + }); + } + Err(err) => { + error!( + error = ?err, + "Failed to initialize internal storage for Codex updates" + ); + } + } + run_ratatui_app( cli, config, @@ -263,6 +297,7 @@ pub async fn run_main( cli_kv_overrides, active_profile, feedback, + cached_update_status, ) .await .map_err(|err| std::io::Error::other(err.to_string())) @@ -275,9 +310,19 @@ async fn run_ratatui_app( cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, feedback: codex_feedback::CodexFeedback, + cached_update_status: Option, ) -> color_eyre::Result { color_eyre::install()?; + let mut initial_events = Vec::new(); + if let Some(status) = cached_update_status + && status.update_available + { + initial_events.push(AppEvent::InsertHistoryCell(Box::new( + UpdateAvailableHistoryCell::new(status.current_version, status.latest_version, None), + ))); + } + // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. // Chain to the previous hook so users still get a rich panic report @@ -447,6 +492,7 @@ async fn run_ratatui_app( images, resume_selection, feedback, + initial_events, ) .await;