@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.24 → 0.1.2-dev.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/README.md CHANGED
@@ -10,10 +10,10 @@ muscat-pk new my-plugin
10
10
  ```
11
11
 
12
12
  ```bash
13
- muscat-pk new my-plugin [--type sdk|ros2|ui] [--pm npm|yarn|pnpm|bun] [--yes]
13
+ muscat-pk new my-plugin [--type python|cpp|ros2|ros2-cpp] [--pm npm|yarn|pnpm|bun] [--yes]
14
14
  muscat-pk build # ui/content.tsx -> dist/ (minified) + distributable ZIP (--no-zip to skip)
15
15
  muscat-pk dev:ui # preview the plugin in a mini-Muscat (preview-host)
16
- muscat-pk dev:back # run the back end (.venv + python main.py)
16
+ muscat-pk dev:back # run the back end in a Docker container (python / cpp / ros2)
17
17
  muscat-pk dev # dev:ui + dev:back
18
18
  muscat-pk update # update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
19
19
  muscat-pk sdk list | sdk update [version] # manage rainbow-rb-sdk (python)
@@ -3,6 +3,6 @@
3
3
  import { runNew } from "../src/new.mjs";
4
4
 
5
5
  runNew(process.argv.slice(2)).catch((err) => {
6
- console.error(`\n✖ ${err?.message || err}`);
6
+ console.error(`\n ✖ ${err?.message || err}`);
7
7
  process.exit(1);
8
8
  });
