stackenv 0.1.7

Native, zero-container multi-project dev environment manager. No Docker, no VMs.
use anyhow::{Context, Result, bail};
use std::process::{Command, Stdio};

pub fn detach(cmd: &mut Command) {
    use std::os::windows::process::CommandExt;
    const DETACHED_PROCESS: u32 = 0x0000_0008;
    const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
    const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
    cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB);
}

pub fn kill_tree(pid: u32) -> Result<()> {
    let status = Command::new("taskkill")
        .args(["/T", "/F", "/PID", &pid.to_string()])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .context("failed to run taskkill")?;
    if !status.success() {
        bail!("taskkill failed for pid {pid}");
    }
    Ok(())
}

pub fn is_alive(pid: u32) -> bool {
    Command::new("tasklist")
        .args(["/FI", &format!("PID eq {pid}"), "/NH"])
        .output()
        .is_ok_and(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
}

pub fn install_pinned(tool: &str, version: &str) -> Result<()> {
    match tool {
        "vfox" => install_inno(
            &format!("https://github.com/version-fox/vfox/releases/download/v{version}/vfox_{version}_windows_setup_x86_64.exe"),
            "vfox",
        ),
        "uv" => install_portable_zip(
            &format!("https://github.com/astral-sh/uv/releases/download/{version}/uv-x86_64-pc-windows-msvc.zip"),
            "uv",
            version,
        ),
        "caddy" => install_portable_zip(
            &format!("https://github.com/caddyserver/caddy/releases/download/v{version}/caddy_{version}_windows_amd64.zip"),
            "caddy",
            version,
        ),
        _ => bail!("no install_pinned strategy known for '{tool}' on Windows"),
    }
}

fn download(url: &str, dest: &std::path::Path) -> Result<()> {
    println!("  downloading {url}");
    let response = ureq::get(url).call().context("download failed")?;
    let mut file = std::fs::File::create(dest).with_context(|| format!("failed to create {}", dest.display()))?;
    std::io::copy(&mut response.into_body().into_reader(), &mut file).context("failed to write download")?;
    Ok(())
}

fn install_inno(url: &str, name: &str) -> Result<()> {
    let installer_path = std::env::temp_dir().join(format!("{name}-installer.exe"));
    download(url, &installer_path)?;
    println!("  running {name} installer silently...");
    let status = std::process::Command::new(&installer_path)
        .args(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"])
        .status()
        .with_context(|| format!("failed to run {name} installer"))?;
    if !status.success() {
        bail!("{name} installer exited with a failure status");
    }
    Ok(())
}

fn install_portable_zip(url: &str, name: &str, version: &str) -> Result<()> {
    let base = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
    let install_dir = base.join(".stack").join("tools").join(name).join(version);
    std::fs::create_dir_all(&install_dir).with_context(|| format!("failed to create {}", install_dir.display()))?;

    let zip_path = std::env::temp_dir().join(format!("{name}-{version}.zip"));
    download(url, &zip_path)?;

    println!("  extracting {name} to {}", install_dir.display());
    let escape_for_pwsh_single_quotes = |s: &std::path::Path| s.display().to_string().replace('\'', "''");
    let status = std::process::Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            &format!(
                "Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
                escape_for_pwsh_single_quotes(&zip_path),
                escape_for_pwsh_single_quotes(&install_dir)
            ),
        ])
        .status()
        .context("failed to run Expand-Archive")?;
    if !status.success() {
        bail!("extracting {name} failed");
    }
    println!("  {name} {version} is at {} (not yet wired onto PATH — resolution is a separate step)", install_dir.display());
    Ok(())
}