mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Upload command handler
//!
//! Uploads robot binaries to the binary registry for deployment via launcher.

use crate::commands::UploadArgs;
use crate::context::CliContext;
use crate::services::credentials::CredentialsService;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tempfile::TempDir;

/// Binary registry API path (appended to control plane URL)
const BINARY_REGISTRY_PATH: &str = "/api/builds";

/// Handle the upload command
pub async fn handle_upload(ctx: &mut CliContext, args: &UploadArgs) -> Result<()> {
    println!();
    println!("📦 Mecha10 Robot Binary Upload");
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    // Verify we're in a project
    if !ctx.is_project_initialized() {
        println!("⚠️  Not in a Mecha10 project directory");
        println!();
        return Err(anyhow::anyhow!("Not in a Mecha10 project"));
    }

    // Load project config
    let project = ctx.project()?;
    let project_name = project.name()?;

    // Determine paths
    let binary_path = args
        .binary
        .clone()
        .unwrap_or_else(|| ctx.working_dir.join("dist").join(&project_name));

    let config_path = args
        .config
        .clone()
        .unwrap_or_else(|| ctx.working_dir.join("dist").join("mecha10.json"));

    // Validate binary exists
    if !binary_path.exists() {
        println!("❌ Binary not found: {}", binary_path.display());
        println!();
        println!("Build first with:");
        println!("  mecha10 build robot --docker");
        println!();
        return Err(anyhow::anyhow!("Binary not found: {}", binary_path.display()));
    }

    // Validate config exists
    if !config_path.exists() {
        println!("❌ Config not found: {}", config_path.display());
        println!();
        println!("The mecha10.json should be in dist/ after building with:");
        println!("  mecha10 build robot --docker");
        println!();
        return Err(anyhow::anyhow!("Config not found: {}", config_path.display()));
    }

    // Load project config from project root (for control plane URL)
    let project_config = ctx.load_project_config().await?;
    let control_plane_url = project_config.environments.control_plane_url();

    // Read dist config to get version and other metadata
    let dist_config_content = tokio::fs::read_to_string(&config_path).await?;
    let dist_config: serde_json::Value = serde_json::from_str(&dist_config_content)?;

    let config_name = dist_config["name"].as_str().unwrap_or(&project_name);
    let robot_id = project_config.robot.id.clone();
    let version = args
        .version
        .clone()
        .unwrap_or_else(|| dist_config["version"].as_str().unwrap_or("0.1.0").to_string());

    // Detect architecture from binary or use provided
    let arch = args.arch.clone().unwrap_or_else(|| detect_arch(&binary_path));
    let platform = "linux";

    // Detect target from dist config or use provided
    let target = args
        .target
        .clone()
        .unwrap_or_else(|| dist_config["build_target"].as_str().unwrap_or("robot").to_string());

    // Determine registry URL: CLI arg > env var > control plane URL from config
    let registry_url = args.registry_url.clone().unwrap_or_else(|| {
        std::env::var("MECHA10_REGISTRY_URL")
            .unwrap_or_else(|_| format!("{}{}", control_plane_url, BINARY_REGISTRY_PATH))
    });

    // Get binary filename
    let binary_filename = binary_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(&project_name);

    // Load credentials for authentication and created_by field
    let credentials_service = CredentialsService::new();
    let credentials = credentials_service.load()?;
    let created_by = credentials.as_ref().map(|c| c.user_id.clone());
    let api_key = credentials.as_ref().map(|c| c.api_key.clone());

    println!("Project:      {}", config_name);
    println!("Robot ID:     {}", robot_id);
    println!("Version:      {}", version);
    println!("Platform:     {}", platform);
    println!("Architecture: {}", arch);
    println!("Target:       {}", target);
    println!("Binary:       {}", binary_path.display());
    println!("Config:       {}", config_path.display());
    println!("Registry:     {}", registry_url);
    println!("Uploaded by:  {}", created_by.as_deref().unwrap_or("anonymous"));
    println!();

    // Create tarball
    println!("Creating tarball...");
    let temp_dir = TempDir::new()?;
    let tarball_name = format!("{}-v{}-{}-{}.tar.gz", config_name, version, platform, arch);
    let tarball_path = temp_dir.path().join(&tarball_name);

    // Check for configs/ directory in dist/
    let configs_dir = config_path.parent().map(|p| p.join("configs"));
    let configs_dir_ref = configs_dir.as_ref().filter(|p| p.exists());

    create_tarball(
        &binary_path,
        binary_filename,
        &config_path,
        configs_dir_ref.map(|p| p.as_path()),
        &tarball_path,
    )?;

    if configs_dir_ref.is_some() {
        println!("  Included configs/ directory");
    }

    let tarball_size = std::fs::metadata(&tarball_path)?.len();

    println!();
    println!("Tarball:      {} ({} bytes)", tarball_name, tarball_size);
    println!();

    if args.dry_run {
        println!("🔍 DRY RUN - No changes will be made");
        println!();
        println!("Would upload:");
        println!("  POST {}", registry_url);
        println!("  - name: {}", config_name);
        println!("  - version: {}", version);
        println!("  - platform: {}", platform);
        println!("  - arch: {}", arch);
        println!("  - target: {}", target);
        println!("  - tags: robot,{}", robot_id);
        println!("  - robot_id: {}", robot_id);
        println!("  - created_by: {}", created_by.as_deref().unwrap_or("(none)"));
        println!("  - file: {}", tarball_name);
        println!();
        return Ok(());
    }

    // Upload to binary registry
    println!("Uploading tarball...");
    println!();

    let response = upload_to_registry(
        &registry_url,
        config_name,
        &version,
        platform,
        &arch,
        &target,
        &robot_id,
        created_by.as_deref(),
        api_key.as_deref(),
        &tarball_path,
        &tarball_name,
    )
    .await?;

    // Parse response
    if let Some(binary_id) = response.get("id").and_then(|v| v.as_str()) {
        println!("✅ Upload successful!");
        println!();
        println!("Binary ID:    {}", binary_id);
        println!("Download URL: {}/{}/download", registry_url, binary_id);
        println!();
        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        println!("✅ Upload Complete");
        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        println!();
    } else {
        println!("❌ Upload failed!");
        println!();
        println!("Response: {}", serde_json::to_string_pretty(&response)?);
        return Err(anyhow::anyhow!("Upload failed"));
    }

    Ok(())
}

