Skip to main content

rbnx/
main.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Robonix CLI Main Entry
3//
4// Main entry point for robonix-cli command-line tool
5
6use anyhow::Result;
7use clap::Parser;
8use robonix_cli::Config;
9use robonix_scribe::warn;
10use std::path::{Path, PathBuf};
11
12mod cmd;
13mod pb;
14
15#[derive(Parser)]
16#[command(name = "rbnx")]
17#[command(version)]
18#[command(about = "Robonix helper CLI for package validation, build, and local orchestration", long_about = None)]
19struct Cli {
20    #[command(subcommand)]
21    command: cmd::Commands,
22}
23
24/// Ensure the loopback control plane is never tunneled through an HTTP proxy.
25///
26/// Robonix's atlas (gRPC), the executor↔package MCP calls (HTTP), and the CLI
27/// itself all talk over `127.0.0.1` / `localhost`. If the environment has
28/// `http_proxy` set without those hosts in `no_proxy`, every local call gets
29/// routed through the proxy and fails (gRPC "Socket closed", MCP "502 Bad
30/// Gateway"). Merge the loopback hosts into `no_proxy`/`NO_PROXY` (preserving
31/// any existing entries, case-insensitively) so this process and every child
32/// it spawns — atlas, executor, pilot, the Python packages — bypass the proxy
33/// for the local control plane.
34fn ensure_loopback_bypasses_proxy() {
35    const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"];
36    for var in ["no_proxy", "NO_PROXY"] {
37        let mut entries: Vec<String> = std::env::var(var)
38            .unwrap_or_default()
39            .split(',')
40            .map(|s| s.trim().to_string())
41            .filter(|s| !s.is_empty())
42            .collect();
43        let have: std::collections::HashSet<String> =
44            entries.iter().map(|s| s.to_ascii_lowercase()).collect();
45        for host in LOOPBACK {
46            if !have.contains(host) {
47                entries.push(host.to_string());
48            }
49        }
50        // SAFETY: called once at the very top of main, before any worker
51        // thread or child process reads proxy configuration from the env.
52        unsafe { std::env::set_var(var, entries.join(",")) };
53    }
54}
55
56fn boot_log_dir(command: &cmd::Commands) -> Option<PathBuf> {
57    let cmd::Commands::Boot { file, log_dir, .. } = command else {
58        return None;
59    };
60    if let Some(log_dir) = log_dir {
61        return Some(log_dir.clone());
62    }
63    let manifest = if file.is_absolute() {
64        file.clone()
65    } else {
66        std::env::current_dir().ok()?.join(file)
67    };
68    Some(
69        manifest
70            .parent()
71            .unwrap_or_else(|| Path::new("."))
72            .join("rbnx-boot")
73            .join("logs"),
74    )
75}
76
77fn prepare_boot_log_dir(command: &cmd::Commands) -> Result<()> {
78    let Some(log_dir) = boot_log_dir(command) else {
79        return Ok(());
80    };
81    std::fs::create_dir_all(&log_dir)?;
82    for entry in std::fs::read_dir(&log_dir)?.flatten() {
83        let path = entry.path();
84        if path.extension().and_then(|s| s.to_str()) == Some("log") {
85            let _ = std::fs::remove_file(path);
86        }
87    }
88    // Safety: startup is still single-threaded and Scribe has not been used.
89    unsafe { std::env::set_var("SCRIBE_LOG_DIR", &log_dir) };
90    Ok(())
91}
92
93#[tokio::main]
94async fn main() -> Result<()> {
95    ensure_loopback_bypasses_proxy();
96
97    let cli = Cli::parse();
98    // The detached boot watchdog must not depend on user config, package DB
99    // sync, a terminal, or Scribe initialization. It only reads the persisted
100    // boot state and performs teardown if the exact parent process vanishes.
101    if let cmd::Commands::WatchBoot {
102        state,
103        boot_pid,
104        boot_start_time_ticks,
105        boot_id,
106    } = &cli.command
107    {
108        return cmd::boot_watchdog::execute(
109            state.clone(),
110            *boot_pid,
111            *boot_start_time_ticks,
112            boot_id.clone(),
113        )
114        .await;
115    }
116    let verbose_boot = matches!(&cli.command, cmd::Commands::Boot { verbose: true, .. });
117    prepare_boot_log_dir(&cli.command)?;
118
119    // Select the Scribe console threshold before its LazyLock is initialized.
120    // Normal boot keeps package logs in files; verbose boot streams the same
121    // Scribe records to the terminal without introducing a second log path.
122    // Safety: no other threads exist yet (tokio runtime not started).
123    unsafe {
124        std::env::set_var(
125            "SCRIBE_CONSOLE_LEVEL",
126            if verbose_boot { "info" } else { "error" },
127        );
128    }
129
130    robonix_scribe::init("rbnx");
131
132    let config = Config::load()?;
133    config.ensure_storage_dir()?;
134
135    // Enforce robonix_source_path migration: subcommands that build / start packages
136    // need the new ~/.robonix/config.yaml field. Exempt commands that bootstrap the
137    // config itself (Setup), read it (Config, Path), or operate purely on atlas (Nodes,
138    // Describe, Tools, Channels, Inspect, Chat, Graph, List, Info).
139    let needs_source = matches!(
140        &cli.command,
141        cmd::Commands::Build { .. }
142            | cmd::Commands::Start { .. }
143            | cmd::Commands::Validate { .. }
144            | cmd::Commands::Install { .. }
145            | cmd::Commands::Codegen { .. }
146    );
147    if needs_source {
148        let _ = config.require_source_path();
149    }
150
151    if let Err(e) = robonix_cli::PackageDatabase::sync(&config.package_storage_path) {
152        warn!("Package database sync failed: {}", e);
153    }
154
155    cmd::execute(cli.command, config).await?;
156
157    Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn build_accepts_explicit_deploy_manifest() {
166        let cli = Cli::try_parse_from(["rbnx", "build", "-f", "robot/robonix_manifest.yaml"])
167            .expect("build -f should parse");
168        match cli.command {
169            cmd::Commands::Build {
170                file, path, global, ..
171            } => {
172                assert_eq!(file, Some("robot/robonix_manifest.yaml".into()));
173                assert!(path.is_none());
174                assert!(global.is_none());
175            }
176            _ => panic!("expected build command"),
177        }
178    }
179
180    #[test]
181    fn build_manifest_conflicts_with_single_package_selectors() {
182        assert!(Cli::try_parse_from(["rbnx", "build", "-f", "deploy.yaml", "-p", "."]).is_err());
183        assert!(Cli::try_parse_from(["rbnx", "build", "-f", "deploy.yaml", "-g", "pkg"]).is_err());
184    }
185
186    #[test]
187    fn build_accepts_no_update_check() {
188        let cli = Cli::try_parse_from([
189            "rbnx",
190            "build",
191            "-f",
192            "robot/robonix_manifest.yaml",
193            "--no-update-check",
194        ])
195        .expect("build --no-update-check should parse");
196        assert!(matches!(
197            cli.command,
198            cmd::Commands::Build {
199                no_update_check: true,
200                ..
201            }
202        ));
203    }
204
205    #[test]
206    fn boot_accepts_verbose_mode() {
207        let cli = Cli::try_parse_from(["rbnx", "boot", "-v"]).expect("boot -v should parse");
208        assert!(matches!(
209            cli.command,
210            cmd::Commands::Boot { verbose: true, .. }
211        ));
212    }
213
214    #[test]
215    fn boot_uses_manifest_local_scribe_directory() {
216        let cli = Cli::try_parse_from(["rbnx", "boot", "-f", "robot/robonix_manifest.yaml"])
217            .expect("boot manifest should parse");
218        assert_eq!(
219            boot_log_dir(&cli.command),
220            std::env::current_dir()
221                .ok()
222                .map(|cwd| cwd.join("robot/rbnx-boot/logs"))
223        );
224    }
225}