mirror of
https://github.com/openai/codex.git
synced 2026-02-06 17:03:42 +00:00
Compare commits
7 Commits
queue/stee
...
jif/auto-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69a9979b8a | ||
|
|
e7de8118ce | ||
|
|
27b1991588 | ||
|
|
ba19ee3b44 | ||
|
|
f45cf49b3d | ||
|
|
5544ec8cc6 | ||
|
|
00dad73abd |
27
codex-rs/Cargo.lock
generated
27
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
22
codex-rs/auto-updater/Cargo.toml
Normal file
22
codex-rs/auto-updater/Cargo.toml
Normal 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
|
||||
940
codex-rs/auto-updater/src/brew.rs
Normal file
940
codex-rs/auto-updater/src/brew.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
codex-rs/auto-updater/src/errors.rs
Normal file
22
codex-rs/auto-updater/src/errors.rs
Normal 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),
|
||||
}
|
||||
88
codex-rs/auto-updater/src/lib.rs
Normal file
88
codex-rs/auto-updater/src/lib.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
12
codex-rs/internal-storage/Cargo.toml
Normal file
12
codex-rs/internal-storage/Cargo.toml
Normal 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
|
||||
136
codex-rs/internal-storage/src/lib.rs
Normal file
136
codex-rs/internal-storage/src/lib.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
"",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user