zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use anyhow::{anyhow, bail, Context, Result};
use std::cmp::Ordering;
use std::io::{IsTerminal, Write};
use std::process::Command;
use std::time::Duration;

const RELEASES_API_URL: &str = "https://api.github.com/repos/zilliztech/zilliz-cli/releases/latest";
const INSTALL_SH_URL: &str =
    "https://raw.githubusercontent.com/zilliztech/zilliz-cli/master/install.sh";
const INSTALL_PS1_URL: &str =
    "https://raw.githubusercontent.com/zilliztech/zilliz-cli/master/install.ps1";

const MANUAL_INSTALL_HINT: &str = "\
Manual install:
  macOS/Linux: curl -fsSL https://raw.githubusercontent.com/zilliztech/zilliz-cli/master/install.sh | bash
  Windows:     irm https://raw.githubusercontent.com/zilliztech/zilliz-cli/master/install.ps1 | iex";

pub async fn run(check: bool, yes: bool, force: bool) -> Result<()> {
    let current = env!("CARGO_PKG_VERSION").to_string();
    let latest = match fetch_latest_version().await {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Failed to query latest version: {}", e);
            eprintln!("\n{}", MANUAL_INSTALL_HINT);
            bail!("upgrade aborted");
        }
    };

    println!("Current version: {}", current);
    println!("Latest version:  {}", latest);

    let cmp = compare_versions(&current, &latest);

    if check {
        match cmp {
            Ordering::Less => {
                println!("\nA newer version is available. Run `zilliz upgrade` to update.")
            }
            _ => println!("\nAlready on the latest version."),
        }
        return Ok(());
    }

    if !force && cmp != Ordering::Less {
        println!("\nAlready on the latest version.");
        return Ok(());
    }

    let installer_cmd = installer_command_str();
    println!("\nInstaller command:\n  {}\n", installer_cmd);

    if !yes {
        if !std::io::stdin().is_terminal() {
            eprintln!("Refusing to run installer non-interactively. Pass --yes to proceed.");
            bail!("upgrade aborted");
        }
        if !prompt_confirm("Proceed with upgrade? [y/N]: ")? {
            println!("Upgrade cancelled.");
            return Ok(());
        }
    }

    let status = run_installer()?;
    if !status.success() {
        eprintln!("\nInstaller exited with status {}.", status);
        eprintln!("{}", MANUAL_INSTALL_HINT);
        let code = status.code().unwrap_or(1);
        std::process::exit(code);
    }

    println!("\nUpgrade complete.");
    if let Err(e) = path_hint() {
        tracing::debug!("path_hint error: {}", e);
    }
    Ok(())
}

pub(crate) async fn fetch_latest_version() -> Result<String> {
    let ua = format!("zilliz-tui/{}", env!("CARGO_PKG_VERSION"));
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(15))
        .build()
        .context("build http client")?;
    let resp = client
        .get(RELEASES_API_URL)
        .header("User-Agent", ua)
        .header("Accept", "application/vnd.github+json")
        .send()
        .await
        .context("request GitHub releases API")?;
    let status = resp.status();
    if !status.is_success() {
        return Err(anyhow!("GitHub API returned HTTP {}", status));
    }
    let body: serde_json::Value = resp.json().await.context("parse GitHub response")?;
    let tag = body
        .get("tag_name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow!("missing tag_name in GitHub response"))?;
    Ok(normalize_tag(tag))
}

/// Normalize a GitHub release tag into a semver string. Handles both plain
/// `v1.4.0` tags and monorepo-style prefixes like `zilliz-v1.4.0` by trimming
/// everything before the first digit.
fn normalize_tag(tag: &str) -> String {
    match tag.find(|c: char| c.is_ascii_digit()) {
        Some(idx) => tag[idx..].to_string(),
        None => tag.to_string(),
    }
}

/// Compare two version strings using semver-like ordering: split on `.`,
/// compare numeric components numerically, fall back to lexicographic for
/// non-numeric suffixes. Missing trailing components count as 0.
pub fn compare_versions(a: &str, b: &str) -> Ordering {
    let a_owned = normalize_tag(a);
    let b_owned = normalize_tag(b);
    let a = a_owned.as_str();
    let b = b_owned.as_str();
    let a_parts: Vec<&str> = a.split('.').collect();
    let b_parts: Vec<&str> = b.split('.').collect();
    let len = a_parts.len().max(b_parts.len());
    for i in 0..len {
        let ap = a_parts.get(i).copied().unwrap_or("0");
        let bp = b_parts.get(i).copied().unwrap_or("0");
        match (ap.parse::<u64>(), bp.parse::<u64>()) {
            (Ok(x), Ok(y)) => match x.cmp(&y) {
                Ordering::Equal => continue,
                other => return other,
            },
            _ => match ap.cmp(bp) {
                Ordering::Equal => continue,
                other => return other,
            },
        }
    }
    Ordering::Equal
}

fn installer_command_str() -> String {
    if cfg!(target_os = "windows") {
        format!(
            "powershell -NoProfile -Command \"irm {} | iex\"",
            INSTALL_PS1_URL
        )
    } else {
        format!("bash -c \"curl -fsSL {} | bash\"", INSTALL_SH_URL)
    }
}

fn run_installer() -> Result<std::process::ExitStatus> {
    let mut cmd = if cfg!(target_os = "windows") {
        let mut c = Command::new("powershell");
        c.args([
            "-NoProfile",
            "-Command",
            &format!("irm {} | iex", INSTALL_PS1_URL),
        ]);
        c
    } else {
        let mut c = Command::new("bash");
        c.args(["-c", &format!("curl -fsSL {} | bash", INSTALL_SH_URL)]);
        c
    };
    let status = cmd.status().context("failed to spawn installer process")?;
    Ok(status)
}

fn prompt_confirm(prompt: &str) -> Result<bool> {
    print!("{}", prompt);
    std::io::stdout().flush().ok();
    let mut buf = String::new();
    std::io::stdin().read_line(&mut buf).context("read stdin")?;
    let trimmed = buf.trim();
    Ok(trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes"))
}

fn path_hint() -> Result<()> {
    let exe = std::env::current_exe().context("current_exe")?;
    let exe_str = exe.to_string_lossy().to_string();
    let home = dirs::home_dir().map(|p| p.to_string_lossy().to_string());
    let expected = home.as_ref().map(|h| format!("{}/.zilliz/bin", h));
    if let Some(expected) = expected {
        if !exe_str.starts_with(&expected) {
            println!(
                "Note: the running binary is at {}. The installer writes to {}. \
                If `zilliz` still reports the old version after this command, \
                reopen your shell or ensure {} is first on PATH.",
                exe_str, expected, expected
            );
        }
    }
    Ok(())
}