use anyhow::{anyhow, bail, Context, Result};
use clap::Args as ClapArgs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::style;
const REPO: &str = "wavekat/wavekat-cli";
const CURRENT: &str = env!("CARGO_PKG_VERSION");
const INSTALL_SH_URL: &str =
"https://github.com/wavekat/wavekat-cli/releases/latest/download/install.sh";
#[derive(ClapArgs)]
pub struct Args {
#[arg(long)]
check: bool,
#[arg(long)]
version: Option<String>,
#[arg(long)]
force: bool,
}
pub async fn run(args: Args) -> Result<()> {
if args.check {
return run_check(args.version.as_deref()).await;
}
run_install(args.version.as_deref(), args.force).await
}
async fn run_check(pin: Option<&str>) -> Result<()> {
let target = resolve_target_version(pin).await?;
if target == CURRENT {
println!("{} wk {CURRENT} is the latest.", style::green("✓"));
} else {
println!(
"{} wk {} → {}",
style::yellow("update available:"),
CURRENT,
style::bold(&target),
);
println!("Run `wk update` to install.");
}
Ok(())
}
async fn run_install(pin: Option<&str>, force: bool) -> Result<()> {
let target_v = resolve_target_version(pin).await?;
if target_v == CURRENT && !force {
println!(
"{} wk {CURRENT} is the latest — nothing to do. \
Pass `--force` to reinstall.",
style::green("✓"),
);
return Ok(());
}
eprintln!(
"{} updating wk {} → {}",
style::dim("·"),
CURRENT,
style::bold(&target_v),
);
let cur = std::env::current_exe().context("resolving current executable path")?;
let install_dir = cur
.parent()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("current executable has no parent dir: {}", cur.display()))?;
let aside = cur.with_file_name(format!(
"{}.old",
cur.file_name().and_then(|s| s.to_str()).unwrap_or("wk"),
));
let _ = std::fs::remove_file(&aside);
std::fs::rename(&cur, &aside).with_context(|| {
format!(
"renaming {} aside (need write permission to {})",
cur.display(),
install_dir.display(),
)
})?;
let pinned = with_v_prefix(&target_v);
let outcome = run_installer(&install_dir, &pinned).await;
if outcome.is_err() {
let _ = std::fs::rename(&aside, &cur);
} else {
let _ = std::fs::remove_file(&aside);
println!(
"{} wk updated to {}.",
style::green("✓"),
style::bold(&target_v)
);
}
outcome
}
async fn run_installer(install_dir: &Path, pinned_tag: &str) -> Result<()> {
eprintln!("{} fetching installer…", style::dim("·"));
let client = reqwest::Client::builder()
.user_agent(concat!("wavekat-cli/", env!("CARGO_PKG_VERSION")))
.build()?;
let resp = client
.get(INSTALL_SH_URL)
.send()
.await
.with_context(|| format!("GET {INSTALL_SH_URL}"))?
.error_for_status()
.with_context(|| format!("GET {INSTALL_SH_URL}"))?;
let script = resp.text().await?;
let mut cmd = Command::new("sh");
cmd.arg("-s")
.env("WK_INSTALL_DIR", install_dir)
.env("WK_VERSION", pinned_tag)
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let mut child = cmd.spawn().context("spawning sh to run installer")?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(script.as_bytes()).await?;
stdin.shutdown().await?;
}
let status = child.wait().await?;
if !status.success() {
bail!("installer exited with status {status}");
}
Ok(())
}
async fn resolve_target_version(pin: Option<&str>) -> Result<String> {
Ok(match pin {
Some(v) => v.trim_start_matches('v').to_string(),
None => resolve_latest_tag()
.await?
.trim_start_matches('v')
.to_string(),
})
}
fn with_v_prefix(version: &str) -> String {
if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
}
}
async fn resolve_latest_tag() -> Result<String> {
let url = format!("https://github.com/{REPO}/releases/latest");
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("wavekat-cli/", env!("CARGO_PKG_VERSION")))
.build()?;
let resp = client
.head(&url)
.send()
.await
.with_context(|| format!("HEAD {url}"))?;
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("no Location header from {url}"))?;
location
.rsplit_once("/tag/")
.map(|(_, t)| t.to_string())
.ok_or_else(|| anyhow!("could not parse tag from redirect: {location}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_v_prefix_adds_when_missing() {
assert_eq!(with_v_prefix("0.0.7"), "v0.0.7");
}
#[test]
fn with_v_prefix_idempotent() {
assert_eq!(with_v_prefix("v0.0.7"), "v0.0.7");
}
}