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