/// Detect architecture from ELF binary
fn detect_arch(binary_path: &Path) -> String {
    // Try to read ELF header to detect architecture
    if let Ok(bytes) = std::fs::read(binary_path) {
        if bytes.len() >= 20 && &bytes[0..4] == b"\x7fELF" {
            // ELF magic number confirmed
            // e_machine is at offset 18 (2 bytes)
            let e_machine = u16::from_le_bytes([bytes[18], bytes[19]]);
            match e_machine {
                0x3E => return "x86_64".to_string(),  // EM_X86_64
                0xB7 => return "aarch64".to_string(), // EM_AARCH64
                _ => {}
            }
        }
    }

    // Default to x86_64
    "x86_64".to_string()
}

/// Create a tarball containing the binary, mecha10.json, and configs/
fn create_tarball(
    binary_path: &Path,
    binary_filename: &str,
    config_path: &Path,
    configs_dir: Option<&Path>,
    tarball_path: &Path,
) -> Result<()> {
    use flate2::write::GzEncoder;
    use flate2::Compression;
    use tar::Builder;

    let tarball_file = std::fs::File::create(tarball_path)?;
    let encoder = GzEncoder::new(tarball_file, Compression::default());
    let mut builder = Builder::new(encoder);

    // Add binary
    builder
        .append_path_with_name(binary_path, binary_filename)
        .context("Failed to add binary to tarball")?;

    // Add mecha10.json
    builder
        .append_path_with_name(config_path, "mecha10.json")
        .context("Failed to add mecha10.json to tarball")?;

    // Add configs/ directory if present
    if let Some(configs) = configs_dir {
        if configs.exists() && configs.is_dir() {
            add_dir_to_tarball(&mut builder, configs, Path::new("configs"))
                .context("Failed to add configs/ to tarball")?;
        }
    }

    builder.finish()?;

    Ok(())
}

/// Recursively add a directory to a tarball
fn add_dir_to_tarball<W: std::io::Write>(
    builder: &mut tar::Builder<W>,
    src_dir: &Path,
    archive_path: &Path,
) -> Result<()> {
    for entry in std::fs::read_dir(src_dir)? {
        let entry = entry?;
        let path = entry.path();
        let name = entry.file_name();
        let archive_entry = archive_path.join(&name);

        if path.is_dir() {
            add_dir_to_tarball(builder, &path, &archive_entry)?;
        } else {
            builder
                .append_path_with_name(&path, &archive_entry)
                .with_context(|| format!("Failed to add {} to tarball", path.display()))?;
        }
    }
    Ok(())
}

/// Upload tarball to binary registry
async fn upload_to_registry(
    registry_url: &str,
    name: &str,
    version: &str,
    platform: &str,
    arch: &str,
    target: &str,
    robot_id: &str,
    created_by: Option<&str>,
    api_key: Option<&str>,
    tarball_path: &PathBuf,
    tarball_name: &str,
) -> Result<serde_json::Value> {
    let client = reqwest::Client::new();

    // Read tarball
    let tarball_bytes = tokio::fs::read(tarball_path).await?;

    // Create multipart form
    let file_part = reqwest::multipart::Part::bytes(tarball_bytes)
        .file_name(tarball_name.to_string())
        .mime_str("application/gzip")?;

    let mut form = reqwest::multipart::Form::new()
        .text("name", name.to_string())
        .text("description", format!("Robot binary bundle for {}", name))
        .text("version", version.to_string())
        .text("platform", platform.to_string())
        .text("arch", arch.to_string())
        .text("target", target.to_string())
        .text("tags", format!("robot,{}", robot_id))
        .text("robot_id", robot_id.to_string())
        .part("file", file_part);

    // Add created_by if available
    if let Some(user) = created_by {
        form = form.text("created_by", user.to_string());
    }

    // Build request with authentication headers
    let mut request = client.post(registry_url).multipart(form);

    // Add authentication if credentials available
    if let (Some(key), Some(user_id)) = (api_key, created_by) {
        request = request
            .header("Authorization", format!("Bearer {}", key))
            .header("X-User-ID", user_id);
    }

    // Send request
    let response = request.send().await.context("Failed to send upload request")?;

    let status = response.status();
    let body = response.text().await?;

    if !status.is_success() {
        return Err(anyhow::anyhow!("Upload failed with status {}: {}", status, body));
    }

    // Parse JSON response
    let json: serde_json::Value = serde_json::from_str(&body).context("Failed to parse response JSON")?;

    Ok(json)
}