Compare commits

...

7 Commits

Author SHA1 Message Date
jif-oai
69a9979b8a tokio locks 2025-10-29 21:10:31 +00:00
jif-oai
e7de8118ce Drop legacy way 2025-10-29 20:56:28 +00:00
jif-oai
27b1991588 brew update 2025-10-29 20:05:20 +00:00
jif-oai
ba19ee3b44 V4 2025-10-29 17:10:10 +00:00
jif-oai
f45cf49b3d V3 2025-10-29 16:24:54 +00:00
jif-oai
5544ec8cc6 V2 2025-10-29 12:31:44 +00:00
jif-oai
00dad73abd V1 2025-10-29 11:21:41 +00:00
12 changed files with 1311 additions and 20 deletions

27
codex-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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<Option<Self>, 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<InstallStatus, Error> {
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<String, Error> {
match method {
InstallMethod::Formula => self.formula_current_version(),
InstallMethod::Cask => self.cask_current_version(),
}
}
fn formula_info(&self) -> Result<Option<BrewFormulaInfo>, 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<String> = 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<Option<BrewCaskInfo>, 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<String> = 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<String, Error> {
self.parse_current_version(&["list", "--formula", "--versions", CODENAME])
}
fn cask_current_version(&self) -> Result<String, Error> {
self.parse_current_version(&["list", "--cask", "--versions", CODENAME])
}
fn parse_current_version(&self, args: &[&str]) -> Result<String, Error> {
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<UpdateStatus, Error> {
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<String, Error> {
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<bool, Error> {
compare_versions(&self.current_version, &self.latest_version)
}
}
#[derive(Debug, Deserialize)]
struct BrewFormulaInfoResponse {
formulae: Vec<BrewFormulaEntry>,
}
#[derive(Debug, Deserialize)]
struct BrewFormulaEntry {
installed: Vec<BrewFormulaInstalledEntry>,
versions: BrewFormulaVersions,
}
#[derive(Debug, Deserialize)]
struct BrewFormulaInstalledEntry {
version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BrewFormulaVersions {
stable: Option<String>,
}
#[derive(Debug)]
struct BrewFormulaInfo {
current_version: String,
latest_version: String,
}
#[derive(Debug, Deserialize)]
struct BrewCaskInfoResponse {
casks: Vec<BrewCaskEntry>,
}
#[derive(Debug, Deserialize)]
struct BrewCaskEntry {
installed: Vec<BrewCaskInstalledEntry>,
version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BrewCaskInstalledEntry {
version: Option<String>,
}
#[derive(Debug)]
struct BrewCaskInfo {
current_version: String,
latest_version: String,
}
struct CommandOutput {
stdout: String,
}
fn run_command_sync(path: &Path, args: &[&str]) -> Result<CommandOutput, Error> {
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<CommandOutput, Error> {
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<CommandOutput, Error> {
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<CommandOutput, Error> {
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<String, Error> {
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<String, Error> {
let mut best: Option<String> = 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<bool, Error> {
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<bool> {
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::<i64>().ok()?
};
Some((semver, revision_value))
}
fn normalize_semver(version: &str) -> Option<Version> {
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<F, T>(func: F) -> Result<T, Error>
where
F: FnOnce() -> Result<T, Error> + 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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<dyn StdError>> {
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<VarGuard>,
update_log: PathBuf,
upgrade_log: PathBuf,
formula_list: PathBuf,
cask_list: PathBuf,
}
impl FakeBrew {
fn formula(
current: &str,
latest: &str,
updated: &str,
) -> Result<Self, Box<dyn StdError>> {
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, Box<dyn StdError>> {
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, Box<dyn StdError>> {
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<Self, Box<dyn StdError>> {
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<String, Box<dyn StdError>> {
Ok(fs::read_to_string(&self.update_log)?)
}
fn upgrade_log_contents(&self) -> Result<String, Box<dyn StdError>> {
Ok(fs::read_to_string(&self.upgrade_log)?)
}
fn current_list_contents(
&self,
method: InstallMethod,
) -> Result<String, Box<dyn StdError>> {
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::<Vec<_>>(),
"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::<Vec<_>>(),
"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<OsString>,
}
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<OsString>,
}
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<Mutex<()>> = OnceLock::new();
match LOCK.get_or_init(|| Mutex::new(())).lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
}
}

View File

@@ -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<i32>,
stdout: String,
stderr: String,
},
#[error("json parse error: {0}")]
Json(String),
#[error("version parse error: {0}")]
Version(String),
#[error("io error: {0}")]
Io(String),
}

View File

@@ -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<UpdateStatus, Error>;
fn update_available(&self) -> Result<bool, Error> {
self.version_status().map(|status| status.update_available)
}
async fn update(&self) -> Result<String, Error>;
}
pub fn installer() -> Result<Box<dyn Installer>, Error> {
if let Some(installer) = BrewInstaller::detect()? {
return Ok(Box::new(installer));
}
Err(Error::Unsupported)
}
fn compute_update_status() -> Result<UpdateStatus, Error> {
installer()?.version_status()
}
pub fn update_status() -> Result<UpdateStatus, Error> {
compute_update_status()
}
pub fn update_available() -> Result<bool, Error> {
installer()?.update_available()
}
pub async fn update() -> Result<String, Error> {
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<Option<UpdateStatus>, 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<UpdateStatus, Error> {
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())
}
}
}

View File

@@ -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

View File

@@ -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<JsonMap<String, Value>, 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<String, Value>) -> 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<Option<String>, 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<Storage> = 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<Option<String>, 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<F>(fut: F) -> Result<F::Output, io::Error>
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))
}
}
}

View File

@@ -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 }

View File

@@ -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<PathBuf>,
resume_selection: ResumeSelection,
feedback: codex_feedback::CodexFeedback,
initial_events: Vec<AppEvent>,
) -> Result<AppExitInfo> {
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();

View File

@@ -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<UpdateAction>,
}
#[cfg_attr(debug_assertions, allow(dead_code))]
impl UpdateAvailableHistoryCell {
pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {
pub(crate) fn new(
current_version: impl Into<String>,
latest_version: impl Into<String>,
update_action: Option<UpdateAction>,
) -> 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,
"",

View File

@@ -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<UpdateStatus> = 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<String>,
feedback: codex_feedback::CodexFeedback,
cached_update_status: Option<UpdateStatus>,
) -> color_eyre::Result<AppExitInfo> {
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;