mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
//! Run command handler
//!
//! Runs the built project binary for a specific target.

use crate::commands::{RunCommand, RunRemoteArgs, RunRlArgs, RunRobotArgs};
use crate::config_validation::{validate_configs, ConfigKind};
use crate::context::CliContext;
use crate::handlers::dev::ops::inject_locomotion_limits_env;
use crate::paths;
use crate::services::credentials::CredentialsService;
use crate::services::publish_status::check_published_version;
use crate::services::rl_bundle;
use anyhow::{Context, Result};
use mecha10_cli_core::types::EnvironmentsConfig;
use mecha10_core::enum_str_method;
use std::collections::HashMap;
use std::process::Command;

#[derive(Debug, Clone, Copy)]
pub enum RunTarget {
    Robot,
    Remote,
    Rl,
}

enum_str_method!(
    /// Get display name for this target
    pub(crate) fn display_name(RunTarget) -> &'static str {
        Robot => "Robot",
        Remote => "Remote",
        Rl => "RL",
    }
);

enum_str_method!(
    /// Get description for this target
    pub(crate) fn description(RunTarget) -> &'static str {
        Robot => "edge/on-robot nodes",
        Remote => "cloud/remote server nodes",
        Rl => "trained RL policy nodes (excludes the simulator)",
    }
);

/// Handle the run command
pub async fn handle_run(ctx: &mut CliContext, command: &RunCommand) -> Result<()> {
    mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&ctx.working_dir)
        .context("Failed to regenerate websocket-bridge relay topics")?;

    match command {
        RunCommand::Robot(args) => run_robot_target(ctx, args).await,
        RunCommand::Remote(args) => run_remote_target(ctx, args).await,
        RunCommand::Rl(args) if args.download_bundle_only => download_rl_bundle_only(ctx, args).await,
        RunCommand::Rl(args) => run_rl_target(ctx, args).await,
    }
}

/// Download-only mode for `run rl` (LAB-2769, `--download-bundle-only --robot-id <id>`):
/// downloads LAB-2767's robot-scoped RL project bundle and unpacks it into `ctx.working_dir`,
/// then returns - does not touch Docker or start the `rl` target's compose stack. See
/// `services::rl_bundle`'s module doc and `RunRlArgs::download_bundle_only`'s doc comment for the
/// full training-dispatch story this exists for.
///
/// Deliberately skips every other `run` subcommand's "must be inside an initialized mecha10
/// project" check: the trainer host this runs on starts from the bare `mecha10/trainer` image,
/// not a checked-out project, so `robot_id` comes straight from `--robot-id` (never
/// `mecha10.json`, which won't exist yet) and the control plane URL is resolved the same
/// project-independent way `mecha10_cli_core::types::EnvironmentsConfig::default()` resolves it
/// for any other env-var-only caller (`MECHA10_CONTROL_PLANE_URL`, else the built-in
/// dev/prod default) rather than through a loaded `ProjectConfig`.
///
/// Credentials come from `CredentialsService::load` alone - never
/// `mecha10_auth::ensure_authenticated_interactive`'s browser-based login flow, since a
/// Kestra-dispatched trainer container is non-interactive. `CredentialsService::load` already
/// special-cases exactly this "ephemeral service context" via the `MECHA10_API_KEY` env var (see
/// its own doc comment) - this is the same mechanism `docker-compose.rl.yml`'s `ROBOT_API_KEY`
/// plumbing exists for on the production-inference side.
async fn download_rl_bundle_only(ctx: &mut CliContext, args: &RunRlArgs) -> Result<()> {
    let robot_id = args
        .robot_id
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("--robot-id is required together with --download-bundle-only"))?;

    println!();
    println!("📦 Downloading RL project bundle for robot {}...", robot_id);
    println!();

    let credentials_service = CredentialsService::new();
    let credentials = credentials_service.load()?.ok_or_else(|| {
        anyhow::anyhow!(
            "Not logged in. Set the MECHA10_API_KEY env var (non-interactive/service contexts) or run \
             `mecha10 auth login` first."
        )
    })?;

    let control_plane_url = EnvironmentsConfig::default().control_plane_url();

    let archive_bytes =
        rl_bundle::download_rl_bundle(&control_plane_url, robot_id, &credentials.api_key, &credentials.user_id)
            .await
            .context("Failed to download RL project bundle")?;

    rl_bundle::unpack_rl_bundle_archive(&archive_bytes, &ctx.working_dir)
        .context("Failed to unpack RL project bundle")?;

    println!(
        "✅ RL project bundle downloaded and unpacked into {}",
        ctx.working_dir.display()
    );
    println!();

    Ok(())
}

