1use anyhow::{Context, Result};
12use robonix_cli::output;
13use std::path::{Path, PathBuf};
14
15fn 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 if p.is_absolute() {
67 p.to_path_buf()
68 } else {
69 std::env::current_dir()?.join(p)
70 }
71 } else {
72 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 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 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 std::fs::write(dir.join(".gitkeep"), "")
107 .with_context(|| format!("failed to write {sub}/.gitkeep"))?;
108 }
109
110 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 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 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 let _ = std::fs::remove_file(pkg_dir.join("scripts/.gitkeep"));
171
172 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 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}