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    use clap::CommandFactory;
164
165    #[test]
166    fn build_accepts_explicit_deploy_manifest() {
167        let cli = Cli::try_parse_from(["rbnx", "build", "-f", "robot/robonix_manifest.yaml"])
168            .expect("build -f should parse");
169        match cli.command {
170            cmd::Commands::Build {
171                file, path, global, ..
172            } => {
173                assert_eq!(file, Some("robot/robonix_manifest.yaml".into()));
174                assert!(path.is_none());
175                assert!(global.is_none());
176            }
177            _ => panic!("expected build command"),
178        }
179    }
180
181    #[test]
182    fn build_manifest_conflicts_with_single_package_selectors() {
183        assert!(Cli::try_parse_from(["rbnx", "build", "-f", "deploy.yaml", "-p", "."]).is_err());
184        assert!(Cli::try_parse_from(["rbnx", "build", "-f", "deploy.yaml", "-g", "pkg"]).is_err());
185    }
186
187    #[test]
188    fn build_accepts_no_update_check() {
189        let cli = Cli::try_parse_from([
190            "rbnx",
191            "build",
192            "-f",
193            "robot/robonix_manifest.yaml",
194            "--no-update-check",
195        ])
196        .expect("build --no-update-check should parse");
197        assert!(matches!(
198            cli.command,
199            cmd::Commands::Build {
200                no_update_check: true,
201                ..
202            }
203        ));
204    }
205
206    #[test]
207    fn boot_accepts_verbose_mode() {
208        let cli = Cli::try_parse_from(["rbnx", "boot", "-v"]).expect("boot -v should parse");
209        assert!(matches!(
210            cli.command,
211            cmd::Commands::Boot { verbose: true, .. }
212        ));
213    }
214
215    /// Keep boot help aligned with the supported built-in system components.
216    #[test]
217    fn boot_help_lists_vitals() {
218        let mut command = Cli::command();
219        let help = command
220            .find_subcommand_mut("boot")
221            .expect("boot subcommand")
222            .render_long_help()
223            .to_string();
224        assert!(help.contains("vitals"));
225    }
226
227    #[test]
228    fn boot_uses_manifest_local_scribe_directory() {
229        let cli = Cli::try_parse_from(["rbnx", "boot", "-f", "robot/robonix_manifest.yaml"])
230            .expect("boot manifest should parse");
231        assert_eq!(
232            boot_log_dir(&cli.command),
233            std::env::current_dir()
234                .ok()
235                .map(|cwd| cwd.join("robot/rbnx-boot/logs"))
236        );
237    }
238}