/// Run the robot target
///
/// Consolidated (LAB-2212, per LAB-1804's node-lifecycle-runner investigation) onto
/// `mecha10-node-runner` - the same per-node supervisor `mecha10 dev` and `mecha10 node <name>`
/// already route through (see `services::process::spawn_node::spawn_node_runner` and
/// `handlers::dev::ops::spawn::spawn_nodes_by_name`) - instead of this handler's former
/// direct-exec-the-monolith-binary path (`target/release/{project_name} run`). One
/// `mecha10-node-runner` child is spawned per configured node, each independently supervised
/// (restart policy, health checks, model pulling), rather than a single monolith process making
/// those decisions internally. Falls back to spawning nodes directly (`spawn_node_direct`) when
/// `mecha10-node-runner` isn't installed (standalone/crates.io installs), mirroring dev mode's
/// own fallback.
async fn run_robot_target(ctx: &mut CliContext, args: &RunRobotArgs) -> Result<()> {
    let target = RunTarget::Robot;

    println!();
    println!("🚀 Running Mecha10 Project - {} Target", target.display_name());
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    if !ctx.is_project_initialized() {
        println!("⚠️  Not in a Mecha10 project directory");
        println!();
        return Err(anyhow::anyhow!("Not in a Mecha10 project"));
    }

    validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes])
        .context("pre-flight config validation failed for `mecha10 run robot`")?;

    let project = ctx.project()?;
    let project_name = project.name()?.to_string();
    println!("Project: {}", project_name);
    println!("Target:  {} ({})", target.display_name(), target.description());
    println!();

    let redis_url = ctx.redis_url().to_string();

    let config = ctx.load_project_config().await?;
    let control_plane_url = config.environments.control_plane_url();
    let relay_url = config.environments.relay_url();
    let robot_id = config.robot.id.clone();

    let credentials_service = CredentialsService::new();
    let api_key = if ctx.is_ci() {
        credentials_service.get_api_key().ok().flatten()
    } else {
        mecha10_auth::ensure_authenticated_interactive(&credentials_service)
            .await
            .ok()
            .map(|creds| creds.api_key)
    };
    match &api_key {
        Some(_) => println!("Auth:    ✓ (logged in)"),
        None => println!("Auth:    ⚠ Not logged in - run 'mecha10 auth login'"),
    }

    println!("Control: {}", control_plane_url);
    println!("Relay:   {}", relay_url);
    println!("Redis:   {}", redis_url);
    println!("Robot:   {}", robot_id);
    println!();

    let requested_nodes = parse_requested_nodes(args.nodes.as_deref());
    let available_node_names = config.nodes.get_node_names();
    if !requested_nodes.is_empty() {
        crate::dev::validation::validate_node_names(&requested_nodes, &available_node_names)?;
    }
    let node_identifiers: Vec<String> = crate::dev::node_selection::get_nodes_to_run(&requested_nodes, &config)
        .into_iter()
        .map(|n| n.identifier)
        .collect();

    if node_identifiers.is_empty() {
        println!("❌ No nodes configured to run");
        println!();
        return Err(anyhow::anyhow!("No nodes configured for the robot target"));
    }

    println!(
        "Nodes:   {}",
        node_identifiers
            .iter()
            .map(|n| mecha10_cli_core::utils::strip_node_scope(n))
            .collect::<Vec<_>>()
            .join(", ")
    );
    println!();

    let mut env: HashMap<String, String> = mecha10_core::config::robot_process_env(
        &control_plane_url,
        &relay_url,
        &redis_url,
        &robot_id,
        api_key.as_deref(),
    )
    .into_iter()
    .map(|(k, v)| (k.to_string(), v))
    .collect();
    env.insert(
        "MECHA10_ENV".to_string(),
        config.environments.current_env_name().to_string(),
    );
    env.insert("LOG_LEVEL".to_string(), ctx.log_level.to_string().to_lowercase());

    inject_locomotion_limits_env(&config, &mut env);

    let use_node_runner = crate::services::ProcessService::is_node_runner_available();
    if !use_node_runner {
        println!("  (standalone mode - mecha10-node-runner not found, spawning nodes directly)");
        println!();
    }

    println!(
        "Starting {} node(s) via {}...",
        node_identifiers.len(),
        if use_node_runner {
            "mecha10-node-runner"
        } else {
            "direct spawn"
        }
    );
    println!();

    let process_service = ctx.process();
    let mut pids = HashMap::new();
    let mut failures = Vec::new();
    for node_identifier in &node_identifiers {
        let result = if use_node_runner {
            process_service.spawn_node_runner(node_identifier, &project_name, Some(env.clone()))
        } else {
            process_service.spawn_node_direct(node_identifier, &project_name, Some(env.clone()))
        };

        match result {
            Ok(pid) => {
                println!("  ✅ {} (PID: {})", node_identifier, pid);
                pids.insert(node_identifier.clone(), pid);
            }
            Err(e) => {
                println!("  ❌ {} failed to start: {}", node_identifier, e);
                failures.push((node_identifier.clone(), e.to_string()));
            }
        }
    }
    println!();

    if pids.is_empty() {
        return Err(anyhow::anyhow!("Failed to start any robot nodes"));
    }
    if !failures.is_empty() {
        println!(
            "⚠️  {} node(s) failed to start: {}",
            failures.len(),
            failures.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>().join(", ")
        );
        println!();
    }

    println!("Robot running. Press Ctrl+C to stop.");
    println!();

    wait_for_all_nodes_to_exit_or_ctrl_c(process_service).await;
    process_service.cleanup();

    println!();
    println!("✅ {} target stopped", target.display_name());

    Ok(())
}

