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
26pub async fn execute(name: &str, pkg_type: &str, path: Option<&Path>) -> Result<()> {
27    validate_name(name)?;
28
29    let pkg_dir: PathBuf = if let Some(p) = path {
30        // --path given: use it directly, no type inference needed.
31        if p.is_absolute() {
32            p.to_path_buf()
33        } else {
34            std::env::current_dir()?.join(p)
35        }
36    } else {
37        // No --path: derive from --type.
38        let role_dir = match pkg_type {
39            "primitive" => "primitives",
40            "service" => "services",
41            "skill" => "skills",
42            other => {
43                anyhow::bail!("unknown package type '{other}'; expected: primitive, service, skill")
44            }
45        };
46        std::env::current_dir()?.join(role_dir).join(name)
47    };
48
49    if pkg_dir.exists() {
50        anyhow::bail!("directory '{}' already exists", pkg_dir.display());
51    }
52
53    output::action("PackageNew", &format!("creating package '{name}'"));
54
55    // Provider class + namespace segment for the generated skeleton.
56    // `--path` mode leaves pkg_type at its clap default ("service"); fall
57    // back to primitive for anything unrecognised so the skeleton is still
58    // valid Python the author can edit.
59    let (provider_class, ns_kind) = match pkg_type {
60        "service" => ("Service", "service"),
61        "skill" => ("Skill", "skill"),
62        _ => ("Primitive", "primitive"),
63    };
64
65    // Create directory structure; put .gitkeep in empty dirs.
66    for sub in ["scripts", "capabilities"] {
67        let dir = pkg_dir.join(sub);
68        std::fs::create_dir_all(&dir)
69            .with_context(|| format!("failed to create {sub}/ directory"))?;
70        // .gitkeep so git tracks the empty directory.
71        std::fs::write(dir.join(".gitkeep"), "")
72            .with_context(|| format!("failed to write {sub}/.gitkeep"))?;
73    }
74
75    // package_manifest.yaml — capabilities default to empty.
76    let manifest = format!(
77        r#"manifestVersion: 1
78
79build: bash scripts/build.sh
80
81start: bash scripts/start.sh
82
83package:
84  name: com.vendor.{name}
85  version: 0.0.1
86  vendor: vendor
87  description: TODO
88  license: Apache-2.0
89
90capabilities: []
91
92depends: []
93"#
94    );
95    std::fs::write(pkg_dir.join("package_manifest.yaml"), manifest)
96        .context("failed to write package_manifest.yaml")?;
97
98    // scripts/build.sh — generate the gRPC / MCP stubs for the contracts
99    // this package declares. `robonix-api` auto-discovers the output under
100    // <pkg>/rbnx-build/codegen/, so nothing else is needed for a pure
101    // Python package. Append your own steps (cargo, pip install -e, docker
102    // build, ...) after this line if your package needs them.
103    let build_sh = format!(
104        r#"#!/usr/bin/env bash
105set -euo pipefail
106PKG="${{RBNX_PACKAGE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}}"
107
108rbnx codegen -p "$PKG"
109echo "[{name}] build done"
110"#
111    );
112    std::fs::write(pkg_dir.join("scripts/build.sh"), build_sh)
113        .context("failed to write scripts/build.sh")?;
114    #[cfg(unix)]
115    {
116        use std::os::unix::fs::PermissionsExt;
117        std::fs::set_permissions(
118            pkg_dir.join("scripts/build.sh"),
119            std::fs::Permissions::from_mode(0o755),
120        )?;
121    }
122
123    // scripts/start.sh — launch the provider process. `rbnx path
124    // robonix-api` puts the pip-installed client library on PYTHONPATH so
125    // `from robonix_api import ...` resolves. Edit the final line if your
126    // entrypoint module differs, or replace it entirely (docker run, ssh,
127    // a compiled binary, ...).
128    let start_sh = format!(
129        r#"#!/usr/bin/env bash
130set -eo pipefail
131PKG_ROOT="${{RBNX_PACKAGE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}}"
132cd "$PKG_ROOT"
133
134export PYTHONPATH="$(rbnx path robonix-api):$PKG_ROOT:${{PYTHONPATH:-}}"
135
136exec python3 -m {name}.main
137"#
138    );
139    std::fs::write(pkg_dir.join("scripts/start.sh"), start_sh)
140        .context("failed to write scripts/start.sh")?;
141    #[cfg(unix)]
142    {
143        use std::os::unix::fs::PermissionsExt;
144        std::fs::set_permissions(
145            pkg_dir.join("scripts/start.sh"),
146            std::fs::Permissions::from_mode(0o755),
147        )?;
148    }
149
150    // Remove .gitkeep from scripts/ since it now has real files.
151    let _ = std::fs::remove_file(pkg_dir.join("scripts/.gitkeep"));
152
153    // Minimal Python provider skeleton: <pkg>/<name>/{__init__.py, main.py}
154    // so `python3 -m {name}.main` (from start.sh) runs out of the box. The
155    // author fills in lifecycle handlers + capability declarations.
156    let module_dir = pkg_dir.join(name);
157    std::fs::create_dir_all(&module_dir).context("failed to create python module directory")?;
158    std::fs::write(module_dir.join("__init__.py"), "").context("failed to write __init__.py")?;
159    let main_py = format!(
160        r#"#!/usr/bin/env python3
161"""{name} — Robonix {provider_class_lower} provider."""
162from robonix_api import {provider_class}, Ok
163
164# `id` must equal this entry's `name:` in the deploy robonix_manifest.yaml.
165# `namespace` groups the capabilities this provider declares.
166provider = {provider_class}(id="{name}", namespace="robonix/{ns_kind}/{name}")
167
168
169@provider.on_init
170def init(cfg: dict):
171    # TODO: initialise hardware / resources; declare capabilities to atlas.
172    return Ok()
173
174
175if __name__ == "__main__":
176    provider.run()
177"#,
178        provider_class_lower = provider_class.to_lowercase(),
179    );
180    std::fs::write(module_dir.join("main.py"), main_py).context("failed to write main.py")?;
181
182    // .gitignore
183    std::fs::write(
184        pkg_dir.join(".gitignore"),
185        "rbnx-build/\n__pycache__/\n*.pyc\n.venv/\n",
186    )
187    .context("failed to write .gitignore")?;
188
189    output::success(&format!(
190        "Package '{name}' created at {}",
191        pkg_dir.display()
192    ));
193    output::sub_step("package_manifest.yaml");
194    output::sub_step("scripts/build.sh");
195    output::sub_step("scripts/start.sh");
196    output::sub_step(&format!("{name}/main.py  (provider skeleton)"));
197    output::sub_step("capabilities/  (.gitkeep)");
198    output::sub_step(".gitignore");
199
200    Ok(())
201}