Skip to main content

rbnx/cmd/
init.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx init <name>` — scaffold a robot deployment directory.
3
4use anyhow::{Context, Result};
5use robonix_cli::output;
6use std::path::Path;
7
8/// Reject names containing path separators or traversal components.
9fn validate_name(name: &str) -> Result<()> {
10    if name.is_empty() {
11        anyhow::bail!("name must not be empty");
12    }
13    if name.contains('/') || name.contains('\\') || name == ".." || name.starts_with("../") {
14        anyhow::bail!("invalid name '{name}': must not contain path separators or '..' components");
15    }
16    Ok(())
17}
18
19fn robot_catalog_name(name: &str) -> String {
20    let suffix = name.strip_prefix("robot-").unwrap_or(name);
21    format!("robonix.robot.{}", suffix.replace('-', "."))
22}
23
24fn deployment_manifest(name: &str) -> String {
25    let catalog_name = robot_catalog_name(name);
26    format!(
27        r#"manifestVersion: 1
28catalog:
29  name: {catalog_name}
30  version: 0.1.0
31  description: "TODO: describe this robot deployment."
32  license: Apache-2.0
33  tags:
34    - robot
35    - deploy
36    - robonix
37  maintainers:
38    - Your Name <you@example.com>
39
40name: {name}
41
42env:
43  LOG: "INFO"
44
45system:
46  atlas:
47    listen: 127.0.0.1:50051
48    log: info
49  executor:
50    listen: 127.0.0.1:50061
51    log: info
52  pilot:
53    listen: 127.0.0.1:50071
54    log: info
55    vlm:
56      upstream: ${{VLM_BASE_URL}}
57      api_key: ${{VLM_API_KEY}}
58      model: ${{VLM_MODEL}}
59      api_format: openai
60  liaison:
61    listen: 0.0.0.0:50081
62    log: info
63
64primitive: []
65
66service: []
67
68skill: []
69"#
70    )
71}
72
73pub async fn execute(name: &str, path: Option<&Path>) -> Result<()> {
74    validate_name(name)?;
75
76    let base = match path {
77        Some(p) => p.to_path_buf(),
78        None => std::env::current_dir()?,
79    };
80    let project_dir = base.join(name);
81
82    if project_dir.exists() {
83        anyhow::bail!("directory '{}' already exists", project_dir.display());
84    }
85
86    output::action("Init", &format!("creating robot deployment '{name}'"));
87
88    std::fs::create_dir_all(&project_dir).context("failed to create deployment directory")?;
89
90    // robonix_manifest.yaml (deployment manifest).
91    let manifest = deployment_manifest(name);
92    std::fs::write(project_dir.join("robonix_manifest.yaml"), manifest)
93        .context("failed to write robonix_manifest.yaml")?;
94
95    // .gitignore
96    let gitignore = "\
97rbnx-build/
98rbnx-boot/
99__pycache__/
100*.pyc
101.venv/
102";
103    std::fs::write(project_dir.join(".gitignore"), gitignore)
104        .context("failed to write .gitignore")?;
105
106    output::success(&format!(
107        "Robot deployment '{name}' created at {}",
108        project_dir.display()
109    ));
110    output::sub_step("robonix_manifest.yaml");
111    output::sub_step(".gitignore");
112
113    Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn generated_manifest_has_complete_robot_catalog_metadata() {
122        let root: serde_yaml::Value =
123            serde_yaml::from_str(&deployment_manifest("robot-example-mobile")).unwrap();
124        let catalog = root
125            .get("catalog")
126            .and_then(|value| value.as_mapping())
127            .unwrap();
128
129        assert_eq!(
130            root.get("manifestVersion").and_then(|value| value.as_u64()),
131            Some(1)
132        );
133        for key in [
134            "name",
135            "version",
136            "description",
137            "license",
138            "tags",
139            "maintainers",
140        ] {
141            assert!(
142                catalog.contains_key(serde_yaml::Value::String(key.into())),
143                "missing catalog.{key}"
144            );
145        }
146    }
147}