/// Blocks until either Ctrl+C is received, or every process `process_service` is tracking has
/// exited on its own (checked on a 500ms poll, since `ProcessService::get_status` isn't
/// notify-based). Split out from `run_robot_target` so the polling loop itself - which needs a
/// real `ProcessService`/OS signal handler - stays out of the way of that function's otherwise
/// unit-testable pieces (node selection, request parsing).
async fn wait_for_all_nodes_to_exit_or_ctrl_c(process_service: &mut crate::services::ProcessService) {
    loop {
        tokio::select! {
            _ = tokio::signal::ctrl_c() => return,
            _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
                let status = process_service.get_status();
                if !status.is_empty() && status.values().all(|s| s != "running") {
                    return;
                }
            }
        }
    }
}

/// Parse `mecha10 run robot --nodes a,b,c` into a list of requested short node names, trimming
/// whitespace and dropping empty segments (e.g. a stray trailing comma). `None`/absent `--nodes`
/// (the common case - run every configured node) resolves to an empty `Vec`, which
/// `dev::node_selection::get_nodes_to_run` already treats as "no filter, use everything" -
/// mirroring `mecha10 dev`'s own `--nodes` parsing (`DevArgs::nodes: Vec<String>` there is
/// populated directly by clap as a repeatable positional/flag, but `RunRobotArgs::nodes` is a
/// single `Option<String>` flag instead, so this replicates the split by hand). Split out as a
/// pure function so the comma/whitespace/empty-segment handling is unit testable without a real
/// project or CLI args.
fn parse_requested_nodes(nodes_arg: Option<&str>) -> Vec<String> {
    nodes_arg
        .map(|s| {
            s.split(',')
                .map(|n| n.trim().to_string())
                .filter(|n| !n.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

/// Run the remote target (docker compose)
async fn run_remote_target(ctx: &mut CliContext, _args: &RunRemoteArgs) -> Result<()> {
    let target = RunTarget::Remote;

    println!();
    println!("🐳 Running Mecha10 Project - {} Target (Docker)", target.display_name());
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    if !ctx.is_project_initialized() {
        println!("⚠️  Not in a Mecha10 project directory");
        println!();
        return Err(anyhow::anyhow!("Not in a Mecha10 project"));
    }

    validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes])
        .context("pre-flight config validation failed for `mecha10 run remote`")?;

    let project = ctx.project()?;
    let project_name = project.name()?;
    println!("Project: {}", project_name);
    println!("Target:  {} ({})", target.display_name(), target.description());
    println!();

    let compose_file = ctx.working_dir.join("docker").join("docker-compose.remote.yml");
    if !compose_file.exists() {
        println!("❌ Docker compose file not found: {}", compose_file.display());
        println!();
        println!("Expected: docker/docker-compose.remote.yml");
        println!("Run 'mecha10 init' to create a new project with Docker support.");
        return Err(anyhow::anyhow!("docker-compose.remote.yml not found"));
    }

    println!("Compose: {}", compose_file.display());

    let env_file = ctx.working_dir.join(paths::env::FILE);
    if !env_file.exists() {
        return Err(anyhow::anyhow!(
            "{} not found.\nCopy {} to {} and fill in your project's values first.",
            env_file.display(),
            paths::env::EXAMPLE,
            paths::env::FILE
        ));
    }

    let credentials_service = CredentialsService::new();
    let api_key = if ctx.is_ci() {
        credentials_service.get_api_key().ok().flatten()
    } else {
        mecha10_auth::ensure_authenticated_interactive(&credentials_service)
            .await
            .ok()
            .map(|creds| creds.api_key)
    };
    match &api_key {
        Some(_) => println!("Auth:    ✓ (logged in)"),
        None => println!("Auth:    ⚠ Not logged in - run 'mecha10 auth login'"),
    }
    let api_key = api_key.unwrap_or_default();

    let project_config = ctx.load_project_config().await?;
    let robot_id = project_config.robot.id.clone();
    println!("Robot:   {}", robot_id);
    println!();

    warn_if_remote_version_not_published(
        &project_config.environments.control_plane_url(),
        &project_config.name,
        &project_config.version,
        &credentials_service,
    )
    .await;

    println!("Starting Docker containers...");
    println!();
    println!("Mode:    Detached (background)");
    println!();

    let env_file_str = env_file.to_string_lossy().to_string();
    let mut compose_args = vec![
        "compose",
        "--env-file",
        &env_file_str,
        "-f",
        "docker/docker-compose.remote.yml",
    ];

    let needs_ollama = mecha10_core::ollama::project_needs_ollama(&ctx.working_dir).unwrap_or(false);
    if needs_ollama {
        compose_args.extend(["--profile", "ollama"]);
    }

    compose_args.extend(["up", "--build", "-d"]);

    let mut cmd = Command::new("docker");
    cmd.args(&compose_args)
        .current_dir(&ctx.working_dir)
        .env("ROBOT_ID", &robot_id)
        .env("ROBOT_API_KEY", &api_key);

    // Auto-derive MECHA10_REMOTE_SIM from the project's own config rather than requiring it be
    // exported by hand: the Docker build needs it (Dockerfile.remote.template's MuJoCo/Python
    // install is gated behind this ARG, not driven by mecha10.json directly), but whether a
    // `remote` deployment includes the simulator node is already fully determined by
    // `targets.remote.nodes` - the same "derive the flag from the project" precedent
    // `needs_ollama` above already follows. Only auto-set it when the caller hasn't already
    // exported MECHA10_REMOTE_SIM themselves, so an explicit override (e.g. forcing it off)
    // still wins.
    if std::env::var("MECHA10_REMOTE_SIM").is_err() {
        let needs_sim = project_config
            .targets
            .nodes_for("remote")
            .iter()
            .any(|node| node.name() == "@mecha10/simulator");
        if needs_sim {
            cmd.env("MECHA10_REMOTE_SIM", "1");
        }
    }

    cmd.stdin(std::process::Stdio::inherit())
        .stdout(std::process::Stdio::inherit())
        .stderr(std::process::Stdio::inherit());

    let status = cmd.status()?;

    println!();

    if status.success() {
        println!("✅ Remote containers started in background");
        println!();
        println!("View logs:  docker compose -f docker/docker-compose.remote.yml logs -f");
        println!("Stop:       docker compose -f docker/docker-compose.remote.yml down");
    } else {
        println!("❌ Docker compose exited with error: {:?}", status.code());
    }

    Ok(())
}

/// Run the rl target - a trained RL policy in production.
///
/// Mirrors `run_remote_target` exactly (same `.env`/`ROBOT_API_KEY` injection, same
/// `docker compose ... up --build -d` invocation) but against `docker/docker-compose.rl.yml`
/// instead, and with no Ollama check (the rl node's policy is pulled via the model registry, not
/// Ollama). Routes through Docker rather than `run_robot_target`'s per-node-runner path
/// deliberately: `run_robot_target` runs every node declared in `mecha10.json` regardless of
/// `targets.rl.nodes`, which would silently defeat the rl target's simulator exclusion.
async fn run_rl_target(ctx: &mut CliContext, _args: &RunRlArgs) -> Result<()> {
    let target = RunTarget::Rl;

    println!();
    println!("🐳 Running Mecha10 Project - {} Target (Docker)", target.display_name());
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    if !ctx.is_project_initialized() {
        println!("⚠️  Not in a Mecha10 project directory");
        println!();
        return Err(anyhow::anyhow!("Not in a Mecha10 project"));
    }

    validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes])
        .context("pre-flight config validation failed for `mecha10 run rl`")?;

    let project = ctx.project()?;
    let project_name = project.name()?;
    println!("Project: {}", project_name);
    println!("Target:  {} ({})", target.display_name(), target.description());
    println!();

    let compose_file = ctx.working_dir.join("docker").join("docker-compose.rl.yml");
    if !compose_file.exists() {
        println!("❌ Docker compose file not found: {}", compose_file.display());
        println!();
        println!("Expected: docker/docker-compose.rl.yml");
        println!("Run 'mecha10 init' to create a new project with Docker support.");
        return Err(anyhow::anyhow!("docker-compose.rl.yml not found"));
    }

    println!("Compose: {}", compose_file.display());

    let env_file = ctx.working_dir.join(paths::env::FILE);
    if !env_file.exists() {
        return Err(anyhow::anyhow!(
            "{} not found.\nCopy {} to {} and fill in your project's values first.",
            env_file.display(),
            paths::env::EXAMPLE,
            paths::env::FILE
        ));
    }

    let credentials_service = CredentialsService::new();
    let api_key = if ctx.is_ci() {
        credentials_service.get_api_key().ok().flatten()
    } else {
        mecha10_auth::ensure_authenticated_interactive(&credentials_service)
            .await
            .ok()
            .map(|creds| creds.api_key)
    };
    match &api_key {
        Some(_) => println!("Auth:    ✓ (logged in)"),
        None => println!("Auth:    ⚠ Not logged in - run 'mecha10 auth login'"),
    }
    let api_key = api_key.unwrap_or_default();

    let project_config = ctx.load_project_config().await?;
    let robot_id = project_config.robot.id.clone();
    println!("Robot:   {}", robot_id);
    println!();

    println!("Starting Docker containers...");
    println!();
    println!("Mode:    Detached (background)");
    println!();

    let env_file_str = env_file.to_string_lossy().to_string();
    let compose_args = [
        "compose",
        "--env-file",
        &env_file_str,
        "-f",
        "docker/docker-compose.rl.yml",
        "up",
        "--build",
        "-d",
    ];

    let mut locomotion_env = HashMap::new();
    inject_locomotion_limits_env(&project_config, &mut locomotion_env);

    let mut cmd = Command::new("docker");
    cmd.args(compose_args)
        .current_dir(&ctx.working_dir)
        .env("ROBOT_ID", &robot_id)
        .env("ROBOT_API_KEY", &api_key);
    for (key, value) in &locomotion_env {
        cmd.env(key, value);
    }

    cmd.stdin(std::process::Stdio::inherit())
        .stdout(std::process::Stdio::inherit())
        .stderr(std::process::Stdio::inherit());

    let status = cmd.status()?;

    println!();

    if status.success() {
        println!("✅ RL containers started in background");
        println!();
        println!("View logs:  docker compose -f docker/docker-compose.rl.yml logs -f");
        println!("Stop:       docker compose -f docker/docker-compose.rl.yml down");
    } else {
        println!("❌ Docker compose exited with error: {:?}", status.code());
    }

    Ok(())
}

