Skip to main content

cli/update_check/
upgrade.rs

1use super::github::{GithubRelease, download_asset_bytes, fetch_release};
2use super::{ReleaseChannel, UpgradeResult, parse_release_tag, unix_timestamp_now};
3use crate::config::Config;
4use crate::{platform, version};
5use anyhow::{Context, Result, anyhow, bail};
6use flate2::read::GzDecoder;
7use semver::Version;
8use std::ffi::OsStr;
9use std::path::Path;
10use tar::Archive;
11use tokio::fs;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14struct ReleaseAsset {
15    release_tag: String,
16    target: String,
17    download_url: String,
18}
19
20pub async fn upgrade_to_release(
21    config: &Config,
22    channel: ReleaseChannel,
23    force_install: bool,
24) -> Result<UpgradeResult> {
25    let current = Version::parse(env!("CARGO_PKG_VERSION"))
26        .context("current package version must be valid semver")?;
27    let current_display = version::semver();
28
29    let release = fetch_release(channel).await?;
30    let latest = match channel {
31        ReleaseChannel::Stable => {
32            let latest = parse_release_tag(&release.tag_name)?;
33            let now_secs = unix_timestamp_now()?;
34            let cache_path = config.shine_dir().join(super::UPDATE_CACHE_FILE);
35            super::store_cache_if_possible(&cache_path, &latest, now_secs).await;
36
37            if !force_install && latest <= current {
38                return Ok(UpgradeResult::AlreadyUpToDate {
39                    channel,
40                    latest: latest.to_string(),
41                });
42            }
43            Some(latest)
44        }
45        ReleaseChannel::Preview => {
46            if preview_release_matches_commit(&release, version::preview_commit()) {
47                return Ok(UpgradeResult::AlreadyUpToDate {
48                    channel,
49                    latest: current_display.to_string(),
50                });
51            }
52            None
53        }
54    };
55
56    let asset = find_release_asset(
57        &release,
58        channel,
59        std::env::consts::OS,
60        std::env::consts::ARCH,
61    )?;
62    let archive_bytes = download_asset_bytes(&asset.download_url).await?;
63    let current_exe = std::env::current_exe().context("failed to resolve current executable")?;
64    install_downloaded_archive(
65        &archive_bytes,
66        &current_exe,
67        platform::current_executable_name(),
68    )
69    .await?;
70    let installed_version = installed_version_label(&current_exe, &asset.release_tag).await;
71
72    if let Some(latest) = &latest {
73        let now_secs = unix_timestamp_now()?;
74        let cache_path = config.shine_dir().join(super::UPDATE_CACHE_FILE);
75        super::store_cache_if_possible(&cache_path, latest, now_secs).await;
76    }
77
78    Ok(UpgradeResult::Upgraded {
79        channel,
80        previous: current,
81        previous_display: current_display.to_string(),
82        release_tag: asset.release_tag,
83        installed_version,
84        installed_path: current_exe,
85    })
86}
87
88async fn install_downloaded_archive(
89    archive_bytes: &[u8],
90    current_exe: &Path,
91    binary_name: &str,
92) -> Result<()> {
93    let extracted = extract_binary_from_archive(archive_bytes, binary_name)?;
94
95    let parent_dir = current_exe
96        .parent()
97        .context("current executable path must have a parent directory")?;
98    let staged_path = parent_dir.join(format!(".shine-upgrade-{}", uuid::Uuid::new_v4()));
99    let backup_path = parent_dir.join(format!(".shine-backup-{}", uuid::Uuid::new_v4()));
100
101    fs::write(&staged_path, extracted).await.with_context(|| {
102        format!(
103            "failed to stage upgrade binary at {}",
104            staged_path.display()
105        )
106    })?;
107    set_executable_permissions(&staged_path).await?;
108
109    match fs::rename(current_exe, &backup_path).await {
110        Ok(()) => {}
111        Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
112            let _ = fs::remove_file(&staged_path).await;
113            if cfg!(windows) {
114                bail!(
115                    "cannot replace {} due to insufficient permissions; rerun from an elevated terminal or install to a user-writable path",
116                    current_exe.display()
117                );
118            } else {
119                bail!(
120                    "cannot replace {} due to insufficient permissions; reinstall with install.sh into a user-writable directory such as ~/.local/bin",
121                    current_exe.display()
122                );
123            }
124        }
125        Err(err) => {
126            let _ = fs::remove_file(&staged_path).await;
127            return Err(err).with_context(|| {
128                format!(
129                    "failed to prepare existing binary {}",
130                    current_exe.display()
131                )
132            });
133        }
134    }
135
136    match fs::rename(&staged_path, current_exe).await {
137        Ok(()) => {
138            let _ = fs::remove_file(&backup_path).await;
139            Ok(())
140        }
141        Err(err) => match fs::rename(&backup_path, current_exe).await {
142            Ok(()) => {
143                let _ = fs::remove_file(&staged_path).await;
144                Err(err).with_context(|| {
145                    format!(
146                        "failed to install upgraded binary at {}; \
147                             original binary has been restored",
148                        current_exe.display()
149                    )
150                })
151            }
152            Err(rollback_err) => {
153                let _ = fs::remove_file(&staged_path).await;
154                Err(err).with_context(|| {
155                    format!(
156                        "failed to install upgraded binary at {} \
157                             and rollback also failed ({rollback_err:#}); \
158                             {} may be missing — reinstall from install.sh",
159                        current_exe.display(),
160                        current_exe.display()
161                    )
162                })
163            }
164        },
165    }
166}
167
168fn extract_binary_from_archive(archive_bytes: &[u8], binary_name: &str) -> Result<Vec<u8>> {
169    let decoder = GzDecoder::new(std::io::Cursor::new(archive_bytes));
170    let mut archive = Archive::new(decoder);
171
172    for entry_result in archive
173        .entries()
174        .context("failed to read archive entries")?
175    {
176        let mut entry = entry_result.context("failed to read release archive entry")?;
177        let path = entry
178            .path()
179            .context("failed to inspect archive entry path")?;
180
181        if path.file_name() == Some(OsStr::new(binary_name)) {
182            let mut extracted = Vec::new();
183            std::io::copy(&mut entry, &mut extracted)
184                .context("failed to extract shine binary from release archive")?;
185            if extracted.is_empty() {
186                bail!("release archive contained an empty shine binary");
187            }
188            return Ok(extracted);
189        }
190    }
191
192    bail!("release archive does not contain a {binary_name} binary")
193}
194
195fn find_release_asset(
196    release: &GithubRelease,
197    channel: ReleaseChannel,
198    os: &str,
199    arch: &str,
200) -> Result<ReleaseAsset> {
201    let target = platform_target(os, arch)?;
202    let expected_name = asset_file_name(release, channel, &target)?;
203
204    let asset = release
205        .assets
206        .iter()
207        .find(|asset| asset.name == expected_name)
208        .ok_or_else(|| {
209            anyhow!(
210                "no release asset named {expected_name} found for {os}/{arch}; expected it to be published with the release"
211            )
212        })?;
213
214    Ok(ReleaseAsset {
215        release_tag: release.tag_name.clone(),
216        target,
217        download_url: asset.browser_download_url.clone(),
218    })
219}
220
221fn platform_target(os: &str, arch: &str) -> Result<String> {
222    platform::release_target(os, arch)
223}
224
225fn asset_file_name(
226    release: &GithubRelease,
227    channel: ReleaseChannel,
228    target: &str,
229) -> Result<String> {
230    match channel {
231        ReleaseChannel::Stable => {
232            let version = parse_release_tag(&release.tag_name)?;
233            Ok(format!("shine-v{version}-{target}.tar.gz"))
234        }
235        ReleaseChannel::Preview => Ok(format!("shine-preview-{target}.tar.gz")),
236    }
237}
238
239async fn installed_version_label(current_exe: &Path, fallback: &str) -> String {
240    match tokio::process::Command::new(current_exe)
241        .arg("--version")
242        .output()
243        .await
244    {
245        Ok(output) if output.status.success() => {
246            let stdout = String::from_utf8_lossy(&output.stdout);
247            parse_binary_version_output(&stdout)
248                .map(str::to_string)
249                .unwrap_or_else(|| fallback.to_string())
250        }
251        _ => fallback.to_string(),
252    }
253}
254
255fn parse_binary_version_output(output: &str) -> Option<&str> {
256    output
257        .trim()
258        .strip_prefix("shine ")?
259        .split_whitespace()
260        .next()
261}
262
263fn preview_release_matches_commit(release: &GithubRelease, current_commit: Option<&str>) -> bool {
264    let Some(release_commit) = parse_preview_release_commit(&release.body) else {
265        return false;
266    };
267    let Some(current_commit) = current_commit.filter(|commit| !commit.is_empty()) else {
268        return false;
269    };
270
271    release_commit.starts_with(current_commit) || current_commit.starts_with(release_commit)
272}
273
274fn parse_preview_release_commit(body: &str) -> Option<&str> {
275    body.lines().find_map(|line| {
276        line.trim()
277            .strip_prefix("- Commit: `")
278            .and_then(|rest| rest.strip_suffix('`'))
279    })
280}
281
282#[cfg(unix)]
283async fn set_executable_permissions(path: &Path) -> Result<()> {
284    use std::os::unix::fs::PermissionsExt;
285
286    let mut permissions = fs::metadata(path)
287        .await
288        .with_context(|| format!("failed to read metadata for {}", path.display()))?
289        .permissions();
290    permissions.set_mode(0o755);
291    fs::set_permissions(path, permissions)
292        .await
293        .with_context(|| format!("failed to mark {} as executable", path.display()))
294}
295
296#[cfg(not(unix))]
297async fn set_executable_permissions(_path: &Path) -> Result<()> {
298    Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303    use super::super::github::GithubReleaseAsset;
304    use super::*;
305    use flate2::{Compression, write::GzEncoder};
306    use tar::{Builder, Header};
307
308    #[test]
309    fn parse_binary_version_output_reads_shine_version() {
310        assert_eq!(
311            parse_binary_version_output("shine 1.0.0-preview (30a34c682 2026-05-25)\n"),
312            Some("1.0.0-preview")
313        );
314    }
315
316    #[test]
317    fn parse_binary_version_output_rejects_unexpected_output() {
318        assert_eq!(parse_binary_version_output("0.21.3"), None);
319    }
320
321    fn archive_with_file(name: &str, content: &[u8]) -> Vec<u8> {
322        let encoder = GzEncoder::new(Vec::new(), Compression::default());
323        let mut builder = Builder::new(encoder);
324        let mut header = Header::new_gnu();
325        header.set_size(content.len() as u64);
326        header.set_mode(0o755);
327        header.set_cksum();
328        builder
329            .append_data(&mut header, name, content)
330            .expect("archive entry should be written");
331        let encoder = builder.into_inner().expect("archive should finish");
332        encoder.finish().expect("gzip should finish")
333    }
334
335    #[test]
336    fn extract_binary_from_archive_uses_platform_binary_name() {
337        let archive = archive_with_file("shine.exe", b"windows-binary");
338        assert_eq!(
339            extract_binary_from_archive(&archive, "shine.exe").unwrap(),
340            b"windows-binary"
341        );
342
343        let err = extract_binary_from_archive(&archive, "shine").unwrap_err();
344        assert!(err.to_string().contains("shine binary"));
345    }
346
347    #[test]
348    fn platform_target_maps_supported_targets() {
349        assert_eq!(
350            platform_target("macos", "aarch64").unwrap(),
351            "darwin-aarch64"
352        );
353        assert_eq!(platform_target("linux", "x86_64").unwrap(), "linux-x86_64");
354        assert_eq!(
355            platform_target("windows", "x86_64").unwrap(),
356            "windows-x86_64"
357        );
358        assert_eq!(
359            platform_target("windows", "aarch64").unwrap(),
360            "windows-aarch64"
361        );
362    }
363
364    #[test]
365    fn asset_file_name_uses_versioned_target_name_for_stable() {
366        let release = GithubRelease {
367            tag_name: "v1.2.3".to_string(),
368            body: String::new(),
369            assets: vec![],
370        };
371        assert_eq!(
372            asset_file_name(&release, ReleaseChannel::Stable, "darwin-aarch64").unwrap(),
373            "shine-v1.2.3-darwin-aarch64.tar.gz"
374        );
375    }
376
377    #[test]
378    fn asset_file_name_uses_fixed_target_name_for_preview() {
379        let release = GithubRelease {
380            tag_name: "preview".to_string(),
381            body: String::new(),
382            assets: vec![],
383        };
384        assert_eq!(
385            asset_file_name(&release, ReleaseChannel::Preview, "linux-x86_64").unwrap(),
386            "shine-preview-linux-x86_64.tar.gz"
387        );
388        assert_eq!(
389            asset_file_name(&release, ReleaseChannel::Preview, "windows-x86_64").unwrap(),
390            "shine-preview-windows-x86_64.tar.gz"
391        );
392    }
393
394    #[test]
395    fn find_release_asset_selects_matching_stable_asset() {
396        let release = GithubRelease {
397            tag_name: "v1.2.3".to_string(),
398            body: String::new(),
399            assets: vec![
400                GithubReleaseAsset {
401                    name: "shine-v1.2.3-linux-x86_64.tar.gz".to_string(),
402                    browser_download_url: "https://example.test/linux".to_string(),
403                },
404                GithubReleaseAsset {
405                    name: "shine-v1.2.3-darwin-aarch64.tar.gz".to_string(),
406                    browser_download_url: "https://example.test/macos".to_string(),
407                },
408            ],
409        };
410
411        let asset =
412            find_release_asset(&release, ReleaseChannel::Stable, "macos", "aarch64").unwrap();
413        assert_eq!(asset.release_tag, "v1.2.3");
414        assert_eq!(asset.target, "darwin-aarch64");
415        assert_eq!(asset.download_url, "https://example.test/macos");
416    }
417
418    #[test]
419    fn find_release_asset_selects_matching_preview_asset_without_semver_tag() {
420        let release = GithubRelease {
421            tag_name: "preview".to_string(),
422            body: String::new(),
423            assets: vec![GithubReleaseAsset {
424                name: "shine-preview-linux-x86_64.tar.gz".to_string(),
425                browser_download_url: "https://example.test/preview".to_string(),
426            }],
427        };
428
429        let asset =
430            find_release_asset(&release, ReleaseChannel::Preview, "linux", "x86_64").unwrap();
431        assert_eq!(asset.release_tag, "preview");
432        assert_eq!(asset.target, "linux-x86_64");
433        assert_eq!(asset.download_url, "https://example.test/preview");
434    }
435
436    #[test]
437    fn find_release_asset_selects_matching_windows_asset() {
438        let release = GithubRelease {
439            tag_name: "v1.2.3".to_string(),
440            body: String::new(),
441            assets: vec![GithubReleaseAsset {
442                name: "shine-v1.2.3-windows-x86_64.tar.gz".to_string(),
443                browser_download_url: "https://example.test/windows".to_string(),
444            }],
445        };
446
447        let asset =
448            find_release_asset(&release, ReleaseChannel::Stable, "windows", "x86_64").unwrap();
449        assert_eq!(asset.release_tag, "v1.2.3");
450        assert_eq!(asset.target, "windows-x86_64");
451        assert_eq!(asset.download_url, "https://example.test/windows");
452    }
453
454    #[test]
455    fn find_release_asset_errors_when_target_missing() {
456        let release = GithubRelease {
457            tag_name: "v1.2.3".to_string(),
458            body: String::new(),
459            assets: vec![],
460        };
461
462        let error =
463            find_release_asset(&release, ReleaseChannel::Stable, "linux", "x86_64").unwrap_err();
464        assert!(
465            error
466                .to_string()
467                .contains("no release asset named shine-v1.2.3-linux-x86_64.tar.gz")
468        );
469    }
470
471    #[test]
472    fn parse_preview_release_commit_reads_commit_line_from_release_body() {
473        let body = "Automated preview build from the release branch.\n- Commit: `a618d4af0a8ec0f0d0c5d4f6f9e3e2970cb12345`\n";
474
475        assert_eq!(
476            parse_preview_release_commit(body),
477            Some("a618d4af0a8ec0f0d0c5d4f6f9e3e2970cb12345")
478        );
479    }
480
481    #[test]
482    fn preview_release_matches_current_commit_by_prefix() {
483        let release = GithubRelease {
484            tag_name: "preview".to_string(),
485            body:
486                "Automated preview build.\n- Commit: `a618d4af0a8ec0f0d0c5d4f6f9e3e2970cb12345`\n"
487                    .to_string(),
488            assets: vec![],
489        };
490
491        assert!(preview_release_matches_commit(&release, Some("a618d4af0")));
492        assert!(!preview_release_matches_commit(&release, Some("b00000000")));
493        assert!(!preview_release_matches_commit(&release, None));
494    }
495}