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 anyhow::{Context, Result};
use std::path::{Path, PathBuf};
type InitProcessResult = (String, PathBuf, Option<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, "assets", &project_path.join(paths::project::ASSETS_DIR))?;
template_download.copy_template_dir(
version,
"simulation/models",
&project_path.join(paths::project::SIMULATION_MODELS_DIR),
)?;
template_download.copy_template_dir(
version,
"simulation/environments",
&project_path.join(paths::project::SIMULATION_ENVIRONMENTS_DIR),
)?;
template_download.copy_template_dir(
version,
"simulation/tasks",
&project_path.join(paths::project::SIMULATION_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());
}
}
let mut parsed: Vec<(PathBuf, String, serde_json::Value)> = Vec::new();
let mut references: Vec<(String, String)> = Vec::new();
for env_dir in &env_dirs {
let env_name = env_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
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(value) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
if let Some(objects) = value
.get("features")
.and_then(|f| f.get("objects"))
.and_then(|o| o.as_array())
{
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()) else {
continue;
};
if !source.starts_with(FRAMEWORK_ROOT_PREFIX) {
continue;
}
if let Some(basename) = Path::new(source).file_name().map(|f| f.to_string_lossy().into_owned()) {
references.push((env_name.clone(), basename));
}
}
}
parsed.push((env_json_path, env_name, value));
}
let usage = mecha10_cli_core::simulation_assets::count_referencing_environments(
references
.iter()
.map(|(env, basename)| (env.as_str(), basename.as_str())),
);
for (env_json_path, env_name, mut value) in parsed {
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 referencing_env_count = usage.get(&basename).map(|envs| envs.len()).unwrap_or(1);
let new_source = mecha10_cli_core::simulation_assets::bundled_image_project_path(
&env_name,
&basename,
referencing_env_count,
);
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::SIMULATION_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?;
Ok(())
}
async fn create_mecha10_json_from_template(
ctx: &mut CliContext,
project_path: &Path,
project_name: &str,
template: &Option<String>,
template_version: &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_str = serde_json::to_string_pretty(&config)?;
tokio::fs::write(project_path.join(paths::PROJECT_CONFIG), config_str).await?;
Ok(())
}
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 = if args.no_examples {
Vec::new()
} else {
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);
node_ids
.iter()
.enumerate()
.filter(|(i, _)| defaults[*i])
.map(|(_, id)| id.clone())
.collect()
};
Ok((project_name, project_path, template, nodes))
}
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 !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(());
}
println!();
println!("🤖 Initialize Mecha10 Project");
println!();
let working_dir = ctx.working_dir.clone();
let catalog = ctx.component_catalog();
let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
let use_wizard = is_tty && !args.non_interactive;
let (project_name, project_path, template, nodes) = if use_wizard {
let default_name = args.name.clone();
match run_wizard_tui(default_name.as_deref(), catalog) {
Ok((name, template, selected_nodes)) => {
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)
}
Err(e) => {
println!("⚠️ Wizard failed: {}", e);
println!("Using command-line arguments...\n");
process_non_interactive_args(args, &working_dir, catalog)?
}
}
} else {
if !is_tty {
println!("ℹ️ Non-interactive mode\n");
}
process_non_interactive_args(args, &working_dir, catalog)?
};
validation::check_directory_not_exists(&project_path)?;
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).await;
if let Err(e) = result {
cleanup_on_error(&project_path).await;
return Err(e);
}
print_success_message(&project_name);
Ok(())
}
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(())
}
async fn perform_init(
ctx: &mut CliContext,
project_path: &Path,
project_name: &str,
template: &Option<String>,
nodes: &[String],
template_version: &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, template_version).await?;
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?;
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_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) {
println!("✨ Project {} created successfully!", project_name);
println!();
println!("📋 Next steps:");
println!(" 1. cd {}", project_name);
println!(" 2. Configure .env");
println!(" 3. Start development:");
println!(" mecha10 dev");
println!();
println!(" 's' → Start simulation 'wasd' → Teleop control");
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["lifecycle"]["modes"]["simulation"]["nodes"]
.as_array()
.expect("simulation mode 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();
let modes = config["lifecycle"]["modes"]
.as_object()
.expect("lifecycle.modes must be an object");
for (mode_name, mode_config) in modes {
let nodes = mode_config["nodes"]
.as_array()
.unwrap_or_else(|| panic!("mode '{mode_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"),
"mode '{mode_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();
let modes = config["lifecycle"]["modes"]
.as_object()
.expect("lifecycle.modes must be an object");
for (mode_name, mode_config) in modes {
let nodes = mode_config["nodes"]
.as_array()
.unwrap_or_else(|| panic!("mode '{mode_name}' must declare a nodes array"));
let nodes: Vec<&str> = nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
nodes.contains(&"@mecha10/diagnostics"),
"mode '{mode_name}' must declare @mecha10/diagnostics so switching into it \
doesn't stop system diagnostics reporting, 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)
.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("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("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_env_specific_tier() {
let temp = tempfile::tempdir().unwrap();
write_environment_json(
temp.path(),
"basic_arena",
r#"{
"id": "basic_arena",
"features": {
"objects": [
{
"type": "image",
"source": "packages/simulation/environments/basic_arena/assets/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"],
"assets/images/basic_arena/aiko.jpg"
);
}
#[tokio::test]
async fn rewrites_source_shared_by_more_than_one_environment_to_the_shared_tier() {
let temp = tempfile::tempdir().unwrap();
write_environment_json(
temp.path(),
"basic_arena",
r#"{
"id": "basic_arena",
"features": {
"objects": [
{
"type": "image",
"source": "packages/simulation/environments/basic_arena/assets/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/environments/obstacle_course/assets/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"],
"assets/images/logo.jpg"
);
assert_eq!(
obstacle_course["features"]["objects"][0]["source"],
"assets/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": "assets/images/basic_arena/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"],
"assets/images/basic_arena/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();
}
}