use crate::commands::config::{ConfigListArgs, ConfigPullArgs, ConfigPushArgs, ConfigValidateArgs};
use crate::context::CliContext;
use crate::services::credentials::CredentialsService;
use anyhow::{Context, Result};
use mecha10_robot_config::{build_configs_archive, unpack_configs_archive, RobotConfigClient};
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!();
if args.dry_run {
println!("DRY RUN - No changes will be made");
return Ok(());
}
let mecha10_json = ctx.working_dir.join("mecha10.json");
let archive = build_configs_archive(&configs_dir, Some(&mecha10_json))
.map_err(|e| anyhow::anyhow!("Failed to archive configs: {}", e))?;
let client = create_config_client(ctx).await?;
client
.upload_configs_archive(&robot_id, archive)
.await
.map_err(|e| anyhow::anyhow!("Failed to push configs: {}", e))?;
println!("Pushed configs/");
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 archive = client
.download_configs_archive(&robot_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to fetch configs: {}", e))?;
let mecha10_json_dest = ctx.working_dir.join("mecha10.json");
let wrote_mecha10_json = unpack_configs_archive(&archive, &output_dir, Some(&mecha10_json_dest))
.await
.map_err(|e| anyhow::anyhow!("Failed to unpack configs: {}", e))?;
println!(
"Pulled configs/{}",
if wrote_mecha10_json { " + 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 archive = build_configs_archive(&configs_dir, Some(&mecha10_json))
.map_err(|e| anyhow::anyhow!("Failed to archive configs: {}", e))?;
let client = create_config_client(ctx).await?;
println!(" Pushing configs/ for robot {}...", robot_id);
client
.upload_configs_archive(&robot_id, archive)
.await
.map_err(|e| anyhow::anyhow!("Failed to push configs: {}", e))?;
println!(" ✅ Pushed configs/");
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 = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let status = std::process::Command::new(&editor)
.arg(&path)
.status()
.with_context(|| format!("Failed to launch editor '{editor}'"))?;
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,
})
}
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 mecha10_robot_config::extract_configs_archive;
use tempfile::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"));
}
#[test]
fn configs_archive_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 archive = build_configs_archive(&configs_dir, None).unwrap();
let extract_dir = tempdir().unwrap();
extract_configs_archive(&archive, extract_dir.path()).unwrap();
assert_eq!(
std::fs::read_to_string(extract_dir.path().join("configs/nodes/@mecha10/motor/config.json")).unwrap(),
r#"{"speed": 1}"#
);
assert_eq!(
std::fs::read(extract_dir.path().join("configs/simulation/images/aiko.jpg")).unwrap(),
vec![0xFF, 0xD8, 0xFF, 0x00]
);
}
}