use crate::commands::{BuildSource, BuildTargetArgs};
use crate::context::CliContext;
use crate::handlers::build::BuildTarget;
use crate::services::build_job_client::{self, BuildJobState, GithubConnection};
use crate::services::credentials::CredentialsService;
use crate::ui::prompts::{self, is_interactive_terminal};
use crate::ui::style;
use anyhow::{Context, Result};
use std::time::Duration;
const POLL_INTERVAL: Duration = Duration::from_secs(3);
const GITHUB_CONNECT_POLL_ROUNDS_BEFORE_PROMPT: u32 = 20;
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 interactive = is_interactive_terminal() && !args.non_interactive;
let connection = resolve_build_source(args.source, &control_plane_url, &credentials.api_key, interactive).await?;
println!(
"Source: GitHub ({})",
connection.repo_full_name.as_deref().unwrap_or("?")
);
println!();
style::step("Creating build job from the connected GitHub repo (no zip, no upload)...");
let created = build_job_client::create_build_job_from_github(build_job_client::CreateBuildJobFromGithubParams {
control_plane_url: &control_plane_url,
robot_id: &robot_id,
connection_id: &connection.id,
target_platform: &target_platform,
target_arch: &target_arch,
config: None,
api_key: &credentials.api_key,
})
.await
.context("Failed to create build job from GitHub")?;
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 resolve_build_source(
explicit: Option<BuildSource>,
control_plane_url: &str,
api_key: &str,
interactive: bool,
) -> Result<GithubConnection> {
let connections = build_job_client::list_github_connections(control_plane_url, api_key)
.await
.context("Failed to check for a connected GitHub repo")?;
if let Some(connection) = pick_connected_repo(connections) {
return Ok(connection);
}
if matches!(explicit, Some(BuildSource::Github)) {
return connect_github_interactively(control_plane_url, api_key, interactive)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"No connected GitHub repo, and --source github was requested. Connect a \
repo (this requires an interactive terminal) and try again."
)
});
}
if !interactive {
return Err(anyhow::anyhow!(
"A GitHub connection is required to build via the dashboard. Connecting one \
requires an interactive terminal."
));
}
connect_github_interactively(control_plane_url, api_key, interactive)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"A GitHub connection is required to build via the dashboard without uploading a \
zip."
)
})
}
fn pick_connected_repo(connections: Vec<GithubConnection>) -> Option<GithubConnection> {
connections.into_iter().find(|c| c.repo_full_name.is_some())
}
async fn connect_github_interactively(
control_plane_url: &str,
api_key: &str,
interactive: bool,
) -> Result<Option<GithubConnection>> {
if !interactive {
return Ok(None);
}
println!("{}", style::warn("⚠️ No connected GitHub repo found."));
if !prompts::confirm("Connect a GitHub repo now?", true)? {
return Ok(None);
}
let start = build_job_client::start_github_install(control_plane_url, api_key)
.await
.context("Failed to start the GitHub App install flow")?;
println!();
println!("Open this URL in a browser to connect your GitHub repo:");
println!(" {}", start.install_url);
println!();
loop {
style::step("Waiting for the connection to complete...");
for _ in 0..GITHUB_CONNECT_POLL_ROUNDS_BEFORE_PROMPT {
let connections = build_job_client::list_github_connections(control_plane_url, api_key)
.await
.context("Failed to check GitHub connection status")?;
if let Some(connection) = pick_connected_repo(connections) {
println!(
"{}",
style::success(format!(
"✅ Connected: {}",
connection.repo_full_name.as_deref().unwrap_or("?")
))
);
println!();
return Ok(Some(connection));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
if !prompts::confirm("Still waiting for the GitHub connection - keep polling?", true)? {
return Ok(None);
}
}
}
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::*;
use std::io::{Read, Write};
fn connection(id: &str, repo_full_name: Option<&str>) -> GithubConnection {
GithubConnection {
id: id.to_string(),
repo_full_name: repo_full_name.map(str::to_string),
}
}
#[test]
fn pick_connected_repo_returns_none_when_list_is_empty() {
assert!(pick_connected_repo(Vec::new()).is_none());
}
#[test]
fn pick_connected_repo_skips_connections_without_a_repo_selected() {
let connections = vec![connection("conn-1", None), connection("conn-2", None)];
assert!(pick_connected_repo(connections).is_none());
}
#[test]
fn pick_connected_repo_returns_the_first_with_a_repo_selected() {
let connections = vec![
connection("conn-1", None),
connection("conn-2", Some("mecha-industries/mecha10")),
];
let picked = pick_connected_repo(connections).unwrap();
assert_eq!(picked.id, "conn-2");
assert_eq!(picked.repo_full_name, Some("mecha-industries/mecha10".to_string()));
}
fn start_fake_server(response_body: String) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let mut buf = [0u8; 65536];
let _ = stream.read(&mut buf);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response_body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(response_body.as_bytes());
let _ = stream.flush();
}
});
format!("http://{}", addr)
}
#[tokio::test]
async fn resolve_build_source_uses_github_when_a_connected_repo_exists() {
let body = serde_json::json!({
"connections": [{
"id": "conn-1",
"installation_id": 1,
"account_login": "mecha-industries",
"repo_full_name": "mecha-industries/mecha10",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}],
"total": 1
})
.to_string();
let url = start_fake_server(body);
let connection = resolve_build_source(None, &url, "key", false).await.unwrap();
assert_eq!(connection.repo_full_name, Some("mecha-industries/mecha10".to_string()));
}
#[tokio::test]
async fn resolve_build_source_errors_when_non_interactive_with_no_connection() {
let url = start_fake_server(serde_json::json!({"connections": [], "total": 0}).to_string());
let err = resolve_build_source(None, &url, "key", false).await.unwrap_err();
assert!(err.to_string().contains("GitHub connection is required"));
}
#[tokio::test]
async fn resolve_build_source_errors_when_auto_and_non_interactive_with_no_connection() {
let url = start_fake_server(serde_json::json!({"connections": [], "total": 0}).to_string());
let err = resolve_build_source(Some(BuildSource::Auto), &url, "key", false)
.await
.unwrap_err();
assert!(err.to_string().contains("GitHub connection is required"));
}
#[tokio::test]
async fn resolve_build_source_errors_when_source_github_and_non_interactive_with_no_connection() {
let url = start_fake_server(serde_json::json!({"connections": [], "total": 0}).to_string());
let err = resolve_build_source(Some(BuildSource::Github), &url, "key", false)
.await
.unwrap_err();
assert!(err.to_string().contains("--source github was requested"));
}
#[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");
}
}