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
16const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum UpdateAction {
28 NpmGlobalLatest,
30 BunGlobalLatest,
32 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 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 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 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 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
444pub 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}