cufflink-cli 0.7.13

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::github::GitHubSource;
use crate::package_manifest::PackageManifest;
use comfy_table::{presets::NOTHING, Table, TableComponent};

const PACKAGES_DIR: &str = "cufflink-packages";

pub async fn run(source: &str, env: Option<&str>) -> eyre::Result<()> {
    let (source_dir, _temp) = if source.starts_with("github:") {
        let gh = GitHubSource::parse(source)?;
        println!("Installing {}...", gh.display());
        let temp = tempfile::tempdir()?;
        crate::github::download(&gh, temp.path()).await?;
        (temp.path().to_path_buf(), Some(temp))
    } else {
        let path = std::path::PathBuf::from(shellexpand::tilde(source).as_ref());
        if !path.exists() {
            eyre::bail!("Path '{}' does not exist", path.display());
        }
        println!("Installing from {}...", path.display());
        (path, None)
    };

    let manifest = PackageManifest::load(&source_dir)?;
    manifest.validate_paths(&source_dir)?;

    let packages_dir = std::env::current_dir()?.join(PACKAGES_DIR);
    let dest = packages_dir.join(&manifest.package.name);

    if dest.exists() {
        print!(
            "Package '{}' already installed. Overwrite? [y/N] ",
            manifest.package.name
        );
        use std::io::Write;
        std::io::stdout().flush()?;
        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        if !input.trim().eq_ignore_ascii_case("y") {
            println!("Aborted.");
            return Ok(());
        }
        std::fs::remove_dir_all(&dest)?;
    }

    std::fs::create_dir_all(&packages_dir)?;

    let output = std::process::Command::new("cp")
        .args(["-r", &source_dir.to_string_lossy(), &dest.to_string_lossy()])
        .output()?;
    if !output.status.success() {
        eyre::bail!("Failed to copy package to {}", dest.display());
    }

    update_lockfile(&packages_dir, &manifest, source)?;

    println!();
    manifest.print_summary();

    println!("Installed to {}/{}/", PACKAGES_DIR, manifest.package.name);
    println!();

    print!("Deploy all components? [y/N] ");
    use std::io::Write;
    std::io::stdout().flush()?;
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;

    if input.trim().eq_ignore_ascii_case("y") {
        deploy_package(&manifest, &dest, env).await?;
    } else {
        println!("Skipped deploy. Deploy later from each component directory:");
        for s in &manifest.services {
            println!(
                "  cd {}/{}/{} && cufflink deploy",
                PACKAGES_DIR, manifest.package.name, s.path
            );
        }
        for f in &manifest.frontends {
            println!(
                "  cd {}/{}/{} && cufflink deploy",
                PACKAGES_DIR, manifest.package.name, f.path
            );
        }
    }

    Ok(())
}

fn update_lockfile(
    packages_dir: &std::path::Path,
    manifest: &PackageManifest,
    source: &str,
) -> eyre::Result<()> {
    let lockfile = packages_dir.join("cufflink-packages.toml");
    let mut content = std::fs::read_to_string(&lockfile).unwrap_or_default();

    let version = manifest.package.version.as_deref().unwrap_or("0.0.0");
    let entry = format!(
        "\n[{}]\nversion = \"{}\"\nsource = \"{}\"\ninstalled_at = \"{}\"\n",
        manifest.package.name,
        version,
        source,
        chrono::Utc::now().to_rfc3339(),
    );

    // Remove existing entry for this package if present
    if let Some(start) = content.find(&format!("[{}]", manifest.package.name)) {
        let end = content[start + 1..]
            .find("\n[")
            .map(|i| start + 1 + i)
            .unwrap_or(content.len());
        content.replace_range(start..end, "");
    }

    content.push_str(&entry);
    std::fs::write(&lockfile, content.trim_start())?;
    Ok(())
}

async fn deploy_package(
    manifest: &PackageManifest,
    package_dir: &std::path::Path,
    env: Option<&str>,
) -> eyre::Result<()> {
    if !manifest.config.required.is_empty() {
        println!("Note: Set these config keys after deployment:");
        for key in &manifest.config.required {
            println!("  cufflink config set {} <value>", key);
        }
        println!();
    }

    let original_dir = std::env::current_dir()?;
    let mut results: Vec<(&str, &str, Result<(), String>)> = Vec::new();

    for service in &manifest.services {
        let service_dir = package_dir.join(&service.path);
        println!("Deploying service '{}'...", service.name);
        std::env::set_current_dir(&service_dir)?;

        match super::deploy::run(false, None, env).await {
            Ok(_) => results.push((&service.name, "service", Ok(()))),
            Err(e) => {
                let msg = format!("{}", e);
                eprintln!("  Failed: {}", msg);
                results.push((&service.name, "service", Err(msg)));
            }
        }
    }

    for frontend in &manifest.frontends {
        let frontend_dir = package_dir.join(&frontend.path);
        println!("Deploying frontend '{}'...", frontend.name);
        std::env::set_current_dir(&frontend_dir)?;

        match super::deploy::run(false, None, env).await {
            Ok(_) => results.push((&frontend.name, "frontend", Ok(()))),
            Err(e) => {
                let msg = format!("{}", e);
                eprintln!("  Failed: {}", msg);
                results.push((&frontend.name, "frontend", Err(msg)));
            }
        }
    }

    std::env::set_current_dir(&original_dir)?;

    println!();

    let mut table = Table::new();
    table.load_preset(NOTHING);
    table.set_style(TableComponent::HeaderLines, '-');
    table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
    table.set_header(vec!["COMPONENT", "TYPE", "STATUS"]);

    let mut failures = 0;
    for (name, kind, result) in &results {
        let status = match result {
            Ok(_) => "deployed",
            Err(_) => {
                failures += 1;
                "FAILED"
            }
        };
        table.add_row(vec![*name, *kind, status]);
    }

    println!("{table}");

    if failures > 0 {
        eyre::bail!("{} component(s) failed to deploy", failures);
    }

    Ok(())
}