use anyhow::{Context, Result};
use semver::Version;
use crate::upgrade::apply::current_binary_path;
use crate::upgrade::monitor::UpgradeMonitor;
use crate::upgrade::UpgradeError;
const REPO: &str = "saorsa-labs/x0x";
pub async fn run(check_only: bool, force: bool) -> Result<()> {
let current = crate::VERSION;
eprintln!("x0x v{current}");
eprintln!("Checking for updates...");
let monitor = UpgradeMonitor::new(REPO, "x0x", current)
.map_err(|e| anyhow::anyhow!("failed to create upgrade monitor: {e}"))?;
let verified = if force {
match monitor.fetch_current_manifest().await {
Ok(v) => v,
Err(e) => {
print_signature_recovery_hint(&e, current);
return Err(anyhow::anyhow!("failed to fetch release from GitHub: {e}"));
}
}
} else {
match monitor.check_for_updates().await {
Ok(Some(v)) => Some(v),
Ok(None) => {
eprintln!("Already on the latest version (v{current}).");
return Ok(());
}
Err(e) => {
print_signature_recovery_hint(&e, current);
return Err(anyhow::anyhow!("failed to check for updates: {e}"));
}
}
};
let verified = match verified {
Some(v) => v,
None => {
eprintln!("No release found on GitHub.");
return Ok(());
}
};
let new_version = &verified.manifest.version;
if check_only {
eprintln!("Update available: v{current} → v{new_version}");
eprintln!("Run `x0x upgrade` to install.");
return Ok(());
}
if force {
eprintln!("Force installing v{new_version}...");
} else {
eprintln!("Upgrading v{current} → v{new_version}...");
}
let x0x_path = current_binary_path().context("cannot resolve x0x binary path")?;
let bin_dir = x0x_path
.parent()
.context("x0x binary has no parent directory")?;
let x0xd_path = bin_dir.join(if cfg!(windows) { "x0xd.exe" } else { "x0xd" });
let has_x0xd = x0xd_path.exists();
let daemon_was_running = stop_daemon_if_running().await;
if daemon_was_running {
eprintln!("Stopped running daemon.");
}
if has_x0xd {
eprintln!("Upgrading x0xd...");
upgrade_binary("x0xd", &verified.manifest, force).await?;
eprintln!(" x0xd → v{new_version}");
}
eprintln!("Upgrading x0x...");
upgrade_binary("x0x", &verified.manifest, force).await?;
eprintln!(" x0x → v{new_version}");
let bootstrap_path = bin_dir.join(if cfg!(windows) {
"x0x-bootstrap.exe"
} else {
"x0x-bootstrap"
});
if bootstrap_path.exists() {
let _ = std::fs::remove_file(&bootstrap_path);
eprintln!(" Removed stale x0x-bootstrap (no longer needed since v0.8.0)");
}
eprintln!();
eprintln!("Upgrade complete: v{new_version}");
if daemon_was_running {
eprintln!("Restarting daemon...");
if let Err(e) = restart_daemon().await {
eprintln!(" Failed to restart daemon: {e}");
eprintln!(" Start manually: x0x start");
} else {
eprintln!(" Daemon restarted.");
}
}
Ok(())
}
async fn upgrade_binary(
binary_name: &str,
manifest: &crate::upgrade::manifest::ReleaseManifest,
force: bool,
) -> Result<()> {
let target_version = Version::parse(&manifest.version)
.map_err(|e| anyhow::anyhow!("invalid version in manifest: {e}"))?;
let current_version = Version::parse(crate::VERSION)
.map_err(|e| anyhow::anyhow!("invalid current version: {e}"))?;
upgrade_binary_manual(
binary_name,
manifest,
¤t_version,
&target_version,
force,
)
.await
}
async fn upgrade_binary_manual(
binary_name: &str,
manifest: &crate::upgrade::manifest::ReleaseManifest,
current_version: &Version,
target_version: &Version,
force: bool,
) -> Result<()> {
use crate::upgrade::manifest::current_platform_target;
use crate::upgrade::signature::{verify_bytes_signature_with_key, RELEASE_SIGNING_KEY};
use crate::upgrade::Upgrader;
use sha2::{Digest, Sha256};
let platform_target = current_platform_target().context("unsupported platform for upgrade")?;
let asset = manifest
.matches_platform(platform_target)
.context("no release asset for this platform")?;
let target_path = if binary_name == "x0x" {
current_binary_path().context("cannot resolve binary path")?
} else {
let x0x_path = current_binary_path().context("cannot resolve x0x path")?;
let dir = x0x_path
.parent()
.context("binary has no parent directory")?;
let name = if cfg!(windows) && !binary_name.ends_with(".exe") {
format!("{binary_name}.exe")
} else {
binary_name.to_string()
};
dir.join(name)
};
if !target_path.exists() {
anyhow::bail!("{binary_name} not found at {}", target_path.display());
}
let upgrader = if force {
Upgrader::new(target_path.clone(), Version::new(0, 0, 0))
} else {
Upgrader::new(target_path.clone(), current_version.clone())
};
let temp_dir = upgrader
.create_temp_dir()
.context("failed to create temp directory")?;
let archive_path = temp_dir.join("archive");
let sig_path = temp_dir.join("archive.sig");
download_to_file(&asset.archive_url, &archive_path).await?;
let archive_data = std::fs::read(&archive_path).context("failed to read downloaded archive")?;
let actual_hash: [u8; 32] = Sha256::digest(&archive_data).into();
if actual_hash != asset.archive_sha256 {
let _ = std::fs::remove_dir_all(&temp_dir);
anyhow::bail!(
"SHA-256 mismatch: expected {}, got {}",
hex::encode(asset.archive_sha256),
hex::encode(actual_hash)
);
}
download_to_file(&asset.signature_url, &sig_path).await?;
let sig_data = std::fs::read(&sig_path).context("failed to read signature")?;
verify_bytes_signature_with_key(&archive_data, &sig_data, RELEASE_SIGNING_KEY)
.context("archive signature verification failed")?;
let binary_filename = if cfg!(target_os = "windows") && !binary_name.ends_with(".exe") {
format!("{binary_name}.exe")
} else {
binary_name.to_string()
};
let extracted_path = temp_dir.join("extracted-binary");
crate::upgrade::apply::extract_binary_from_archive(
&archive_path,
&extracted_path,
&binary_filename,
)
.context("failed to extract binary from archive")?;
upgrader
.perform_upgrade(&extracted_path, target_version)
.context("failed to replace binary")?;
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(())
}
fn discover_daemon_api() -> Option<String> {
let data_dir = dirs::data_dir()?;
let port_file = data_dir.join("x0x").join("api.port");
std::fs::read_to_string(port_file)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn stop_daemon_if_running() -> bool {
let addr = match discover_daemon_api() {
Some(a) => a,
None => return false,
};
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
let health_url = format!("http://{addr}/health");
if client.get(&health_url).send().await.is_err() {
return false;
}
let token = read_api_token();
let shutdown_url = format!("http://{addr}/shutdown");
let mut req = client.post(&shutdown_url);
if let Some(ref t) = token {
req = req.bearer_auth(t);
}
let _ = req.send().await;
for _ in 0..10 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if client.get(&health_url).send().await.is_err() {
return true;
}
}
true
}
fn read_api_token() -> Option<String> {
let data_dir = dirs::data_dir()?;
let token_file = data_dir.join("x0x").join("api.token");
std::fs::read_to_string(token_file)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn restart_daemon() -> Result<()> {
let x0x_path = current_binary_path().context("cannot resolve x0x binary path")?;
let bin_dir = x0x_path
.parent()
.context("x0x binary has no parent directory")?;
let x0xd_name = if cfg!(windows) { "x0xd.exe" } else { "x0xd" };
let x0xd_path = bin_dir.join(x0xd_name);
if !x0xd_path.exists() {
anyhow::bail!("x0xd not found at {}", x0xd_path.display());
}
let data_dir = dirs::data_dir().context("cannot determine data directory")?;
let log_dir = data_dir.join("x0x");
std::fs::create_dir_all(&log_dir).ok();
let log_file = log_dir.join("x0xd.log");
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_file)
.context("failed to open log file")?;
std::process::Command::new(&x0xd_path)
.arg("--skip-update-check")
.stdout(log.try_clone().context("failed to clone log handle")?)
.stderr(log)
.spawn()
.context("failed to spawn x0xd")?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.context("failed to build HTTP client")?;
for _ in 0..15 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if let Some(addr) = discover_daemon_api() {
let url = format!("http://{addr}/health");
if client.get(&url).send().await.is_ok() {
return Ok(());
}
}
}
anyhow::bail!("daemon started but did not become healthy within 15 seconds")
}
async fn download_to_file(url: &str, destination: &std::path::Path) -> Result<()> {
use crate::upgrade::MAX_BINARY_SIZE_BYTES;
use futures::StreamExt;
use std::io::Write;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
let response = client
.get(url)
.send()
.await
.context("download failed")?
.error_for_status()
.context("download returned error status")?;
if let Some(content_length) = response.content_length() {
if content_length > MAX_BINARY_SIZE_BYTES {
anyhow::bail!(
"binary too large: {} bytes (limit: {} bytes)",
content_length,
MAX_BINARY_SIZE_BYTES
);
}
}
let mut file = std::fs::File::create(destination).context("failed to create download file")?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.context("download stream error")?;
downloaded += chunk.len() as u64;
if downloaded > MAX_BINARY_SIZE_BYTES {
drop(file);
let _ = std::fs::remove_file(destination);
anyhow::bail!(
"binary too large: {} bytes (limit: {} bytes)",
downloaded,
MAX_BINARY_SIZE_BYTES
);
}
file.write_all(&chunk).context("failed to write chunk")?;
}
Ok(())
}
fn print_signature_recovery_hint(err: &UpgradeError, current: &str) {
if !matches!(err, UpgradeError::ManifestSignatureInvalid) {
return;
}
eprintln!();
eprintln!("The release signature could not be verified with this binary's");
eprintln!("embedded signing key. This typically means your x0x installation");
eprintln!("(v{current}) predates a signing key update.");
eprintln!();
eprintln!("To update manually, run:");
eprintln!();
eprintln!(" curl -sfL https://raw.githubusercontent.com/saorsa-labs/x0x/main/scripts/install.sh | sh");
eprintln!();
eprintln!("Or install via cargo:");
eprintln!();
eprintln!(" cargo install x0x --force");
eprintln!();
}