Skip to main content

cli/update_check/
mod.rs

1use crate::commands::{AppCommands, Commands, ShellCommands, SysCommands, TaskCommands};
2use crate::{config::Config, version};
3use anyhow::{Context, Result, anyhow, bail};
4use clap::ValueEnum;
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9use tokio::fs;
10
11mod github;
12mod upgrade;
13
14use github::{current_auth_mode, fetch_latest_release_for_version_check};
15pub use upgrade::upgrade_to_release;
16
17const GITHUB_LATEST_RELEASE_URL: &str =
18    "https://api.github.com/repos/biulight/shine/releases/latest";
19const GITHUB_PREVIEW_RELEASE_URL: &str =
20    "https://api.github.com/repos/biulight/shine/releases/tags/preview";
21const UPDATE_CACHE_FILE: &str = "update-check.json";
22const UPDATE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
23
24#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
25pub enum ReleaseChannel {
26    Stable,
27    Preview,
28}
29
30impl ReleaseChannel {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Stable => "stable",
34            Self::Preview => "preview",
35        }
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum UpdateStatus {
41    UpToDate,
42    UpdateAvailable { latest: Version },
43    UpdateRequired { latest: Version },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum UpgradeResult {
48    AlreadyUpToDate {
49        channel: ReleaseChannel,
50        latest: String,
51    },
52    Upgraded {
53        channel: ReleaseChannel,
54        previous: Version,
55        previous_display: String,
56        release_tag: String,
57        installed_version: String,
58        installed_path: PathBuf,
59    },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63struct UpdateCache {
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    latest_version: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    checked_at_unix_secs: Option<u64>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    rate_limited_until_unix_secs: Option<u64>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    rate_limited_auth_mode: Option<AuthMode>,
72}
73
74#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76enum AuthMode {
77    Anonymous,
78    Token,
79}
80
81/// Always fetches from GitHub, ignoring the 24-hour cache.
82pub async fn check_for_update_forced(config: &Config) -> Result<UpdateStatus> {
83    let current = Version::parse(env!("CARGO_PKG_VERSION"))
84        .context("current package version must be valid semver")?;
85    let now_secs = unix_timestamp_now()?;
86    let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
87
88    guard_rate_limit_cooldown(&cache_path, now_secs, current_auth_mode()).await?;
89    let release = fetch_latest_release_for_version_check(&cache_path, now_secs).await?;
90    let latest = parse_release_tag(&release.tag_name)?;
91    store_cache_if_possible(&cache_path, &latest, now_secs).await;
92
93    Ok(compare_versions(&current, &latest))
94}
95
96pub async fn check_for_update(config: &Config) -> Result<UpdateStatus> {
97    let current = Version::parse(env!("CARGO_PKG_VERSION"))
98        .context("current package version must be valid semver")?;
99    let now_secs = unix_timestamp_now()?;
100    let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
101
102    let latest = match load_cached_version_if_fresh(&cache_path, now_secs).await? {
103        Some(version) => version,
104        None => {
105            guard_rate_limit_cooldown(&cache_path, now_secs, current_auth_mode()).await?;
106            let release = fetch_latest_release_for_version_check(&cache_path, now_secs).await?;
107            let fetched = parse_release_tag(&release.tag_name)?;
108            store_cache_if_possible(&cache_path, &fetched, now_secs).await;
109            fetched
110        }
111    };
112
113    Ok(compare_versions(&current, &latest))
114}
115
116/// Runs the background version check for `command`, unless `command` is one
117/// that already does its own forced fetch (`shine update`, `shine self
118/// upgrade`) or otherwise shouldn't be gated on it (`shine self install`
119/// should stay available even when the current binary is version-gated).
120///
121/// A check failure must never fail the user's command: network errors,
122/// GitHub API errors, and the like are swallowed silently here, same as
123/// before this was extracted from `main.rs`.
124pub async fn maybe_notify(config: &Config, command: &Commands) -> Result<()> {
125    // Skip the background version check for update/self commands. `shine update`
126    // and `shine self upgrade` do their own forced fetch below; `shine self install`
127    // should remain available even when the current binary is version-gated.
128    if !skip_background_update_check(command) {
129        match check_for_update(config).await {
130            Ok(UpdateStatus::UpToDate) => {}
131            Ok(UpdateStatus::UpdateAvailable { latest }) => {
132                eprintln!(
133                    "A newer version of shine is available: {} -> {}. Run `shine self upgrade` when convenient.",
134                    version::semver(),
135                    latest
136                );
137            }
138            Ok(UpdateStatus::UpdateRequired { latest }) => {
139                bail!(
140                    "A newer patch release of shine is required: {} -> {}. Run `shine self upgrade` before continuing.",
141                    version::semver(),
142                    latest
143                );
144            }
145            Err(_) => {}
146        }
147    }
148    Ok(())
149}
150
151fn skip_background_update_check(command: &Commands) -> bool {
152    matches!(
153        command,
154        Commands::Update(..)
155            | Commands::Preset { .. }
156            | Commands::State { .. }
157            | Commands::Self_ { .. }
158            | Commands::Serve { .. }
159            | Commands::Env { .. }
160            | Commands::Run(..)
161    ) || matches!(command, Commands::Upgrade(cmd) if cmd.pull)
162        || matches!(
163            command,
164            Commands::App {
165                command: AppCommands::Recover { .. }
166            }
167        )
168        || matches!(
169            command,
170            Commands::Shell {
171                command: ShellCommands::Recover { .. }
172            }
173        )
174        || matches!(
175            command,
176            Commands::Sys {
177                command: SysCommands::Recover { .. }
178            }
179        )
180        || matches!(
181            command,
182            Commands::Task {
183                command: TaskCommands::Run(..)
184            }
185        )
186}
187
188fn compare_versions(current: &Version, latest: &Version) -> UpdateStatus {
189    if latest <= current {
190        return UpdateStatus::UpToDate;
191    }
192
193    if current.major == latest.major && current.minor == latest.minor {
194        return UpdateStatus::UpdateRequired {
195            latest: latest.clone(),
196        };
197    }
198
199    UpdateStatus::UpdateAvailable {
200        latest: latest.clone(),
201    }
202}
203
204fn parse_release_tag(tag_name: &str) -> Result<Version> {
205    let normalized = tag_name.trim().trim_start_matches('v');
206    let version = Version::parse(normalized)
207        .with_context(|| format!("invalid release tag version: {tag_name}"))?;
208
209    if !version.pre.is_empty() {
210        return Err(anyhow!(
211            "pre-release tags are not eligible for update checks"
212        ));
213    }
214
215    Ok(version)
216}
217
218async fn load_cached_version_if_fresh(cache_path: &Path, now_secs: u64) -> Result<Option<Version>> {
219    let cache = match fs::read_to_string(cache_path).await {
220        Ok(content) => serde_json::from_str::<UpdateCache>(&content).ok(),
221        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
222        Err(err) => return Err(err).context("failed to read update cache"),
223    };
224
225    let Some(cache) = cache else {
226        return Ok(None);
227    };
228
229    let Some(checked_at_unix_secs) = cache.checked_at_unix_secs else {
230        return Ok(None);
231    };
232    let Some(latest_version) = cache.latest_version else {
233        return Ok(None);
234    };
235
236    if checked_at_unix_secs > now_secs {
237        return Ok(None);
238    }
239
240    if now_secs - checked_at_unix_secs >= UPDATE_CACHE_TTL.as_secs() {
241        return Ok(None);
242    }
243
244    Ok(parse_release_tag(&latest_version).ok())
245}
246
247async fn store_cache(cache_path: &Path, latest: &Version, checked_at_unix_secs: u64) -> Result<()> {
248    let cache = UpdateCache {
249        latest_version: Some(latest.to_string()),
250        checked_at_unix_secs: Some(checked_at_unix_secs),
251        rate_limited_until_unix_secs: None,
252        rate_limited_auth_mode: None,
253    };
254    write_cache(cache_path, &cache).await
255}
256
257async fn store_rate_limit_cache(
258    cache_path: &Path,
259    rate_limited_until_unix_secs: u64,
260    auth_mode: AuthMode,
261) -> Result<()> {
262    let mut cache = load_cache(cache_path).await?.unwrap_or(UpdateCache {
263        latest_version: None,
264        checked_at_unix_secs: None,
265        rate_limited_until_unix_secs: None,
266        rate_limited_auth_mode: None,
267    });
268    cache.rate_limited_until_unix_secs = Some(rate_limited_until_unix_secs);
269    cache.rate_limited_auth_mode = Some(auth_mode);
270    write_cache(cache_path, &cache).await
271}
272
273async fn write_cache(cache_path: &Path, cache: &UpdateCache) -> Result<()> {
274    if let Some(parent) = cache_path.parent() {
275        fs::create_dir_all(parent)
276            .await
277            .with_context(|| format!("failed to create update cache dir {}", parent.display()))?;
278    }
279
280    let encoded = serde_json::to_vec_pretty(&cache).context("failed to serialize update cache")?;
281    fs::write(cache_path, encoded)
282        .await
283        .context("failed to write update cache")?;
284    Ok(())
285}
286
287async fn store_cache_if_possible(cache_path: &Path, latest: &Version, checked_at_unix_secs: u64) {
288    if let Err(e) = store_cache(cache_path, latest, checked_at_unix_secs).await {
289        eprintln!("warning: failed to write update cache: {e:#}");
290    }
291}
292
293async fn store_rate_limit_cache_if_possible(
294    cache_path: &Path,
295    rate_limited_until_unix_secs: u64,
296    auth_mode: AuthMode,
297) {
298    if let Err(e) =
299        store_rate_limit_cache(cache_path, rate_limited_until_unix_secs, auth_mode).await
300    {
301        eprintln!("warning: failed to write update rate-limit cache: {e:#}");
302    }
303}
304
305async fn load_cache(cache_path: &Path) -> Result<Option<UpdateCache>> {
306    match fs::read_to_string(cache_path).await {
307        Ok(content) => Ok(serde_json::from_str::<UpdateCache>(&content).ok()),
308        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
309        Err(err) => Err(err).context("failed to read update cache"),
310    }
311}
312
313async fn guard_rate_limit_cooldown(
314    cache_path: &Path,
315    now_secs: u64,
316    auth_mode: AuthMode,
317) -> Result<()> {
318    let Some(cache) = load_cache(cache_path).await? else {
319        return Ok(());
320    };
321    let Some(rate_limited_until) = cache.rate_limited_until_unix_secs else {
322        return Ok(());
323    };
324    let Some(rate_limited_auth_mode) = cache.rate_limited_auth_mode else {
325        return Ok(());
326    };
327
328    if rate_limited_auth_mode == auth_mode && rate_limited_until > now_secs {
329        bail!(
330            "GitHub version check skipped until Unix timestamp {rate_limited_until} due to rate limiting"
331        );
332    }
333
334    Ok(())
335}
336
337/// Removes the on-disk update cache so the next command performs a fresh fetch
338/// rather than reading a stale "update required" entry left behind by a failed upgrade.
339pub async fn invalidate_update_cache(config: &Config) {
340    let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
341    if let Err(e) = fs::remove_file(&cache_path).await
342        && e.kind() != std::io::ErrorKind::NotFound
343    {
344        eprintln!("warning: failed to remove update cache: {e:#}");
345    }
346}
347
348fn unix_timestamp_now() -> Result<u64> {
349    Ok(SystemTime::now()
350        .duration_since(UNIX_EPOCH)
351        .context("system clock is before unix epoch")?
352        .as_secs())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::path::PathBuf;
359
360    async fn make_temp_dir() -> PathBuf {
361        crate::test_support::make_temp_dir("shine-update-check").await
362    }
363
364    #[test]
365    fn explicit_app_recovery_skips_background_update_gate() {
366        let command = Commands::App {
367            command: AppCommands::Recover { yes: false },
368        };
369        assert!(skip_background_update_check(&command));
370    }
371
372    #[test]
373    fn explicit_shell_recovery_skips_background_update_gate() {
374        let command = Commands::Shell {
375            command: ShellCommands::Recover { yes: false },
376        };
377        assert!(skip_background_update_check(&command));
378    }
379
380    #[test]
381    fn explicit_sys_recovery_skips_background_update_gate() {
382        let command = Commands::Sys {
383            command: SysCommands::Recover { yes: false },
384        };
385        assert!(skip_background_update_check(&command));
386    }
387
388    #[test]
389    fn compare_versions_is_up_to_date_when_latest_is_not_newer() {
390        let current = Version::parse("0.2.0").unwrap();
391        let latest = Version::parse("0.2.0").unwrap();
392
393        assert_eq!(compare_versions(&current, &latest), UpdateStatus::UpToDate);
394    }
395
396    #[test]
397    fn compare_versions_requires_update_for_newer_patch_release() {
398        let current = Version::parse("0.2.0").unwrap();
399        let latest = Version::parse("0.2.1").unwrap();
400
401        assert_eq!(
402            compare_versions(&current, &latest),
403            UpdateStatus::UpdateRequired { latest }
404        );
405    }
406
407    #[test]
408    fn compare_versions_warns_for_newer_minor_release() {
409        let current = Version::parse("0.2.0").unwrap();
410        let latest = Version::parse("0.3.0").unwrap();
411
412        assert_eq!(
413            compare_versions(&current, &latest),
414            UpdateStatus::UpdateAvailable { latest }
415        );
416    }
417
418    #[test]
419    fn parse_release_tag_accepts_v_prefix() {
420        let version = parse_release_tag("v1.2.3").unwrap();
421        assert_eq!(version, Version::parse("1.2.3").unwrap());
422    }
423
424    #[test]
425    fn parse_release_tag_rejects_prerelease_versions() {
426        assert!(parse_release_tag("v1.2.3-beta.1").is_err());
427    }
428
429    #[tokio::test]
430    async fn load_cached_version_returns_none_when_cache_missing() {
431        let dir = make_temp_dir().await;
432        let cache_path = dir.join(UPDATE_CACHE_FILE);
433
434        let cached = load_cached_version_if_fresh(&cache_path, UPDATE_CACHE_TTL.as_secs())
435            .await
436            .unwrap();
437        assert_eq!(cached, None);
438
439        fs::remove_dir_all(dir).await.unwrap();
440    }
441
442    #[tokio::test]
443    async fn load_cached_version_uses_fresh_cache() {
444        let dir = make_temp_dir().await;
445        let cache_path = dir.join(UPDATE_CACHE_FILE);
446        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
447            .await
448            .unwrap();
449
450        let cached =
451            load_cached_version_if_fresh(&cache_path, 1_000 + UPDATE_CACHE_TTL.as_secs() - 1)
452                .await
453                .unwrap();
454        assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
455
456        fs::remove_dir_all(dir).await.unwrap();
457    }
458
459    #[tokio::test]
460    async fn load_cached_version_supports_legacy_cache_shape() {
461        let dir = make_temp_dir().await;
462        let cache_path = dir.join(UPDATE_CACHE_FILE);
463        fs::write(
464            &cache_path,
465            br#"{"latest_version":"0.2.3","checked_at_unix_secs":1000}"#,
466        )
467        .await
468        .unwrap();
469
470        let cached = load_cached_version_if_fresh(&cache_path, 1_001)
471            .await
472            .unwrap();
473        assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
474
475        fs::remove_dir_all(dir).await.unwrap();
476    }
477
478    #[tokio::test]
479    async fn load_cached_version_ignores_stale_cache() {
480        let dir = make_temp_dir().await;
481        let cache_path = dir.join(UPDATE_CACHE_FILE);
482        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
483            .await
484            .unwrap();
485
486        let cached = load_cached_version_if_fresh(&cache_path, 1_000 + UPDATE_CACHE_TTL.as_secs())
487            .await
488            .unwrap();
489        assert_eq!(cached, None);
490
491        fs::remove_dir_all(dir).await.unwrap();
492    }
493
494    #[tokio::test]
495    async fn load_cached_version_ignores_invalid_cache_contents() {
496        let dir = make_temp_dir().await;
497        let cache_path = dir.join(UPDATE_CACHE_FILE);
498        fs::write(&cache_path, b"{not valid json").await.unwrap();
499
500        let cached = load_cached_version_if_fresh(&cache_path, UPDATE_CACHE_TTL.as_secs())
501            .await
502            .unwrap();
503        assert_eq!(cached, None);
504
505        fs::remove_dir_all(dir).await.unwrap();
506    }
507
508    #[tokio::test]
509    async fn store_cache_creates_missing_parent_directory() {
510        let dir = make_temp_dir().await;
511        let cache_path = dir.join("nested").join(UPDATE_CACHE_FILE);
512
513        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
514            .await
515            .unwrap();
516
517        let cached = load_cached_version_if_fresh(&cache_path, 1_000 + 1)
518            .await
519            .unwrap();
520        assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
521
522        fs::remove_dir_all(dir).await.unwrap();
523    }
524
525    #[tokio::test]
526    async fn rate_limit_cooldown_skips_same_auth_mode() {
527        let dir = make_temp_dir().await;
528        let cache_path = dir.join(UPDATE_CACHE_FILE);
529        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
530            .await
531            .unwrap();
532
533        let err = guard_rate_limit_cooldown(&cache_path, 1_000, AuthMode::Anonymous)
534            .await
535            .unwrap_err();
536        assert!(
537            err.to_string()
538                .contains("GitHub version check skipped until Unix timestamp 2000")
539        );
540
541        fs::remove_dir_all(dir).await.unwrap();
542    }
543
544    #[tokio::test]
545    async fn rate_limit_cooldown_allows_changed_auth_mode() {
546        let dir = make_temp_dir().await;
547        let cache_path = dir.join(UPDATE_CACHE_FILE);
548        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
549            .await
550            .unwrap();
551
552        guard_rate_limit_cooldown(&cache_path, 1_000, AuthMode::Token)
553            .await
554            .unwrap();
555
556        fs::remove_dir_all(dir).await.unwrap();
557    }
558
559    #[tokio::test]
560    async fn rate_limit_cooldown_allows_expired_reset() {
561        let dir = make_temp_dir().await;
562        let cache_path = dir.join(UPDATE_CACHE_FILE);
563        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Token)
564            .await
565            .unwrap();
566
567        guard_rate_limit_cooldown(&cache_path, 2_001, AuthMode::Token)
568            .await
569            .unwrap();
570
571        fs::remove_dir_all(dir).await.unwrap();
572    }
573
574    #[tokio::test]
575    async fn successful_cache_write_clears_rate_limit_cooldown() {
576        let dir = make_temp_dir().await;
577        let cache_path = dir.join(UPDATE_CACHE_FILE);
578        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
579            .await
580            .unwrap();
581        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
582            .await
583            .unwrap();
584
585        let cache = load_cache(&cache_path).await.unwrap().unwrap();
586        assert_eq!(cache.latest_version.as_deref(), Some("0.2.3"));
587        assert_eq!(cache.rate_limited_until_unix_secs, None);
588        assert_eq!(cache.rate_limited_auth_mode, None);
589
590        fs::remove_dir_all(dir).await.unwrap();
591    }
592
593    #[tokio::test]
594    async fn invalidate_update_cache_removes_existing_cache_file() {
595        use crate::config::Config;
596
597        let dir = make_temp_dir().await;
598        let config = Config::new_for_test(&dir);
599        let cache_path = dir.join(UPDATE_CACHE_FILE);
600
601        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
602            .await
603            .unwrap();
604        assert!(
605            cache_path.exists(),
606            "cache file should exist before invalidation"
607        );
608
609        invalidate_update_cache(&config).await;
610        assert!(
611            !cache_path.exists(),
612            "cache file should be removed after invalidation"
613        );
614
615        fs::remove_dir_all(dir).await.unwrap();
616    }
617
618    #[tokio::test]
619    async fn invalidate_update_cache_is_a_no_op_when_cache_absent() {
620        use crate::config::Config;
621
622        let dir = make_temp_dir().await;
623        let config = Config::new_for_test(&dir);
624
625        // Should not return an error when the cache file does not exist.
626        invalidate_update_cache(&config).await;
627
628        fs::remove_dir_all(dir).await.unwrap();
629    }
630}