use crate::commands::config::{ConfigListArgs, ConfigPullArgs, ConfigPushArgs, ConfigValidateArgs};
use crate::context::CliContext;
use crate::services::credentials::CredentialsService;
use anyhow::{Context, Result};
use mecha10_core::config::config_key_to_path;
use mecha10_robot_config::{
collect_configs_from_dir, write_configs_to_dir, CollectOptions, RobotConfigClient, WriteOptions,
};
const ROBOT_CONFIG_PATH: &str = "/api/robot-config";
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"),
};
let options = CollectOptions {
include_mecha10_json: true,
project_root: Some(ctx.working_dir.clone()),
};
let configs = collect_configs_from_dir(&configs_dir, &options)
.await
.map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;
if configs.is_empty() {
println!("No config files found");
println!();
println!("Expected:");
println!(" - configs/ directory with node configs");
println!(" - mecha10.json at project root");
return Ok(());
}
println!("Robot ID: {}", robot_id);
if configs_dir.exists() {
println!("Configs dir: {}", configs_dir.display());
}
println!();
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?;
match client.bulk_upsert_configs(&robot_id, configs).await {
Ok(result) => {
println!(
"Pushed {} config(s) ({} created, {} updated)",
result.configs.len(),
result.created,
result.updated
);
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to push configs: {}", e));
}
}
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!();
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));
}
};
let configs = exclude_sim_assets(configs);
if configs.is_empty() {
println!("No configs found for robot {}", robot_id);
return Ok(());
}
println!("Found {} config(s):", configs.len());
for config in &configs {
println!(" - {}", config.config_key);
}
println!();
let write_options = WriteOptions {
force: args.force,
project_root: Some(ctx.working_dir.clone()),
};
let result = write_configs_to_dir(&configs, &output_dir, &write_options)
.await
.map_err(|e| anyhow::anyhow!("Failed to write configs: {}", e))?;
for key in &result.written_keys {
let display_name = config_key_to_path(key);
println!(" Wrote {}", display_name);
}
for key in &result.skipped_keys {
let display_name = config_key_to_path(key);
println!(" Skipped {} (exists, use --force to overwrite)", display_name);
}
println!();
println!("Pulled {} config(s), skipped {}", result.written, result.skipped);
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));
}
};
let configs = exclude_sim_assets(configs);
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"));
if let Some(ref user) = config.updated_by {
println!(" By: {}", user);
}
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 options = CollectOptions {
include_mecha10_json: true,
project_root: Some(ctx.working_dir.clone()),
};
let local_configs = collect_configs_from_dir(&configs_dir, &options)
.await
.map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;
if local_configs.is_empty() {
println!(" No configs found in configs/ directory");
return Ok(());
}
let client = create_config_client(ctx).await?;
let remote_configs = client.get_configs(&robot_id).await.unwrap_or_default();
let remote_map: std::collections::HashMap<&str, &mecha10_robot_config::ConfigValue> = remote_configs
.iter()
.map(|c| (c.config_key.as_str(), &c.config_value))
.collect();
let changed: Vec<(String, mecha10_robot_config::ConfigValue)> = local_configs
.into_iter()
.filter(|(key, value)| {
remote_map
.get(key.as_str())
.map_or(true, |remote_value| *remote_value != value)
})
.collect();
if changed.is_empty() {
println!(" No config changes detected");
return Ok(());
}
println!(
" Pushing {} changed config(s) for robot {}...",
changed.len(),
robot_id
);
match client.bulk_upsert_configs(&robot_id, changed).await {
Ok(result) => {
println!(
" ✅ Pushed {} config(s) ({} created, {} updated)",
result.configs.len(),
result.created,
result.updated
);
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to push configs: {}", e));
}
}
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!();
println!("Note: `Project name` and `Robot ID` above must match `robot_project.name` and");
println!("`robot_id` in the robot's ~/.mecha10/launcher/config.json — the launcher's binary");
println!("registry looks up binaries by project name, and config/relay routing is scoped");
println!("by robot ID. Run `mecha10-launcher config show` on the robot to verify.");
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)
}
}
}
fn exclude_sim_assets(configs: Vec<mecha10_robot_config::ConfigResponse>) -> Vec<mecha10_robot_config::ConfigResponse> {
configs
.into_iter()
.filter(|c| !mecha10_robot_config::is_sim_asset_key(&c.config_key))
.collect()
}
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 = credentials_service
.load()?
.ok_or_else(|| anyhow::anyhow!("Not logged in. Run `mecha10 auth login` first."))?;
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;
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
}
fn make_config_response(config_key: &str) -> mecha10_robot_config::ConfigResponse {
mecha10_robot_config::ConfigResponse {
id: "id".to_string(),
robot_id: "robot".to_string(),
config_key: config_key.to_string(),
config_value: mecha10_robot_config::ConfigValue::new(),
updated_by: None,
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
}
}
#[test]
fn exclude_sim_assets_drops_simulation_asset_keys_only() {
let configs = vec![
make_config_response("@mecha10/motor"),
make_config_response("mecha10.json"),
make_config_response("simulation-assets/models/rover/model.json"),
make_config_response("simulation-assets/environments/basic_arena/environment.json"),
];
let filtered = exclude_sim_assets(configs);
let keys: Vec<&str> = filtered.iter().map(|c| c.config_key.as_str()).collect();
assert_eq!(keys, vec!["@mecha10/motor", "mecha10.json"]);
}
#[test]
fn exclude_sim_assets_is_a_noop_when_nothing_matches() {
let configs = vec![
make_config_response("@mecha10/motor"),
make_config_response("mecha10.json"),
];
let filtered = exclude_sim_assets(configs);
assert_eq!(filtered.len(), 2);
}
#[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"));
}
}