use anyhow::Result;
use tokio::process::Command;
use super::UpdateArgs;
pub(super) async fn run_update(args: UpdateArgs) -> Result<()> {
use tokio::process::Command;
let current = env!("CARGO_PKG_VERSION");
println!("Current version: {current}");
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let target = match (os, arch) {
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("macos", "x86_64") => "x86_64-apple-darwin",
("macos", "aarch64") => "aarch64-apple-darwin",
_ => {
anyhow::bail!("Unsupported platform: {os} {arch}");
}
};
println!("Checking for latest release...");
let latest = match tokio::time::timeout(
std::time::Duration::from_secs(30),
Command::new("curl")
.args([
"-fsSL",
"-H",
"Accept: application/vnd.github+json",
"https://api.github.com/repos/ekhodzitsky/oh-my-kimi/releases/latest",
])
.output(),
)
.await
{
Ok(Ok(out)) if out.status.success() => {
let json: serde_json::Value = serde_json::from_slice(&out.stdout)?;
json["tag_name"].as_str().unwrap_or("").to_string()
}
_ => {
anyhow::bail!("Failed to check for updates. Are you online?");
}
};
if latest.is_empty() {
anyhow::bail!("Could not determine latest version");
}
let latest_version = latest.trim_start_matches('v');
println!("Latest version: {latest_version}");
if latest_version == current {
println!("✓ You are already on the latest version ({current}).");
return Ok(());
}
if args.check {
println!("Update available: {current} → {latest_version}");
println!("Run `omk update` to install.");
return Ok(());
}
let asset = format!("omk-{latest_version}-{target}.tar.gz");
let base_url = format!("https://github.com/ekhodzitsky/oh-my-kimi/releases/download/{latest}");
let url = format!("{base_url}/{asset}");
let sha_url = format!("{base_url}/{asset}.sha256");
println!("Downloading {url}...");
let tmp_dir = tempfile::tempdir()?;
let tar_path = tmp_dir.path().join(&asset);
let sha_path = tmp_dir.path().join(format!("{asset}.sha256"));
let download = tokio::time::timeout(
std::time::Duration::from_secs(30),
Command::new("curl")
.args(["-fsSL", "-o"])
.arg(&tar_path)
.arg(&url)
.status(),
)
.await??;
if !download.success() {
anyhow::bail!("Download failed. Prebuilt binary may not be available for {target}.");
}
println!("Fetching checksum...");
let sha_download = tokio::time::timeout(
std::time::Duration::from_secs(30),
Command::new("curl")
.args(["-fsSL", "-o"])
.arg(&sha_path)
.arg(&sha_url)
.status(),
)
.await??;
if !sha_download.success() {
anyhow::bail!(
"Checksum file not found at {sha_url}. \
Refusing to install an unverified binary. \
Re-run with cargo: cargo install --git https://github.com/ekhodzitsky/oh-my-kimi.git"
);
}
verify_sha256(&tar_path, &sha_path).await?;
println!("✓ SHA256 verified");
println!("Extracting...");
let extract = tokio::time::timeout(
std::time::Duration::from_secs(60),
Command::new("tar")
.args(["--no-same-owner", "-xzf"])
.arg(&tar_path)
.arg("-C")
.arg(tmp_dir.path())
.status(),
)
.await??;
if !extract.success() {
anyhow::bail!("Failed to extract archive");
}
let new_binary = tmp_dir.path().join("omk");
if !new_binary.exists() {
let legacy = tmp_dir
.path()
.join(format!("omk-{latest_version}-{target}"))
.join("omk");
if legacy.exists() {
tokio::fs::copy(&legacy, &new_binary).await?;
} else {
anyhow::bail!("Could not find omk binary in downloaded archive");
}
}
let current_exe = std::env::current_exe()?;
println!("Replacing {}...", current_exe.display());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&new_binary).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&new_binary, perms).await?;
}
install_binary_atomically(&new_binary, ¤t_exe).await?;
println!("✓ Updated to {latest_version}");
println!(" Binary: {}", current_exe.display());
println!("Updating shell completions...");
let _ = tokio::time::timeout(
std::time::Duration::from_secs(60),
Command::new(¤t_exe)
.args(["completions", "bash"])
.output(),
)
.await;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(60),
Command::new(¤t_exe)
.args(["completions", "zsh"])
.output(),
)
.await;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(60),
Command::new(¤t_exe)
.args(["completions", "fish"])
.output(),
)
.await;
println!("Run `omk doctor` to verify the installation.");
Ok(())
}
async fn verify_sha256(archive_path: &std::path::Path, sha_path: &std::path::Path) -> Result<()> {
use anyhow::Context;
let parent = archive_path.parent().unwrap_or(std::path::Path::new("."));
let sha_file_name = sha_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("checksum file path has no name"))?;
let archive_name = archive_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("archive path has no name"))?
.to_string_lossy()
.into_owned();
let sha_contents = tokio::fs::read_to_string(sha_path)
.await
.with_context(|| format!("failed to read checksum file {}", sha_path.display()))?;
let mut referenced = false;
for line in sha_contents.lines() {
if let Some(name) = line.split_whitespace().nth(1) {
if name == archive_name {
referenced = true;
break;
}
}
}
if !referenced {
anyhow::bail!(
"Checksum file {} does not reference {}; refusing to verify against an unrelated digest",
sha_path.display(),
archive_name
);
}
for cmd in [
("sha256sum", vec!["-c"]),
("shasum", vec!["-a", "256", "-c"]),
] {
if which::which(cmd.0).is_err() {
continue;
}
let status = tokio::time::timeout(
std::time::Duration::from_secs(30),
Command::new(cmd.0)
.args(&cmd.1)
.arg(sha_file_name)
.current_dir(parent)
.status(),
)
.await
.context("sha256 verification command timed out")?
.context("failed to spawn sha256 verification command")?;
if status.success() {
return Ok(());
}
anyhow::bail!(
"Checksum mismatch for {}; refusing to install an unverified binary",
archive_path.display()
);
}
anyhow::bail!(
"Neither sha256sum nor shasum is installed; cannot verify the download. \
Install one and re-run, or use `cargo install`."
);
}
async fn install_binary_atomically(
new_binary: &std::path::Path,
current_exe: &std::path::Path,
) -> Result<()> {
use anyhow::Context;
let install_dir = current_exe
.parent()
.ok_or_else(|| anyhow::anyhow!("current_exe has no parent directory"))?;
let probe = install_dir.join(".omk.write-probe");
if let Err(e) = tokio::fs::write(&probe, b"").await {
anyhow::bail!(
"No write access to {}: {}. Re-run with sudo, or install to a user-writable \
location (e.g. cargo install path / ~/.local/bin / Homebrew prefix).",
install_dir.display(),
e
);
}
let _ = tokio::fs::remove_file(&probe).await;
let staging = install_dir.join(".omk.new");
if staging.exists() {
let _ = tokio::fs::remove_file(&staging).await;
}
tokio::fs::copy(new_binary, &staging)
.await
.with_context(|| format!("failed to stage new binary at {}", staging.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&staging).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&staging, perms).await?;
}
let staging_for_sync = staging.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let f = std::fs::OpenOptions::new()
.read(true)
.open(&staging_for_sync)?;
f.sync_data()
})
.await
.context("sync task panicked")?
.context("failed to fsync staged binary")?;
tokio::fs::rename(&staging, current_exe)
.await
.with_context(|| {
format!(
"failed to rename {} into {}",
staging.display(),
current_exe.display()
)
})?;
Ok(())
}