use color_eyre::eyre::{Context, Result, bail, eyre};
use semver::Version;
use yansi::Paint;
use serde::Deserialize;
use std::{path::Path, time::Duration};
use tokio::task;
use crate::{App, tools, app::utils};
use walkdir::WalkDir;
#[derive(Deserialize, Debug, Clone)]
struct GitHubRelease {
tag_name: String,
assets: Vec<GitHubAsset>,
}
#[derive(Deserialize, Debug, Clone)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
pub async fn update_zv(app: &mut App, force: bool, include_prerelease: bool) -> Result<()> {
println!("{}", "Checking for zv updates...".cyan());
let current_version = Version::parse(env!("CARGO_PKG_VERSION"))
.expect("CARGO_PKG_VERSION should be valid semver");
println!("Current version: {}", Paint::yellow(¤t_version));
let target = env!("TARGET");
println!(" {} Detected platform: {}", "→".blue(), target);
let client = reqwest::Client::builder()
.user_agent(utils::zv_agent())
.connect_timeout(Duration::from_secs(*crate::app::FETCH_TIMEOUT_SECS))
.build()
.wrap_err("Failed to create HTTP client")?;
let (latest_release, latest_version) = if include_prerelease {
let releases = fetch_all_releases(&client).await
.wrap_err("Failed to fetch releases from GitHub")?;
find_latest_version(&releases, true)
.wrap_err("No valid releases found")?
} else {
let release = fetch_latest_release(&client).await
.wrap_err("Failed to fetch latest release from GitHub")?;
let version_str = release.tag_name.strip_prefix('v').unwrap_or(&release.tag_name);
let version = Version::parse(version_str)
.wrap_err("Failed to parse latest version from GitHub release tag")?;
(release, version)
};
let release_type = if latest_version.pre.is_empty() { "stable" } else { "pre-release" };
println!(
" {} Latest {} version from releases: {}",
"→".blue(),
release_type,
Paint::green(&latest_version)
);
if latest_version <= current_version && !force {
println!(" {} Already up to date!", "✓".green());
return Ok(());
}
if force && latest_version <= current_version {
println!(
" {} Forcing reinstall of version {}",
"→".blue(),
latest_version
);
} else {
println!(
" {} Update available: {} -> {}",
"→".blue(),
Paint::yellow(¤t_version),
Paint::green(&latest_version)
);
}
let asset = if cfg!(windows) {
let expected_asset_name = format!("zv-{target}.zip");
latest_release
.assets
.iter()
.find(|asset| asset.name == expected_asset_name)
.ok_or_else(|| {
let available_assets: Vec<&str> = latest_release
.assets
.iter()
.map(|a| a.name.as_str())
.filter(|name| name.starts_with("zv-") && name.ends_with(".zip"))
.collect();
eyre!(
"No compatible release asset found for platform: {} (expected: {})\nAvailable assets: {:?}",
target,
expected_asset_name,
available_assets
)
})?
} else {
let gz_asset_name = format!("zv-{target}.tar.gz");
let xz_asset_name = format!("zv-{target}.tar.xz");
latest_release
.assets
.iter()
.find(|asset| asset.name == gz_asset_name)
.or_else(|| {
latest_release
.assets
.iter()
.find(|asset| asset.name == xz_asset_name)
})
.ok_or_else(|| {
let available_assets: Vec<&str> = latest_release
.assets
.iter()
.map(|a| a.name.as_str())
.filter(|name| {
name.starts_with("zv-") && (name.ends_with(".tar.gz") || name.ends_with(".tar.xz"))
})
.collect();
eyre!(
"No compatible release asset found for platform: {} (tried: {} and {})\nAvailable assets: {:?}",
target,
gz_asset_name,
xz_asset_name,
available_assets
)
})?
};
tracing::trace!(target: "zv::update", "Found asset: {}", asset.name);
let current_exe = std::env::current_exe().wrap_err("Failed to get current executable path")?;
let (zv_dir, _) = tools::fetch_zv_dir()?;
let expected_zv_exe_path = zv_dir
.join("bin")
.join(if cfg!(windows) { "zv.exe" } else { "zv" });
let running_from_zv_dir = tools::canonicalize(¤t_exe)
.ok()
.and_then(|ce| {
tools::canonicalize(&expected_zv_exe_path)
.ok()
.map(|ez| ce == ez)
})
.unwrap_or(false);
if running_from_zv_dir {
println!(" {} Downloading and installing update...", "→".blue());
let _temp_extract_dir = download_and_replace_binary(&client, asset, &expected_zv_exe_path, true).await
.wrap_err("Failed to update zv")?;
println!(
" {} Updated successfully to zv {}!",
"✓".green(),
latest_version
);
} else {
println!(
" {} Running from outside ZV_DIR, downloading to temporary location...",
"→".blue()
);
let temp_dir = tempfile::Builder::new()
.prefix("zv-update-")
.tempdir()
.wrap_err("Failed to create temporary directory")?;
let temp_binary = temp_dir
.path()
.join(if cfg!(windows) { "zv.exe" } else { "zv" });
let _temp_extract_dir = download_and_replace_binary(&client, asset, &temp_binary, false).await
.wrap_err("Failed to download binary to temporary location")?;
println!(" {} Downloaded version {}", "✓".green(), latest_version);
println!(" {} Installing ...", "→".blue());
if let Some(parent) = expected_zv_exe_path.parent() {
tokio::fs::create_dir_all(parent).await
.wrap_err_with(|| format!("Failed to create ZV_DIR/bin directory: {}", parent.display()))?;
}
tokio::fs::copy(&temp_binary, &expected_zv_exe_path).await
.wrap_err("Failed to copy binary to ZV_DIR")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = tokio::fs::set_permissions(&expected_zv_exe_path, std::fs::Permissions::from_mode(0o755)).await {
tools::warn(format!("Failed to set binary permissions: {}", e));
}
}
println!(
" {} Updated successfully to zv {}!",
"✓".green(),
latest_version
);
}
if let Some(install) = app.toolchain_manager.get_active_install() {
println!(" {} Regenerating shims...", "→".blue());
app.toolchain_manager
.deploy_shims(install, true, false)
.await
.wrap_err("Failed to regenerate shims after update")?;
println!(" {} Shims regenerated successfully", "✓".green());
}
println!("\n{} {}", "✓".green(), "Update complete".green().bold());
Ok(())
}
async fn fetch_latest_release(client: &reqwest::Client) -> Result<GitHubRelease> {
let url = "https://api.github.com/repos/weezy20/zv/releases/latest";
let response = client
.get(url)
.send()
.await
.wrap_err("Failed to send request to GitHub API")?;
if !response.status().is_success() {
bail!("GitHub API request failed with status: {}", response.status());
}
let release = response
.json::<GitHubRelease>()
.await
.wrap_err("Failed to parse GitHub API response")?;
Ok(release)
}
async fn fetch_all_releases(client: &reqwest::Client) -> Result<Vec<GitHubRelease>> {
let url = "https://api.github.com/repos/weezy20/zv/releases";
let response = client
.get(url)
.send()
.await
.wrap_err("Failed to send request to GitHub API")?;
if !response.status().is_success() {
bail!("GitHub API request failed with status: {}", response.status());
}
let releases = response
.json::<Vec<GitHubRelease>>()
.await
.wrap_err("Failed to parse GitHub API response")?;
Ok(releases)
}
fn find_latest_version(releases: &[GitHubRelease], include_prerelease: bool) -> Result<(GitHubRelease, Version)> {
let mut best_release: Option<&GitHubRelease> = None;
let mut best_version: Option<Version> = None;
for release in releases {
let version_str = release.tag_name.strip_prefix('v').unwrap_or(&release.tag_name);
if let Ok(version) = Version::parse(version_str) {
if !include_prerelease && !version.pre.is_empty() {
continue;
}
if best_version.as_ref().map_or(true, |best| version > *best) {
best_version = Some(version);
best_release = Some(release);
}
}
}
match (best_release, best_version) {
(Some(release), Some(version)) => Ok((release.clone(), version)),
_ => bail!("No valid releases found"),
}
}
async fn download_and_replace_binary(
client: &reqwest::Client,
asset: &GitHubAsset,
target_path: &Path,
use_self_replace: bool,
) -> Result<tempfile::TempDir> {
let temp_dir = tempfile::tempdir()
.wrap_err("Failed to create temporary directory for download")?;
let temp_file_path = temp_dir.path().join(&asset.name);
println!(" {} Downloading {}...", "→".blue(), asset.name);
let response = client
.get(&asset.browser_download_url)
.send()
.await
.wrap_err("Failed to download release asset")?;
if !response.status().is_success() {
bail!("Failed to download asset: HTTP {}", response.status());
}
let mut file = tokio::fs::File::create(&temp_file_path).await
.wrap_err("Failed to create temporary download file")?;
let mut stream = response.bytes_stream();
use futures::StreamExt;
use tokio::io::AsyncWriteExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.wrap_err("Failed to read download chunk")?;
file.write_all(&chunk).await
.wrap_err("Failed to write download chunk")?;
}
file.sync_all().await.wrap_err("Failed to sync download file to disk")?;
drop(file);
println!(" {} Verifying checksum...", "→".blue());
let checksum_url = format!("{}.sha256", &asset.browser_download_url);
let checksum_response = client
.get(&checksum_url)
.send()
.await
.wrap_err("Failed to download checksum file")?;
if !checksum_response.status().is_success() {
bail!("Failed to download checksum file: HTTP {}", checksum_response.status());
}
let checksum_content = checksum_response
.text()
.await
.wrap_err("Failed to read checksum file content")?;
let expected_shasum = checksum_content
.split_whitespace()
.next()
.ok_or_else(|| eyre!("Checksum file is empty or invalid"))?
.trim();
utils::verify_checksum(&temp_file_path, expected_shasum)
.await
.wrap_err("Checksum verification failed - the downloaded file may be corrupted")?;
println!(" {} Checksum verified successfully", "✓".green());
println!(" {} Extracting binary...", "→".blue());
let temp_extract_dir = tempfile::tempdir()
.wrap_err("Failed to create temporary extraction directory")?;
extract(&temp_file_path, temp_extract_dir.path()).await?;
let target = env!("TARGET");
let binary_name = if cfg!(windows) { "zv.exe" } else { "zv" };
let mut extracted_binary = temp_extract_dir
.path()
.join(format!("zv-{target}"))
.join(binary_name);
if !extracted_binary.is_file() {
extracted_binary = temp_extract_dir.path().join(binary_name);
}
if !extracted_binary.is_file() {
println!(" {} Debug: Listing extracted contents...", "!".yellow());
for entry in WalkDir::new(temp_extract_dir.path())
.into_iter()
.filter_map(Result::ok)
{
let depth = entry.depth();
let prefix = " ".repeat(1 + depth); println!("{prefix}{}", entry.path().display());
}
bail!("Could not find zv binary in extracted archive at: {}", extracted_binary.display());
}
println!(" {} Installing update...", "→".blue());
if let Some(parent) = target_path.parent() {
tokio::fs::create_dir_all(parent).await
.wrap_err("Failed to create target directory")?;
}
if use_self_replace {
self_replace::self_replace(&extracted_binary)
.wrap_err("Failed to replace binary with updated version")?;
} else {
tokio::fs::copy(&extracted_binary, target_path).await
.wrap_err("Failed to copy binary to target location")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = tokio::fs::set_permissions(target_path, std::fs::Permissions::from_mode(0o755)).await {
tools::warn(format!("Failed to set binary permissions: {}", e));
}
}
}
Ok(temp_extract_dir)
}
async fn extract(archive: &Path, dest: &Path) -> Result<()> {
let ext = archive.extension().and_then(|e| e.to_str()).unwrap_or_default();
let ext2 = archive.file_stem()
.and_then(|n| n.to_str()?.rsplit_once('.'))
.map(|(_, e)| e);
match (ext, ext2) {
("gz", Some("tar")) => extract_tar(archive, dest, TarDecoder::Gz).await,
("xz", Some("tar")) => extract_tar(archive, dest, TarDecoder::Xz).await,
("zip", _) => extract_zip(archive, dest).await,
_ => bail!("Unsupported archive type: {}", archive.display()),
}
}
enum TarDecoder {
Gz,
Xz,
}
async fn extract_tar(archive: &Path, dest: &Path, decoder: TarDecoder) -> Result<()> {
let archive = archive.to_owned();
let dest = dest.to_owned();
task::spawn_blocking(move || {
let file = std::fs::File::open(&archive).wrap_err("Failed to open tar archive")?;
let boxed_decoder: Box<dyn std::io::Read> = match decoder {
TarDecoder::Gz => Box::new(flate2::read::GzDecoder::new(file)),
TarDecoder::Xz => Box::new(xz2::read::XzDecoder::new(file)),
};
let mut archive = tar::Archive::new(boxed_decoder);
archive.unpack(&dest).wrap_err("Failed to unpack tar archive")?;
Ok(())
})
.await
.wrap_err("tar extraction task panicked")?
}
async fn extract_zip(archive: &Path, dest: &Path) -> Result<()> {
let archive = archive.to_owned();
let dest = dest.to_owned();
task::spawn_blocking(move || {
let file = std::fs::File::open(&archive).wrap_err("Failed to open zip archive")?;
let mut zip = zip::ZipArchive::new(file).wrap_err("Failed to read zip archive")?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let out_path = match entry.enclosed_name() {
Some(p) => dest.join(p),
None => continue,
};
if entry.name().ends_with('/') {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out)?;
}
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
Ok(())
})
.await
.wrap_err("zip extraction task panicked")?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_latest_version_stable_only() {
let releases = vec![
GitHubRelease {
tag_name: "v0.3.1".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.4.0-rc1".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.4.0-rc.2".to_string(),
assets: vec![],
},
];
let (_, version) = find_latest_version(&releases, false).unwrap();
assert_eq!(version.to_string(), "0.3.1");
}
#[test]
fn test_find_latest_version_with_prerelease() {
let releases = vec![
GitHubRelease {
tag_name: "v0.3.1".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.4.0-rc.1".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.4.0-rc.2".to_string(),
assets: vec![],
},
];
let (_, version) = find_latest_version(&releases, true).unwrap();
assert_eq!(version.to_string(), "0.4.0-rc.2");
}
#[test]
fn test_find_latest_version_mixed_order() {
let releases = vec![
GitHubRelease {
tag_name: "v0.4.0-rc.1".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.3.1".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.4.0-rc.2".to_string(),
assets: vec![],
},
GitHubRelease {
tag_name: "v0.5.0".to_string(),
assets: vec![],
},
];
let (_, version) = find_latest_version(&releases, false).unwrap();
assert_eq!(version.to_string(), "0.5.0");
let (_, version) = find_latest_version(&releases, true).unwrap();
assert_eq!(version.to_string(), "0.5.0");
}
}