Codex Handbook
cli/src/doctor/updates.rs 234 lines
//! Diagnoses whether Codex update paths target the running installation.//!//! Update diagnostics combine cached version metadata, install-channel hints,//! and bounded latest-version probes. For npm-managed launches, this module also//! verifies that npm install -g would update the package root that launched the//! current process, which catches PATH and prefix mismatches before the user runs//! an update command.use std::path::Path;use codex_core::config::Config;use codex_install_context::InstallContext;use codex_install_context::InstallMethod;use serde::Deserialize;use super::CheckStatus;use super::DoctorCheck;use super::NpmRootCheck;use super::doctor_install_context;use super::doctor_managed_by_npm;use super::npm_global_root_check;use super::run_command;const VERSION_FILE_NAME: &str = "version.json";const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json";/// Builds the update-health row for the current installation.////// Network failures while fetching latest-version metadata degrade the row to a/// warning instead of failing doctor outright; update freshness is useful/// support context but should not mask more direct install/config failures.pub(super) fn updates_check(config: &Config) -> DoctorCheck {    let current_exe = std::env::current_exe().ok();    let install_context = doctor_install_context(current_exe.as_deref());    let mut details = vec![        format!(            "check for update on startup: {}",            config.check_for_update_on_startup        ),        format!("update action: {}", update_action_label(&install_context)),    ];    let version_file = config.codex_home.join(VERSION_FILE_NAME);    push_cached_version_details(&mut details, &version_file);    let mut status = CheckStatus::Ok;    let mut summary = "update configuration is locally consistent".to_string();    let mut remediation = None;    if doctor_managed_by_npm(current_exe.as_deref()) {        match npm_global_root_check() {            NpmRootCheck::Match { package_root } => {                details.push(format!("npm update target: {}", package_root.display()));            }            NpmRootCheck::Mismatch {                running_package_root,                npm_package_root,            } => {                status = CheckStatus::Fail;                summary = "update would target a different npm install".to_string();                details.push(format!(                    "running package root: {}",                    running_package_root.display()                ));                details.push(format!("npm package root: {}", npm_package_root.display()));                remediation = Some(format!(                    "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).",                    running_package_root.display(),                    npm_package_root.display()                ));            }            NpmRootCheck::MissingPackageRoot => {                status = status.max(CheckStatus::Warning);                summary = "npm update target could not be proven".to_string();                remediation = Some(                    "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT."                        .to_string(),                );            }            NpmRootCheck::NpmUnavailable(error) => {                status = status.max(CheckStatus::Warning);                summary = "npm update target could not be inspected".to_string();                details.push(format!("npm root -g failed: {error}"));            }        }    }    match fetch_latest_version(&install_context) {        Ok(latest_version) => {            details.push(format!("latest version: {latest_version}"));            if is_newer(&latest_version, env!("CARGO_PKG_VERSION")) == Some(true) {                details.push("latest version status: newer version is available".to_string());            } else {                details.push("latest version status: current version is not older".to_string());            }        }        Err(err) => {            status = status.max(CheckStatus::Warning);            details.push(format!("latest version probe: {err}"));        }    }    let mut check = DoctorCheck::new("updates.status", "updates", status, summary).details(details);    if let Some(remediation) = remediation {        check = check.remediation(remediation);    }    check}fn push_cached_version_details(details: &mut Vec<String>, version_file: &Path) {    details.push(format!("version cache: {}", version_file.display()));    match std::fs::read_to_string(version_file) {        Ok(contents) => match serde_json::from_str::<VersionInfo>(&contents) {            Ok(info) => {                details.push(format!("cached latest version: {}", info.latest_version));                if let Some(last_checked_at) = info.last_checked_at {                    details.push(format!("last checked at: {last_checked_at}"));                }                if let Some(dismissed_version) = info.dismissed_version {                    details.push(format!("dismissed version: {dismissed_version}"));                }            }            Err(err) => details.push(format!("version cache parse: {err}")),        },        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {            details.push("version cache: missing".to_string());        }        Err(err) => details.push(format!("version cache read: {err}")),    }}fn update_action_label(context: &InstallContext) -> &'static str {    match &context.method {        InstallMethod::Npm => "npm install -g @openai/codex",        InstallMethod::Bun => "bun install -g @openai/codex",        InstallMethod::Brew => "brew upgrade --cask codex",        InstallMethod::Standalone { .. } => "standalone installer",        InstallMethod::Other => "manual or unknown",    }}fn fetch_latest_version(context: &InstallContext) -> Result<String, String> {    match &context.method {        InstallMethod::Brew => fetch_homebrew_cask_version(),        InstallMethod::Npm        | InstallMethod::Bun        | InstallMethod::Standalone { .. }        | InstallMethod::Other => fetch_latest_github_release_version(),    }}fn fetch_latest_github_release_version() -> Result<String, String> {    #[derive(Deserialize)]    struct ReleaseInfo {        tag_name: String,    }    let info = http_get_json::<ReleaseInfo>(GITHUB_LATEST_RELEASE_URL)?;    info.tag_name        .strip_prefix("rust-v")        .map(str::to_string)        .ok_or_else(|| format!("failed to parse latest tag {}", info.tag_name))}fn fetch_homebrew_cask_version() -> Result<String, String> {    #[derive(Deserialize)]    struct HomebrewCaskInfo {        version: String,    }    http_get_json::<HomebrewCaskInfo>(HOMEBREW_CASK_API_URL).map(|info| info.version)}fn http_get_json<T>(url: &str) -> Result<T, String>where    T: for<'de> Deserialize<'de>,{    let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?;    serde_json::from_str::<T>(&body).map_err(|err| err.to_string())}fn is_newer(latest: &str, current: &str) -> Option<bool> {    match (parse_version(latest), parse_version(current)) {        (Some(latest), Some(current)) => Some(latest > current),        (Some(_), None) | (None, Some(_)) | (None, None) => None,    }}fn parse_version(value: &str) -> Option<(u64, u64, u64)> {    let mut parts = value.trim().split('.');    let major = parts.next()?.parse::<u64>().ok()?;    let minor = parts.next()?.parse::<u64>().ok()?;    let patch = parts.next()?.parse::<u64>().ok()?;    Some((major, minor, patch))}#[derive(Deserialize)]struct VersionInfo {    latest_version: String,    #[serde(default)]    last_checked_at: Option<String>,    #[serde(default)]    dismissed_version: Option<String>,}#[cfg(test)]mod tests {    use super::*;    #[test]    fn is_newer_compares_plain_semver() {        assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true));        assert_eq!(is_newer("1.2.3", "1.2.4"), Some(false));        assert_eq!(is_newer("1.2.3-beta.1", "1.2.2"), None);    }    #[test]    fn update_action_labels_install_contexts() {        assert_eq!(            update_action_label(&InstallContext {                method: InstallMethod::Npm,                package_layout: None,            }),            "npm install -g @openai/codex"        );        assert_eq!(            update_action_label(&InstallContext {                method: InstallMethod::Other,                package_layout: None,            }),            "manual or unknown"        );    }}