Skip to main content

rbnx/cmd/
package_new.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx package-new <name>` — scaffold a new package.
3//
4// Two modes:
5//   1. `rbnx package-new my_cam --path ./primitives/my_cam`
6//       → creates directly at the given path (--type is ignored).
7//   2. `rbnx package-new my_cam -t primitive`
8//       → creates at `<cwd>/<role_dir>/my_cam` where role_dir is
9//         derived from --type (primitives/ services/ skills/).
10
11use anyhow::{Context, Result};
12use robonix_cli::output;
13use std::path::{Path, PathBuf};
14
15/// Reject names containing path separators or traversal components.
16fn validate_name(name: &str) -> Result<()> {
17    if name.is_empty() {
18        anyhow::bail!("name must not be empty");
19    }
20    if name.contains('/') || name.contains('\\') || name == ".." || name.starts_with("../") {
21        anyhow::bail!("invalid name '{name}': must not contain path separators or '..' components");
22    }
23    Ok(())
24}
25
26fn package_name(name: &str, ns_kind: &str) -> String {
27    let normalized = name.replace('-', "_");
28    format!("robonix.{ns_kind}.{normalized}")
29}
30
31fn python_module_name(name: &str) -> String {
32    name.replace('-', "_")
33}
34
35fn package_manifest(package_name: &str, ns_kind: &str) -> String {
36    format!(
37        r#"manifestVersion: 1
38
39build: bash scripts/build.sh
40
41start: bash scripts/start.sh
42
43package:
44  name: "{package_name}"
45  version: 0.0.1
46  description: "TODO: describe what this {ns_kind} package provides."
47  tags:
48    - {ns_kind}
49    - robonix
50  maintainers:
51    - Your Name <you@example.com>
52  license: Apache-2.0
53
54capabilities: []
55
56depends: []
57"#
58    )
59}
60
61pub async fn execute(name: &str, pkg_type: &str, path: Option<&Path>) -> Result<()> {
62    validate_name(name)?;
63
64    let pkg_dir: PathBuf = if let Some(p) = path {
65        // --path given: use it directly, no type inference needed.
66        if p.is_absolute() {
67            p.to_path_buf()
68        } else {
69            std::env::current_dir()?.join(p)
70        }
71    } else {
72        // No --path: derive from --type.
73        let role_dir = match pkg_type {
74            "primitive" => "primitives",
75            "service" => "services",
76            "skill" => "skills",
77            other => {
78                anyhow::bail!("unknown package type '{other}'; expected: primitive, service, skill")
79            }
80        };
81        std::env::current_dir()?.join(role_dir).join(name)
82    };
83
84    if pkg_dir.exists() {
85        anyhow::bail!("directory '{}' already exists", pkg_dir.display());
86    }
87
88    output::action("PackageNew", &format!("creating package '{name}'"));
89
90    // Provider class + namespace segment for the generated skeleton.
91    // `--path` mode leaves pkg_type at its clap default ("service").
92    let (provider_class, ns_kind) = match pkg_type {
93        "service" => ("Service", "service"),
94        "skill" => ("Skill", "skill"),
95        _ => ("Primitive", "primitive"),
96    };
97    let package_name = package_name(name, ns_kind);
98    let module_name = python_module_name(name);
99
100    // Create directory structure; put .gitkeep in empty dirs.
101    for sub in ["scripts", "capabilities"] {
102        let dir = pkg_dir.join(sub);
103        std::fs::create_dir_all(&dir)
104            .with_context(|| format!("failed to create {sub}/ directory"))?;
105        // .gitkeep so git tracks the empty directory.
106        std::fs::write(dir.join(".gitkeep"), "")
107            .with_context(|| format!("failed to write {sub}/.gitkeep"))?;
108    }
109
110    // package_manifest.yaml — Driver omission canonically selects the shared
111    // lifecycle contract, so authors only list domain capabilities here.
112    let manifest = package_manifest(&package_name, ns_kind);
113    std::fs::write(pkg_dir.join("package_manifest.yaml"), manifest)
114        .context("failed to write package_manifest.yaml")?;
115
116    // scripts/build.sh — generate the gRPC / MCP stubs for the contracts
117    // this package declares. `robonix-api` auto-discovers the output under
118    // <pkg>/rbnx-build/codegen/, so nothing else is needed for a pure
119    // Python package. Append your own steps (cargo, pip install -e, docker
120    // build, ...) after this line if your package needs them.
121    let build_sh = format!(
122        r#"#!/usr/bin/env bash
123set -euo pipefail
124PKG="${{RBNX_PACKAGE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}}"
125
126rbnx codegen -p "$PKG"
127echo "[{name}] build done"
128"#
129    );
130    std::fs::write(pkg_dir.join("scripts/build.sh"), build_sh)
131        .context("failed to write scripts/build.sh")?;
132    #[cfg(unix)]
133    {
134        use std::os::unix::fs::PermissionsExt;
135        std::fs::set_permissions(
136            pkg_dir.join("scripts/build.sh"),
137            std::fs::Permissions::from_mode(0o755),
138        )?;
139    }
140
141    // scripts/start.sh — launch the provider process. `rbnx path
142    // robonix-api` prints the path to the in-tree client library
143    // (`<robonix>/pylib/robonix-api`), which start.sh puts on PYTHONPATH so
144    // `from robonix_api import ...` resolves. Edit the final line if your
145    // entrypoint module differs, or replace it entirely (docker run, ssh,
146    // a compiled binary, ...).
147    let start_sh = format!(
148        r#"#!/usr/bin/env bash
149set -eo pipefail
150PKG_ROOT="${{RBNX_PACKAGE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}}"
151cd "$PKG_ROOT"
152
153export PYTHONPATH="$(rbnx path robonix-api):$PKG_ROOT:${{PYTHONPATH:-}}"
154
155exec python3 -m {module_name}.main
156"#
157    );
158    std::fs::write(pkg_dir.join("scripts/start.sh"), start_sh)
159        .context("failed to write scripts/start.sh")?;
160    #[cfg(unix)]
161    {
162        use std::os::unix::fs::PermissionsExt;
163        std::fs::set_permissions(
164            pkg_dir.join("scripts/start.sh"),
165            std::fs::Permissions::from_mode(0o755),
166        )?;
167    }
168
169    // Remove .gitkeep from scripts/ since it now has real files.
170    let _ = std::fs::remove_file(pkg_dir.join("scripts/.gitkeep"));
171
172    // Minimal Python provider skeleton: <pkg>/<name>/{__init__.py, main.py}
173    // so `python3 -m {name}.main` (from start.sh) runs out of the box. The
174    // author fills in lifecycle handlers + capability declarations.
175    let module_dir = pkg_dir.join(&module_name);
176    std::fs::create_dir_all(&module_dir).context("failed to create python module directory")?;
177    std::fs::write(module_dir.join("__init__.py"), "").context("failed to write __init__.py")?;
178    let main_py = format!(
179        r#"#!/usr/bin/env python3
180"""{name} — Robonix {provider_class_lower} provider."""
181from robonix_api import {provider_class}, Ok
182
183# `id` must equal this entry's `name:` in the deploy robonix_manifest.yaml.
184# `namespace` groups the capabilities this provider declares.
185provider = {provider_class}(id="{name}", namespace="robonix/{ns_kind}/{name}")
186
187
188@provider.on_init
189def init(cfg: dict):
190    # TODO: initialise hardware / resources; declare capabilities to atlas.
191    return Ok()
192
193
194if __name__ == "__main__":
195    provider.run()
196"#,
197        provider_class_lower = provider_class.to_lowercase(),
198    );
199    std::fs::write(module_dir.join("main.py"), main_py).context("failed to write main.py")?;
200
201    // .gitignore
202    std::fs::write(
203        pkg_dir.join(".gitignore"),
204        "rbnx-build/\n__pycache__/\n*.pyc\n.venv/\n",
205    )
206    .context("failed to write .gitignore")?;
207
208    output::success(&format!(
209        "Package '{name}' created at {}",
210        pkg_dir.display()
211    ));
212    output::sub_step("package_manifest.yaml");
213    output::sub_step("scripts/build.sh");
214    output::sub_step("scripts/start.sh");
215    output::sub_step(&format!("{module_name}/main.py  (provider skeleton)"));
216    output::sub_step("capabilities/  (.gitkeep)");
217    output::sub_step(".gitignore");
218
219    Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn generated_manifest_has_complete_package_metadata() {
228        let root: serde_yaml::Value =
229            serde_yaml::from_str(&package_manifest("robonix.service.example", "service")).unwrap();
230        let package = root
231            .get("package")
232            .and_then(|value| value.as_mapping())
233            .unwrap();
234
235        assert_eq!(
236            root.get("manifestVersion").and_then(|value| value.as_u64()),
237            Some(1)
238        );
239        for key in [
240            "name",
241            "version",
242            "description",
243            "license",
244            "tags",
245            "maintainers",
246        ] {
247            assert!(
248                package.contains_key(serde_yaml::Value::String(key.into())),
249                "missing package.{key}"
250            );
251        }
252        assert!(root.get("start").and_then(|value| value.as_str()).is_some());
253        assert!(
254            root.get("capabilities")
255                .and_then(|value| value.as_sequence())
256                .is_some()
257        );
258        let capabilities = root
259            .get("capabilities")
260            .and_then(|value| value.as_sequence())
261            .unwrap();
262        assert!(capabilities.is_empty());
263        assert!(
264            root.get("depends")
265                .and_then(|value| value.as_sequence())
266                .is_some()
267        );
268    }
269}