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";
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum UpdateAction {
30 NpmGlobalLatest,
32 BunGlobalLatest,
34 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 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 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 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 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 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
455pub 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}