sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use super::*;
use anyhow::anyhow;
use std::io::Write;
use std::process::{Command, Stdio};

const DEFAULT_REPO: &str = "secemp9/sac";
const DEFAULT_BRANCH: &str = "main";
const RAW_GITHUB_BASE: &str = "https://raw.githubusercontent.com";

pub(super) async fn run_upgrade_cli(cli: UpgradeCli) -> Result<()> {
    let install_dir = upgrade_install_dir(cli.install_dir)?;
    let uninstall_url = script_url("uninstall.sh");
    let install_url = script_url("install.sh");
    let client = reqwest::Client::new();

    println!("upgrading sac in {}", install_dir.display());
    println!("downloading {uninstall_url}");
    let uninstall_script = download_script(&client, &uninstall_url).await?;
    println!("downloading {install_url}");
    let install_script = download_script(&client, &install_url).await?;

    ensure_installer_downloader_available()?;
    run_script("uninstall.sh", &uninstall_script, &install_dir)?;
    run_script("install.sh", &install_script, &install_dir)?;

    Ok(())
}

fn upgrade_install_dir(override_dir: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(dir) = override_dir {
        return Ok(dir);
    }
    if let Some(dir) = std::env::var_os("INSTALL_DIR") {
        return Ok(PathBuf::from(dir));
    }
    let current_exe =
        std::env::current_exe().context("failed to determine current sac executable path")?;
    current_exe
        .parent()
        .map(Path::to_path_buf)
        .ok_or_else(|| anyhow!("current sac executable does not have a parent directory"))
}

fn script_url(script: &str) -> String {
    if let Ok(base_url) = std::env::var("SAC_SCRIPT_BASE_URL") {
        return format!("{}/{}", base_url.trim_end_matches('/'), script);
    }
    let repo = std::env::var("SAC_REPO").unwrap_or_else(|_| DEFAULT_REPO.to_string());
    let branch = std::env::var("SAC_SCRIPT_BRANCH").unwrap_or_else(|_| DEFAULT_BRANCH.to_string());
    format!(
        "{}/{}/{}/scripts/{}",
        RAW_GITHUB_BASE,
        repo.trim_matches('/'),
        branch.trim_matches('/'),
        script
    )
}

async fn download_script(client: &reqwest::Client, url: &str) -> Result<String> {
    let response = client
        .get(url)
        .header("User-Agent", format!("sac/{}", env!("CARGO_PKG_VERSION")))
        .send()
        .await
        .with_context(|| format!("failed to download {url}"))?;
    let status = response.status();
    let body = response
        .text()
        .await
        .with_context(|| format!("failed to read {url}"))?;
    if !status.is_success() {
        return Err(anyhow!(
            "failed to download {}: HTTP {}: {}",
            url,
            status.as_u16(),
            body.chars().take(500).collect::<String>()
        ));
    }
    Ok(body)
}

fn run_script(name: &str, script: &str, install_dir: &Path) -> Result<()> {
    println!("running {name}");
    let mut child = Command::new("sh")
        .arg("-s")
        .env("INSTALL_DIR", install_dir)
        .stdin(Stdio::piped())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn()
        .with_context(|| format!("failed to start {name}"))?;

    {
        let stdin = child
            .stdin
            .as_mut()
            .ok_or_else(|| anyhow!("failed to open stdin for {name}"))?;
        stdin
            .write_all(script.as_bytes())
            .with_context(|| format!("failed to write {name} to shell"))?;
    }

    let status = child
        .wait()
        .with_context(|| format!("failed to wait for {name}"))?;
    if !status.success() {
        return Err(anyhow!("{name} failed with status {status}"));
    }
    Ok(())
}

fn ensure_installer_downloader_available() -> Result<()> {
    if command_exists("curl") || command_exists("wget") {
        return Ok(());
    }
    Err(anyhow!(
        "sac upgrade needs curl or wget because scripts/install.sh uses one to fetch the release archive"
    ))
}

fn command_exists(name: &str) -> bool {
    Command::new("sh")
        .arg("-c")
        .arg("command -v \"$1\" >/dev/null 2>&1")
        .arg("sh")
        .arg(name)
        .status()
        .map(|status| status.success())
        .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_env_lock;
    use std::ffi::OsString;

    fn restore_env(name: &str, value: Option<OsString>) {
        match value {
            Some(value) => unsafe { std::env::set_var(name, value) },
            None => unsafe { std::env::remove_var(name) },
        }
    }

    #[test]
    fn script_url_uses_defaults_and_env_overrides() {
        let _guard = test_env_lock();
        let original_repo = std::env::var_os("SAC_REPO");
        let original_branch = std::env::var_os("SAC_SCRIPT_BRANCH");
        let original_base = std::env::var_os("SAC_SCRIPT_BASE_URL");
        unsafe {
            std::env::remove_var("SAC_REPO");
            std::env::remove_var("SAC_SCRIPT_BRANCH");
            std::env::remove_var("SAC_SCRIPT_BASE_URL");
        }

        assert_eq!(
            script_url("install.sh"),
            "https://raw.githubusercontent.com/secemp9/sac/main/scripts/install.sh"
        );

        unsafe {
            std::env::set_var("SAC_REPO", "owner/repo");
            std::env::set_var("SAC_SCRIPT_BRANCH", "dev");
        }
        assert_eq!(
            script_url("uninstall.sh"),
            "https://raw.githubusercontent.com/owner/repo/dev/scripts/uninstall.sh"
        );

        unsafe {
            std::env::set_var("SAC_SCRIPT_BASE_URL", "https://example.com/scripts/");
        }
        assert_eq!(
            script_url("install.sh"),
            "https://example.com/scripts/install.sh"
        );

        restore_env("SAC_REPO", original_repo);
        restore_env("SAC_SCRIPT_BRANCH", original_branch);
        restore_env("SAC_SCRIPT_BASE_URL", original_base);
    }
}