Skip to main content

cli/update_check/
mod.rs

1use crate::commands::{Commands, 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    let skip_background_update_check = matches!(
129        command,
130        Commands::Update(..)
131            | Commands::Preset { .. }
132            | Commands::State { .. }
133            | Commands::Self_ { .. }
134            | Commands::Serve { .. }
135            | Commands::Env { .. }
136            | Commands::Run(..)
137    ) || matches!(command, Commands::Upgrade(cmd) if cmd.pull)
138        || matches!(
139            command,
140            Commands::Task {
141                command: TaskCommands::Run(..)
142            }
143        );
144    if !skip_background_update_check {
145        match check_for_update(config).await {
146            Ok(UpdateStatus::UpToDate) => {}
147            Ok(UpdateStatus::UpdateAvailable { latest }) => {
148                eprintln!(
149                    "A newer version of shine is available: {} -> {}. Run `shine self upgrade` when convenient.",
150                    version::semver(),
151                    latest
152                );
153            }
154            Ok(UpdateStatus::UpdateRequired { latest }) => {
155                bail!(
156                    "A newer patch release of shine is required: {} -> {}. Run `shine self upgrade` before continuing.",
157                    version::semver(),
158                    latest
159                );
160            }
161            Err(_) => {}
162        }
163    }
164    Ok(())
165}
166
167fn compare_versions(current: &Version, latest: &Version) -> UpdateStatus {
168    if latest <= current {
169        return UpdateStatus::UpToDate;
170    }
171
172    if current.major == latest.major && current.minor == latest.minor {
173        return UpdateStatus::UpdateRequired {
174            latest: latest.clone(),
175        };
176    }
177
178    UpdateStatus::UpdateAvailable {
179        latest: latest.clone(),
180    }
181}
182
183fn parse_release_tag(tag_name: &str) -> Result<Version> {
184    let normalized = tag_name.trim().trim_start_matches('v');
185    let version = Version::parse(normalized)
186        .with_context(|| format!("invalid release tag version: {tag_name}"))?;
187
188    if !version.pre.is_empty() {
189        return Err(anyhow!(
190            "pre-release tags are not eligible for update checks"
191        ));
192    }
193
194    Ok(version)
195}
196
197async fn load_cached_version_if_fresh(cache_path: &Path, now_secs: u64) -> Result<Option<Version>> {
198    let cache = match fs::read_to_string(cache_path).await {
199        Ok(content) => serde_json::from_str::<UpdateCache>(&content).ok(),
200        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
201        Err(err) => return Err(err).context("failed to read update cache"),
202    };
203
204    let Some(cache) = cache else {
205        return Ok(None);
206    };
207
208    let Some(checked_at_unix_secs) = cache.checked_at_unix_secs else {
209        return Ok(None);
210    };
211    let Some(latest_version) = cache.latest_version else {
212        return Ok(None);
213    };
214
215    if checked_at_unix_secs > now_secs {
216        return Ok(None);
217    }
218
219    if now_secs - checked_at_unix_secs >= UPDATE_CACHE_TTL.as_secs() {
220        return Ok(None);
221    }
222
223    Ok(parse_release_tag(&latest_version).ok())
224}
225
226async fn store_cache(cache_path: &Path, latest: &Version, checked_at_unix_secs: u64) -> Result<()> {
227    let cache = UpdateCache {
228        latest_version: Some(latest.to_string()),
229        checked_at_unix_secs: Some(checked_at_unix_secs),
230        rate_limited_until_unix_secs: None,
231        rate_limited_auth_mode: None,
232    };
233    write_cache(cache_path, &cache).await
234}
235
236async fn store_rate_limit_cache(
237    cache_path: &Path,
238    rate_limited_until_unix_secs: u64,
239    auth_mode: AuthMode,
240) -> Result<()> {
241    let mut cache = load_cache(cache_path).await?.unwrap_or(UpdateCache {
242        latest_version: None,
243        checked_at_unix_secs: None,
244        rate_limited_until_unix_secs: None,
245        rate_limited_auth_mode: None,
246    });
247    cache.rate_limited_until_unix_secs = Some(rate_limited_until_unix_secs);
248    cache.rate_limited_auth_mode = Some(auth_mode);
249    write_cache(cache_path, &cache).await
250}
251
252async fn write_cache(cache_path: &Path, cache: &UpdateCache) -> Result<()> {
253    if let Some(parent) = cache_path.parent() {
254        fs::create_dir_all(parent)
255            .await
256            .with_context(|| format!("failed to create update cache dir {}", parent.display()))?;
257    }
258
259    let encoded = serde_json::to_vec_pretty(&cache).context("failed to serialize update cache")?;
260    fs::write(cache_path, encoded)
261        .await
262        .context("failed to write update cache")?;
263    Ok(())
264}
265
266async fn store_cache_if_possible(cache_path: &Path, latest: &Version, checked_at_unix_secs: u64) {
267    if let Err(e) = store_cache(cache_path, latest, checked_at_unix_secs).await {
268        eprintln!("warning: failed to write update cache: {e:#}");
269    }
270}
271
272async fn store_rate_limit_cache_if_possible(
273    cache_path: &Path,
274    rate_limited_until_unix_secs: u64,
275    auth_mode: AuthMode,
276) {
277    if let Err(e) =
278        store_rate_limit_cache(cache_path, rate_limited_until_unix_secs, auth_mode).await
279    {
280        eprintln!("warning: failed to write update rate-limit cache: {e:#}");
281    }
282}
283
284async fn load_cache(cache_path: &Path) -> Result<Option<UpdateCache>> {
285    match fs::read_to_string(cache_path).await {
286        Ok(content) => Ok(serde_json::from_str::<UpdateCache>(&content).ok()),
287        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
288        Err(err) => Err(err).context("failed to read update cache"),
289    }
290}
291
292async fn guard_rate_limit_cooldown(
293    cache_path: &Path,
294    now_secs: u64,
295    auth_mode: AuthMode,
296) -> Result<()> {
297    let Some(cache) = load_cache(cache_path).await? else {
298        return Ok(());
299    };
300    let Some(rate_limited_until) = cache.rate_limited_until_unix_secs else {
301        return Ok(());
302    };
303    let Some(rate_limited_auth_mode) = cache.rate_limited_auth_mode else {
304        return Ok(());
305    };
306
307    if rate_limited_auth_mode == auth_mode && rate_limited_until > now_secs {
308        bail!(
309            "GitHub version check skipped until Unix timestamp {rate_limited_until} due to rate limiting"
310        );
311    }
312
313    Ok(())
314}
315
316/// Removes the on-disk update cache so the next command performs a fresh fetch
317/// rather than reading a stale "update required" entry left behind by a failed upgrade.
318pub async fn invalidate_update_cache(config: &Config) {
319    let cache_path = config.shine_dir().join(UPDATE_CACHE_FILE);
320    if let Err(e) = fs::remove_file(&cache_path).await
321        && e.kind() != std::io::ErrorKind::NotFound
322    {
323        eprintln!("warning: failed to remove update cache: {e:#}");
324    }
325}
326
327fn unix_timestamp_now() -> Result<u64> {
328    Ok(SystemTime::now()
329        .duration_since(UNIX_EPOCH)
330        .context("system clock is before unix epoch")?
331        .as_secs())
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::path::PathBuf;
338
339    async fn make_temp_dir() -> PathBuf {
340        crate::test_support::make_temp_dir("shine-update-check").await
341    }
342
343    #[test]
344    fn compare_versions_is_up_to_date_when_latest_is_not_newer() {
345        let current = Version::parse("0.2.0").unwrap();
346        let latest = Version::parse("0.2.0").unwrap();
347
348        assert_eq!(compare_versions(&current, &latest), UpdateStatus::UpToDate);
349    }
350
351    #[test]
352    fn compare_versions_requires_update_for_newer_patch_release() {
353        let current = Version::parse("0.2.0").unwrap();
354        let latest = Version::parse("0.2.1").unwrap();
355
356        assert_eq!(
357            compare_versions(&current, &latest),
358            UpdateStatus::UpdateRequired { latest }
359        );
360    }
361
362    #[test]
363    fn compare_versions_warns_for_newer_minor_release() {
364        let current = Version::parse("0.2.0").unwrap();
365        let latest = Version::parse("0.3.0").unwrap();
366
367        assert_eq!(
368            compare_versions(&current, &latest),
369            UpdateStatus::UpdateAvailable { latest }
370        );
371    }
372
373    #[test]
374    fn parse_release_tag_accepts_v_prefix() {
375        let version = parse_release_tag("v1.2.3").unwrap();
376        assert_eq!(version, Version::parse("1.2.3").unwrap());
377    }
378
379    #[test]
380    fn parse_release_tag_rejects_prerelease_versions() {
381        assert!(parse_release_tag("v1.2.3-beta.1").is_err());
382    }
383
384    #[tokio::test]
385    async fn load_cached_version_returns_none_when_cache_missing() {
386        let dir = make_temp_dir().await;
387        let cache_path = dir.join(UPDATE_CACHE_FILE);
388
389        let cached = load_cached_version_if_fresh(&cache_path, UPDATE_CACHE_TTL.as_secs())
390            .await
391            .unwrap();
392        assert_eq!(cached, None);
393
394        fs::remove_dir_all(dir).await.unwrap();
395    }
396
397    #[tokio::test]
398    async fn load_cached_version_uses_fresh_cache() {
399        let dir = make_temp_dir().await;
400        let cache_path = dir.join(UPDATE_CACHE_FILE);
401        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
402            .await
403            .unwrap();
404
405        let cached =
406            load_cached_version_if_fresh(&cache_path, 1_000 + UPDATE_CACHE_TTL.as_secs() - 1)
407                .await
408                .unwrap();
409        assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
410
411        fs::remove_dir_all(dir).await.unwrap();
412    }
413
414    #[tokio::test]
415    async fn load_cached_version_supports_legacy_cache_shape() {
416        let dir = make_temp_dir().await;
417        let cache_path = dir.join(UPDATE_CACHE_FILE);
418        fs::write(
419            &cache_path,
420            br#"{"latest_version":"0.2.3","checked_at_unix_secs":1000}"#,
421        )
422        .await
423        .unwrap();
424
425        let cached = load_cached_version_if_fresh(&cache_path, 1_001)
426            .await
427            .unwrap();
428        assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
429
430        fs::remove_dir_all(dir).await.unwrap();
431    }
432
433    #[tokio::test]
434    async fn load_cached_version_ignores_stale_cache() {
435        let dir = make_temp_dir().await;
436        let cache_path = dir.join(UPDATE_CACHE_FILE);
437        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
438            .await
439            .unwrap();
440
441        let cached = load_cached_version_if_fresh(&cache_path, 1_000 + UPDATE_CACHE_TTL.as_secs())
442            .await
443            .unwrap();
444        assert_eq!(cached, None);
445
446        fs::remove_dir_all(dir).await.unwrap();
447    }
448
449    #[tokio::test]
450    async fn load_cached_version_ignores_invalid_cache_contents() {
451        let dir = make_temp_dir().await;
452        let cache_path = dir.join(UPDATE_CACHE_FILE);
453        fs::write(&cache_path, b"{not valid json").await.unwrap();
454
455        let cached = load_cached_version_if_fresh(&cache_path, UPDATE_CACHE_TTL.as_secs())
456            .await
457            .unwrap();
458        assert_eq!(cached, None);
459
460        fs::remove_dir_all(dir).await.unwrap();
461    }
462
463    #[tokio::test]
464    async fn store_cache_creates_missing_parent_directory() {
465        let dir = make_temp_dir().await;
466        let cache_path = dir.join("nested").join(UPDATE_CACHE_FILE);
467
468        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
469            .await
470            .unwrap();
471
472        let cached = load_cached_version_if_fresh(&cache_path, 1_000 + 1)
473            .await
474            .unwrap();
475        assert_eq!(cached, Some(Version::parse("0.2.3").unwrap()));
476
477        fs::remove_dir_all(dir).await.unwrap();
478    }
479
480    #[tokio::test]
481    async fn rate_limit_cooldown_skips_same_auth_mode() {
482        let dir = make_temp_dir().await;
483        let cache_path = dir.join(UPDATE_CACHE_FILE);
484        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
485            .await
486            .unwrap();
487
488        let err = guard_rate_limit_cooldown(&cache_path, 1_000, AuthMode::Anonymous)
489            .await
490            .unwrap_err();
491        assert!(
492            err.to_string()
493                .contains("GitHub version check skipped until Unix timestamp 2000")
494        );
495
496        fs::remove_dir_all(dir).await.unwrap();
497    }
498
499    #[tokio::test]
500    async fn rate_limit_cooldown_allows_changed_auth_mode() {
501        let dir = make_temp_dir().await;
502        let cache_path = dir.join(UPDATE_CACHE_FILE);
503        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
504            .await
505            .unwrap();
506
507        guard_rate_limit_cooldown(&cache_path, 1_000, AuthMode::Token)
508            .await
509            .unwrap();
510
511        fs::remove_dir_all(dir).await.unwrap();
512    }
513
514    #[tokio::test]
515    async fn rate_limit_cooldown_allows_expired_reset() {
516        let dir = make_temp_dir().await;
517        let cache_path = dir.join(UPDATE_CACHE_FILE);
518        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Token)
519            .await
520            .unwrap();
521
522        guard_rate_limit_cooldown(&cache_path, 2_001, AuthMode::Token)
523            .await
524            .unwrap();
525
526        fs::remove_dir_all(dir).await.unwrap();
527    }
528
529    #[tokio::test]
530    async fn successful_cache_write_clears_rate_limit_cooldown() {
531        let dir = make_temp_dir().await;
532        let cache_path = dir.join(UPDATE_CACHE_FILE);
533        store_rate_limit_cache(&cache_path, 2_000, AuthMode::Anonymous)
534            .await
535            .unwrap();
536        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
537            .await
538            .unwrap();
539
540        let cache = load_cache(&cache_path).await.unwrap().unwrap();
541        assert_eq!(cache.latest_version.as_deref(), Some("0.2.3"));
542        assert_eq!(cache.rate_limited_until_unix_secs, None);
543        assert_eq!(cache.rate_limited_auth_mode, None);
544
545        fs::remove_dir_all(dir).await.unwrap();
546    }
547
548    #[tokio::test]
549    async fn invalidate_update_cache_removes_existing_cache_file() {
550        use crate::config::Config;
551
552        let dir = make_temp_dir().await;
553        let config = Config::new_for_test(&dir);
554        let cache_path = dir.join(UPDATE_CACHE_FILE);
555
556        store_cache(&cache_path, &Version::parse("0.2.3").unwrap(), 1_000)
557            .await
558            .unwrap();
559        assert!(
560            cache_path.exists(),
561            "cache file should exist before invalidation"
562        );
563
564        invalidate_update_cache(&config).await;
565        assert!(
566            !cache_path.exists(),
567            "cache file should be removed after invalidation"
568        );
569
570        fs::remove_dir_all(dir).await.unwrap();
571    }
572
573    #[tokio::test]
574    async fn invalidate_update_cache_is_a_no_op_when_cache_absent() {
575        use crate::config::Config;
576
577        let dir = make_temp_dir().await;
578        let config = Config::new_for_test(&dir);
579
580        // Should not return an error when the cache file does not exist.
581        invalidate_update_cache(&config).await;
582
583        fs::remove_dir_all(dir).await.unwrap();
584    }
585}