use crate::commands::UploadArgs;
use crate::context::CliContext;
use crate::paths;
use crate::services::credentials::CredentialsService;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tempfile::TempDir;
const BINARY_REGISTRY_PATH: &str = "/api/builds";
pub async fn handle_upload(ctx: &mut CliContext, args: &UploadArgs) -> Result<()> {
println!();
println!("📦 Mecha10 Robot Binary Upload");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
if !ctx.is_project_initialized() {
println!("⚠️ Not in a Mecha10 project directory");
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project"));
}
let project = ctx.project()?;
let project_name = project.name()?;
let binary_path = args.binary.clone().unwrap_or_else(|| {
ctx.working_dir
.join(paths::target_path_with_triple(None, "release"))
.join(&project_name)
});
let config_path = args
.config
.clone()
.unwrap_or_else(|| ctx.working_dir.join(paths::PROJECT_CONFIG));
if !binary_path.exists() {
println!("❌ Binary not found: {}", binary_path.display());
println!();
println!("Build first with:");
println!(" mecha10 build robot");
println!();
return Err(anyhow::anyhow!("Binary not found: {}", binary_path.display()));
}
if !config_path.exists() {
println!("❌ Config not found: {}", config_path.display());
println!();
println!("The mecha10.json is needed for version metadata.");
println!("Build first with:");
println!(" mecha10 build robot");
println!();
return Err(anyhow::anyhow!("Config not found: {}", config_path.display()));
}
if let Some(warning) = detect_stale_binary(&binary_path, &ctx.working_dir) {
println!("⚠️ {}", warning);
println!(" Run `mecha10 build robot` to rebuild before uploading.");
println!();
}
let project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
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());
let arch = args.arch.clone().unwrap_or_else(|| detect_arch(&binary_path));
let platform = "linux";
let target = resolve_target(args.target.as_deref(), &dist_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))
});
let binary_filename = binary_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&project_name);
let credentials_service = CredentialsService::new();
let credentials = if ctx.is_ci() {
credentials_service.load()?
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service)
.await
.ok()
};
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!();
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);
create_tarball(&binary_path, binary_filename, &tarball_path)?;
println!(" Note: configs are managed via `mecha10 config push`");
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(());
}
println!("Uploading tarball...");
println!();
let response = upload_to_registry(UploadParams {
registry_url: ®istry_url,
name: config_name,
version: &version,
platform,
arch: &arch,
target: &target,
robot_id: &robot_id,
created_by: created_by.as_deref(),
api_key: api_key.as_deref(),
tarball_path: &tarball_path,
tarball_name: &tarball_name,
})
.await?;
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(())
}
fn resolve_target(explicit: Option<&str>, dist_config: &serde_json::Value) -> Result<String> {
if let Some(t) = explicit {
return Ok(t.to_string());
}
match dist_config["build_target"].as_str() {
Some(known) => Ok(known.to_string()),
None => {
println!("❌ No --target given and this binary's build target isn't known here");
println!();
println!("Pass --target explicitly, e.g.:");
println!(" mecha10 upload --target robot");
println!();
Err(anyhow::anyhow!(
"--target is required: binary's build target could not be determined"
))
}
}
}
fn detect_arch(binary_path: &Path) -> String {
if let Ok(bytes) = std::fs::read(binary_path) {
if bytes.len() >= 20 && &bytes[0..4] == b"\x7fELF" {
let e_machine = u16::from_le_bytes([bytes[18], bytes[19]]);
match e_machine {
0x3E => return "x86_64".to_string(),
0xB7 => return "aarch64".to_string(),
_ => {}
}
}
}
"x86_64".to_string()
}
fn detect_stale_binary(binary_path: &Path, project_root: &Path) -> Option<String> {
let binary_mtime = std::fs::metadata(binary_path).ok()?.modified().ok()?;
let sources_to_check = [
project_root.join("src"),
project_root.join("Cargo.toml"),
project_root.join("Cargo.lock"),
];
for source in &sources_to_check {
if !source.exists() {
continue;
}
if source.is_file() {
if let Ok(mtime) = std::fs::metadata(source).and_then(|m| m.modified()) {
if mtime > binary_mtime {
return Some(format!(
"Binary may be stale ({} was modified after the binary was built)",
source.file_name().unwrap_or_default().to_string_lossy()
));
}
}
} else if source.is_dir() {
if let Ok(entries) = std::fs::read_dir(source) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "rs") {
if let Ok(mtime) = std::fs::metadata(&path).and_then(|m| m.modified()) {
if mtime > binary_mtime {
return Some(format!(
"Binary may be stale (src/{} was modified after the binary was built)",
path.file_name().unwrap_or_default().to_string_lossy()
));
}
}
}
}
}
}
}
None
}
fn create_tarball(binary_path: &Path, binary_filename: &str, 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);
builder
.append_path_with_name(binary_path, binary_filename)
.context("Failed to add binary to tarball")?;
builder.finish()?;
Ok(())
}
struct UploadParams<'a> {
registry_url: &'a str,
name: &'a str,
version: &'a str,
platform: &'a str,
arch: &'a str,
target: &'a str,
robot_id: &'a str,
created_by: Option<&'a str>,
api_key: Option<&'a str>,
tarball_path: &'a PathBuf,
tarball_name: &'a str,
}
async fn upload_to_registry(params: UploadParams<'_>) -> Result<serde_json::Value> {
let UploadParams {
registry_url,
name,
version,
platform,
arch,
target,
robot_id,
created_by,
api_key,
tarball_path,
tarball_name,
} = params;
let client = reqwest::Client::new();
let tarball_bytes = tokio::fs::read(tarball_path).await?;
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);
if let Some(user) = created_by {
form = form.text("created_by", user.to_string());
}
let mut request = client.post(registry_url).multipart(form);
if let (Some(key), Some(user_id)) = (api_key, created_by) {
request = request
.header("Authorization", format!("Bearer {}", key))
.header("X-User-ID", user_id);
}
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));
}
let json: serde_json::Value = serde_json::from_str(&body).context("Failed to parse response JSON")?;
Ok(json)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::UploadArgs;
use crate::context::CliContextBuilder;
use std::time::{Duration, SystemTime};
use tempfile::tempdir;
fn no_op_upload_args() -> UploadArgs {
UploadArgs {
binary: None,
config: None,
registry_url: None,
version: None,
arch: None,
target: None,
dry_run: false,
}
}
#[test]
fn resolve_target_prefers_explicit_target() {
let dist_config = serde_json::json!({"build_target": "robot"});
assert_eq!(resolve_target(Some("remote"), &dist_config).unwrap(), "remote");
}
#[test]
fn resolve_target_falls_back_to_known_build_target() {
let dist_config = serde_json::json!({"build_target": "remote"});
assert_eq!(resolve_target(None, &dist_config).unwrap(), "remote");
}
#[test]
fn resolve_target_errors_when_unknown_and_no_explicit_target() {
let dist_config = serde_json::json!({});
let err = resolve_target(None, &dist_config).unwrap_err();
assert!(err.to_string().contains("--target is required"));
}
#[test]
fn detect_arch_identifies_x86_64_elf() {
let dir = tempdir().unwrap();
let path = dir.path().join("bin");
let mut bytes = vec![0u8; 20];
bytes[0..4].copy_from_slice(b"\x7fELF");
bytes[18..20].copy_from_slice(&0x3Eu16.to_le_bytes());
std::fs::write(&path, &bytes).unwrap();
assert_eq!(detect_arch(&path), "x86_64");
}
#[test]
fn detect_arch_identifies_aarch64_elf() {
let dir = tempdir().unwrap();
let path = dir.path().join("bin");
let mut bytes = vec![0u8; 20];
bytes[0..4].copy_from_slice(b"\x7fELF");
bytes[18..20].copy_from_slice(&0xB7u16.to_le_bytes());
std::fs::write(&path, &bytes).unwrap();
assert_eq!(detect_arch(&path), "aarch64");
}
#[test]
fn detect_arch_defaults_to_x86_64_for_non_elf() {
let dir = tempdir().unwrap();
let path = dir.path().join("bin");
std::fs::write(&path, b"not an elf file").unwrap();
assert_eq!(detect_arch(&path), "x86_64");
}
#[test]
fn detect_arch_defaults_to_x86_64_when_file_missing() {
let path = std::path::PathBuf::from("/nonexistent/binary/path");
assert_eq!(detect_arch(&path), "x86_64");
}
#[test]
fn detect_stale_binary_none_when_binary_is_newer() {
let dir = tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let source_file = src_dir.join("main.rs");
std::fs::write(&source_file, "fn main() {}").unwrap();
std::thread::sleep(Duration::from_millis(20));
let binary_path = dir.path().join("bin");
std::fs::write(&binary_path, b"binary").unwrap();
assert!(detect_stale_binary(&binary_path, dir.path()).is_none());
}
#[test]
fn detect_stale_binary_warns_when_source_is_newer() {
let dir = tempdir().unwrap();
let binary_path = dir.path().join("bin");
std::fs::write(&binary_path, b"binary").unwrap();
std::thread::sleep(Duration::from_millis(20));
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
let warning = detect_stale_binary(&binary_path, dir.path());
assert!(warning.is_some());
assert!(warning.unwrap().contains("may be stale"));
}
#[test]
fn detect_stale_binary_none_when_binary_missing() {
let dir = tempdir().unwrap();
let binary_path = dir.path().join("missing-bin");
assert!(detect_stale_binary(&binary_path, dir.path()).is_none());
}
#[test]
fn detect_stale_binary_ignores_future_and_missing_sources() {
let dir = tempdir().unwrap();
let binary_path = dir.path().join("bin");
std::fs::write(&binary_path, b"binary").unwrap();
assert!(detect_stale_binary(&binary_path, dir.path()).is_none());
let _ = SystemTime::now();
}
#[test]
fn create_tarball_writes_gzip_archive() {
let dir = tempdir().unwrap();
let binary_path = dir.path().join("myapp");
std::fs::write(&binary_path, b"fake binary contents").unwrap();
let tarball_path = dir.path().join("out.tar.gz");
create_tarball(&binary_path, "myapp", &tarball_path).unwrap();
assert!(tarball_path.exists());
assert!(std::fs::metadata(&tarball_path).unwrap().len() > 0);
}
#[test]
fn create_tarball_errors_when_binary_missing() {
let dir = tempdir().unwrap();
let binary_path = dir.path().join("missing");
let tarball_path = dir.path().join("out.tar.gz");
assert!(create_tarball(&binary_path, "missing", &tarball_path).is_err());
}
#[tokio::test]
async fn handle_upload_errors_when_not_a_project() {
let dir = tempdir().unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(dir.path().join("mecha10.json")))
.build()
.unwrap();
let err = handle_upload(&mut ctx, &no_op_upload_args()).await.unwrap_err();
assert!(err.to_string().contains("Not in a Mecha10 project"));
}
#[tokio::test]
async fn handle_upload_errors_when_binary_missing() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "0.1.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let err = handle_upload(&mut ctx, &no_op_upload_args()).await.unwrap_err();
assert!(err.to_string().contains("Binary not found"));
}
#[tokio::test]
async fn handle_upload_errors_when_config_missing() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "0.1.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let target_dir = dir.path().join("target").join("release");
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::write(target_dir.join("proj"), b"binary").unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let mut args = no_op_upload_args();
args.config = Some(dir.path().join("nonexistent-config.json"));
let err = handle_upload(&mut ctx, &args).await.unwrap_err();
assert!(err.to_string().contains("Config not found"));
}
#[tokio::test]
async fn handle_upload_errors_when_target_unknown() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "0.1.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let target_dir = dir.path().join("target").join("release");
std::fs::create_dir_all(&target_dir).unwrap();
std::fs::write(target_dir.join("proj"), b"binary").unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let err = handle_upload(&mut ctx, &no_op_upload_args()).await.unwrap_err();
assert!(err.to_string().contains("--target is required"));
}
}