use crate::commands::BuildTargetArgs;
use crate::context::CliContext;
use crate::handlers::build::BuildTarget;
use crate::services::build_job_client::{self, BuildJobState};
use crate::services::build_source;
use crate::services::credentials::CredentialsService;
use crate::ui::style;
use anyhow::{Context, Result};
use std::time::Duration;
use tempfile::tempdir;
const POLL_INTERVAL: Duration = Duration::from_secs(3);
pub async fn build_via_dashboard(ctx: &CliContext, target: BuildTarget, args: &BuildTargetArgs) -> Result<()> {
println!();
println!("{}", style::heading("☁️ Building via the mecha10 dashboard"));
println!("{}", style::separator(47));
println!();
let project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
let robot_id = project_config.robot.id.clone();
let credentials_service = CredentialsService::new();
let credentials = mecha10_auth::ensure_authenticated_interactive(&credentials_service)
.await
.context("Remote builds require you to be logged in")?;
let (target_platform, target_arch) = platform_arch_for_target(args.target.as_deref());
println!("Robot ID: {}", robot_id);
println!("Target: {} ({})", target.display_name(), target.as_str());
println!("Target platform: {}", target_platform);
println!("Target arch: {}", target_arch);
println!();
let (_temp_dir, zip_path) = package_project_source(&ctx.working_dir).await?;
println!();
println!("Uploading source and creating build job...");
let created = build_job_client::create_build_job(build_job_client::CreateBuildJobParams {
control_plane_url: &control_plane_url,
robot_id: &robot_id,
target_platform: &target_platform,
target_arch: &target_arch,
config: None,
api_key: &credentials.api_key,
zip_path: &zip_path,
})
.await
.context("Failed to create build job")?;
println!("{}", style::success(format!("✅ Build job created: {}", created.id)));
println!();
poll_until_terminal(&control_plane_url, &robot_id, &created.id, &credentials.api_key).await
}
async fn package_project_source(project_root: &std::path::Path) -> Result<(tempfile::TempDir, std::path::PathBuf)> {
println!(
"Packaging project source ({} applied)...",
build_source::IGNORE_FILE_NAME
);
let temp_dir = tempdir().context("Failed to create a temp directory for the source zip")?;
let zip_path = temp_dir.path().join("source.zip");
let project_root = project_root.to_path_buf();
let zip_path_for_blocking = zip_path.clone();
let zip_size = tokio::task::spawn_blocking(move || -> Result<u64> {
let ignore_set = build_source::build_ignore_set(&project_root)?;
let file = std::fs::File::create(&zip_path_for_blocking)
.with_context(|| format!("Failed to create {}", zip_path_for_blocking.display()))?;
build_source::write_project_zip(&project_root, &ignore_set, file)?;
std::fs::metadata(&zip_path_for_blocking)
.map(|m| m.len())
.context("Failed to read constructed zip's size")
})
.await
.context("Zip-packaging task panicked")??;
println!(" Source zip: {} ({} bytes)", zip_path.display(), zip_size);
build_source::check_zip_size(zip_size)?;
Ok((temp_dir, zip_path))
}
fn platform_arch_for_target(target_triple: Option<&str>) -> (String, String) {
match target_triple {
Some(triple) => {
let arch = triple.split('-').next().unwrap_or(triple).to_string();
let platform = if triple.contains("-linux-") {
"linux"
} else if triple.contains("-darwin") {
"macos"
} else if triple.contains("-windows-") {
"windows"
} else {
"linux"
};
(platform.to_string(), arch)
}
None => (host_platform(), std::env::consts::ARCH.to_string()),
}
}
fn host_platform() -> String {
std::env::consts::OS.to_string()
}
async fn poll_until_terminal(control_plane_url: &str, robot_id: &str, id: &str, api_key: &str) -> Result<()> {
let mut last_reported: Option<String> = None;
loop {
let job = build_job_client::get_build_job(control_plane_url, robot_id, id, api_key)
.await
.context("Failed to poll build job status")?;
let status_line = format_status_line(&job);
if last_reported.as_deref() != Some(status_line.as_str()) {
println!("Status: {}", status_line);
last_reported = Some(status_line);
}
match job.state {
BuildJobState::Succeeded => {
println!();
println!("{}", style::success("✅ Build succeeded!"));
println!("The binary is now available in the mecha10 binary registry.");
println!();
return Ok(());
}
BuildJobState::Failed => {
println!();
println!("{}", style::error("❌ Build failed"));
if let Some(msg) = job.error_message.as_deref() {
println!("{}", style::error(msg));
}
println!();
return Err(anyhow::anyhow!(
"Remote build failed{}",
job.error_message
.as_deref()
.map(|m| format!(": {m}"))
.unwrap_or_default()
));
}
BuildJobState::Pending | BuildJobState::Running => {
tokio::time::sleep(POLL_INTERVAL).await;
}
}
}
}
fn format_status_line(job: &build_job_client::BuildJob) -> String {
let state_label = match job.state {
BuildJobState::Pending => "pending",
BuildJobState::Running => "running",
BuildJobState::Succeeded => "succeeded",
BuildJobState::Failed => "failed",
};
match job.progress_message.as_deref() {
Some(message) if !message.is_empty() => format!("{state_label} - {message}"),
_ => state_label.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_arch_for_target_falls_back_to_host_when_no_triple_given() {
let (platform, arch) = platform_arch_for_target(None);
assert_eq!(platform, std::env::consts::OS);
assert_eq!(arch, std::env::consts::ARCH);
}
#[test]
fn platform_arch_for_target_parses_a_linux_gnu_triple() {
let (platform, arch) = platform_arch_for_target(Some("aarch64-unknown-linux-gnu"));
assert_eq!(platform, "linux");
assert_eq!(arch, "aarch64");
}
#[test]
fn platform_arch_for_target_parses_a_linux_musl_triple() {
let (platform, arch) = platform_arch_for_target(Some("x86_64-unknown-linux-musl"));
assert_eq!(platform, "linux");
assert_eq!(arch, "x86_64");
}
#[test]
fn platform_arch_for_target_parses_a_darwin_triple() {
let (platform, arch) = platform_arch_for_target(Some("aarch64-apple-darwin"));
assert_eq!(platform, "macos");
assert_eq!(arch, "aarch64");
}
fn job(state: BuildJobState, progress: Option<&str>) -> build_job_client::BuildJob {
build_job_client::BuildJob {
state,
error_message: None,
progress_message: progress.map(str::to_string),
}
}
#[test]
fn format_status_line_without_progress_message_is_just_the_state() {
assert_eq!(format_status_line(&job(BuildJobState::Pending, None)), "pending");
}
#[test]
fn format_status_line_appends_a_non_empty_progress_message() {
assert_eq!(
format_status_line(&job(BuildJobState::Running, Some("compiling"))),
"running - compiling"
);
}
#[test]
fn format_status_line_ignores_an_empty_progress_message() {
assert_eq!(format_status_line(&job(BuildJobState::Running, Some(""))), "running");
}
}