pub(super) const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const REPO: &str = "Zaberahmed/hostcraft";
const GITHUB_RELEASES_URL: &str =
"https://api.github.com/repos/Zaberahmed/hostcraft/releases?per_page=50";
#[derive(serde::Deserialize)]
pub(super) struct GitHubRelease {
pub(super) tag_name: String,
pub(super) draft: bool,
pub(super) prerelease: bool,
}
fn user_agent() -> String {
format!("hostcraft-cli/{}", CURRENT_VERSION)
}
pub(super) fn fetch_latest_cli_release() -> Option<GitHubRelease> {
let releases: Vec<GitHubRelease> = ureq::get(GITHUB_RELEASES_URL)
.header("User-Agent", &user_agent())
.call()
.ok()?
.body_mut()
.read_json()
.ok()?;
releases
.into_iter()
.find(|r| r.tag_name.starts_with("cli-v") && !r.draft && !r.prerelease)
}
pub(super) fn version_from_tag(tag: &str) -> Option<&str> {
tag.strip_prefix("cli-v")
}
pub(super) fn is_newer_version(version: &str) -> Option<bool> {
let current = semver::Version::parse(CURRENT_VERSION).ok()?;
let latest = semver::Version::parse(version).ok()?;
Some(latest > current)
}
pub(super) fn current_target() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", _) => Some("universal-apple-darwin"),
("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
("windows", "x86_64") => Some("x86_64-pc-windows-msvc"),
_ => None,
}
}
pub(super) fn binary_download_url(version: &str, target: &str) -> String {
let filename = if cfg!(target_os = "windows") {
format!("hostcraft-{}.exe", target)
} else {
format!("hostcraft-{}", target)
};
format!(
"https://github.com/{}/releases/download/cli-v{}/{}",
REPO, version, filename
)
}
pub(super) fn download_bytes(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let response = ureq::get(url)
.header("User-Agent", &user_agent())
.call()
.map_err(|e| format!("Download failed: {}", e))?;
let bytes = response
.into_body()
.read_to_vec()
.map_err(|e| format!("Failed to read response body: {}", e))?;
Ok(bytes)
}
pub(super) fn replace_binary(new_bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let current_exe = std::env::current_exe()
.map_err(|e| format!("Could not locate current executable: {}", e))?;
let tmp_path = current_exe.with_extension("new");
std::fs::write(&tmp_path, new_bytes)
.map_err(|e| format!("Failed to write new binary: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("Failed to set permissions: {}", e))?;
std::fs::rename(&tmp_path, ¤t_exe).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path); if e.kind() == std::io::ErrorKind::PermissionDenied {
"Permission denied — try: sudo hostcraft update".into()
} else {
format!("Failed to replace binary: {}", e)
}
})?;
}
#[cfg(windows)]
{
let backup = current_exe.with_extension("bak");
let _ = std::fs::remove_file(&backup);
std::fs::rename(¤t_exe, &backup)
.map_err(|e| format!("Could not move current binary: {}", e))?;
if let Err(e) = std::fs::rename(&tmp_path, ¤t_exe) {
let _ = std::fs::rename(&backup, ¤t_exe);
return Err(format!("Failed to place new binary: {}", e).into());
}
let _ = std::fs::remove_file(&backup);
}
Ok(())
}
pub(super) const CHECK_INTERVAL_SECS: u64 = 60 * 60 * 24;
pub(super) fn should_check_for_update() -> bool {
let Some(path) = last_checked_path() else {
return true;
};
let Ok(contents) = std::fs::read_to_string(&path) else {
return true;
};
let Ok(timestamp) = contents.trim().parse::<u64>() else {
return true;
};
let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
return true;
};
now.as_secs().saturating_sub(timestamp) >= CHECK_INTERVAL_SECS
}
pub(super) fn record_last_checked() {
let Some(path) = last_checked_path() else {
return;
};
let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, now.as_secs().to_string());
}
fn last_checked_path() -> Option<std::path::PathBuf> {
directories::ProjectDirs::from("", "", "hostcraft")
.map(|dirs| dirs.cache_dir().join("last_update_check"))
}