@@ -19,8 +19,8 @@ const HELP = `muscat-pk — Muscat plugin kit
19
19
  Usage: muscat-pk <command> [options]
20
20
 
21
21
  Commands:
22
- new [dir] Scaffold a new plugin project
23
- type change Switch backend type (python <-> ros2)
22
+ new [dir] Scaffold a new plugin project (prompts: author, email, description, type, pm)
23
+ type change Switch backend type (python | cpp | ros2 | ros2-cpp)
24
24
  build Build ui/content.tsx -> dist/ and zip into a distributable ZIP (--no-zip to skip)
25
25
  dev Run Socket.IO bridge, dev:ui, and dev:back together
26
26
  dev:ui Preview the plugin in a mini-Muscat (preview-host)
@@ -0,0 +1,16 @@
1
+ # Socket.IO bridge container — the frontend (preview-host) and the backend
2
+ # container rendezvous here during `muscat-pk dev`.
3
+ # - frontend (host) → published port localhost:10000
4
+ # - backend container → shares this container's netns (--network container:),
5
+ # so it reaches the bridge at localhost:10000 too
6
+ # Build context is the `cli/` dir so it can copy src/socketBridge.mjs.
7
+ FROM node:20-slim
8
+
9
+ WORKDIR /bridge
10
+ COPY docker/bridge-package.json package.json
11
+ RUN npm install --omit=dev --no-audit --no-fund
12
+
13
+ COPY src/socketBridge.mjs ./socketBridge.mjs
14
+
15
+ EXPOSE 10000
16
+ CMD ["node", "socketBridge.mjs"]
@@ -0,0 +1,21 @@
1
+ # Dev/build container for the pure C++ plugin back end.
2
+ # Matches the Muscat robot backend OS (Ubuntu 24.04) so the compiled binary
3
+ # is ABI-compatible with the robot. Used by `muscat-pk dev:back` and `build`
4
+ # — NOT shipped in the plugin ZIP.
5
+ FROM ubuntu:24.04
6
+
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ cmake \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # TODO(cpp-sdk): install the rainbow cpp SDK (dev package) here, pinned to the
13
+ # robot's version, so plugins build/link against the same SDK as production.
14
+ # Plan: apt package + dynamic link (the robot provides the .so at runtime;
15
+ # we do NOT bundle it into the plugin binary). e.g.:
16
+ # RUN apt-get update && apt-get install -y rainbow-cpp-sdk-dev=<robot-version> \
17
+ # && rm -rf /var/lib/apt/lists/*
18
+
19
+ WORKDIR /plugin
20
+
21
+ CMD ["bash"]
@@ -0,0 +1,18 @@
1
+ # Dev container for the Python plugin back end.
2
+ # Matches the Muscat robot backend: Ubuntu 24.04 + Python 3.12.
3
+ # Used by `muscat-pk dev:back` — NOT shipped in the plugin ZIP (the robot
4
+ # pip-installs requirements itself at load time).
5
+ FROM ubuntu:24.04
6
+
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ python3 \
9
+ python3-pip \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # TODO(cpp-sdk): the Python SDK is a pybind wrapper over the cpp SDK, so the
13
+ # cpp SDK shared lib must be present for the python module to load. Install the
14
+ # rainbow cpp SDK (apt, pinned to the robot's version) here once it exists.
15
+
16
+ WORKDIR /plugin
17
+
18
+ CMD ["bash"]
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "muscat-pk-bridge",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "socket.io": "^4.8.1"
7
+ }
8
+ }
@@ -0,0 +1,91 @@
1
+ // Socket.IO bridge container lifecycle for `muscat-pk dev`.
2
+ // The bridge runs in Docker and publishes port 10000 to the host:
3
+ // - frontend (preview-host, on the host) connects to localhost:10000
4
+ // - the backend container joins via `--network container:<bridge>` and reaches
5
+ // it at localhost:10000 too — no host.docker.internal needed (robust cross-OS).
6
+ import { spawnSync } from "node:child_process";
7
+ import { createServer, connect } from "node:net";
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { hasBin, kitRoot, log } from "./util.mjs";
11
+
12
+ export const BRIDGE_IMAGE = "muscat-pk-bridge";
13
+ export const BRIDGE_CONTAINER = "muscat-pk-bridge-run";
14
+ export const BRIDGE_PORT = 10000;
15
+
16
+ const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.bridge");
17
+ const BUILD_CONTEXT = resolve(kitRoot, "cli");
18
+
19
+ function assertDockerReady() {
20
+ if (!hasBin("docker")) {
21
+ throw new Error("Docker not found. `muscat-pk dev` runs the Socket.IO bridge in a container.");
22
+ }
23
+ if (!existsSync(DOCKERFILE)) {
24
+ throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
25
+ }
26
+ }
27
+
28
+ // Is a host TCP port already bound? (Catches another bridge or a stray process.)
29
+ function isPortInUse(port) {
30
+ return new Promise((res) => {
31
+ const srv = createServer();
32
+ srv.once("error", (err) => res(err.code === "EADDRINUSE"));
33
+ srv.once("listening", () => srv.close(() => res(false)));
34
+ srv.listen(port, "0.0.0.0");
35
+ });
36
+ }
37
+
38
+ // Wait until the published port accepts a TCP connection (bridge is ready).
39
+ function waitForPort(port, timeoutMs = 15_000) {
40
+ const start = Date.now();
41
+ return new Promise((res, rej) => {
42
+ const tryOnce = () => {
43
+ const sock = connect(port, "127.0.0.1");
44
+ sock.once("connect", () => { sock.destroy(); res(); });
45
+ sock.once("error", () => {
46
+ sock.destroy();
47
+ if (Date.now() - start > timeoutMs) rej(new Error("bridge container did not become ready in time"));
48
+ else setTimeout(tryOnce, 300);
49
+ });
50
+ };
51
+ tryOnce();
52
+ });
53
+ }
54
+
55
+ function ensureImage() {
56
+ log.step(`docker build -t ${BRIDGE_IMAGE} (first run is slow, then cached)…`);
57
+ const r = spawnSync("docker", ["build", "-t", BRIDGE_IMAGE, "-f", DOCKERFILE, BUILD_CONTEXT], { stdio: "inherit" });
58
+ if (r.status !== 0) throw new Error("docker build failed (bridge image).");
59
+ }
60
+
61
+ /** Build the image, preflight the port, and start the bridge container. */
62
+ export async function startBridge() {
63
+ assertDockerReady();
64
+
65
+ // remove our own stale container first, then check the host port is free
66
+ spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
67
+ if (await isPortInUse(BRIDGE_PORT)) {
68
+ throw new Error(
69
+ `port ${BRIDGE_PORT} is already in use.\n` +
70
+ ` Another 'muscat-pk dev' may be running, or a process is holding the port.\n` +
71
+ ` Stop it, or free the port, then retry.`,
72
+ );
73
+ }
74
+
75
+ ensureImage();
76
+
77
+ log.step("starting Socket.IO bridge container…");
78
+ const up = spawnSync(
79
+ "docker",
80
+ ["run", "-d", "--name", BRIDGE_CONTAINER, "-p", `${BRIDGE_PORT}:${BRIDGE_PORT}`, BRIDGE_IMAGE],
81
+ { stdio: "inherit" },
82
+ );
83
+ if (up.status !== 0) throw new Error("failed to start bridge container.");
84
+
85
+ await waitForPort(BRIDGE_PORT);
86
+ log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
87
+ }
88
+
89
+ export function stopBridge() {
90
+ spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
91
+ }
package/cli/src/build.mjs CHANGED
@@ -9,11 +9,11 @@
9
9
  // The plugin author's `@rainbow-robotics/plugin-ui` and React imports are left external.
10
10
  // Muscat/robot and preview-host provide those modules through a sandbox import
11
11
  // map, so plugin ZIPs do not carry the UI runtime.
12
- import { cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import { createRequire } from "node:module";
14
14
  import { basename, dirname, resolve } from "node:path";
15
15
  import { execSync, spawnSync } from "node:child_process";
16
- import { findPluginRoot, hasBin, humanSize, log } from "./util.mjs";
16
+ import { ensureDocker, findPluginRoot, hasBin, humanSize, log } from "./util.mjs";
17
17
  import { assertManifest } from "./manifest.mjs";
18
18
 
19
19
  // Find a usable node binary (for running vite as subprocess).
@@ -84,6 +84,33 @@ export async function exportRos2Build(root) {
84
84
  log.ok("ros2_ws/install ready (will be included in the ZIP)");
85
85
  }
86
86
 
87
+ // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) and copy
88
+ // the binary into dist/ so the ZIP carries a robot-compatible executable.
89
+ // Skipped (with a warning) if Docker is unavailable.
90
+ export async function exportCppBuild(root, distDir) {
91
+ if (manifestRuntime(root) !== "cpp") return;
92
+ const { assertDockerReady, ensureImage, cmakeBuild } = await import("./cpp.mjs");
93
+ try {
94
+ assertDockerReady();
95
+ } catch (e) {
96
+ log.warn(`cpp build skipped: ${e.message}`);
97
+ return;
98
+ }
99
+ ensureImage();
100
+ cmakeBuild(root, { buildType: "Release" });
101
+
102
+ const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
103
+ const built = resolve(root, "cpp/build", entry ?? "");
104
+ if (entry && existsSync(built)) {
105
+ const dest = resolve(distDir, entry);
106
+ copyFileSync(built, dest);
107
+ chmodSync(dest, 0o755);
108
+ log.ok(`cpp binary → dist/${entry} (will be included in the ZIP)`);
109
+ } else {
110
+ log.warn(`cpp binary not found at cpp/build/${entry} — skipped`);
111
+ }
112
+ }
113
+
87
114
  const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
88
115
 
89
116
  function isPrerelease(version) {
@@ -199,9 +226,9 @@ export async function buildPlugin({ root, minify = false } = {}) {
199
226
  cpSync(assetsSrc, resolve(distDir, "assets"), { recursive: true });
200
227
  }
201
228
 
202
- // README.md and README.[lang].md → dist/
229
+ // README.md, README.[lang].md, CHANGELOG.md → dist/
203
230
  for (const f of readdirSync(pluginRoot)) {
204
- if (/^README(\.[a-z]{2}(-[A-Z]{2})?)?\.md$/i.test(f)) {
231
+ if (/^README(\.[a-z]{2}(-[A-Z]{2})?)?\.md$/i.test(f) || /^CHANGELOG\.md$/i.test(f)) {
205
232
  copyFileSync(resolve(pluginRoot, f), resolve(distDir, f));
206
233
  }
207
234
  }
@@ -263,12 +290,16 @@ function packZip(pluginRoot) {
263
290
  export async function runBuild(args) {
264
291
  const noZip = args.includes("--no-zip");
265
292
  const root = findPluginRoot();
293
+ // cpp / ros2 compile inside Docker — require it up front (python/none don't).
294
+ const rt = manifestRuntime(root);
295
+ if (rt === "cpp" || rt === "ros2") ensureDocker();
266
296
  if (!args.includes("--no-pin")) {
267
297
  const stableOnly = isMainBranch();
268
298
  log.step(`pinning requirements.txt to latest ${stableOnly ? "stable " : ""}PyPI versions`);
269
299
  await pinRequirements(root, { stableOnly });
270
300
  }
271
- const { pluginRoot } = await buildPlugin({ root, minify: true });
301
+ const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
272
302
  await exportRos2Build(root);
303
+ await exportCppBuild(root, distDir);
273
304
  if (!noZip) packZip(pluginRoot);
274
305
  }
@@ -0,0 +1,51 @@
1
+ // Shared C++ Docker helpers (Ubuntu 24.04 + build-essential + cmake).
2
+ // Matches the robot OS so the compiled binary is relocatable to the robot.
3
+ // Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import { hasBin, kitRoot, log } from "./util.mjs";
8
+
9
+ export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
10
+
11
+ // Dockerfile lives inside the kit — plugin authors never see or edit it.
12
+ const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.cpp");
13
+
14
+ export function assertDockerReady() {
15
+ if (!hasBin("docker")) {
16
+ throw new Error("Docker not found. C++ builds need Docker (Ubuntu 24.04 + cmake container).");
17
+ }
18
+ if (!existsSync(DOCKERFILE)) {
19
+ throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
20
+ }
21
+ }
22
+
23
+ /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
24
+ export function ensureImage(image = CPP_DEV_IMAGE) {
25
+ log.step(`docker build -t ${image} (first run is slow, then cached)…`);
26
+ const r = spawnSync(
27
+ "docker",
28
+ ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
29
+ { stdio: "inherit" },
30
+ );
31
+ if (r.status !== 0) throw new Error("docker build failed.");
32
+ }
33
+
34
+ /**
35
+ * Configure + build cpp/ inside the container, producing cpp/build/<entry> on
36
+ * the host (the workspace is mounted). buildType "Debug" for dev, "Release" for
37
+ * export builds that ship in the ZIP.
38
+ */
39
+ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug" } = {}) {
40
+ const script = [
41
+ `cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=${buildType}`,
42
+ "cmake --build cpp/build -j",
43
+ ].join(" && ");
44
+ log.step(`cmake build (${buildType}, in container)…`);
45
+ const r = spawnSync(
46
+ "docker",
47
+ ["run", "--rm", "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
48
+ { cwd: root, stdio: "inherit" },
49
+ );
50
+ if (r.status !== 0) throw new Error("cmake build failed.");
51
+ }
package/cli/src/dev.mjs CHANGED
@@ -1,13 +1,19 @@
1
- // muscat-pk dev — run Socket.IO bridge, dev:ui, and dev:back together.
1
+ // muscat-pk dev — run the Socket.IO bridge (Docker), dev:ui, and dev:back together.
2
2
  import { runDevUi } from "./devUi.mjs";
3
3
  import { runDevBack } from "./devBack.mjs";
4
- import { startSocketBridge } from "./socketBridge.mjs";
5
- import { log } from "./util.mjs";
4
+ import { startBridge, stopBridge, BRIDGE_CONTAINER } from "./bridge.mjs";
5
+ import { ensureDocker, log } from "./util.mjs";
6
6
 
7
7
  export async function runDev(args = []) {
8
- log.step("starting Socket.IO bridge + dev:ui + dev:back");
9
- const bridge = await startSocketBridge();
10
- const cleanup = () => bridge.close();
8
+ ensureDocker();
9
+ log.step("starting Socket.IO bridge (Docker) + dev:ui + dev:back");
10
+ await startBridge();
11
+
12
+ // Tell dev:back which bridge container to share a netns with, so the backend
13
+ // reaches the bridge at localhost:10000 (no host.docker.internal).
14
+ process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
15
+
16
+ const cleanup = () => stopBridge();
11
17
  process.once("SIGINT", cleanup);
12
18
  process.once("SIGTERM", cleanup);
13
19
  process.once("exit", cleanup);
@@ -1,20 +1,33 @@
1
1
  // muscat-pk dev:back — run the plugin back end during development.
2
- // python : create/refresh .venv, then run main.py with watchfiles hot-reload
3
- // (uvicorn's reloader). Watches ALL .py files, not just main.py.
4
- // ros2 : keep a Jazzy + Python 3.12 container warm, colcon build once with
5
- // --symlink-install, run the node, and restart on source changes —
6
- // i.e. develop as if inside the container.
2
+ // Every backend runs in a warm Docker container that matches the robot
3
+ // (Ubuntu 24.04), so dev behaves like production.
4
+ // python : Python 3.12 container, pip install, run main.py with watchfiles
5
+ // hot-reload. Shares the bridge container's netns (localhost:10000).
6
+ // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
+ // restart on source changes.
8
+ // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
+ // on source changes (rclcpp rebuilds on change).
7
10
  import { spawn, spawnSync } from "node:child_process";
8
11
  import { existsSync, readFileSync, watch } from "node:fs";
9
12
  import { resolve } from "node:path";
10
- import { detectPythonCmd, hasBin, findPluginRoot, log, venvPython } from "./util.mjs";
13
+ import { ensureDocker, findPluginRoot, log } from "./util.mjs";
11
14
  import { readManifest as readManifestSchema, validateManifest } from "./manifest.mjs";
15
+ import {
16
+ PYTHON_DEV_IMAGE,
17
+ assertDockerReady as pyAssertDockerReady,
18
+ ensureImage as pyEnsureImage,
19
+ } from "./python.mjs";
12
20
  import {
13
21
  ROS2_DEV_IMAGE,
14
22
  assertDockerReady,
15
23
  ensureImage,
16
24
  ros2PackageName,
17
25
  } from "./ros2.mjs";
26
+ import {
27
+ CPP_DEV_IMAGE,
28
+ assertDockerReady as cppAssertDockerReady,
29
+ ensureImage as cppEnsureImage,
30
+ } from "./cpp.mjs";
18
31
 
19
32
  function readManifest(root) {
20
33
  try {
@@ -29,50 +42,64 @@ function manifestRuntime(root) {
29
42
  }
30
43
 
31
44
  // ── python ────────────────────────────────────────────────────────────────────
45
+ // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
46
+ // Under `muscat-pk dev`, it shares the bridge container's netns, so the SDK
47
+ // reaches the Socket.IO bridge at the default localhost:10000.
32
48
  function runPython(root) {
33
- const venv = resolve(root, ".venv");
34
- const py = venvPython(venv);
35
- const pyDir = resolve(root, "py");
36
-
37
- if (!existsSync(py)) {
38
- log.step("creating .venv…");
39
- if (hasBin("uv")) {
40
- spawnSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: root, stdio: "inherit" });
41
- } else if (hasBin("conda")) {
42
- spawnSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: root, stdio: "inherit" });
43
- } else if (hasBin("python3.12")) {
44
- spawnSync("python3.12", ["-m", "venv", venv], { cwd: root, stdio: "inherit" });
45
- } else {
46
- throw new Error(
47
- "Python 3.12 not found.\n" +
48
- " The rainbow SDK requires Python >=3.12,<3.13.\n" +
49
- " Install Python 3.12: https://www.python.org/downloads/release/python-3120/\n" +
50
- " Or use uv (recommended): https://docs.astral.sh/uv/getting-started/installation/"
51
- );
52
- }
53
- }
49
+ pyAssertDockerReady();
50
+ pyEnsureImage();
54
51
 
55
- const reqs = resolve(root, "requirements.txt");
56
- if (existsSync(reqs)) {
57
- log.step("pip install -r requirements.txt…");
58
- spawnSync(py, ["-m", "pip", "install", "-q", "--no-cache-dir", "-r", reqs], { cwd: root, stdio: "inherit" });
59
- }
52
+ const manifest = readManifest(root);
53
+ const pluginId = manifest.id ?? "";
54
+ const container = `${PYTHON_DEV_IMAGE}-run`;
55
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
56
+ // Share the bridge container's network namespace so localhost:10000 is the
57
+ // bridge (set by `muscat-pk dev`). Standalone dev:back has no bridge to join.
58
+ const bridgeContainer = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
59
+ const netFlags = bridgeContainer ? ["--network", `container:${bridgeContainer}`] : [];
60
+ const docker = (args, opts = {}) =>
61
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
60
62
 
61
- log.step("pip install -q watchfiles (dev reloader)…");
62
- spawnSync(py, ["-m", "pip", "install", "-q", "watchfiles"], { cwd: root, stdio: "inherit" });
63
+ // start a warm, long-lived container with the plugin mounted
64
+ docker(["rm", "-f", container], { stdio: "ignore" });
65
+ log.step("starting Python dev container…");
66
+ const up = docker(
67
+ ["run", "-d", "--name", container, ...netFlags,
68
+ "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
69
+ { stdio: "inherit" },
70
+ );
71
+ if (up.status !== 0) throw new Error("failed to start dev container.");
63
72
 
64
- const pluginId = readManifest(root).id ?? "";
65
- const backEnv = { ...process.env, ...(pluginId ? { RB_PLUGIN_ID: pluginId } : {}) };
66
- if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
73
+ log.step("pip install requirements + watchfiles (in container)…");
74
+ docker(["exec", container, "bash", "-lc",
75
+ "pip install --break-system-packages --ignore-installed -q watchfiles" +
76
+ " && { [ -f requirements.txt ] && pip install --break-system-packages --ignore-installed -q -r requirements.txt || true; }"]);
67
77
 
78
+ if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
68
79
  log.step("watchfiles → py/main.py (reload on any .py change in py/)");
69
80
  const child = spawn(
70
- py,
71
- ["-m", "watchfiles", "--filter", "python", "--target-type", "command", `${py} py/main.py`, "py"],
72
- { cwd: root, stdio: "inherit", env: backEnv },
81
+ "docker",
82
+ ["exec", ...envFlags, container, "bash", "-lc",
83
+ "python3 -m watchfiles --filter python --target-type command 'python3 py/main.py' py"],
84
+ { cwd: root, stdio: ["inherit", "inherit", "pipe"] },
73
85
  );
74
- const cleanup = () => { if (!child.killed) child.kill(); };
75
- child.on("exit", (code) => process.exit(code ?? 0));
86
+ // watchfiles prints a KeyboardInterrupt traceback on SIGTERM — suppress it
87
+ child.stderr.on("data", (buf) => {
88
+ const text = buf.toString();
89
+ if (!text.includes("KeyboardInterrupt") && !text.includes("raise_keyboard_interrupt")) {
90
+ process.stderr.write(buf);
91
+ }
92
+ });
93
+
94
+ const cleanup = () => {
95
+ if (!child.killed) child.kill();
96
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
97
+ process.exit(0);
98
+ };
99
+ child.on("exit", (code) => {
100
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
101
+ process.exit(code ?? 0);
102
+ });
76
103
  process.once("SIGINT", cleanup);
77
104
  process.once("SIGTERM", cleanup);
78
105
  }
@@ -85,7 +112,9 @@ function runRos2(root) {
85
112
 
86
113
  ensureImage();
87
114
 
88
- const pluginId = readManifest(root).id ?? "";
115
+ const manifest = readManifest(root);
116
+ const pluginId = manifest.id ?? "";
117
+ const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
89
118
  const container = `${ROS2_DEV_IMAGE}-run`;
90
119
  const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
91
120
  const docker = (args, opts = {}) =>
@@ -118,14 +147,15 @@ function runRos2(root) {
118
147
  };
119
148
  startNode();
120
149
 
121
- // restart node on source change; rebuild only when package metadata changes
150
+ // restart node on source change. rclpy reflects .py edits live (symlink-install);
151
+ // rclcpp (C++) and package-metadata changes require a colcon rebuild.
122
152
  let timer = null;
123
153
  const onChange = (_event, filename) => {
124
154
  clearTimeout(timer);
125
155
  timer = setTimeout(() => {
126
156
  const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
127
- if (structural) {
128
- log.info("package metadata changed — colcon rebuild…");
157
+ if (structural || isRclcpp) {
158
+ log.info(structural ? "package metadata changed — colcon rebuild…" : "source changed — colcon rebuild…");
129
159
  docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
130
160
  } else {
131
161
  log.info("source changed — restarting node…");
@@ -147,6 +177,73 @@ function runRos2(root) {
147
177
  process.on("SIGTERM", cleanup);
148
178
  }
149
179
 
180
+ // ── cpp ──────────────────────────────────────────────────────────────────────
181
+ function runCpp(root) {
182
+ cppAssertDockerReady();
183
+ const manifest = readManifest(root);
184
+ const entry = manifest.runtime?.entry;
185
+ if (!entry) throw new Error("runtime.entry (the built executable name) is required for cpp.");
186
+
187
+ cppEnsureImage();
188
+
189
+ const pluginId = manifest.id ?? "";
190
+ const container = `${CPP_DEV_IMAGE}-run`;
191
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
192
+ const docker = (args, opts = {}) =>
193
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
194
+
195
+ // start a warm, long-lived container with the plugin mounted
196
+ docker(["rm", "-f", container], { stdio: "ignore" });
197
+ log.step("starting C++ dev container…");
198
+ const up = docker(
199
+ ["run", "-d", "--name", container, "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
200
+ { stdio: "inherit" },
201
+ );
202
+ if (up.status !== 0) throw new Error("failed to start dev container.");
203
+
204
+ const build = () => {
205
+ log.step("cmake build…");
206
+ return docker(["exec", container, "bash", "-lc",
207
+ "cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Debug && cmake --build cpp/build -j"]);
208
+ };
209
+ build();
210
+
211
+ let child = null;
212
+ const startBin = () => {
213
+ if (child) child.kill();
214
+ log.step(`run cpp/build/${entry}`);
215
+ child = spawn(
216
+ "docker",
217
+ ["exec", ...envFlags, container, "bash", "-lc", `./cpp/build/${entry}`],
218
+ { cwd: root, stdio: "inherit" },
219
+ );
220
+ };
221
+ startBin();
222
+
223
+ // rebuild + restart on C++ source changes; ignore the build/ output dir.
224
+ let timer = null;
225
+ const onChange = (_event, filename) => {
226
+ if (filename && String(filename).startsWith("build")) return;
227
+ clearTimeout(timer);
228
+ timer = setTimeout(() => {
229
+ log.info("source changed — rebuilding…");
230
+ const r = build();
231
+ if (r.status === 0) startBin();
232
+ else log.warn("build failed — keeping previous binary");
233
+ }, 300);
234
+ };
235
+ const cppDir = resolve(root, "cpp");
236
+ if (existsSync(cppDir)) watch(cppDir, { recursive: true }, onChange);
237
+
238
+ const cleanup = () => {
239
+ if (child) child.kill();
240
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
241
+ process.exit(0);
242
+ };
243
+ process.on("SIGINT", cleanup);
244
+ process.on("SIGTERM", cleanup);
245
+ }
246
+
150
247
  function warnManifest(root) {
151
248
  const manifest = readManifestSchema(root);
152
249
  if (!manifest || Object.keys(manifest).length === 0) return;
@@ -155,9 +252,11 @@ function warnManifest(root) {
155
252
  }
156
253
 
157
254
  export async function runDevBack() {
255
+ ensureDocker();
158
256
  const root = findPluginRoot();
159
257
  warnManifest(root);
160
258
  const type = manifestRuntime(root);
161
259
  if (type === "ros2") return runRos2(root);
260
+ if (type === "cpp") return runCpp(root);
162
261
  return runPython(root);
163
262
  }
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
4
4
 
5
5
  export const CONTRIBUTION_POINTS = ["program.command", "toolbar", "move.rnb"];
6
6
  const ICON_SIZES = ["16", "48", "128"];
7
- const RUNTIME_TYPES = ["none", "python", "ros2"];
7
+ const RUNTIME_TYPES = ["none", "python", "cpp", "ros2"];
8
8
  const ROS_CLIENTS = ["rclpy", "rclcpp"];
9
9
  const I18N_LANGS = ["ko", "en", "ja", "zh", "vi", "th", "es"];
10
10
  const TARGET_CATEGORIES = ["manipulate", "amr", "rby1", "vision"];
@@ -66,6 +66,8 @@ function validateRuntime(runtime, errors) {
66
66
  errors.push(`runtime.type must be one of ${RUNTIME_TYPES.map((t) => `"${t}"`).join(" | ")}`);
67
67
  if (runtime.type === "python" && !runtime.entry)
68
68
  errors.push("runtime.entry is required for python runtime");
69
+ if (runtime.type === "cpp" && !runtime.entry)
70
+ errors.push("runtime.entry is required for cpp runtime (the built executable name)");
69
71
  if (runtime.type === "ros2") {
70
72
  if (!runtime.package) errors.push("runtime.package is required for ros2 runtime");
71
73
  if (!runtime.launch) errors.push("runtime.launch is required for ros2 runtime");
@@ -136,6 +138,10 @@ export function validateManifest(manifest) {
136
138
  }
137
139
  if (typeof manifest.author !== "string" || manifest.author.length === 0)
138
140
  errors.push(`"author" is required`);
141
+ if (typeof manifest.email !== "string" || manifest.email.length === 0)
142
+ errors.push(`"email" is required`);
143
+ if (manifest.company !== undefined && manifest.company !== null && typeof manifest.company !== "string")
144
+ errors.push(`"company" must be a string`);
139
145
 
140
146
  const hasModels = manifest.targetModels !== undefined && manifest.targetModels !== null;
141
147
  const hasCategorys = manifest.targetCategorys !== undefined && manifest.targetCategorys !== null;