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
10const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum UpdateAction {
23 NpmGlobalLatest,
25 BunGlobalLatest,
27 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 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 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 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
385pub 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 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
424pub 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}