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
81pub 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(¤t, &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(¤t, &latest))
114}
115
116pub async fn maybe_notify(config: &Config, command: &Commands) -> Result<()> {
125 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
337pub 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(¤t, &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(¤t, &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(¤t, &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 invalidate_update_cache(&config).await;
627
628 fs::remove_dir_all(dir).await.unwrap();
629 }
630}