Skip to main content

codex_profiles/
updates.rs

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