use crate::commands::config::{ConfigListArgs, ConfigPullArgs, ConfigPushArgs, ConfigValidateArgs};
use crate::context::CliContext;
use crate::paths;
use crate::services::credentials::CredentialsService;
use crate::services::TemplateDownloadService;
use anyhow::{Context, Result};
use mecha10_robot_config::{collect_config_files, write_config_value, RobotConfigClient};
use std::path::Path;
pub(crate) const ROBOT_CONFIG_PATH: &str = "/api/robots";
pub async fn handle_config_push(ctx: &mut CliContext, args: &ConfigPushArgs) -> Result<()> {
println!();
println!("Pushing configurations...");
println!();
mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&ctx.working_dir)
.context("Failed to regenerate websocket-bridge relay topics")?;
let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;
let configs_dir = match &args.path {
Some(path) => path.clone(),
None => ctx.working_dir.join("configs"),
};
if !configs_dir.exists() {
println!("No configs/ directory found");
println!();
println!("Expected:");
println!(" - configs/ directory with node/behavior/simulation/rl configs");
println!(" - mecha10.json at project root");
return Ok(());
}
println!("Robot ID: {}", robot_id);
println!("Configs dir: {}", configs_dir.display());
println!();
println!("Validating configs...");
match crate::config_validation::validate_configs(&ctx.working_dir, crate::config_validation::ConfigKind::ALL) {
Ok(()) => println!("✅ All configs are valid"),
Err(e) => {
println!("❌ Config validation failed:");
println!();
println!("{:#}", e);
println!();
return Err(e.context("Refusing to push: fix the invalid config(s) above and try again"));
}
}
println!();
let mecha10_json = ctx.working_dir.join("mecha10.json");
let configs = collect_config_files(&configs_dir, Some(&mecha10_json))
.await
.map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;
if configs.is_empty() {
println!("No config files found");
println!();
return Ok(());
}
println!("Found {} config(s):", configs.len());
for (key, _) in &configs {
println!(" - {}", key);
}
println!();
if args.dry_run {
println!("DRY RUN - No changes will be made");
return Ok(());
}
let client = create_config_client(ctx).await?;
for (key, value) in &configs {
client
.upsert_config(&robot_id, key, value.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to push config '{}': {}", key, e))?;
}
println!("Pushed {} config(s)", configs.len());
println!();
Ok(())
}
pub async fn handle_config_pull(ctx: &mut CliContext, args: &ConfigPullArgs) -> Result<()> {
println!();
println!("Pulling configurations...");
println!();
let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;
let output_dir = args.output.clone().unwrap_or_else(|| ctx.working_dir.join("configs"));
println!("Robot ID: {}", robot_id);
println!("Output dir: {}", output_dir.display());
println!();
if output_dir.exists() && output_dir.read_dir().is_ok_and(|mut d| d.next().is_some()) && !args.force {
return Err(anyhow::anyhow!(
"{} already exists and is not empty - pass --force to overwrite",
output_dir.display()
));
}
let client = create_config_client(ctx).await?;
let configs = client
.get_configs(&robot_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to fetch configs: {}", e))?;
if configs.is_empty() {
println!("No configs found for robot {}", robot_id);
return Ok(());
}
let mecha10_json_dest = ctx.working_dir.join("mecha10.json");
let mut wrote_mecha10_json = false;
for config in &configs {
write_config_value(
&output_dir,
Some(&mecha10_json_dest),
&config.config_key,
&config.config_value,
)
.await
.map_err(|e| anyhow::anyhow!("Failed to write config '{}': {}", config.config_key, e))?;
if config.config_key == "mecha10.json" {
wrote_mecha10_json = true;
}
}
println!(
"Pulled {} config(s){}",
configs.len(),
if wrote_mecha10_json {
" (incl. mecha10.json)"
} else {
""
}
);
println!();
Ok(())
}
pub async fn handle_config_list(ctx: &mut CliContext, args: &ConfigListArgs) -> Result<()> {
println!();
let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;
println!("Configs for robot: {}", robot_id);
println!();
let client = create_config_client(ctx).await?;
let configs = match client.get_configs(&robot_id).await {
Ok(configs) => configs,
Err(e) => {
return Err(anyhow::anyhow!("Failed to fetch configs: {}", e));
}
};
if configs.is_empty() {
println!("No configs found");
println!();
return Ok(());
}
for config in configs {
println!("{}:", config.config_key);
println!(" Updated: {}", config.updated_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!(" Created by: {}", config.created_by);
if args.verbose {
let value_str = serde_json::to_string_pretty(&config.config_value).unwrap_or_else(|_| "{}".to_string());
for line in value_str.lines() {
println!(" {}", line);
}
}
println!();
}
Ok(())
}
pub async fn push_configs(ctx: &mut CliContext) -> Result<()> {
mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&ctx.working_dir)
.context("Failed to regenerate websocket-bridge relay topics")?;
let configs_dir = ctx.working_dir.join("configs");
if !configs_dir.exists() {
println!(" No configs/ directory found, skipping config push");
return Ok(());
}
let robot_id = get_robot_id(ctx, None).await?;
let mecha10_json = ctx.working_dir.join("mecha10.json");
let configs = collect_config_files(&configs_dir, Some(&mecha10_json))
.await
.map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;
if configs.is_empty() {
println!(" No configs found in configs/ directory");
return Ok(());
}
let client = create_config_client(ctx).await?;
let remote = client
.get_configs(&robot_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to fetch current configs for robot {}: {}", robot_id, e))?;
let remote_by_key: std::collections::HashMap<&str, &mecha10_robot_config::ConfigValue> = remote
.iter()
.map(|c| (c.config_key.as_str(), &c.config_value))
.collect();
let to_push: Vec<&(String, mecha10_robot_config::ConfigValue)> = configs
.iter()
.filter(|(key, value)| remote_by_key.get(key.as_str()) != Some(&value))
.collect();
if to_push.is_empty() {
println!(" Configs unchanged, skipping push");
return Ok(());
}
println!(
" Pushing {} changed config(s) for robot {}...",
to_push.len(),
robot_id
);
for (key, value) in &to_push {
client
.upsert_config(&robot_id, key, value.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to push config '{}': {}", key, e))?;
}
println!(" ✅ Pushed {} config(s)", to_push.len());
Ok(())
}
pub async fn handle_config_show(ctx: &mut CliContext) -> Result<()> {
println!();
if !ctx.is_project_initialized() {
println!("Not inside a mecha10 project (no mecha10.json found).");
println!();
println!("Run `mecha10 init` to create a new project.");
println!();
return Ok(());
}
let project_config = ctx.load_project_config().await?;
let env_name = project_config.environments.current_env_name();
let control_plane_url = project_config.environments.control_plane_url();
let auth_status = mecha10_auth::commands::auth_status_line()?;
let configs_dir = ctx.working_dir.join("configs");
println!("Project name: {}", project_config.name);
println!("Robot ID: {}", project_config.robot.id);
println!("Environment: {}", env_name);
println!("Control plane URL: {}", control_plane_url);
println!("Auth status: {}", auth_status);
println!("Configs dir: {}", configs_dir.display());
println!();
Ok(())
}
pub async fn handle_config_validate(ctx: &mut CliContext, args: &ConfigValidateArgs) -> Result<()> {
println!();
println!("🔎 Config Validation");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
if args.path.is_none() && !ctx.is_project_initialized() {
println!("❌ Not in a Mecha10 project directory (no mecha10.json found).");
println!();
println!("Run `mecha10 init` to create a new project, or pass --path to validate a single file.");
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project directory"));
}
match crate::config_validation::validate_config(args.path.as_deref()) {
Ok(target) => {
println!("✅ {} is valid", target.display());
println!();
Ok(())
}
Err(e) => {
println!("❌ Config validation failed:");
println!();
println!("{:#}", e);
println!();
Err(e)
}
}
}
pub async fn handle_config_edit(ctx: &mut CliContext) -> Result<()> {
println!();
if !ctx.is_project_initialized() {
println!("Not inside a mecha10 project (no mecha10.json found).");
println!();
println!("Run `mecha10 init` to create a new project.");
println!();
return Ok(());
}
let entries = crate::ui::discover_editable_configs(&ctx.working_dir);
if entries.is_empty() {
println!("No editable configs found under configs/, mecha10.json, or simulation/.");
println!();
println!("Run `mecha10 init` to scaffold a project, or `mecha10 config pull` to fetch existing configs.");
println!();
return Ok(());
}
let selected_path = run_config_edit_tui(entries)?;
let Some(path) = selected_path else {
return Ok(());
};
let editor_command = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let mut parts = editor_command.split_whitespace();
let editor = parts
.next()
.ok_or_else(|| anyhow::anyhow!("$VISUAL/$EDITOR is set but empty"))?;
let status = std::process::Command::new(editor)
.args(parts)
.arg(&path)
.status()
.with_context(|| format!("Failed to launch editor '{editor_command}'"))?;
if !status.success() {
return Err(anyhow::anyhow!(
"Editor '{editor}' exited with {}",
status
.code()
.map_or_else(|| "a signal".to_string(), |code| code.to_string())
));
}
Ok(())
}
fn run_config_edit_tui(entries: Vec<crate::ui::EditableConfigEntry>) -> Result<Option<std::path::PathBuf>> {
use crate::ui::{ConfigEditOutcome, ConfigEditTui};
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::stdout;
enable_raw_mode()?;
let mut stdout_handle = stdout();
execute!(stdout_handle, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout_handle);
let mut terminal = Terminal::new(backend)?;
let mut tui = ConfigEditTui::new(entries);
let result: Result<Option<ConfigEditOutcome>> = loop {
if let Err(e) = terminal.draw(|f| tui.draw(f)) {
break Err(e.into());
}
match event::poll(std::time::Duration::from_millis(100)) {
Ok(true) => match event::read() {
Ok(Event::Key(key_event)) => tui.handle_key(key_event),
Ok(_) => {}
Err(e) => break Err(e.into()),
},
Ok(false) => {}
Err(e) => break Err(e.into()),
}
if tui.should_quit() {
break Ok(tui.take_outcome());
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
let outcome = result?;
Ok(match outcome {
Some(ConfigEditOutcome::Selected(path)) => Some(path),
Some(ConfigEditOutcome::Cancelled) | None => None,
})
}
const FRAMEWORK_BEHAVIORS_TEMPLATE_DIR: &str = "configs/behaviors/@mecha10";
pub async fn handle_config_sync(ctx: &mut CliContext) -> Result<()> {
println!();
println!("Syncing framework behaviors...");
println!();
if !ctx.is_project_initialized() {
anyhow::bail!("Not in a Mecha10 project directory");
}
let project_config = ctx.load_project_config().await?;
let platform = project_config.robot.platform.as_deref().unwrap_or("rover").to_string();
let working_dir = ctx.working_dir.clone();
let template_download = ctx.template_download();
let version = template_download
.resolve_version()
.await
.context("Failed to resolve templates version")?;
template_download
.ensure_templates(&version)
.await
.context("Failed to fetch bundled templates")?;
let added = sync_framework_behaviors(template_download, &working_dir, &version)?;
println!("Platform: {}", platform);
println!();
if added.is_empty() {
println!("✅ Framework behaviors are already up to date.");
} else {
println!("Added {} new framework behavior(s):", added.len());
for name in &added {
println!(" - @mecha10/{}", name);
}
}
println!();
Ok(())
}
fn sync_framework_behaviors(
template_download: &TemplateDownloadService,
project_root: &Path,
version: &str,
) -> Result<Vec<String>> {
let entries = template_download
.list_template_dir(version, FRAMEWORK_BEHAVIORS_TEMPLATE_DIR)
.context("Failed to list bundled framework behaviors")?;
let mut behavior_names: Vec<String> = entries
.into_iter()
.filter(|path| path.is_dir())
.filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
behavior_names.sort();
let mut added = Vec::new();
for name in behavior_names {
let dest = project_root.join(paths::config::framework_behavior_dir(&name));
if dest.exists() {
continue;
}
let template_rel = format!("{}/{}", FRAMEWORK_BEHAVIORS_TEMPLATE_DIR, name);
template_download
.copy_template_dir(version, &template_rel, &dest)
.with_context(|| format!("Failed to copy bundled behavior '{}'", name))?;
added.push(name);
}
Ok(added)
}
pub(crate) async fn get_robot_id(ctx: &mut CliContext, arg_robot_id: Option<&str>) -> Result<String> {
if let Some(id) = arg_robot_id {
return Ok(id.to_string());
}
if ctx.is_project_initialized() {
let project_config = ctx.load_project_config().await?;
return Ok(project_config.robot.id);
}
Err(anyhow::anyhow!(
"No robot ID specified. Use --robot-id or run from a mecha10 project directory."
))
}
pub(crate) async fn create_config_client(ctx: &mut CliContext) -> Result<RobotConfigClient> {
let credentials_service = CredentialsService::new();
let credentials = if ctx.is_ci() {
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 project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
let config_url = format!("{}{}", control_plane_url, ROBOT_CONFIG_PATH);
Ok(RobotConfigClient::with_user_id(
config_url,
credentials.api_key,
Some(credentials.user_id),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::CliContextBuilder;
use tempfile::{tempdir, TempDir};
fn write_project_config(dir: &std::path::Path, robot_id: &str) -> std::path::PathBuf {
let config_path = dir.join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"robot_id": robot_id,
})
.to_string(),
)
.unwrap();
config_path
}
#[tokio::test]
async fn get_robot_id_prefers_explicit_arg_over_project_config() {
let dir = tempdir().unwrap();
let config_path = write_project_config(dir.path(), "from-config");
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let robot_id = get_robot_id(&mut ctx, Some("from-arg")).await.unwrap();
assert_eq!(robot_id, "from-arg");
}
#[tokio::test]
async fn get_robot_id_falls_back_to_project_config() {
let dir = tempdir().unwrap();
let config_path = write_project_config(dir.path(), "from-config");
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let robot_id = get_robot_id(&mut ctx, None).await.unwrap();
assert_eq!(robot_id, "from-config");
}
#[tokio::test]
async fn get_robot_id_errors_when_no_arg_and_no_project() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let err = get_robot_id(&mut ctx, None).await.unwrap_err();
assert!(err.to_string().contains("No robot ID specified"));
}
#[tokio::test]
async fn config_push_fails_cleanly_and_does_not_push_when_a_config_is_invalid() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("mecha10.json"),
serde_json::json!({"name": "proj", "version": "1.0.0"}).to_string(),
)
.unwrap();
std::fs::create_dir_all(dir.path().join("configs/simulation/environments/broken_env")).unwrap();
std::fs::write(
dir.path()
.join("configs/simulation/environments/broken_env/environment.json"),
serde_json::json!({}).to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(dir.path().join("mecha10.json")))
.build()
.unwrap();
let args = ConfigPushArgs {
path: None,
robot_id: Some("test-robot".to_string()),
dry_run: true,
};
let err = handle_config_push(&mut ctx, &args).await.unwrap_err();
assert!(
err.to_string().contains("Refusing to push"),
"expected the push to be refused, got: {}",
err
);
}
#[tokio::test]
async fn config_push_proceeds_past_validation_when_every_config_is_valid() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("mecha10.json"),
serde_json::json!({"name": "proj", "version": "1.0.0"}).to_string(),
)
.unwrap();
std::fs::create_dir_all(dir.path().join("configs/simulation/environments/basic_arena")).unwrap();
std::fs::write(
dir.path()
.join("configs/simulation/environments/basic_arena/environment.json"),
serde_json::json!({
"id": "basic_arena",
"version": "1.0.0",
"name": "Basic Arena",
"description": "A test arena",
"scene_path": "res://basic_arena.tscn",
"dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
"features": {}
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(dir.path().join("mecha10.json")))
.build()
.unwrap();
let args = ConfigPushArgs {
path: None,
robot_id: Some("test-robot".to_string()),
dry_run: true,
};
let result = handle_config_push(&mut ctx, &args).await;
assert!(result.is_ok(), "{:?}", result.err());
}
#[tokio::test]
async fn per_key_push_round_trips_nodes_and_simulation_images_together() {
let dir = tempdir().unwrap();
let configs_dir = dir.path().join("configs");
std::fs::create_dir_all(configs_dir.join("nodes/@mecha10/motor")).unwrap();
std::fs::write(configs_dir.join("nodes/@mecha10/motor/config.json"), r#"{"speed": 1}"#).unwrap();
std::fs::create_dir_all(configs_dir.join("simulation/images")).unwrap();
std::fs::write(configs_dir.join("simulation/images/aiko.jpg"), [0xFF, 0xD8, 0xFF, 0x00]).unwrap();
let collected = collect_config_files(&configs_dir, None).await.unwrap();
assert_eq!(collected.len(), 2);
let extract_dir = tempdir().unwrap();
for (key, value) in &collected {
write_config_value(&extract_dir.path().join("configs"), None, key, value)
.await
.unwrap();
}
assert_eq!(
std::fs::read_to_string(extract_dir.path().join("configs/nodes/@mecha10/motor/config.json")).unwrap(),
"{\n \"speed\": 1\n}"
);
assert_eq!(
std::fs::read(extract_dir.path().join("configs/simulation/images/aiko.jpg")).unwrap(),
vec![0xFF, 0xD8, 0xFF, 0x00]
);
}
fn stage_bundled_behaviors(version: &str, behaviors: &[(&str, &str)]) -> (TempDir, TemplateDownloadService) {
let cache = tempdir().unwrap();
let templates_dir = cache.path().join(format!("v{version}")).join("templates");
for (name, config_json) in behaviors {
let dir = templates_dir.join(FRAMEWORK_BEHAVIORS_TEMPLATE_DIR).join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config.json"), config_json).unwrap();
}
let service = TemplateDownloadService::with_cache_dir(cache.path().to_path_buf());
(cache, service)
}
#[test]
fn sync_framework_behaviors_adds_a_missing_bundled_behavior() {
let version = "1.0.0";
let (_cache, service) = stage_bundled_behaviors(
version,
&[
("idle-wander", r#"{"name": "idle-wander"}"#),
("instance-track", r#"{"name": "instance-track"}"#),
],
);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/@mecha10/idle-wander")).unwrap();
std::fs::write(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&service, project.path(), version).unwrap();
assert_eq!(added, vec!["instance-track".to_string()]);
assert_eq!(
std::fs::read_to_string(
project
.path()
.join("configs/behaviors/@mecha10/instance-track/config.json")
)
.unwrap(),
r#"{"name": "instance-track"}"#
);
}
#[test]
fn sync_framework_behaviors_reports_nothing_to_do_when_already_up_to_date() {
let version = "1.0.0";
let (_cache, service) = stage_bundled_behaviors(version, &[("idle-wander", r#"{"name": "idle-wander"}"#)]);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/@mecha10/idle-wander")).unwrap();
std::fs::write(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&service, project.path(), version).unwrap();
assert!(added.is_empty(), "expected nothing to sync, got {:?}", added);
}
#[test]
fn sync_framework_behaviors_never_overwrites_an_existing_customized_behavior() {
let version = "1.0.0";
let (_cache, service) =
stage_bundled_behaviors(version, &[("idle-wander", r#"{"name": "idle-wander-bundled"}"#)]);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/@mecha10/idle-wander")).unwrap();
std::fs::write(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander-customized"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&service, project.path(), version).unwrap();
assert!(added.is_empty());
assert_eq!(
std::fs::read_to_string(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json")
)
.unwrap(),
r#"{"name": "idle-wander-customized"}"#,
"an existing @mecha10/ behavior must never be overwritten by sync"
);
}
#[test]
fn sync_framework_behaviors_leaves_project_tier_custom_behaviors_untouched() {
let version = "1.0.0";
let (_cache, service) =
stage_bundled_behaviors(version, &[("instance-track", r#"{"name": "instance-track"}"#)]);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/my-custom-patrol")).unwrap();
std::fs::write(
project.path().join("configs/behaviors/my-custom-patrol/config.json"),
r#"{"name": "my-custom-patrol"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&service, project.path(), version).unwrap();
assert_eq!(added, vec!["instance-track".to_string()]);
assert_eq!(
std::fs::read_to_string(project.path().join("configs/behaviors/my-custom-patrol/config.json")).unwrap(),
r#"{"name": "my-custom-patrol"}"#,
"project-tier custom behaviors must be left untouched by sync"
);
}
}