/// Print an advisory warning if `name`@`version` hasn't been published for `target=remote` -
/// purely informational, never gates `mecha10 run remote`. Silently does nothing if the check
/// can't be completed (offline, not logged in, registry unreachable) or the version *has* been
/// published - this must never add a required network dependency to running remote locally.
async fn warn_if_remote_version_not_published(
    control_plane_url: &str,
    name: &str,
    version: &str,
    credentials_service: &CredentialsService,
) {
    let credentials = credentials_service.load().unwrap_or(None);
    let Some(credentials) = credentials else {
        return;
    };

    let status = match check_published_version(
        control_plane_url,
        name,
        version,
        "remote",
        Some(&credentials.api_key),
        Some(&credentials.user_id),
    )
    .await
    {
        Ok(status) => status,
        Err(_) => return,
    };

    if !status.exists {
        println!(
            "⚠️  Version {} has not been published for remote - this is a local build, not the latest deployed version.",
            version
        );
        println!();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn parses_none_as_no_filter() {
        assert_eq!(parse_requested_nodes(None), Vec::<String>::new());
    }

    #[test]
    fn parses_single_node_name() {
        assert_eq!(parse_requested_nodes(Some("teleop")), vec!["teleop".to_string()]);
    }

    #[test]
    fn parses_comma_separated_node_names() {
        assert_eq!(
            parse_requested_nodes(Some("teleop,simulator,camera")),
            vec!["teleop".to_string(), "simulator".to_string(), "camera".to_string()]
        );
    }

    #[test]
    fn trims_whitespace_around_node_names() {
        assert_eq!(
            parse_requested_nodes(Some(" teleop , simulator ")),
            vec!["teleop".to_string(), "simulator".to_string()]
        );
    }

    #[test]
    fn drops_empty_segments_from_stray_commas() {
        assert_eq!(
            parse_requested_nodes(Some("teleop,,simulator,")),
            vec!["teleop".to_string(), "simulator".to_string()]
        );
    }

    #[test]
    fn empty_string_parses_as_no_filter() {
        assert_eq!(parse_requested_nodes(Some("")), Vec::<String>::new());
    }

    /// `run_robot_target`/`run_remote_target`/`run_rl_target` all call
    /// `validate_configs(&ctx.working_dir, &[ConfigKind::ProjectAndNodes])` immediately after the
    /// `is_project_initialized` check, before loading the project config or touching Docker/node
    /// spawning. Exercising the full handlers isn't practical here (they spawn real processes/
    /// Docker), so this directly confirms the exact pre-flight call they make rejects a project
    /// whose `mecha10.json` is invalid - matching the "fail fast, before real work" requirement.
    #[test]
    fn run_preflight_validation_rejects_an_invalid_project_config() {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("mecha10.json"),
            serde_json::json!({"name": "proj", "version": "1.0.0", "logging": {"disk_logging_enabled": false}})
                .to_string(),
        )
        .unwrap();

        let err = validate_configs(dir.path(), &[ConfigKind::ProjectAndNodes]).unwrap_err();
        assert!(
            err.to_string().contains("environments.<env>.logging"),
            "expected the legacy top-level `logging` block to be rejected, got: {err}"
        );
    }

    #[tokio::test]
    async fn warn_if_remote_version_not_published_is_a_noop_without_credentials() {
        let dir = tempdir().unwrap();
        let credentials_service = CredentialsService::with_path(dir.path().join("credentials.json"));

        warn_if_remote_version_not_published("https://api.example.com", "my-robot", "1.2.3", &credentials_service)
            .await;
    }
}