use crate::commands::InitArgs;
use crate::context::CliContext;
use crate::init::validation;
use crate::paths;
use crate::services::CredentialsService;
use crate::types::EnvironmentsConfig;
use crate::ui::init_wizard::run_wizard_tui;
use crate::ui::prompts::is_interactive_terminal;
use anyhow::{Context, Result};
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
use mecha10_robot_config::RobotConfigClient;
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!("⚠️ 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) -> 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_file(
version,
"configs/simulation/config.json",
&project_path.join(paths::config::SIMULATION_CONFIG),
)?;
template_download.copy_template_dir(
version,
"configs/behaviors",
&project_path.join(paths::project::BEHAVIORS_DIR),
)?;
template_download.copy_template_dir(
version,
"configs/simulation/images",
&project_path.join(paths::project::SIMULATION_IMAGES_DIR),
)?;
template_download.copy_template_dir(
version,
"configs/simulation/models",
&project_path.join(paths::project::SIMULATION_MODELS_DIR),
)?;
template_download.copy_template_dir(
version,
"configs/simulation/environments",
&project_path.join(paths::project::SIMULATION_ENVIRONMENTS_DIR),
)?;
template_download.copy_template_dir(
version,
"configs/rl/tasks",
&project_path.join(paths::project::RL_TASKS_DIR),
)?;
Ok(())
}
const FRAMEWORK_ROOT_PREFIX: &str = "packages/";
async fn rewrite_bundled_image_source_paths(project_path: &Path) -> Result<()> {
let environments_dir = project_path.join(paths::project::SIMULATION_ENVIRONMENTS_DIR);
let mut env_dirs: Vec<PathBuf> = Vec::new();
let mut dir_entries = match tokio::fs::read_dir(&environments_dir).await {
Ok(entries) => entries,
Err(_) => return Ok(()),
};
while let Ok(Some(entry)) = dir_entries.next_entry().await {
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
env_dirs.push(entry.path());
}
}
for env_dir in &env_dirs {
let env_json_path = env_dir.join("environment.json");
let Ok(content) = tokio::fs::read_to_string(&env_json_path).await else {
continue;
};
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let mut changed = false;
if let Some(objects) = value
.get_mut("features")
.and_then(|f| f.get_mut("objects"))
.and_then(|o| o.as_array_mut())
{
for object in objects {
if object.get("type").and_then(|t| t.as_str()) != Some("image") {
continue;
}
let Some(source) = object.get("source").and_then(|s| s.as_str()).map(str::to_string) else {
continue;
};
if !source.starts_with(FRAMEWORK_ROOT_PREFIX) {
continue;
}
let Some(basename) = Path::new(&source).file_name().map(|f| f.to_string_lossy().into_owned()) else {
continue;
};
let new_source = format!("{}/{}", paths::project::SIMULATION_IMAGES_DIR, basename);
object["source"] = serde_json::Value::String(new_source);
changed = true;
}
}
if changed {
if let Ok(rewritten) = serde_json::to_string_pretty(&value) {
let _ = tokio::fs::write(&env_json_path, rewritten).await;
}
}
}
Ok(())
}
async fn dir_has_any_subdirectory(dir: &Path) -> bool {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return false;
};
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
return true;
}
}
false
}
async fn ensure_simulation_catalog_fallback(ctx: &mut CliContext, project_path: &Path) -> Result<()> {
let tasks_dir = project_path.join(paths::project::RL_TASKS_DIR);
let models_dir = project_path.join(paths::project::SIMULATION_MODELS_DIR);
if dir_has_any_subdirectory(&tasks_dir).await || dir_has_any_subdirectory(&models_dir).await {
return Ok(());
}
ctx.project_template_service()
.create_simulation_model_json(project_path)
.await?;
ctx.project_template_service()
.create_simulation_environment_json(project_path)
.await?;
ctx.project_template_service()
.create_simulation_cat_environment_json(project_path)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn create_mecha10_json_from_template(
ctx: &mut CliContext,
project_path: &Path,
project_name: &str,
template: &Option<String>,
nodes: &[String],
template_version: &str,
robot_platform: &str,
remote_platform: &[String],
robot_id: &str,
robot_name: &str,
) -> Result<()> {
let platform = template.as_deref().unwrap_or("basic");
let project_id = project_name.replace('-', "_");
let version = template_version;
let content = ctx
.template_download()
.read_template(version, "config/mecha10.json.template")
.with_context(|| format!("Failed to read config/mecha10.json.template from downloaded templates v{version}"))?;
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);
config["version"] = serde_json::json!(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);
filter_config_to_selected_nodes(&mut config, nodes);
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);
}
#[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 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 filter_config_to_selected_nodes(config: &mut serde_json::Value, nodes: &[String]) {
const ALWAYS_INCLUDED: [&str; 2] = ["websocket-bridge", "diagnostics"];
let selected: std::collections::HashSet<&str> = nodes.iter().map(|s| s.as_str()).collect();
let is_selected = |identifier: &str| {
let bare = identifier.strip_prefix("@mecha10/").unwrap_or(identifier);
ALWAYS_INCLUDED.contains(&bare) || selected.contains(bare)
};
if let Some(nodes_map) = config.get_mut("nodes").and_then(|v| v.as_object_mut()) {
nodes_map.retain(|k, _| is_selected(k));
}
if let Some(targets) = config.get_mut("targets").and_then(|v| v.as_object_mut()) {
for target_config in targets.values_mut() {
if let Some(arr) = target_config.get_mut("nodes").and_then(|v| v.as_array_mut()) {
arr.retain(|v| v.as_str().is_some_and(is_selected));
}
}
}
}
#[cfg(test)]
mod filter_config_to_selected_nodes_tests {
use super::filter_config_to_selected_nodes;
use serde_json::json;
#[test]
fn drops_unselected_nodes_from_nodes_and_targets() {
let mut config = json!({
"nodes": {
"@mecha10/motor": "latest",
"@mecha10/camera": "latest",
"@mecha10/speaker": "latest",
},
"targets": {
"robot": { "nodes": ["@mecha10/motor", "@mecha10/camera"] },
"remote": { "nodes": ["@mecha10/camera"] },
"dev": { "nodes": ["@mecha10/speaker", "@mecha10/motor"] },
},
});
filter_config_to_selected_nodes(&mut config, &["motor".to_string()]);
assert_eq!(config["nodes"].as_object().unwrap().len(), 1);
assert!(config["nodes"].get("@mecha10/motor").is_some());
assert_eq!(config["targets"]["robot"]["nodes"], json!(["@mecha10/motor"]));
assert_eq!(config["targets"]["remote"]["nodes"], json!([]));
assert_eq!(config["targets"]["dev"]["nodes"], json!(["@mecha10/motor"]));
}
#[test]
fn always_keeps_websocket_bridge_and_diagnostics_even_when_unselected() {
let mut config = json!({
"nodes": {
"@mecha10/websocket-bridge": "latest",
"@mecha10/diagnostics": "latest",
"@mecha10/motor": "latest",
},
"targets": { "robot": { "nodes": ["@mecha10/websocket-bridge", "@mecha10/diagnostics", "@mecha10/motor"] } },
});
filter_config_to_selected_nodes(&mut config, &[]);
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"));
}
#[test]
fn keeps_everything_when_all_nodes_are_selected() {
let mut config = json!({
"nodes": { "@mecha10/motor": "latest", "@mecha10/camera": "latest" },
"targets": { "robot": { "nodes": ["@mecha10/motor", "@mecha10/camera"] } },
});
filter_config_to_selected_nodes(&mut config, &["motor".to_string(), "camera".to_string()]);
assert_eq!(config["nodes"].as_object().unwrap().len(), 2);
assert_eq!(
config["targets"]["robot"]["nodes"],
json!(["@mecha10/motor", "@mecha10/camera"])
);
}
}
async fn resolve_robot_link(project_name: &str, interactive: bool) -> Result<(String, String)> {
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 = Input::with_theme(&theme)
.with_prompt("Robot name")
.with_initial_text(&candidate_name)
.interact_text()
.map_err(|e| 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!("✅ Registered robot '{}' (id: {})", response.name, response.id);
return Ok((response.id, response.name));
}
if !interactive {
println!(
"ℹ️ Linking to already-registered robot '{}' (id: {})",
response.name, response.id
);
return Ok((response.id, response.name));
}
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));
}
}
}
fn process_non_interactive_args(
args: &InitArgs,
working_dir: &Path,
catalog: &crate::services::ComponentCatalogService,
) -> Result<InitProcessResult> {
use crate::init::get_template_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 nodes: Vec<String> = {
let mut node_components = catalog.get_by_category("nodes");
node_components.sort_by(|a, b| a.name.cmp(&b.name));
let node_ids: Vec<String> = node_components.iter().map(|c| c.id.clone()).collect();
let defaults = get_template_defaults(&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!("🤖 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) {
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!("⚠️ Wizard failed: {}", e);
println!("Using command-line arguments...\n");
process_non_interactive_args(args, &working_dir, catalog)?
}
}
} else {
if !is_interactive_terminal() {
println!("ℹ️ Non-interactive mode\n");
}
process_non_interactive_args(args, &working_dir, catalog)?
};
validation::check_directory_not_exists(&project_path)?;
let (robot_id, robot_name) = 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 result = perform_init(
ctx,
&project_path,
&project_name,
&template,
&nodes,
&template_version,
&robot_platform,
&remote_platform,
&robot_id,
&robot_name,
)
.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!("⚠️ 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!("✅ Added config for: {}", node_name);
synced += 1;
}
}
}
if synced > 0 {
println!();
println!("✨ Synced {} node configs", synced);
} else {
println!();
println!("✅ 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,
robot_platform: &str,
remote_platform: &[String],
robot_id: &str,
robot_name: &str,
) -> Result<()> {
ctx.init_service().create_project_directories(project_path).await?;
copy_downloaded_templates(ctx, project_path, template_version).await?;
create_mecha10_json_from_template(
ctx,
project_path,
project_name,
template,
nodes,
template_version,
robot_platform,
remote_platform,
robot_id,
robot_name,
)
.await?;
println!("✅ Robot registered");
ensure_simulation_catalog_fallback(ctx, project_path).await?;
rewrite_bundled_image_source_paths(project_path).await?;
println!("✅ Simulation assets");
println!("✅ Node configurations");
ctx.project_template_service()
.create_readme(project_path, project_name)
.await?;
ctx.project_template_service().create_gitignore(project_path).await?;
ctx.project_template_service()
.create_package_json(project_path, project_name)
.await?;
ctx.project_template_service()
.create_requirements_txt(project_path)
.await?;
ctx.project_template_service()
.create_python_version(project_path)
.await?;
if template.as_deref() == Some("rover") {
ctx.project_template_service()
.create_rl_training_bundle(project_path)
.await?;
println!("✅ RL training bundle (rover/ppo)");
ctx.project_template_service()
.create_rl_docker_files(project_path)
.await?;
println!("✅ rl target Docker files (run trained policies via `mecha10 run rl`)");
}
ctx.project_template_service()
.create_cargo_toml(project_path, project_name)
.await?;
ctx.project_template_service()
.create_main_rs(project_path, project_name)
.await?;
ctx.project_template_service().create_build_rs(project_path).await?;
ctx.project_template_service()
.create_env_example(project_path, project_name)
.await?;
ctx.project_template_service().create_rustfmt_toml(project_path).await?;
ctx.project_template_service()
.create_docker_compose(project_path, project_name)
.await?;
ctx.project_template_service()
.create_remote_docker_files(project_path)
.await?;
println!("✅ 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!("⚠️ Failed to add {}: {}", node_name, e);
} else {
println!("✅ Node: {}", node_name);
}
}
println!();
}
Ok(())
}
fn print_success_message(project_name: &str, setup_ran: bool) {
println!("✨ Project {} created successfully!", project_name);
println!();
println!("📋 Next steps:");
println!(" 1. cd {}", project_name);
if setup_ran {
println!(" 2. source .venv/bin/activate");
println!(" 3. mecha10 dev");
} else {
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 {
fn parsed_template() -> serde_json::Value {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../mecha10-templates/templates/config/mecha10.json.template");
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read source mecha10.json.template at {}: {e}", path.display()));
serde_json::from_str(&content).expect("source mecha10.json.template must be valid JSON")
}
#[test]
fn simulation_mode_spawns_simulator_and_teleop() {
let config = parsed_template();
let nodes = config["targets"]["simulation"]["nodes"]
.as_array()
.expect("simulation target must declare a nodes array");
let nodes: Vec<&str> = nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
nodes.contains(&"@mecha10/simulator"),
"simulation mode must spawn @mecha10/simulator, got {nodes:?}"
);
assert!(
nodes.contains(&"@mecha10/teleop"),
"simulation mode must spawn @mecha10/teleop so 's' can enter keyboard control, got {nodes:?}"
);
assert!(
!nodes.iter().any(|n| n.contains("simulation-bridge")),
"simulation mode must not reference the deleted @mecha10/simulation-bridge, got {nodes:?}"
);
}
#[test]
fn every_lifecycle_mode_keeps_websocket_bridge_running() {
let config = parsed_template();
for target_name in ["dev", "simulation"] {
let nodes = config["targets"][target_name]["nodes"]
.as_array()
.unwrap_or_else(|| panic!("target '{target_name}' must declare a nodes array"));
let nodes: Vec<&str> = nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
nodes.contains(&"@mecha10/websocket-bridge"),
"target '{target_name}' must declare @mecha10/websocket-bridge so switching into it \
doesn't stop the node the dashboard's websocket client is connected to, got {nodes:?}"
);
}
}
#[test]
fn every_lifecycle_mode_keeps_diagnostics_running() {
let config = parsed_template();
for target_name in ["dev", "simulation"] {
let nodes = config["targets"][target_name]["nodes"]
.as_array()
.unwrap_or_else(|| panic!("target '{target_name}' must declare a nodes array"));
let nodes: Vec<&str> = nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
nodes.contains(&"@mecha10/diagnostics"),
"target '{target_name}' must declare @mecha10/diagnostics so switching into it \
doesn't stop system diagnostics reporting, got {nodes:?}"
);
}
}
#[test]
fn rl_target_is_present_and_populated_by_default() {
let config = parsed_template();
assert!(
config["nodes"]
.as_object()
.expect("nodes map must be an object")
.contains_key("@mecha10/rl"),
"top-level nodes map must declare @mecha10/rl so `mecha10 build rl` can resolve it"
);
let nodes = config["targets"]["rl"]["nodes"]
.as_array()
.expect("rl target must declare a nodes array");
let nodes: Vec<&str> = nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!(
nodes,
vec!["@mecha10/object-detector"],
"rl target must spawn only @mecha10/object-detector, got {nodes:?}"
);
}
#[test]
fn top_level_nodes_map_declares_simulator_and_no_simulation_bridge() {
let config = parsed_template();
let node_map = config["nodes"].as_object().expect("nodes map must be an object");
assert!(
node_map.contains_key("@mecha10/simulator"),
"top-level nodes map must declare @mecha10/simulator so it resolves via get_node_specs()"
);
assert!(
!node_map.contains_key("@mecha10/simulation-bridge"),
"top-level nodes map must not declare the deleted @mecha10/simulation-bridge"
);
}
}
#[cfg(test)]
mod create_mecha10_json_from_template_error_tests {
use super::create_mecha10_json_from_template;
use crate::context::CliContextBuilder;
use crate::paths;
#[tokio::test]
async fn errors_loudly_instead_of_silently_falling_back_when_template_is_unresolvable() {
let project_dir = tempfile::tempdir().unwrap();
let unresolvable_version = "lab-2034-no-such-template-version-ever-cached";
let mut ctx = CliContextBuilder::new()
.working_dir(project_dir.path().to_path_buf())
.build()
.unwrap();
let result = create_mecha10_json_from_template(
&mut ctx,
project_dir.path(),
"my-robot",
&None,
&[],
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",
)
.await;
let err = result.expect_err("unresolvable template version must produce an error, not a silent fallback");
let message = format!("{err:#}");
assert!(
message.contains("mecha10.json.template") || message.contains(unresolvable_version),
"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"
);
}
}
#[cfg(test)]
mod rewrite_bundled_image_source_paths_tests {
use super::rewrite_bundled_image_source_paths;
use std::path::Path;
async fn write_environment_json(project_root: &Path, env_name: &str, body: &str) {
let dir = project_root.join("configs/simulation/environments").join(env_name);
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("environment.json"), body).await.unwrap();
}
async fn read_environment_json(project_root: &Path, env_name: &str) -> serde_json::Value {
let content = tokio::fs::read_to_string(
project_root
.join("configs/simulation/environments")
.join(env_name)
.join("environment.json"),
)
.await
.unwrap();
serde_json::from_str(&content).unwrap()
}
#[tokio::test]
async fn rewrites_framework_root_relative_source_to_the_project_images_dir() {
let temp = tempfile::tempdir().unwrap();
write_environment_json(
temp.path(),
"basic_arena",
r#"{
"id": "basic_arena",
"features": {
"objects": [
{
"type": "image",
"source": "packages/simulation/images/aiko.jpg",
"position": [0.0, 0.0, 0.0]
}
]
}
}"#,
)
.await;
rewrite_bundled_image_source_paths(temp.path()).await.unwrap();
let rewritten = read_environment_json(temp.path(), "basic_arena").await;
assert_eq!(
rewritten["features"]["objects"][0]["source"],
"configs/simulation/images/aiko.jpg"
);
}
#[tokio::test]
async fn rewrites_source_shared_by_more_than_one_environment_identically() {
let temp = tempfile::tempdir().unwrap();
write_environment_json(
temp.path(),
"basic_arena",
r#"{
"id": "basic_arena",
"features": {
"objects": [
{
"type": "image",
"source": "packages/simulation/images/logo.jpg",
"position": [0.0, 0.0, 0.0]
}
]
}
}"#,
)
.await;
write_environment_json(
temp.path(),
"obstacle_course",
r#"{
"id": "obstacle_course",
"features": {
"objects": [
{
"type": "image",
"source": "packages/simulation/images/logo.jpg",
"position": [0.0, 0.0, 0.0]
}
]
}
}"#,
)
.await;
rewrite_bundled_image_source_paths(temp.path()).await.unwrap();
let basic_arena = read_environment_json(temp.path(), "basic_arena").await;
let obstacle_course = read_environment_json(temp.path(), "obstacle_course").await;
assert_eq!(
basic_arena["features"]["objects"][0]["source"],
"configs/simulation/images/logo.jpg"
);
assert_eq!(
obstacle_course["features"]["objects"][0]["source"],
"configs/simulation/images/logo.jpg"
);
}
#[tokio::test]
async fn leaves_already_project_relative_sources_untouched() {
let temp = tempfile::tempdir().unwrap();
write_environment_json(
temp.path(),
"basic_arena",
r#"{
"id": "basic_arena",
"features": {
"objects": [
{
"type": "image",
"source": "configs/simulation/images/aiko.jpg",
"position": [0.0, 0.0, 0.0]
}
]
}
}"#,
)
.await;
rewrite_bundled_image_source_paths(temp.path()).await.unwrap();
let rewritten = read_environment_json(temp.path(), "basic_arena").await;
assert_eq!(
rewritten["features"]["objects"][0]["source"],
"configs/simulation/images/aiko.jpg"
);
}
#[tokio::test]
async fn leaves_non_image_objects_untouched() {
let temp = tempfile::tempdir().unwrap();
write_environment_json(
temp.path(),
"basic_arena",
r#"{
"id": "basic_arena",
"features": {
"objects": [
{
"type": "cube",
"position": [0.0, 0.0, 0.0]
}
]
}
}"#,
)
.await;
rewrite_bundled_image_source_paths(temp.path()).await.unwrap();
let rewritten = read_environment_json(temp.path(), "basic_arena").await;
assert_eq!(rewritten["features"]["objects"][0]["type"], "cube");
assert!(rewritten["features"]["objects"][0].get("source").is_none());
}
#[tokio::test]
async fn is_a_noop_when_no_environments_directory_exists() {
let temp = tempfile::tempdir().unwrap();
rewrite_bundled_image_source_paths(temp.path()).await.unwrap();
}
}