Skip to main content

codex_profiles/
updates.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::io::IsTerminal as _;
5use std::io::{self, Write};
6use std::path::PathBuf;
7use std::time::Duration as StdDuration;
8
9use crate::{Paths, lock_usage, read_profiles_index, write_atomic, write_profiles_index};
10use crate::{
11    UPDATE_ERR_PERSIST_DISMISSAL, UPDATE_ERR_READ_CHOICE, UPDATE_ERR_REFRESH_VERSION,
12    UPDATE_ERR_SHOW_PROMPT, UPDATE_NON_TTY_RUN, UPDATE_OPTION_NOW, UPDATE_OPTION_SKIP,
13    UPDATE_OPTION_SKIP_VERSION, UPDATE_PROMPT_SELECT, UPDATE_RELEASE_NOTES, UPDATE_TITLE_AVAILABLE,
14};
15
16// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
17const HOMEBREW_CASK_URL: &str =
18    "https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex-profiles.rb";
19const LATEST_RELEASE_URL: &str =
20    "https://api.github.com/repos/midhunmonachan/codex-profiles/releases/latest";
21const RELEASE_NOTES_URL: &str = "https://github.com/midhunmonachan/codex-profiles/releases/latest";
22const HOMEBREW_CASK_URL_OVERRIDE_ENV_VAR: &str = "CODEX_PROFILES_HOMEBREW_CASK_URL";
23const LATEST_RELEASE_URL_OVERRIDE_ENV_VAR: &str = "CODEX_PROFILES_LATEST_RELEASE_URL";
24
25/// Update action the CLI should perform after the prompt exits.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum UpdateAction {
28    /// Update via `npm install -g codex-profiles`.
29    NpmGlobalLatest,
30    /// Update via `bun install -g codex-profiles`.
31    BunGlobalLatest,
32    /// Update via `brew upgrade codex-profiles`.
33    BrewUpgrade,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum InstallSource {
38    Npm,
39    Bun,
40    Brew,
41    Unknown,
42}
43
44impl UpdateAction {
45    /// Returns the list of command-line arguments for invoking the update.
46    pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
47        match self {
48            UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "codex-profiles"]),
49            UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "codex-profiles"]),
50            UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex-profiles"]),
51        }
52    }
53
54    /// Returns string representation of the command-line arguments for invoking the update.
55    pub fn command_str(self) -> String {
56        let (command, args) = self.command_args();
57        shlex::try_join(std::iter::once(command).chain(args.iter().copied()))
58            .unwrap_or_else(|_| format!("{command} {}", args.join(" ")))
59    }
60}
61
62pub fn detect_install_source() -> InstallSource {
63    let exe = std::env::current_exe().unwrap_or_default();
64    let managed_by_npm = std::env::var_os("CODEX_PROFILES_MANAGED_BY_NPM").is_some();
65    let managed_by_bun = std::env::var_os("CODEX_PROFILES_MANAGED_BY_BUN").is_some();
66    detect_install_source_inner(
67        cfg!(target_os = "macos"),
68        &exe,
69        managed_by_npm,
70        managed_by_bun,
71    )
72}
73
74#[doc(hidden)]
75pub fn detect_install_source_inner(
76    is_macos: bool,
77    current_exe: &std::path::Path,
78    managed_by_npm: bool,
79    managed_by_bun: bool,
80) -> InstallSource {
81    if managed_by_npm {
82        InstallSource::Npm
83    } else if managed_by_bun {
84        InstallSource::Bun
85    } else if is_macos && is_brew_install(current_exe) {
86        InstallSource::Brew
87    } else {
88        InstallSource::Unknown
89    }
90}
91
92fn is_brew_install(current_exe: &std::path::Path) -> bool {
93    (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local"))
94        && current_exe.file_name().and_then(|name| name.to_str()) == Some("codex-profiles")
95}
96
97pub(crate) fn get_update_action() -> Option<UpdateAction> {
98    get_update_action_with_debug(cfg!(debug_assertions), detect_install_source())
99}
100
101fn get_update_action_with_debug(
102    is_debug: bool,
103    install_source: InstallSource,
104) -> Option<UpdateAction> {
105    if is_debug {
106        return None;
107    }
108    match install_source {
109        InstallSource::Npm => Some(UpdateAction::NpmGlobalLatest),
110        InstallSource::Bun => Some(UpdateAction::BunGlobalLatest),
111        InstallSource::Brew => Some(UpdateAction::BrewUpgrade),
112        InstallSource::Unknown => None,
113    }
114}
115
116#[derive(Clone, Debug)]
117pub struct UpdateConfig {
118    pub codex_home: PathBuf,
119    pub check_for_update_on_startup: bool,
120}
121
122#[derive(Deserialize, Debug, Clone)]
123struct ReleaseInfo {
124    tag_name: String,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128struct UpdateCache {
129    #[serde(default)]
130    latest_version: String,
131    #[serde(default = "update_cache_checked_default")]
132    last_checked_at: DateTime<Utc>,
133    #[serde(default)]
134    dismissed_version: Option<String>,
135    #[serde(default)]
136    last_prompted_at: Option<DateTime<Utc>>,
137}
138
139fn update_cache_checked_default() -> DateTime<Utc> {
140    DateTime::<Utc>::from_timestamp(0, 0).unwrap_or_else(Utc::now)
141}
142
143pub enum UpdatePromptOutcome {
144    Continue,
145    RunUpdate(UpdateAction),
146}
147
148pub fn run_update_prompt_if_needed(config: &UpdateConfig) -> Result<UpdatePromptOutcome, String> {
149    let mut input = io::stdin().lock();
150    let mut output = io::stderr();
151    run_update_prompt_if_needed_with_io(
152        config,
153        cfg!(debug_assertions),
154        io::stdin().is_terminal(),
155        &mut input,
156        &mut output,
157    )
158}
159
160fn run_update_prompt_if_needed_with_io(
161    config: &UpdateConfig,
162    is_debug: bool,
163    is_tty: bool,
164    input: &mut impl io::BufRead,
165    output: &mut impl Write,
166) -> Result<UpdatePromptOutcome, String> {
167    run_update_prompt_if_needed_with_io_and_source(
168        config,
169        is_debug,
170        is_tty,
171        detect_install_source(),
172        input,
173        output,
174    )
175}
176
177fn run_update_prompt_if_needed_with_io_and_source(
178    config: &UpdateConfig,
179    is_debug: bool,
180    is_tty: bool,
181    install_source: InstallSource,
182    input: &mut impl io::BufRead,
183    output: &mut impl Write,
184) -> Result<UpdatePromptOutcome, String> {
185    if is_debug {
186        return Ok(UpdatePromptOutcome::Continue);
187    }
188
189    let Some(latest_version) = get_upgrade_version_for_popup_with_debug(config, is_debug) else {
190        return Ok(UpdatePromptOutcome::Continue);
191    };
192    let Some(update_action) = get_update_action_with_debug(false, install_source) else {
193        return Ok(UpdatePromptOutcome::Continue);
194    };
195
196    let current_version = current_version();
197    if !is_tty {
198        write_prompt(
199            output,
200            format_args!(
201                "{} {current_version} -> {latest_version}\n",
202                UPDATE_TITLE_AVAILABLE
203            ),
204        )?;
205        write_prompt(
206            output,
207            format_args!(
208                "{}",
209                crate::msg1(UPDATE_NON_TTY_RUN, update_action.command_str())
210            ),
211        )?;
212        return Ok(UpdatePromptOutcome::Continue);
213    }
214
215    write_prompt(
216        output,
217        format_args!(
218            "\n✨ {} {current_version} -> {latest_version}\n",
219            UPDATE_TITLE_AVAILABLE
220        ),
221    )?;
222    write_prompt(
223        output,
224        format_args!("{}", crate::msg1(UPDATE_RELEASE_NOTES, RELEASE_NOTES_URL)),
225    )?;
226    write_prompt(output, format_args!("\n"))?;
227    write_prompt(
228        output,
229        format_args!(
230            "{}",
231            crate::msg1(UPDATE_OPTION_NOW, update_action.command_str())
232        ),
233    )?;
234    write_prompt(output, format_args!("{}", UPDATE_OPTION_SKIP))?;
235    write_prompt(output, format_args!("{}", UPDATE_OPTION_SKIP_VERSION))?;
236    write_prompt(output, format_args!("{}", UPDATE_PROMPT_SELECT))?;
237    output.flush().map_err(prompt_io_error)?;
238
239    let mut selection = String::new();
240    input
241        .read_line(&mut selection)
242        .map_err(|err| crate::msg1(UPDATE_ERR_READ_CHOICE, err))?;
243
244    match selection.trim() {
245        "1" => Ok(UpdatePromptOutcome::RunUpdate(update_action)),
246        "3" => {
247            if let Err(err) = dismiss_version(config, &latest_version) {
248                write_prompt(
249                    output,
250                    format_args!("{}", crate::msg1(UPDATE_ERR_PERSIST_DISMISSAL, err)),
251                )?;
252            }
253            Ok(UpdatePromptOutcome::Continue)
254        }
255        _ => Ok(UpdatePromptOutcome::Continue),
256    }
257}
258
259fn prompt_io_error(err: impl std::fmt::Display) -> String {
260    crate::msg1(UPDATE_ERR_SHOW_PROMPT, err)
261}
262
263fn write_prompt(output: &mut impl Write, args: std::fmt::Arguments) -> Result<(), String> {
264    output.write_fmt(args).map_err(prompt_io_error)
265}
266
267fn current_version() -> &'static str {
268    env!("CARGO_PKG_VERSION")
269}
270
271fn build_update_cache(
272    latest_version: Option<String>,
273    dismissed_version: Option<String>,
274    last_prompted_at: Option<DateTime<Utc>>,
275) -> UpdateCache {
276    UpdateCache {
277        latest_version: latest_version.unwrap_or_else(|| current_version().to_string()),
278        last_checked_at: Utc::now(),
279        dismissed_version,
280        last_prompted_at,
281    }
282}
283
284fn get_upgrade_version_with_debug(config: &UpdateConfig, is_debug: bool) -> Option<String> {
285    if updates_disabled_with_debug(config, is_debug) {
286        return None;
287    }
288    let paths = update_paths(config);
289    let mut info = read_update_cache(&paths).ok().flatten();
290
291    let should_check = match &info {
292        None => true,
293        Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20),
294    };
295    if should_check {
296        if info.is_none() {
297            if let Err(err) = check_for_update(&paths) {
298                eprintln!("{}", crate::msg1(UPDATE_ERR_REFRESH_VERSION, err));
299            }
300            info = read_update_cache(&paths).ok().flatten();
301        } else {
302            let codex_home = config.codex_home.clone();
303            std::thread::spawn(move || {
304                let paths = paths_for_update(codex_home);
305                if let Err(err) = check_for_update(&paths) {
306                    eprintln!("{}", crate::msg1(UPDATE_ERR_REFRESH_VERSION, err));
307                }
308            });
309        }
310    }
311
312    info.and_then(|info| {
313        if is_newer(&info.latest_version, current_version()).unwrap_or(false) {
314            Some(info.latest_version)
315        } else {
316            None
317        }
318    })
319}
320
321fn check_for_update(paths: &Paths) -> anyhow::Result<()> {
322    check_for_update_with_action(paths, get_update_action())
323}
324
325fn check_for_update_with_action(
326    paths: &Paths,
327    update_action: Option<UpdateAction>,
328) -> anyhow::Result<()> {
329    let latest_version = match update_action {
330        Some(UpdateAction::BrewUpgrade) => {
331            fetch_version_from_cask().or_else(fetch_version_from_release)
332        }
333        _ => fetch_version_from_release(),
334    };
335
336    // Preserve any previously dismissed version if present.
337    let prev_info = read_update_cache(paths).ok().flatten();
338    let prev_dismissed = prev_info
339        .as_ref()
340        .and_then(|info| info.dismissed_version.clone());
341    let prev_prompted = prev_info.as_ref().and_then(|info| info.last_prompted_at);
342    let info = build_update_cache(latest_version, prev_dismissed, prev_prompted);
343    write_update_cache(paths, &info)
344}
345
346#[doc(hidden)]
347pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
348    match (parse_version(latest), parse_version(current)) {
349        (Some(l), Some(c)) => Some(l > c),
350        _ => None,
351    }
352}
353
354#[doc(hidden)]
355pub fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
356    cask_contents
357        .lines()
358        .find_map(|line| {
359            let line = line.trim();
360            line.strip_prefix("version \"")
361                .and_then(|rest| rest.strip_suffix('"'))
362                .map(ToString::to_string)
363        })
364        .ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
365}
366
367#[doc(hidden)]
368pub fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
369    for prefix in ["v", "rust-v"] {
370        if let Some(version) = latest_tag_name.strip_prefix(prefix) {
371            return Ok(version.to_string());
372        }
373    }
374    Err(anyhow::anyhow!(
375        "Failed to parse latest tag name '{latest_tag_name}'"
376    ))
377}
378
379fn fetch_version_from_cask() -> Option<String> {
380    let response = update_agent()
381        .get(&homebrew_cask_url())
382        .header("User-Agent", "codex-profiles")
383        .call();
384    match response {
385        Ok(mut resp) => {
386            let contents = resp.body_mut().read_to_string().ok()?;
387            extract_version_from_cask(&contents).ok()
388        }
389        Err(ureq::Error::StatusCode(404)) => None,
390        Err(_) => None,
391    }
392}
393
394fn fetch_version_from_release() -> Option<String> {
395    let response = update_agent()
396        .get(&latest_release_url())
397        .header("User-Agent", "codex-profiles")
398        .call();
399    match response {
400        Ok(mut resp) => {
401            let ReleaseInfo {
402                tag_name: latest_tag_name,
403            } = resp.body_mut().read_json().ok()?;
404            extract_version_from_latest_tag(&latest_tag_name).ok()
405        }
406        Err(ureq::Error::StatusCode(404)) => None,
407        Err(_) => None,
408    }
409}
410
411fn get_upgrade_version_for_popup_with_debug(
412    config: &UpdateConfig,
413    is_debug: bool,
414) -> Option<String> {
415    if updates_disabled_with_debug(config, is_debug) {
416        return None;
417    }
418
419    let paths = update_paths(config);
420    let latest = get_upgrade_version_with_debug(config, is_debug)?;
421    let info = read_update_cache(&paths).ok().flatten();
422    if info
423        .as_ref()
424        .and_then(|info| info.last_prompted_at)
425        .is_some_and(|last| last > Utc::now() - Duration::hours(24))
426    {
427        return None;
428    }
429    // If the user dismissed this exact version previously, do not show the popup.
430    if info
431        .as_ref()
432        .and_then(|info| info.dismissed_version.as_deref())
433        == Some(latest.as_str())
434    {
435        return None;
436    }
437    if let Some(mut info) = info {
438        info.last_prompted_at = Some(Utc::now());
439        let _ = write_update_cache(&paths, &info);
440    }
441    Some(latest)
442}
443
444/// Persist a dismissal for the current latest version so we don't show
445/// the update popup again for this version.
446pub fn dismiss_version(config: &UpdateConfig, version: &str) -> anyhow::Result<()> {
447    if updates_disabled(config) {
448        return Ok(());
449    }
450    let paths = update_paths(config);
451    let mut info = match read_update_cache(&paths) {
452        Ok(Some(info)) => info,
453        _ => return Ok(()),
454    };
455    info.dismissed_version = Some(version.to_string());
456    info.last_prompted_at = Some(Utc::now());
457    write_update_cache(&paths, &info)
458}
459
460fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
461    let mut iter = v.trim().split('.');
462    let maj = iter.next()?.parse::<u64>().ok()?;
463    let min = iter.next()?.parse::<u64>().ok()?;
464    let pat = iter.next()?.parse::<u64>().ok()?;
465    Some((maj, min, pat))
466}
467
468fn updates_disabled(config: &UpdateConfig) -> bool {
469    updates_disabled_with_debug(config, cfg!(debug_assertions))
470}
471
472fn updates_disabled_with_debug(config: &UpdateConfig, is_debug: bool) -> bool {
473    is_debug || !config.check_for_update_on_startup
474}
475
476fn paths_for_update(codex_home: PathBuf) -> Paths {
477    let profiles = codex_home.join("profiles");
478    Paths {
479        auth: codex_home.join("auth.json"),
480        profiles_index: profiles.join("profiles.json"),
481        update_cache: profiles.join("update.json"),
482        profiles_lock: profiles.join("profiles.lock"),
483        codex: codex_home,
484        profiles,
485    }
486}
487
488fn update_paths(config: &UpdateConfig) -> Paths {
489    paths_for_update(config.codex_home.clone())
490}
491
492fn read_update_cache(paths: &Paths) -> anyhow::Result<Option<UpdateCache>> {
493    if !paths.update_cache.is_file() {
494        if let Some(legacy) = read_legacy_update_cache(paths)? {
495            let _ = write_update_cache(paths, &legacy);
496            return Ok(Some(legacy));
497        }
498        return Ok(None);
499    }
500    let contents = fs::read_to_string(&paths.update_cache)?;
501    if contents.trim().is_empty() {
502        return Ok(None);
503    }
504    let cache = serde_json::from_str::<UpdateCache>(&contents)?;
505    Ok(Some(cache))
506}
507
508fn write_update_cache(paths: &Paths, cache: &UpdateCache) -> anyhow::Result<()> {
509    let _lock = lock_usage(paths).map_err(|err| anyhow::anyhow!(err))?;
510    let contents = serde_json::to_string_pretty(cache)?;
511    write_atomic(&paths.update_cache, format!("{contents}\n").as_bytes())
512        .map_err(|err| anyhow::anyhow!(err))
513}
514
515fn read_legacy_update_cache(paths: &Paths) -> anyhow::Result<Option<UpdateCache>> {
516    if !paths.profiles_index.is_file() {
517        return Ok(None);
518    }
519    let contents = fs::read_to_string(&paths.profiles_index)?;
520    let json: serde_json::Value = serde_json::from_str(&contents)?;
521    let Some(value) = json.get("update_cache") else {
522        return Ok(None);
523    };
524    let cache = serde_json::from_value::<UpdateCache>(value.clone())?;
525    if let Ok(index) = read_profiles_index(paths) {
526        let _ = write_profiles_index(paths, &index);
527    }
528    Ok(Some(cache))
529}
530
531fn update_agent() -> ureq::Agent {
532    let config = ureq::Agent::config_builder()
533        .timeout_global(Some(StdDuration::from_secs(5)))
534        .build();
535    config.into()
536}
537
538fn latest_release_url() -> String {
539    std::env::var(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR)
540        .unwrap_or_else(|_| LATEST_RELEASE_URL.to_string())
541}
542
543fn homebrew_cask_url() -> String {
544    std::env::var(HOMEBREW_CASK_URL_OVERRIDE_ENV_VAR)
545        .unwrap_or_else(|_| HOMEBREW_CASK_URL.to_string())
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::test_utils::{ENV_MUTEX, http_ok_response, set_env_guard, spawn_server};
552    use std::fs;
553    use std::path::PathBuf;
554
555    fn seed_version_info(config: &UpdateConfig, version: &str) {
556        let paths = update_paths(config);
557        fs::create_dir_all(&paths.profiles).unwrap();
558        fs::write(&paths.profiles_lock, "").unwrap();
559        let info = UpdateCache {
560            latest_version: version.to_string(),
561            last_checked_at: Utc::now(),
562            dismissed_version: None,
563            last_prompted_at: None,
564        };
565        write_update_cache(&paths, &info).unwrap();
566    }
567
568    #[test]
569    fn update_action_commands() {
570        let (cmd, args) = UpdateAction::NpmGlobalLatest.command_args();
571        assert_eq!(cmd, "npm");
572        assert!(args.contains(&"install"));
573        assert!(UpdateAction::BunGlobalLatest.command_str().contains("bun"));
574    }
575
576    #[test]
577    fn detect_install_source_inner_variants() {
578        let exe = PathBuf::from("/usr/local/bin/codex-profiles");
579        assert_eq!(
580            detect_install_source_inner(true, &exe, false, false),
581            InstallSource::Brew
582        );
583        assert_eq!(
584            detect_install_source_inner(false, &exe, true, false),
585            InstallSource::Npm
586        );
587        assert_eq!(
588            detect_install_source_inner(false, &exe, false, true),
589            InstallSource::Bun
590        );
591    }
592
593    #[test]
594    fn get_update_action_debug() {
595        assert!(get_update_action_with_debug(true, InstallSource::Npm).is_none());
596        assert!(get_update_action_with_debug(false, InstallSource::Npm).is_some());
597    }
598
599    #[test]
600    fn extract_version_helpers() {
601        assert_eq!(extract_version_from_latest_tag("v1.2.3").unwrap(), "1.2.3");
602        assert_eq!(
603            extract_version_from_latest_tag("rust-v2.0.0").unwrap(),
604            "2.0.0"
605        );
606        assert!(extract_version_from_latest_tag("bad").is_err());
607        let cask = "version \"1.2.3\"";
608        assert_eq!(extract_version_from_cask(cask).unwrap(), "1.2.3");
609        assert!(extract_version_from_cask("nope").is_err());
610    }
611
612    #[test]
613    fn parse_version_and_compare() {
614        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
615        assert!(is_newer("2.0.0", "1.9.9").unwrap());
616        assert!(is_newer("bad", "1.0.0").is_none());
617    }
618
619    #[test]
620    fn url_overrides_work() {
621        let _guard = ENV_MUTEX.lock().unwrap();
622        let _env = set_env_guard(
623            LATEST_RELEASE_URL_OVERRIDE_ENV_VAR,
624            Some("http://example.com"),
625        );
626        assert_eq!(latest_release_url(), "http://example.com");
627    }
628
629    #[test]
630    fn fetch_versions_from_servers() {
631        let _guard = ENV_MUTEX.lock().unwrap();
632        let release_body = "{\"tag_name\":\"v9.9.9\"}";
633        let release_resp = http_ok_response(release_body, "application/json");
634        let release_url = spawn_server(release_resp);
635        {
636            let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&release_url));
637            assert_eq!(fetch_version_from_release().unwrap(), "9.9.9");
638        }
639
640        let cask_body = "version \"9.9.9\"";
641        let cask_resp = http_ok_response(cask_body, "text/plain");
642        let cask_url = spawn_server(cask_resp);
643        {
644            let _env = set_env_guard(HOMEBREW_CASK_URL_OVERRIDE_ENV_VAR, Some(&cask_url));
645            assert_eq!(fetch_version_from_cask().unwrap(), "9.9.9");
646        }
647    }
648
649    #[test]
650    fn fetch_versions_handle_404() {
651        let _guard = ENV_MUTEX.lock().unwrap();
652        let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_string();
653        let url = spawn_server(resp);
654        let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&url));
655        assert!(fetch_version_from_release().is_none());
656    }
657
658    #[test]
659    fn check_for_update_writes_version() {
660        let _guard = ENV_MUTEX.lock().unwrap();
661        let release_body = "{\"tag_name\":\"v9.9.9\"}";
662        let release_resp = http_ok_response(release_body, "application/json");
663        let release_url = spawn_server(release_resp);
664        let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&release_url));
665
666        let dir = tempfile::tempdir().expect("tempdir");
667        let paths = paths_for_update(dir.path().to_path_buf());
668        fs::create_dir_all(&paths.profiles).unwrap();
669        fs::write(&paths.profiles_lock, "").unwrap();
670        check_for_update_with_action(&paths, None).unwrap();
671        let contents = fs::read_to_string(&paths.update_cache).unwrap();
672        assert!(contents.contains("9.9.9"));
673    }
674
675    #[test]
676    fn read_update_cache_migrates_legacy_profiles_schema() {
677        let dir = tempfile::tempdir().expect("tempdir");
678        let paths = paths_for_update(dir.path().to_path_buf());
679        fs::create_dir_all(&paths.profiles).unwrap();
680        fs::write(&paths.profiles_lock, "").unwrap();
681        let legacy = serde_json::json!({
682            "version": 1,
683            "profiles": {},
684            "update_cache": {
685                "latest_version": "1.2.3",
686                "last_checked_at": "2024-01-01T00:00:00Z"
687            }
688        });
689        fs::write(
690            &paths.profiles_index,
691            serde_json::to_string_pretty(&legacy).unwrap(),
692        )
693        .unwrap();
694
695        let migrated = read_update_cache(&paths).unwrap().unwrap();
696        assert_eq!(migrated.latest_version, "1.2.3");
697        assert!(paths.update_cache.is_file());
698        let index_contents = fs::read_to_string(&paths.profiles_index).unwrap();
699        assert!(!index_contents.contains("update_cache"));
700    }
701
702    #[test]
703    fn updates_disabled_variants() {
704        let config = UpdateConfig {
705            codex_home: PathBuf::new(),
706            check_for_update_on_startup: false,
707        };
708        assert!(updates_disabled_with_debug(&config, false));
709        let config = UpdateConfig {
710            codex_home: PathBuf::new(),
711            check_for_update_on_startup: true,
712        };
713        assert!(updates_disabled_with_debug(&config, true));
714    }
715
716    #[test]
717    fn run_update_prompt_paths() {
718        let _guard = ENV_MUTEX.lock().unwrap();
719        let release_body = format!("{{\"tag_name\":\"v{}\"}}", "99.0.0");
720        let release_resp = http_ok_response(&release_body, "application/json");
721        let release_url = spawn_server(release_resp);
722        let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&release_url));
723
724        let dir = tempfile::tempdir().expect("tempdir");
725        let config = UpdateConfig {
726            codex_home: dir.path().to_path_buf(),
727            check_for_update_on_startup: true,
728        };
729        seed_version_info(&config, "99.0.0");
730        let mut input = std::io::Cursor::new("2\n");
731        let mut output = Vec::new();
732        let result = run_update_prompt_if_needed_with_io_and_source(
733            &config,
734            false,
735            false,
736            InstallSource::Npm,
737            &mut input,
738            &mut output,
739        )
740        .unwrap();
741        assert!(matches!(result, UpdatePromptOutcome::Continue));
742
743        let dir = tempfile::tempdir().expect("tempdir");
744        let config = UpdateConfig {
745            codex_home: dir.path().to_path_buf(),
746            check_for_update_on_startup: true,
747        };
748        seed_version_info(&config, "99.0.0");
749        let mut input = std::io::Cursor::new("1\n");
750        let mut output = Vec::new();
751        let result = run_update_prompt_if_needed_with_io_and_source(
752            &config,
753            false,
754            true,
755            InstallSource::Npm,
756            &mut input,
757            &mut output,
758        )
759        .unwrap();
760        assert!(matches!(result, UpdatePromptOutcome::RunUpdate(_)));
761
762        let dir = tempfile::tempdir().expect("tempdir");
763        let config = UpdateConfig {
764            codex_home: dir.path().to_path_buf(),
765            check_for_update_on_startup: true,
766        };
767        seed_version_info(&config, "99.0.0");
768        let mut input = std::io::Cursor::new("3\n");
769        let mut output = Vec::new();
770        let result = run_update_prompt_if_needed_with_io_and_source(
771            &config,
772            false,
773            true,
774            InstallSource::Npm,
775            &mut input,
776            &mut output,
777        )
778        .unwrap();
779        assert!(matches!(result, UpdatePromptOutcome::Continue));
780    }
781}