use crate::commands::InitArgs;
use crate::context::CliContext;
use crate::init::validation;
use crate::paths;
use crate::services::{catalog_client, CredentialsService};
use crate::types::EnvironmentsConfig;
use crate::ui::init_wizard::run_wizard_tui;
use crate::ui::prompts::is_interactive_terminal;
use crate::ui::style;
use anyhow::{Context, Result};
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
use mecha10_robot_config::RobotConfigClient;
use mecha10_templates::services::assets_download::copy_dir_recursive;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
type InitProcessResult = (String, PathBuf, Option<String>, Vec<String>, String, Vec<String>);
async fn cleanup_on_error(project_path: &Path) {
if project_path.exists() {
if let Err(e) = tokio::fs::remove_dir_all(project_path).await {
eprintln!("{}", style::warn(format!("⚠️ Failed to cleanup directory: {}", e)));
} else {
println!("🧹 Cleaned up incomplete project directory");
}
}
}
async fn download_templates(ctx: &mut CliContext) -> Result<String> {
let template_download = ctx.template_download();
let version = template_download.resolve_version().await?;
template_download.ensure_templates(&version).await?;
Ok(version)
}
async fn copy_downloaded_templates(
ctx: &mut CliContext,
project_path: &Path,
version: &str,
assets_root: &Path,
) -> Result<()> {
let template_download = ctx.template_download();
template_download.copy_template_dir(
version,
"configs/nodes/@mecha10",
&project_path.join(paths::config::NODES_MECHA10_DIR),
)?;
template_download.copy_template_dir(
version,
"nodes/@mecha10",
&project_path.join(paths::project::NODES_MECHA10_DIR),
)?;
copy_dir_recursive(
&assets_root.join("configs/behaviors/@mecha10"),
&project_path.join(paths::project::BEHAVIORS_DIR).join("@mecha10"),
)?;
copy_dir_recursive(
&assets_root.join("configs/simulation/images"),
&project_path.join(paths::project::SIMULATION_IMAGES_DIR),
)?;
copy_dir_recursive(
&assets_root.join("configs/simulation/models"),
&project_path.join(paths::project::SIMULATION_MODELS_DIR),
)?;
copy_dir_recursive(
&assets_root.join("configs/simulation/environments"),
&project_path.join(paths::project::SIMULATION_ENVIRONMENTS_DIR),
)?;
copy_dir_recursive(
&assets_root.join("configs/rl/tasks"),
&project_path.join(paths::project::RL_TASKS_DIR),
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn create_mecha10_json_from_template(
project_path: &Path,
project_name: &str,
template: &Option<String>,
nodes: &[String],
template_version: &str,
initial_version: &str,
robot_platform: &str,
remote_platform: &[String],
robot_id: &str,
robot_name: &str,
assets_root: &Path,
) -> Result<()> {
let platform = template.as_deref().unwrap_or("rover");
let project_id = project_name.replace('-', "_");
let version = template_version;
let template_path = assets_root.join("mecha10.json.template");
let content = tokio::fs::read_to_string(&template_path).await.with_context(|| {
format!("Failed to read mecha10.json.template from downloaded mecha10-assets at {template_path:?}")
})?;
let mut config_str = content
.replace("{{project_name}}", project_name)
.replace("{{project_id}}", &project_id)
.replace("{{platform}}", platform);
let mut config: serde_json::Value = serde_json::from_str(&config_str)?;
config["template_version"] = serde_json::json!(version);
if version != mecha10_templates::services::template_download::DEV_VERSION {
config["version"] = serde_json::json!(initial_version);
}
config["robot_id"] = serde_json::json!(robot_id);
config["robot_name"] = serde_json::json!(robot_name);
set_robot_platform(&mut config, robot_platform);
set_remote_platforms(&mut config, remote_platform);
set_simulation_model(&mut config, platform);
let node_targets: HashMap<String, Vec<String>> = catalog_client::fetch_node_catalog()
.await
.context("Failed to fetch node catalog - mecha10 init cannot continue without it")?
.into_iter()
.map(|entry| (entry.name, entry.targets))
.collect();
build_node_placement(&mut config, nodes, &node_targets);
config_str = serde_json::to_string_pretty(&config)?;
tokio::fs::write(project_path.join(paths::PROJECT_CONFIG), config_str).await?;
Ok(())
}
fn set_robot_platform(config: &mut serde_json::Value, robot_platform: &str) {
config["targets"]["robot"]["platforms"] = serde_json::json!([robot_platform]);
}
fn set_remote_platforms(config: &mut serde_json::Value, remote_platform: &[String]) {
config["targets"]["remote"]["platforms"] = serde_json::json!(remote_platform);
}
const SIMULATION_MODEL_TEMPLATES: [&str; 4] = ["rover", "drone", "arm", "sentry"];
fn set_simulation_model(config: &mut serde_json::Value, platform: &str) {
if SIMULATION_MODEL_TEMPLATES.contains(&platform) {
if let Some(simulation) = config.get_mut("simulation") {
simulation["model"] = serde_json::Value::String(platform.to_string());
}
}
}
#[cfg(test)]
mod set_robot_platform_tests {
use super::set_robot_platform;
use serde_json::json;
#[test]
fn overwrites_robot_platforms_leaving_remote_untouched() {
let mut config = json!({
"targets": {
"robot": { "platforms": ["aarch64-unknown-linux-gnu"] },
"remote": { "platforms": ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] },
},
});
set_robot_platform(&mut config, "x86_64-unknown-linux-gnu");
assert_eq!(
config["targets"]["robot"]["platforms"],
json!(["x86_64-unknown-linux-gnu"])
);
assert_eq!(
config["targets"]["remote"]["platforms"],
json!(["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"])
);
}
}
#[cfg(test)]
mod set_remote_platforms_tests {
use super::set_remote_platforms;
use serde_json::json;
#[test]
fn overwrites_remote_platforms_leaving_robot_untouched() {
let mut config = json!({
"targets": {
"robot": { "platforms": ["aarch64-unknown-linux-gnu"] },
"remote": { "platforms": ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] },
},
});
set_remote_platforms(
&mut config,
&[
"aarch64-apple-darwin".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
],
);
assert_eq!(
config["targets"]["remote"]["platforms"],
json!(["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"])
);
assert_eq!(
config["targets"]["robot"]["platforms"],
json!(["aarch64-unknown-linux-gnu"])
);
}
#[test]
fn when_default_remote_platform_is_preserved_it_is_generic_linux() {
let mut config = json!({ "targets": { "remote": { "platforms": ["something-stale"] } } });
set_remote_platforms(&mut config, &super::resolve_remote_platforms(None));
assert_eq!(
config["targets"]["remote"]["platforms"],
json!(["x86_64-unknown-linux-gnu"])
);
}
}
fn resolve_robot_platform(input: Option<&str>) -> String {
use crate::services::build_platforms::BUILD_PLATFORMS;
match input {
None => BUILD_PLATFORMS[0].triple.to_string(),
Some(value) => BUILD_PLATFORMS
.iter()
.find(|p| p.triple == value || p.label.eq_ignore_ascii_case(value))
.map_or_else(|| value.to_string(), |p| p.triple.to_string()),
}
}
#[cfg(test)]
mod set_simulation_model_tests {
use super::set_simulation_model;
use serde_json::json;
#[test]
fn sets_model_for_each_known_template() {
for platform in ["rover", "drone", "arm", "sentry"] {
let mut config =
json!({ "simulation": { "model": "rover", "environment": "basic_arena", "preset": "default" } });
set_simulation_model(&mut config, platform);
assert_eq!(config["simulation"]["model"], json!(platform));
assert_eq!(config["simulation"]["environment"], json!("basic_arena"));
assert_eq!(config["simulation"]["preset"], json!("default"));
}
}
#[test]
fn leaves_default_model_untouched_for_unrecognized_template() {
let mut config =
json!({ "simulation": { "model": "rover", "environment": "basic_arena", "preset": "default" } });
set_simulation_model(&mut config, "some-future-template");
assert_eq!(config["simulation"]["model"], json!("rover"));
}
}
#[cfg(test)]
mod resolve_robot_platform_tests {
use super::resolve_robot_platform;
#[test]
fn defaults_to_raspberry_pi_when_omitted() {
assert_eq!(resolve_robot_platform(None), "aarch64-unknown-linux-gnu");
}
#[test]
fn accepts_a_known_triple_unchanged() {
assert_eq!(
resolve_robot_platform(Some("x86_64-unknown-linux-gnu")),
"x86_64-unknown-linux-gnu"
);
}
#[test]
fn matches_a_label_case_insensitively() {
assert_eq!(
resolve_robot_platform(Some("raspberry pi 4/5 (64-bit linux)")),
"aarch64-unknown-linux-gnu"
);
}
#[test]
fn passes_through_an_unrecognized_value() {
assert_eq!(
resolve_robot_platform(Some("riscv64-unknown-linux-gnu")),
"riscv64-unknown-linux-gnu"
);
}
}
const DEFAULT_REMOTE_PLATFORMS: [&str; 1] = ["x86_64-unknown-linux-gnu"];
fn resolve_remote_platforms(input: Option<&str>) -> Vec<String> {
use crate::services::build_platforms::BUILD_PLATFORMS;
match input {
None => DEFAULT_REMOTE_PLATFORMS.iter().map(|s| s.to_string()).collect(),
Some(value) => value
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(|entry| {
BUILD_PLATFORMS
.iter()
.find(|p| p.triple == entry || p.label.eq_ignore_ascii_case(entry))
.map_or_else(|| entry.to_string(), |p| p.triple.to_string())
})
.collect(),
}
}
#[cfg(test)]
mod resolve_remote_platforms_tests {
use super::resolve_remote_platforms;
#[test]
fn defaults_to_generic_linux_when_omitted() {
assert_eq!(
resolve_remote_platforms(None),
vec!["x86_64-unknown-linux-gnu".to_string()]
);
}
#[test]
fn parses_a_comma_separated_list_of_known_triples() {
assert_eq!(
resolve_remote_platforms(Some("x86_64-unknown-linux-gnu,aarch64-apple-darwin")),
vec![
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-apple-darwin".to_string()
]
);
}
#[test]
fn matches_labels_case_insensitively_and_trims_whitespace() {
assert_eq!(
resolve_remote_platforms(Some(
" generic 64-bit linux (x86_64) , Apple Silicon Mac (dev/simulation only) "
)),
vec![
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-apple-darwin".to_string()
]
);
}
#[test]
fn passes_through_unrecognized_entries() {
assert_eq!(
resolve_remote_platforms(Some("riscv64-unknown-linux-gnu")),
vec!["riscv64-unknown-linux-gnu".to_string()]
);
}
}
fn build_node_placement(config: &mut serde_json::Value, nodes: &[String], node_targets: &HashMap<String, Vec<String>>) {
const ALWAYS_INCLUDED: [&str; 2] = ["websocket-bridge", "diagnostics"];
let mut selected: Vec<&str> = nodes.iter().map(|s| s.as_str()).collect();
for always in ALWAYS_INCLUDED {
if node_targets.contains_key(always) && !selected.contains(&always) {
selected.push(always);
}
}
let nodes_map: serde_json::Map<String, serde_json::Value> = selected
.iter()
.map(|id| (format!("@mecha10/{id}"), serde_json::json!(env!("CARGO_PKG_VERSION"))))
.collect();
config["nodes"] = serde_json::Value::Object(nodes_map);
let mut placement: HashMap<&str, Vec<String>> = HashMap::new();
for id in &selected {
let Some(targets) = node_targets.get(*id) else {
continue;
};
let identifier = format!("@mecha10/{id}");
for target in targets {
placement.entry(target.as_str()).or_default().push(identifier.clone());
}
}
if let Some(targets) = config.get_mut("targets").and_then(|v| v.as_object_mut()) {
for (key, target_config) in targets.iter_mut() {
target_config["nodes"] = serde_json::json!(placement.get(key.as_str()).cloned().unwrap_or_default());
}
mecha10_cli_core::types::project::reconcile_video_streamer_camera_id(targets);
}
}
#[cfg(test)]
mod build_node_placement_tests {
use super::build_node_placement;
use serde_json::json;
use std::collections::HashMap;
fn node_targets(pairs: &[(&str, &[&str])]) -> HashMap<String, Vec<String>> {
pairs
.iter()
.map(|(id, targets)| (id.to_string(), targets.iter().map(|t| t.to_string()).collect()))
.collect()
}
#[test]
fn places_each_selected_node_into_exactly_the_targets_its_catalog_entry_names() {
let mut config = json!({
"nodes": {},
"targets": { "robot": {}, "remote": {} },
});
let catalog = node_targets(&[
("motor", &["robot"]),
("camera", &["robot"]),
("llm-command", &["remote"]),
]);
build_node_placement(&mut config, &["motor".to_string(), "llm-command".to_string()], &catalog);
assert_eq!(config["nodes"].as_object().unwrap().len(), 2);
assert!(config["nodes"].get("@mecha10/motor").is_some());
assert!(config["nodes"].get("@mecha10/llm-command").is_some());
assert!(
config["nodes"].get("@mecha10/camera").is_none(),
"an unselected node must not appear in the built nodes map"
);
assert_eq!(config["targets"]["robot"]["nodes"], json!(["@mecha10/motor"]));
assert_eq!(config["targets"]["remote"]["nodes"], json!(["@mecha10/llm-command"]));
}
#[test]
fn always_includes_websocket_bridge_and_diagnostics_even_when_unselected() {
let mut config = json!({
"nodes": {},
"targets": { "dev": {}, "robot": {} },
});
let catalog = node_targets(&[
("websocket-bridge", &["dev", "robot"]),
("diagnostics", &["dev", "robot"]),
("motor", &["robot"]),
]);
build_node_placement(&mut config, &[], &catalog);
let nodes = config["nodes"].as_object().unwrap();
assert!(nodes.contains_key("@mecha10/websocket-bridge"));
assert!(nodes.contains_key("@mecha10/diagnostics"));
assert!(!nodes.contains_key("@mecha10/motor"));
let dev_nodes = config["targets"]["dev"]["nodes"].as_array().unwrap();
let dev_nodes: Vec<&str> = dev_nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(dev_nodes.contains(&"@mecha10/websocket-bridge"));
assert!(dev_nodes.contains(&"@mecha10/diagnostics"));
}
#[test]
fn a_node_in_multiple_targets_is_placed_in_every_one() {
let mut config = json!({
"nodes": {},
"targets": { "simulation": {}, "remote": {} },
});
let catalog = node_targets(&[("image-classifier", &["simulation", "remote"])]);
build_node_placement(&mut config, &["image-classifier".to_string()], &catalog);
assert_eq!(
config["targets"]["simulation"]["nodes"],
json!(["@mecha10/image-classifier"])
);
assert_eq!(
config["targets"]["remote"]["nodes"],
json!(["@mecha10/image-classifier"])
);
}
#[test]
fn a_selected_node_absent_from_the_live_catalog_is_still_listed_but_unplaced() {
let mut config = json!({ "nodes": {}, "targets": { "robot": {} } });
build_node_placement(&mut config, &["ghost-node".to_string()], &HashMap::new());
assert!(config["nodes"].get("@mecha10/ghost-node").is_some());
assert_eq!(config["targets"]["robot"]["nodes"], json!([]));
}
#[test]
fn video_streamer_gets_a_camera_id_per_target_when_selected_alongside_both_camera_sources() {
let mut config = json!({
"nodes": {},
"targets": { "simulation": {}, "robot": {} },
});
let catalog = node_targets(&[
("simulator", &["simulation"]),
("camera", &["robot"]),
("video-streamer", &["simulation", "robot"]),
]);
build_node_placement(
&mut config,
&[
"simulator".to_string(),
"camera".to_string(),
"video-streamer".to_string(),
],
&catalog,
);
assert_eq!(
config["targets"]["simulation"]["nodes"],
json!(["@mecha10/simulator", { "name": "@mecha10/video-streamer", "camera_id": "simulation_camera" }])
);
assert_eq!(
config["targets"]["robot"]["nodes"],
json!(["@mecha10/camera", { "name": "@mecha10/video-streamer", "camera_id": "camera" }])
);
}
}
async fn resolve_robot_link(project_name: &str, interactive: bool) -> Result<(String, String, bool)> {
let credentials_service = CredentialsService::new();
let credentials = if crate::context::is_ci_environment() {
credentials_service
.load()?
.ok_or_else(|| anyhow::anyhow!("Not logged in. Run `mecha10 auth login` first."))?
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service).await?
};
let control_plane_url = EnvironmentsConfig::default().control_plane_url();
let config_url = format!("{}/api/robots", control_plane_url.trim_end_matches('/'));
let client = RobotConfigClient::with_user_id(config_url, credentials.api_key, Some(credentials.user_id));
let theme = ColorfulTheme::default();
let mut candidate_name = project_name.to_string();
loop {
if interactive {
candidate_name = loop {
match Input::with_theme(&theme)
.with_prompt("Robot name")
.with_initial_text(&candidate_name)
.interact_text()
{
Ok(name) => break name,
Err(dialoguer::Error::IO(ref e)) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(anyhow::anyhow!("Failed to read robot name: {e}")),
}
};
}
let response = client
.register_robot(&candidate_name)
.await
.map_err(|e| anyhow::anyhow!("Failed to register robot '{candidate_name}': {e}"))?;
if response.created {
println!(
"{}",
style::success(format!("✅ Registered robot '{}' (id: {})", response.name, response.id))
);
return Ok((response.id, response.name, true));
}
if !interactive {
println!(
"ℹ️ Linking to already-registered robot '{}' (id: {})",
response.name, response.id
);
return Ok((response.id, response.name, false));
}
let link = Confirm::new()
.with_prompt(format!(
"A project named '{}' is already registered to your account (id: {}). Link this new project to it?",
response.name, response.id
))
.default(true)
.interact()
.map_err(|e| anyhow::anyhow!("Failed to read confirmation: {e}"))?;
if link {
println!("🔗 Linked to robot '{}' (id: {})", response.name, response.id);
return Ok((response.id, response.name, false));
}
}
}
async fn resolve_initial_project_version(robot_id: &str, robot_created: bool, template_version: &str) -> String {
if robot_created {
return template_version.to_string();
}
let credentials_service = CredentialsService::new();
let Ok(Some(credentials)) = credentials_service.load() else {
return template_version.to_string();
};
let control_plane_url = EnvironmentsConfig::default().control_plane_url();
let mut candidates: Vec<(String, String)> = Vec::new();
for target in BUILD_TARGETS {
match crate::services::latest_build_version::fetch_latest_build_version(
&control_plane_url,
robot_id,
target,
&credentials.api_key,
&credentials.user_id,
)
.await
{
Ok(Some(version)) => candidates.push((version, format!("{target} build"))),
Ok(None) => {}
Err(e) => {
println!(
"⚠️ Could not look up robot '{}''s last {} build version: {e:#}",
robot_id, target
);
}
}
}
let config_url = format!(
"{}{}",
control_plane_url.trim_end_matches('/'),
crate::handlers::config::ROBOT_CONFIG_PATH
);
let config_client = RobotConfigClient::with_user_id(
config_url,
credentials.api_key.clone(),
Some(credentials.user_id.clone()),
);
if let Ok(response) = config_client.get_config(robot_id, "mecha10.json").await {
if let Some(version) = response.config_value.get("version").and_then(|v| v.as_str()) {
candidates.push((version.to_string(), "last pushed config".to_string()));
}
}
match pick_highest_published_version(robot_id, candidates) {
Some(version) => {
println!(
"📦 Using highest published build/config version v{} from robot '{}'",
version, robot_id
);
version
}
None => template_version.to_string(),
}
}
fn pick_highest_published_version(robot_id: &str, candidates: Vec<(String, String)>) -> Option<String> {
let mut highest: Option<(u32, u32, u32, String)> = None;
for (version, source) in candidates {
match parse_semver_tuple(&version) {
Some(tuple) => {
let is_higher = match &highest {
Some((ma, mi, pa, _)) => tuple > (*ma, *mi, *pa),
None => true,
};
if is_higher {
highest = Some((tuple.0, tuple.1, tuple.2, version));
}
}
None => println!(
"⚠️ Ignoring unparsable {} version '{}' for robot '{}'.",
source, version, robot_id
),
}
}
highest.map(|(_, _, _, version)| version)
}
#[cfg(test)]
mod pick_highest_published_version_tests {
use super::pick_highest_published_version;
#[test]
fn picks_the_highest_build_target_version() {
let candidates = vec![
("1.2.0".to_string(), "robot build".to_string()),
("1.4.0".to_string(), "remote build".to_string()),
("1.3.0".to_string(), "rl build".to_string()),
];
assert_eq!(
pick_highest_published_version("robot-1", candidates),
Some("1.4.0".to_string())
);
}
#[test]
fn prefers_the_pushed_config_version_when_it_is_ahead_of_every_build() {
let candidates = vec![
("1.0.0".to_string(), "robot build".to_string()),
("1.1.0".to_string(), "remote build".to_string()),
("2.0.0".to_string(), "last pushed config".to_string()),
];
assert_eq!(
pick_highest_published_version("robot-1", candidates),
Some("2.0.0".to_string())
);
}
#[test]
fn prefers_a_build_version_when_it_is_ahead_of_the_pushed_config() {
let candidates = vec![
("3.0.0".to_string(), "robot build".to_string()),
("2.5.0".to_string(), "last pushed config".to_string()),
];
assert_eq!(
pick_highest_published_version("robot-1", candidates),
Some("3.0.0".to_string())
);
}
#[test]
fn ignores_unparsable_candidates_and_still_picks_the_best_valid_one() {
let candidates = vec![
("not-a-version".to_string(), "last pushed config".to_string()),
("1.5.0".to_string(), "robot build".to_string()),
];
assert_eq!(
pick_highest_published_version("robot-1", candidates),
Some("1.5.0".to_string())
);
}
#[test]
fn returns_none_when_no_candidates_parse() {
assert_eq!(pick_highest_published_version("robot-1", vec![]), None);
}
}
const BUILD_TARGETS: [&str; 3] = ["robot", "remote", "rl"];
fn parse_semver_tuple(version: &str) -> Option<(u32, u32, u32)> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() < 3 {
return None;
}
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = parts[2].split('-').next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
#[cfg(test)]
mod parse_semver_tuple_tests {
use super::*;
#[test]
fn parses_plain_version() {
assert_eq!(parse_semver_tuple("1.2.3"), Some((1, 2, 3)));
}
#[test]
fn ignores_prerelease_suffix() {
assert_eq!(parse_semver_tuple("1.2.3-beta"), Some((1, 2, 3)));
}
#[test]
fn rejects_too_few_components() {
assert_eq!(parse_semver_tuple("1.2"), None);
}
#[test]
fn rejects_non_numeric_components() {
assert_eq!(parse_semver_tuple("a.b.c"), None);
}
#[test]
fn orders_by_major_then_minor_then_patch() {
assert!(parse_semver_tuple("2.0.0") > parse_semver_tuple("1.9.9"));
assert!(parse_semver_tuple("1.3.0") > parse_semver_tuple("1.2.9"));
assert!(parse_semver_tuple("1.2.4") > parse_semver_tuple("1.2.3"));
}
}
async fn process_non_interactive_args(args: &InitArgs, working_dir: &Path) -> Result<InitProcessResult> {
use crate::services::catalog_client::{self, template_node_defaults};
let name = args.name.clone().ok_or_else(|| {
anyhow::anyhow!(
"Project name required in non-interactive mode.\n\
Usage: mecha10 init <PROJECT_NAME> [OPTIONS]\n\
Example: mecha10 init my-robot"
)
})?;
let path_buf = PathBuf::from(&name);
let (project_name, project_path) = if path_buf.is_absolute() || name.contains('/') || name.contains('\\') {
let final_name = path_buf
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&name)
.to_string();
(final_name, path_buf)
} else {
let final_path = working_dir.join(&name);
(name, final_path)
};
let template = args.template.clone();
let templates = catalog_client::fetch_template_catalog()
.await
.context("Failed to fetch template catalog - mecha10 init cannot continue without it")?;
let nodes: Vec<String> = {
let mut node_entries = catalog_client::fetch_node_catalog()
.await
.context("Failed to fetch node catalog - mecha10 init cannot continue without it")?;
node_entries.sort_by(|a, b| a.name.cmp(&b.name));
let node_ids: Vec<String> = node_entries.into_iter().map(|e| e.name).collect();
let defaults = template_node_defaults(&templates, &template, &node_ids);
let mut selected: Vec<String> = node_ids
.iter()
.enumerate()
.filter(|(i, _)| defaults[*i])
.map(|(_, id)| id.clone())
.collect();
if args.no_examples {
selected.retain(|id| id != "speaker" && id != "listener");
}
selected
};
let robot_platform = resolve_robot_platform(args.robot_platform.as_deref());
let remote_platform = resolve_remote_platforms(args.remote_platform.as_deref());
Ok((
project_name,
project_path,
template,
nodes,
robot_platform,
remote_platform,
))
}
pub async fn handle_init(ctx: &mut CliContext, args: &InitArgs) -> Result<()> {
if args.update {
return handle_update_existing(ctx, args).await;
}
print!("\x1B[2J\x1B[1;1H");
let credentials_service = CredentialsService::new();
if crate::context::is_ci_environment() {
if !credentials_service.is_logged_in() {
println!();
println!("You must be logged in to create a project.");
println!();
println!("Run `mecha10 auth login` to authenticate.");
println!();
return Ok(());
}
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service).await?;
}
println!();
println!("{}", style::heading("🤖 Initialize Mecha10 Project"));
println!();
let working_dir = ctx.working_dir.clone();
let catalog = ctx.component_catalog();
let use_wizard = is_interactive_terminal() && !args.non_interactive;
let (project_name, project_path, template, nodes, robot_platform, remote_platform) = if use_wizard {
let default_name = args.name.clone();
match run_wizard_tui(default_name.as_deref(), catalog).await {
Ok((name, template, selected_nodes, robot_platform, remote_platform)) => {
let path_buf = std::path::PathBuf::from(&name);
let (final_name, path) = if path_buf.is_absolute() || name.contains('/') || name.contains('\\') {
let final_name = path_buf
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&name)
.to_string();
(final_name, path_buf)
} else {
let final_path = ctx.working_dir.join(&name);
(name, final_path)
};
(
final_name,
path,
template,
selected_nodes,
robot_platform,
remote_platform,
)
}
Err(e) => {
println!("{}", style::warn(format!("⚠️ Wizard failed: {}", e)));
println!("Using command-line arguments...\n");
process_non_interactive_args(args, &working_dir).await?
}
}
} else {
if !is_interactive_terminal() {
println!("ℹ️ Non-interactive mode\n");
}
process_non_interactive_args(args, &working_dir).await?
};
validation::check_directory_not_exists(&project_path)?;
let (robot_id, robot_name, robot_created) = resolve_robot_link(&project_name, use_wizard).await?;
let template_version = download_templates(ctx).await.context(
"Failed to download project templates - mecha10 init cannot continue without them.\n\
This usually means either the network is unreachable, or the requested template \
version doesn't exist.\n\
\n\
Try:\n\
\x20 - Checking your network connection\n\
\x20 - Running `scripts/release/index.py --component templates --install` for a local \
dev build if you're iterating on template changes offline",
)?;
println!("📦 Using templates v{}", template_version);
let initial_version = resolve_initial_project_version(&robot_id, robot_created, &template_version).await;
let assets_dir = ctx.assets_download().fetch_and_extract().await.context(
"Failed to download mecha10-assets - mecha10 init cannot continue without them.\n\
This usually means the network is unreachable.\n\
\n\
Try checking your network connection.",
)?;
let assets_root = assets_dir.path().to_path_buf();
let result = perform_init(
ctx,
&project_path,
&project_name,
&template,
&nodes,
&template_version,
&initial_version,
&robot_platform,
&remote_platform,
&robot_id,
&robot_name,
&assets_root,
)
.await;
if let Err(e) = result {
cleanup_on_error(&project_path).await;
return Err(e);
}
let setup_ran = auto_run_setup(ctx, &project_path).await;
print_success_message(&project_name, setup_ran);
Ok(())
}
async fn auto_run_setup(ctx: &mut CliContext, project_path: &Path) -> bool {
let Ok(original_cwd) = std::env::current_dir() else {
return false;
};
if std::env::set_current_dir(project_path).is_err() {
return false;
}
let setup_args = crate::handlers::setup::SetupArgs {
check_only: false,
install_deps: true,
quiet: true,
};
let result = crate::handlers::setup::handle_setup(ctx, &setup_args).await;
let _ = std::env::set_current_dir(&original_cwd);
match result {
Ok(()) => true,
Err(e) => {
println!("{}", style::warn(format!("⚠️ Automatic setup failed: {}", e)));
println!(" Run `mecha10 setup` manually inside the project directory.");
false
}
}
}
async fn handle_update_existing(ctx: &mut CliContext, args: &InitArgs) -> Result<()> {
use crate::services::ConfigService;
println!();
println!("🔄 Updating existing project");
println!();
let project_path = if let Some(name) = &args.name {
let path = std::path::PathBuf::from(name);
if path.is_absolute() {
path
} else {
ctx.working_dir.join(name)
}
} else {
ctx.working_dir.clone()
};
let config_path = project_path.join(crate::paths::PROJECT_CONFIG);
if !config_path.exists() {
anyhow::bail!(
"No mecha10.json found at {}. Use 'mecha10 init' to create a new project.",
project_path.display()
);
}
let config = ConfigService::load_from(&config_path).await?;
let nodes: Vec<String> = config.nodes.0.keys().map(|s| s.to_string()).collect();
println!("📦 Found {} nodes in project", nodes.len());
let init_service = ctx.init_service();
let mut synced = 0;
for node_id in &nodes {
let node_name = node_id.strip_prefix("@mecha10/").unwrap_or(node_id);
let v2_dev_exists = project_path
.join("configs")
.join("dev")
.join("nodes")
.join(node_name)
.join("config.json")
.exists();
let legacy_exists = project_path
.join("configs")
.join("nodes")
.join("@mecha10")
.join(node_name)
.join("config.json")
.exists();
if !v2_dev_exists && !legacy_exists {
if let Err(e) = init_service.add_example_node(&project_path, node_name).await {
tracing::debug!("Could not add config for {}: {}", node_name, e);
} else {
println!("{}", style::success(format!("✅ Added config for: {}", node_name)));
synced += 1;
}
}
}
if synced > 0 {
println!();
println!("✨ Synced {} node configs", synced);
} else {
println!();
println!("{}", style::success("✅ All node configs already present"));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn perform_init(
ctx: &mut CliContext,
project_path: &Path,
project_name: &str,
template: &Option<String>,
nodes: &[String],
template_version: &str,
initial_version: &str,
robot_platform: &str,
remote_platform: &[String],
robot_id: &str,
robot_name: &str,
assets_root: &Path,
) -> Result<()> {
ctx.init_service().create_project_directories(project_path).await?;
copy_downloaded_templates(ctx, project_path, template_version, assets_root).await?;
create_mecha10_json_from_template(
project_path,
project_name,
template,
nodes,
template_version,
initial_version,
robot_platform,
remote_platform,
robot_id,
robot_name,
assets_root,
)
.await?;
println!("{}", style::success("✅ Robot registered"));
println!("{}", style::success("✅ Simulation assets"));
println!("{}", style::success("✅ Node configurations"));
ctx.project_template_service()
.create_readme(project_path, project_name, assets_root)
.await?;
ctx.project_template_service()
.create_gitignore(project_path, assets_root)
.await?;
ctx.project_template_service()
.create_package_json(project_path, project_name, assets_root)
.await?;
ctx.project_template_service()
.create_requirements_txt(project_path, assets_root)
.await?;
ctx.project_template_service()
.create_python_version(project_path, assets_root)
.await?;
if nodes.contains(&"simulator".to_string()) {
ctx.project_template_service()
.create_rl_training_bundle(project_path, assets_root)
.await?;
println!("{}", style::success("✅ RL training bundle (rover/ppo)"));
ctx.project_template_service()
.create_rl_docker_files(project_path, assets_root)
.await?;
println!(
"{}",
style::success("✅ rl target Docker files (run trained policies via `mecha10 run rl`)")
);
}
ctx.project_template_service()
.create_cargo_toml(project_path, project_name, assets_root)
.await?;
ctx.project_template_service()
.create_main_rs(project_path, project_name, assets_root)
.await?;
ctx.project_template_service()
.create_build_rs(project_path, assets_root)
.await?;
ctx.project_template_service()
.create_env_example(project_path, project_name, assets_root)
.await?;
ctx.project_template_service()
.create_rustfmt_toml(project_path, assets_root)
.await?;
ctx.project_template_service()
.create_docker_compose(project_path, project_name, assets_root)
.await?;
ctx.project_template_service()
.create_remote_docker_files(project_path, assets_root)
.await?;
println!("{}", style::success("✅ Project files"));
if !nodes.is_empty() {
for node_name in nodes {
if let Err(e) = ctx.init_service().add_example_node(project_path, node_name).await {
println!("{}", style::warn(format!("⚠️ Failed to add {}: {}", node_name, e)));
} else {
println!("{}", style::success(format!("✅ Node: {}", node_name)));
}
}
println!();
mecha10_robot_config::pull_node_catalog_configs(project_path, nodes, template.as_deref()).await;
}
Ok(())
}
fn print_success_message(project_name: &str, setup_ran: bool) {
println!("✨ Project {} created successfully!", project_name);
println!();
println!("📋 Next steps:");
if setup_ran {
println!(" 1. cd {} && source .venv/bin/activate", project_name);
println!(" 2. mecha10 dev");
} else {
println!(" 1. cd {}", project_name);
println!(" 2. mecha10 setup");
println!(" 3. source .venv/bin/activate");
println!(" 4. mecha10 dev");
}
println!();
println!(" Dashboard: {}", EnvironmentsConfig::default().dashboard_url());
println!();
}
#[cfg(test)]
mod mecha10_json_template_tests {
const TEMPLATE_JSON: &str = r#"{
"name": "{{project_name}}",
"version": "0.1.0",
"robot_id": "{{project_id}}",
"robot_name": "{{project_name}}",
"robot_platform": "{{platform}}",
"robot_description": "A Mecha10 robot project",
"actuator": {
"locomotion": {
"max_linear_vel": 10.0,
"max_angular_vel": 5.0,
"max_acceleration": 10.0
}
},
"behaviors": {
"active": "idle-wander"
},
"docker": {
"robot": ["redis"],
"remote": ["redis", "postgres"],
"compose_file": "docker/docker-compose.yml",
"auto_start": true
},
"environments": {
"staging": {
"logging": {
"disk_logging_enabled": false
}
},
"prod": {
"logging": {
"disk_logging_enabled": false
}
}
},
"nodes": {},
"targets": {
"dev": {},
"simulation": {},
"robot": {
"platforms": ["aarch64-unknown-linux-gnu"]
},
"remote": {
"platforms": ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
}
},
"simulation": {
"model": "rover",
"environment": "basic_arena",
"preset": "default"
}
}"#;
fn parsed_template() -> serde_json::Value {
serde_json::from_str(TEMPLATE_JSON).expect("TEMPLATE_JSON fixture must be valid JSON")
}
#[test]
fn top_level_nodes_map_starts_empty() {
let config = parsed_template();
assert_eq!(
config["nodes"].as_object().expect("nodes map must be an object").len(),
0,
"the template's top-level nodes map must be built entirely by build_node_placement \
from the live catalog, not pre-populated with any static node"
);
}
#[test]
fn no_target_declares_a_static_nodes_array() {
let config = parsed_template();
for target_name in ["dev", "simulation", "robot", "remote"] {
let target = &config["targets"][target_name];
assert!(
target.get("nodes").is_none(),
"targets.{target_name} must not pre-populate a static nodes array - placement \
comes exclusively from build_node_placement now, got {target:?}"
);
}
}
#[test]
fn robot_and_remote_still_declare_their_default_platforms() {
let config = parsed_template();
assert!(
config["targets"]["robot"]["platforms"]
.as_array()
.is_some_and(|a| !a.is_empty()),
"targets.robot.platforms is unrelated to node placement and must survive"
);
assert!(
config["targets"]["remote"]["platforms"]
.as_array()
.is_some_and(|a| !a.is_empty()),
"targets.remote.platforms is unrelated to node placement and must survive"
);
}
#[test]
fn declares_exactly_one_robot_target_key() {
let occurrences = TEMPLATE_JSON.matches("\"robot\": {").count();
assert_eq!(
occurrences, 1,
"mecha10.json.template must declare targets.robot exactly once, found {occurrences} occurrences"
);
}
#[test]
fn simulation_config_declares_a_preset_for_dev_preview() {
let config = parsed_template();
assert_eq!(
config["simulation"]["preset"].as_str(),
Some("default"),
"mecha10.json's simulation.preset drives mecha10 dev's plain preview/teleop \
session - it must be scaffolded, not left for `mecha10 dev` to fall back on a \
bundled RL task that no longer exists (LAB-3629)"
);
}
}
#[cfg(test)]
mod create_mecha10_json_from_template_error_tests {
use super::create_mecha10_json_from_template;
use crate::paths;
#[tokio::test]
async fn errors_loudly_instead_of_silently_falling_back_when_template_is_missing() {
let project_dir = tempfile::tempdir().unwrap();
let empty_assets_root = tempfile::tempdir().unwrap();
let unresolvable_version = "lab-2034-no-such-template-version-ever-cached";
let result = create_mecha10_json_from_template(
project_dir.path(),
"my-robot",
&None,
&[],
unresolvable_version,
unresolvable_version,
"aarch64-unknown-linux-gnu",
&[
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-unknown-linux-gnu".to_string(),
],
"11111111-1111-1111-1111-111111111111",
"my-robot",
empty_assets_root.path(),
)
.await;
let err = result.expect_err("a missing mecha10.json.template must produce an error, not a silent fallback");
let message = format!("{err:#}");
assert!(
message.contains("mecha10.json.template"),
"error should name what failed to help the user act on it, got: {message}"
);
assert!(
!project_dir.path().join(paths::PROJECT_CONFIG).exists(),
"mecha10.json must not be written when template resolution fails"
);
}
}