@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.24 → 0.1.2-dev.25
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 +2 -2
- package/cli/bin/create-muscat-plugin.mjs +1 -1
- package/cli/bin/muscat-pk.mjs +2 -2
- package/cli/docker/Dockerfile.bridge +16 -0
- package/cli/docker/Dockerfile.cpp +21 -0
- package/cli/docker/Dockerfile.python +18 -0
- package/cli/docker/bridge-package.json +8 -0
- package/cli/src/bridge.mjs +91 -0
- package/cli/src/build.mjs +36 -5
- package/cli/src/cpp.mjs +51 -0
- package/cli/src/dev.mjs +12 -6
- package/cli/src/devBack.mjs +145 -46
- package/cli/src/manifest.mjs +7 -1
- package/cli/src/new.mjs +86 -56
- package/cli/src/python.mjs +31 -0
- package/cli/src/socketBridge.mjs +14 -1
- package/cli/src/type.mjs +51 -19
- package/cli/src/util.mjs +10 -0
- package/cli/templates/common/README.md +27 -12
- package/cli/templates/common/manifest.json +2 -1
- package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
- package/cli/templates/cpp/cpp/main.cpp +25 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
- package/package.json +2 -2
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
|
|
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
|
|
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)
|
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -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
|
|
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,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
|
|
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
|
}
|
package/cli/src/cpp.mjs
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
// muscat-pk dev:back — run the plugin back end during development.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
["
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
child.on("
|
|
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
|
|
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
|
|
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
|
}
|
package/cli/src/manifest.mjs
CHANGED
|
@@ -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;
|
package/cli/src/new.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// muscat-pk new — scaffold a new plugin project from templates.
|
|
2
|
-
import {
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
3
|
import {
|
|
4
4
|
cpSync,
|
|
5
5
|
existsSync,
|
|
@@ -12,11 +12,35 @@ import {
|
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
14
|
import { basename, resolve } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
import { rmSync } from "node:fs";
|
|
16
|
+
import { hasBin, kitVersion, parseArgs, templatesDir, log } from "./util.mjs";
|
|
17
|
+
|
|
18
|
+
// `--type` values map to a runtime descriptor. ros2 has a python (rclpy) and a
|
|
19
|
+
// C++ (rclcpp) flavor; we expose them as distinct type choices for clarity.
|
|
20
|
+
export const TYPE_CHOICES = {
|
|
21
|
+
python: { type: "python" },
|
|
22
|
+
cpp: { type: "cpp" },
|
|
23
|
+
ros2: { type: "ros2", rosClient: "rclpy" },
|
|
24
|
+
"ros2-cpp":{ type: "ros2", rosClient: "rclcpp" },
|
|
25
|
+
};
|
|
26
|
+
const TYPES = Object.keys(TYPE_CHOICES);
|
|
18
27
|
const PMS = ["npm", "yarn", "pnpm", "bun"];
|
|
19
28
|
|
|
29
|
+
// Build the manifest runtime object for a given type choice + plugin slug.
|
|
30
|
+
export function runtimeForChoice(choice, slug) {
|
|
31
|
+
switch (choice.type) {
|
|
32
|
+
case "cpp":
|
|
33
|
+
// entry = built executable name (matches add_executable in cpp/CMakeLists.txt)
|
|
34
|
+
return { type: "cpp", entry: slug };
|
|
35
|
+
case "ros2":
|
|
36
|
+
// package = colcon package name; launch = node executable run on the robot
|
|
37
|
+
return { type: "ros2", rosClient: choice.rosClient, package: slug, launch: `${slug}_node` };
|
|
38
|
+
case "python":
|
|
39
|
+
default:
|
|
40
|
+
return { type: "python", entry: "main.py" };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
async function prompt(question, fallback) {
|
|
21
45
|
if (!process.stdin.isTTY) return fallback;
|
|
22
46
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -74,16 +98,25 @@ function renameSlugPaths(dir, slug) {
|
|
|
74
98
|
// Copy the ros2_ws/ overlay into a plugin and substitute the package slug into
|
|
75
99
|
// every path segment. The dev Dockerfile is NOT scaffolded here — it lives in
|
|
76
100
|
// the kit (cli/docker/) so authors can't edit it. Caller runs applyTokens after.
|
|
77
|
-
|
|
78
|
-
|
|
101
|
+
// rclpy → templates/ros2 (ament_python)
|
|
102
|
+
// rclcpp → templates/ros2-cpp (ament_cmake)
|
|
103
|
+
export function overlayRos2(target, slug, rosClient = "rclpy") {
|
|
104
|
+
const tmpl = rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
|
|
105
|
+
cpSync(resolve(templatesDir, tmpl), target, { recursive: true });
|
|
79
106
|
renameSlugPaths(resolve(target, "ros2_ws"), slug);
|
|
80
107
|
}
|
|
81
108
|
|
|
109
|
+
// Copy the cpp/ overlay (CMake project) into a plugin. Caller runs applyTokens after.
|
|
110
|
+
export function overlayCpp(target) {
|
|
111
|
+
cpSync(resolve(templatesDir, "cpp"), target, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
export async function runNew(args) {
|
|
83
115
|
const opts = parseArgs(args, { booleans: ["yes", "no-install", "help"] });
|
|
84
116
|
if (opts.help) {
|
|
85
117
|
console.log(
|
|
86
|
-
"muscat-pk new [dir] [--type python|ros2] [--pm npm|yarn|pnpm|bun]\n" +
|
|
118
|
+
"muscat-pk new [dir] [--type python|cpp|ros2|ros2-cpp] [--pm npm|yarn|pnpm|bun]\n" +
|
|
119
|
+
" [--author <name>] [--email <email>] [--description <text>]\n" +
|
|
87
120
|
" [--yes] [--no-install]",
|
|
88
121
|
);
|
|
89
122
|
return;
|
|
@@ -97,15 +130,28 @@ export async function runNew(args) {
|
|
|
97
130
|
throw new Error(`directory not empty: ${target}`);
|
|
98
131
|
}
|
|
99
132
|
|
|
133
|
+
let authorArg = opts.author;
|
|
134
|
+
let emailArg = opts.email;
|
|
135
|
+
let descriptionArg = opts.description;
|
|
136
|
+
|
|
137
|
+
if (!opts.yes) {
|
|
138
|
+
if (!authorArg) authorArg = await prompt("Author name: ", "");
|
|
139
|
+
if (!emailArg) emailArg = await prompt("Email (required): ", "");
|
|
140
|
+
if (!descriptionArg) descriptionArg = await prompt("Description (English): ", "");
|
|
141
|
+
}
|
|
142
|
+
|
|
100
143
|
let type = opts.type;
|
|
101
144
|
if (!type && !opts.yes) {
|
|
102
145
|
console.log("\n플러그인 타입을 선택하세요:");
|
|
103
146
|
console.log(" 1) Python");
|
|
104
|
-
console.log(" 2)
|
|
105
|
-
|
|
106
|
-
|
|
147
|
+
console.log(" 2) C++");
|
|
148
|
+
console.log(" 3) ROS2 (rclpy / Python)");
|
|
149
|
+
console.log(" 4) ROS2 (rclcpp / C++)");
|
|
150
|
+
const choice = await prompt("\n선택 [1/2/3/4, default: 1]: ", "1");
|
|
151
|
+
type = { "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice] ?? "python";
|
|
107
152
|
}
|
|
108
153
|
type = TYPES.includes(type) ? type : "python";
|
|
154
|
+
const runtimeChoice = TYPE_CHOICES[type];
|
|
109
155
|
|
|
110
156
|
// Auto-detect yarn workspace: if .yarnrc.yml exists anywhere up the tree, default to yarn.
|
|
111
157
|
function isInsideYarnWorkspace(dir) {
|
|
@@ -121,7 +167,7 @@ export async function runNew(args) {
|
|
|
121
167
|
let pm = opts.pm;
|
|
122
168
|
if (!pm && !opts.yes) {
|
|
123
169
|
const defaultPm = isInsideYarnWorkspace(process.cwd()) ? "yarn" : "npm";
|
|
124
|
-
pm = await prompt(`
|
|
170
|
+
pm = await prompt(`Front-end package manager (${PMS.join("/")}) [${defaultPm}]: `, defaultPm);
|
|
125
171
|
}
|
|
126
172
|
pm = PMS.includes(pm) ? pm : "npm";
|
|
127
173
|
|
|
@@ -129,11 +175,18 @@ export async function runNew(args) {
|
|
|
129
175
|
|
|
130
176
|
mkdirSync(target, { recursive: true });
|
|
131
177
|
|
|
132
|
-
// common(UI +
|
|
133
|
-
//
|
|
178
|
+
// common(UI + manifest/config)은 항상 포함. 백엔드 오버레이는 타입별로 얹는다.
|
|
179
|
+
// python → common의 py/ 사용
|
|
180
|
+
// cpp → cpp/ 오버레이, py/·requirements.txt 제거
|
|
181
|
+
// ros2(*) → ros2_ws/ 오버레이 (rclpy=ament_python, rclcpp=ament_cmake)
|
|
134
182
|
log.step(`scaffolding ${type} plugin → ${target}`);
|
|
135
183
|
cpSync(resolve(templatesDir, "common"), target, { recursive: true });
|
|
136
|
-
if (type === "ros2") overlayRos2(target, slug);
|
|
184
|
+
if (runtimeChoice.type === "ros2") overlayRos2(target, slug, runtimeChoice.rosClient);
|
|
185
|
+
if (runtimeChoice.type === "cpp") {
|
|
186
|
+
overlayCpp(target);
|
|
187
|
+
rmSync(resolve(target, "py"), { recursive: true, force: true });
|
|
188
|
+
rmSync(resolve(target, "requirements.txt"), { force: true });
|
|
189
|
+
}
|
|
137
190
|
|
|
138
191
|
applyTokens(target, {
|
|
139
192
|
__PLUGIN_ID__: pluginId,
|
|
@@ -141,15 +194,24 @@ export async function runNew(args) {
|
|
|
141
194
|
__PLUGIN_DIR__: dir,
|
|
142
195
|
__PLUGIN_SLUG__: slug,
|
|
143
196
|
__PLUGIN_CLASS__: pluginClass,
|
|
197
|
+
__PLUGIN_AUTHOR__: authorArg || "",
|
|
198
|
+
__PLUGIN_EMAIL__: emailArg || "",
|
|
144
199
|
__PM__: pm,
|
|
145
|
-
__RUNTIME_TYPE__: type,
|
|
200
|
+
__RUNTIME_TYPE__: runtimeChoice.type,
|
|
146
201
|
__RB_VERSION__: kitVersion(),
|
|
147
202
|
});
|
|
148
203
|
|
|
204
|
+
// Write the correct runtime block into manifest.json (per-type fields).
|
|
205
|
+
const manifestPath = resolve(target, "manifest.json");
|
|
206
|
+
const manifestJson = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
207
|
+
manifestJson.runtime = runtimeForChoice(runtimeChoice, slug);
|
|
208
|
+
if (descriptionArg) manifestJson.description = { en: descriptionArg };
|
|
209
|
+
writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 2) + "\n");
|
|
210
|
+
|
|
149
211
|
// gitignore (templates can't ship a literal .gitignore via npm reliably)
|
|
150
212
|
writeFileSync(
|
|
151
213
|
resolve(target, ".gitignore"),
|
|
152
|
-
["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", ""].join("\n"),
|
|
214
|
+
["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", "cpp/build", ""].join("\n"),
|
|
153
215
|
);
|
|
154
216
|
|
|
155
217
|
if (!opts["no-install"]) {
|
|
@@ -161,38 +223,10 @@ export async function runNew(args) {
|
|
|
161
223
|
}
|
|
162
224
|
}
|
|
163
225
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Python venv (robot targets 3.12) — cross-platform.
|
|
169
|
-
// uv is preferred: handles uv-managed Pythons that break standard ensurepip.
|
|
170
|
-
try {
|
|
171
|
-
const venv = resolve(target, ".venv");
|
|
172
|
-
log.step("creating .venv + installing requirements…");
|
|
173
|
-
if (hasBin("uv")) {
|
|
174
|
-
execFileSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: target, stdio: "inherit" });
|
|
175
|
-
} else if (hasBin("conda")) {
|
|
176
|
-
execFileSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: target, stdio: "inherit" });
|
|
177
|
-
} else if (hasBin("python3.12") || hasBin("python3") || hasBin("python")) {
|
|
178
|
-
const { cmd, is312 } = detectPythonCmd();
|
|
179
|
-
if (!is312) log.warn("python3.12 not found; venv uses fallback python (robot targets 3.12).");
|
|
180
|
-
execFileSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: target, stdio: "inherit" });
|
|
181
|
-
} else {
|
|
182
|
-
noPython = true;
|
|
183
|
-
}
|
|
184
|
-
if (!noPython) {
|
|
185
|
-
const py = venvPython(venv);
|
|
186
|
-
if (existsSync(resolve(target, "requirements.txt"))) {
|
|
187
|
-
execFileSync(py, ["-m", "pip", "install", "-q", "-r", "requirements.txt"], { cwd: target, stdio: "inherit" });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
} catch {
|
|
191
|
-
venvFailed = true;
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
noPython = !hasBin("uv") && !hasBin("conda") && !hasBin("python3.12") && !hasBin("python3") && !hasBin("python");
|
|
195
|
-
}
|
|
226
|
+
// The back end (python / cpp / ros2) runs in a Docker container that matches
|
|
227
|
+
// the robot — no local Python/toolchain setup needed. Only `none` skips it.
|
|
228
|
+
const needsDocker = runtimeChoice.type !== "none";
|
|
229
|
+
const noDocker = needsDocker && !hasBin("docker");
|
|
196
230
|
|
|
197
231
|
log.ok(`created ${targetName} (type: ${type}, pm: ${pm})`);
|
|
198
232
|
log.info(`\n cd ${targetName}`);
|
|
@@ -201,19 +235,15 @@ export async function runNew(args) {
|
|
|
201
235
|
|
|
202
236
|
// ── post-scaffold environment warnings ───────────────────────────────────
|
|
203
237
|
const noNode = !hasBin("node");
|
|
204
|
-
if (noNode ||
|
|
238
|
+
if (noNode || noDocker) {
|
|
205
239
|
log.info("");
|
|
206
240
|
if (noNode) {
|
|
207
241
|
log.warn("Node.js not found. Install Node.js 20+ to run JS deps and dev:ui.");
|
|
208
242
|
log.warn(" https://nodejs.org");
|
|
209
243
|
}
|
|
210
|
-
if (
|
|
211
|
-
log.warn("
|
|
212
|
-
log.warn(" https://
|
|
213
|
-
log.warn(` Then: cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
|
|
214
|
-
} else if (venvFailed) {
|
|
215
|
-
log.warn("venv setup failed. Create it manually:");
|
|
216
|
-
log.warn(` cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
|
|
244
|
+
if (noDocker) {
|
|
245
|
+
log.warn("Docker not found. The plugin back end runs in a container (matches the robot env).");
|
|
246
|
+
log.warn(" https://docs.docker.com/get-docker/");
|
|
217
247
|
}
|
|
218
248
|
log.info("");
|
|
219
249
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared Python Docker helpers (Ubuntu 24.04 + Python 3.12 — matches the robot).
|
|
2
|
+
// Used by dev:back so the dev backend runs in the same environment as the robot.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { hasBin, kitRoot, log } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
|
|
9
|
+
|
|
10
|
+
// Dockerfile lives inside the kit — plugin authors never see or edit it.
|
|
11
|
+
const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.python");
|
|
12
|
+
|
|
13
|
+
export function assertDockerReady() {
|
|
14
|
+
if (!hasBin("docker")) {
|
|
15
|
+
throw new Error("Docker not found. The Python backend runs in a container (Ubuntu 24.04 + Python 3.12).");
|
|
16
|
+
}
|
|
17
|
+
if (!existsSync(DOCKERFILE)) {
|
|
18
|
+
throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
|
|
23
|
+
export function ensureImage(image = PYTHON_DEV_IMAGE) {
|
|
24
|
+
log.step(`docker build -t ${image} (first run is slow, then cached)…`);
|
|
25
|
+
const r = spawnSync(
|
|
26
|
+
"docker",
|
|
27
|
+
["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
|
|
28
|
+
{ stdio: "inherit" },
|
|
29
|
+
);
|
|
30
|
+
if (r.status !== 0) throw new Error("docker build failed.");
|
|
31
|
+
}
|
package/cli/src/socketBridge.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { Server } from "socket.io";
|
|
4
|
-
|
|
5
|
+
|
|
6
|
+
// Self-contained logger — this module also runs standalone inside the bridge
|
|
7
|
+
// Docker container, where ./util.mjs is not available.
|
|
8
|
+
const log = {
|
|
9
|
+
step: (...m) => console.log("›", ...m),
|
|
10
|
+
warn: (...m) => console.warn("⚠", ...m),
|
|
11
|
+
};
|
|
5
12
|
|
|
6
13
|
const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
|
|
7
14
|
|
|
@@ -114,3 +121,9 @@ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
|
|
|
114
121
|
});
|
|
115
122
|
});
|
|
116
123
|
}
|
|
124
|
+
|
|
125
|
+
// When executed directly (inside the bridge Docker container), start the server.
|
|
126
|
+
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
127
|
+
const port = Number(process.env.PORT) || 10000;
|
|
128
|
+
startSocketBridge({ port });
|
|
129
|
+
}
|
package/cli/src/type.mjs
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
// muscat-pk type change — switch an existing plugin's backend type.
|
|
2
|
-
// python
|
|
3
|
-
// the
|
|
2
|
+
// python | cpp | ros2 (rclpy) | ros2-cpp (rclcpp).
|
|
3
|
+
// Adds the matching backend overlay; never deletes existing user code
|
|
4
|
+
// (e.g. ros2_ws/ or cpp/ are left in place when switching away).
|
|
4
5
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { createInterface } from "node:readline/promises";
|
|
6
7
|
import { basename, resolve } from "node:path";
|
|
7
8
|
import { findPluginRoot, log } from "./util.mjs";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
TYPE_CHOICES,
|
|
11
|
+
applyTokens,
|
|
12
|
+
deriveNames,
|
|
13
|
+
overlayCpp,
|
|
14
|
+
overlayRos2,
|
|
15
|
+
runtimeForChoice,
|
|
16
|
+
} from "./new.mjs";
|
|
9
17
|
|
|
10
|
-
const TYPES =
|
|
18
|
+
const TYPES = Object.keys(TYPE_CHOICES);
|
|
11
19
|
|
|
12
20
|
async function prompt(question, fallback) {
|
|
13
21
|
if (!process.stdin.isTTY) return fallback;
|
|
@@ -23,44 +31,54 @@ function readManifest(root) {
|
|
|
23
31
|
return { path, json: JSON.parse(readFileSync(path, "utf8")) };
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
// Derive the current `--type`-style key from a manifest runtime block.
|
|
35
|
+
function currentTypeKey(runtime) {
|
|
36
|
+
if (runtime?.type === "ros2") return runtime.rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
|
|
37
|
+
return runtime?.type ?? "python";
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
export async function runType(args) {
|
|
27
|
-
// accept `type change`, `type`, `type --to ros2`
|
|
41
|
+
// accept `type change`, `type`, `type --to ros2-cpp`
|
|
28
42
|
const sub = args[0];
|
|
29
43
|
const toFlagIdx = args.indexOf("--to");
|
|
30
44
|
let to = toFlagIdx >= 0 ? args[toFlagIdx + 1] : undefined;
|
|
31
45
|
|
|
32
46
|
const root = findPluginRoot();
|
|
33
47
|
const { path, json } = readManifest(root);
|
|
34
|
-
const current = json.runtime
|
|
48
|
+
const current = currentTypeKey(json.runtime);
|
|
35
49
|
|
|
36
50
|
if (sub && sub !== "change") {
|
|
37
|
-
log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|ros2]`);
|
|
51
|
+
log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|cpp|ros2|ros2-cpp]`);
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
if (!to) {
|
|
41
55
|
console.log(`\n현재 타입: ${current}`);
|
|
42
56
|
console.log("변경할 타입을 선택하세요:");
|
|
43
57
|
console.log(" 1) Python");
|
|
44
|
-
console.log(" 2)
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
console.log(" 2) C++");
|
|
59
|
+
console.log(" 3) ROS2 (rclpy / Python)");
|
|
60
|
+
console.log(" 4) ROS2 (rclcpp / C++)");
|
|
61
|
+
const choice = await prompt("\n선택 [1/2/3/4]: ", "");
|
|
62
|
+
to = { "1": "python", "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice];
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
if (!TYPES.includes(to)) {
|
|
50
|
-
throw new Error(`invalid type. choose python
|
|
66
|
+
throw new Error(`invalid type. choose python | cpp | ros2 | ros2-cpp.`);
|
|
51
67
|
}
|
|
52
68
|
if (to === current) {
|
|
53
69
|
log.info(`already '${current}' — nothing to change.`);
|
|
54
70
|
return;
|
|
55
71
|
}
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
const runtimeChoice = TYPE_CHOICES[to];
|
|
74
|
+
const { slug, pluginName, pluginClass, pluginId } = deriveNames(root);
|
|
75
|
+
|
|
76
|
+
if (runtimeChoice.type === "ros2") {
|
|
59
77
|
if (existsSync(resolve(root, "ros2_ws"))) {
|
|
60
78
|
log.warn("ros2_ws/ already exists — keeping it, only flipping manifest type.");
|
|
61
79
|
} else {
|
|
62
|
-
log.step(
|
|
63
|
-
overlayRos2(root, slug);
|
|
80
|
+
log.step(`adding ros2_ws/ overlay (${runtimeChoice.rosClient})`);
|
|
81
|
+
overlayRos2(root, slug, runtimeChoice.rosClient);
|
|
64
82
|
applyTokens(root, {
|
|
65
83
|
__PLUGIN_ID__: pluginId,
|
|
66
84
|
__PLUGIN_NAME__: pluginName,
|
|
@@ -69,14 +87,28 @@ export async function runType(args) {
|
|
|
69
87
|
__RUNTIME_TYPE__: "ros2",
|
|
70
88
|
});
|
|
71
89
|
}
|
|
90
|
+
} else if (runtimeChoice.type === "cpp") {
|
|
91
|
+
if (existsSync(resolve(root, "cpp"))) {
|
|
92
|
+
log.warn("cpp/ already exists — keeping it, only flipping manifest type.");
|
|
93
|
+
} else {
|
|
94
|
+
log.step("adding cpp/ overlay");
|
|
95
|
+
overlayCpp(root);
|
|
96
|
+
applyTokens(root, {
|
|
97
|
+
__PLUGIN_ID__: pluginId,
|
|
98
|
+
__PLUGIN_NAME__: pluginName,
|
|
99
|
+
__PLUGIN_SLUG__: slug,
|
|
100
|
+
__PLUGIN_CLASS__: pluginClass,
|
|
101
|
+
__RUNTIME_TYPE__: "cpp",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
72
104
|
} else {
|
|
73
|
-
//
|
|
74
|
-
if (existsSync(resolve(root, "ros2_ws"))) {
|
|
75
|
-
log.warn("
|
|
105
|
+
// → python. Leave any ros2_ws/ or cpp/ in place (never delete user code).
|
|
106
|
+
if (existsSync(resolve(root, "ros2_ws")) || existsSync(resolve(root, "cpp"))) {
|
|
107
|
+
log.warn("existing backend dir left in place (not deleted). Remove it manually if unused.");
|
|
76
108
|
}
|
|
77
109
|
}
|
|
78
110
|
|
|
79
|
-
json.runtime =
|
|
111
|
+
json.runtime = runtimeForChoice(runtimeChoice, slug);
|
|
80
112
|
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
|
|
81
113
|
log.ok(`${basename(root)} type: ${current} → ${to}`);
|
|
82
114
|
}
|
package/cli/src/util.mjs
CHANGED
|
@@ -91,6 +91,16 @@ export const log = {
|
|
|
91
91
|
error: (...m) => console.error(`${RED}✖${RESET}`, ...m.map(s => `${RED}${s}${RESET}`)),
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
+
// dev / dev:back / build run the plugin back end, the dev bridge, and cpp/ros2
|
|
95
|
+
// compilation in Docker. Show a clear install hint when Docker is missing.
|
|
96
|
+
export function ensureDocker() {
|
|
97
|
+
if (hasBin("docker")) return;
|
|
98
|
+
log.warn("Docker not found.");
|
|
99
|
+
log.warn(" muscat-pk runs the plugin back end, the dev bridge, and cpp/ros2 builds in Docker.");
|
|
100
|
+
log.warn(" Install Docker, then retry: https://docs.docker.com/get-docker/");
|
|
101
|
+
throw new Error("Docker is required for dev / dev:back / build.");
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
export function humanSize(bytes) {
|
|
95
105
|
if (bytes < 1024) return `${bytes} B`;
|
|
96
106
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -102,8 +102,9 @@ connect(App);
|
|
|
102
102
|
## Backend (`py/main.py`)
|
|
103
103
|
|
|
104
104
|
The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
|
|
105
|
-
`muscat-pk dev` starts the
|
|
106
|
-
|
|
105
|
+
`muscat-pk dev` starts the Socket.IO bridge in Docker (published on port 10000) and
|
|
106
|
+
runs the backend container sharing its network, so the SDK connects at localhost:10000.
|
|
107
|
+
`py/main.py` should contain plugin logic only and must not start its own server.
|
|
107
108
|
|
|
108
109
|
### Python plugin
|
|
109
110
|
|
|
@@ -230,11 +231,26 @@ class __PLUGIN_CLASS__Node(Node):
|
|
|
230
231
|
}
|
|
231
232
|
],
|
|
232
233
|
|
|
233
|
-
//
|
|
234
|
+
// runtime — one of:
|
|
235
|
+
// { "type": "none" }
|
|
236
|
+
// { "type": "python", "entry": "main.py" }
|
|
237
|
+
// { "type": "cpp", "entry": "<executable name>" }
|
|
238
|
+
// { "type": "ros2", "rosClient": "rclpy" | "rclcpp", "package": "...", "launch": "..._node" }
|
|
234
239
|
"runtime": { "type": "python", "entry": "main.py" }
|
|
235
240
|
}
|
|
236
241
|
```
|
|
237
242
|
|
|
243
|
+
**Backend types:**
|
|
244
|
+
|
|
245
|
+
| type | source dir | build / run | entry |
|
|
246
|
+
|---|---|---|---|
|
|
247
|
+
| `python` | `py/` | pip + run in Docker | `main.py` |
|
|
248
|
+
| `cpp` | `cpp/` | CMake in Docker | built executable name |
|
|
249
|
+
| `ros2` (rclpy) | `ros2_ws/` | colcon (ament_python) in Docker | — |
|
|
250
|
+
| `ros2` (rclcpp) | `ros2_ws/` | colcon (ament_cmake) in Docker | — |
|
|
251
|
+
|
|
252
|
+
> Every back end runs in a Docker container matching the robot (Ubuntu 24.04 + Python 3.12), so dev behaves like production and compiled binaries are ABI-compatible. **Docker is required** for all backend types (only a frontend-only `none` plugin skips it). No local Python/toolchain setup is needed.
|
|
253
|
+
|
|
238
254
|
**Contribution points:**
|
|
239
255
|
|
|
240
256
|
| point | Where it appears | `commandInfo` required |
|
|
@@ -306,24 +322,23 @@ numpy==1.26.4
|
|
|
306
322
|
opencv-python
|
|
307
323
|
```
|
|
308
324
|
|
|
309
|
-
Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically on every startup.
|
|
310
|
-
|
|
311
|
-
- **Python plugin**: installed into `.venv` via `pip install -r requirements.txt`
|
|
312
|
-
- **ROS2 plugin**: installed inside the Docker container via `pip install -r requirements.txt`
|
|
325
|
+
Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically inside the dev container (`pip install -r requirements.txt`) on every startup. No local Python or venv needed.
|
|
313
326
|
|
|
314
|
-
> `muscat-pk build` only **pins** versions in `requirements.txt` — it does not run `pip install
|
|
327
|
+
> `muscat-pk build` only **pins** versions in `requirements.txt` — it does not run `pip install` (the robot installs them at load time).
|
|
315
328
|
|
|
316
329
|
---
|
|
317
330
|
|
|
318
331
|
## Switching backend type
|
|
319
332
|
|
|
320
333
|
```bash
|
|
321
|
-
muscat-pk type change
|
|
322
|
-
muscat-pk type change --to
|
|
323
|
-
muscat-pk type change --to
|
|
334
|
+
muscat-pk type change # interactive
|
|
335
|
+
muscat-pk type change --to cpp # → C++
|
|
336
|
+
muscat-pk type change --to ros2 # → ROS2 (rclpy / Python)
|
|
337
|
+
muscat-pk type change --to ros2-cpp # → ROS2 (rclcpp / C++)
|
|
338
|
+
muscat-pk type change --to python # → Python
|
|
324
339
|
```
|
|
325
340
|
|
|
326
|
-
> `ros2_ws/`
|
|
341
|
+
> Existing backend dirs (`ros2_ws/`, `cpp/`) are not deleted when switching away. Remove them manually if no longer needed.
|
|
327
342
|
|
|
328
343
|
---
|
|
329
344
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.16)
|
|
2
|
+
project(__PLUGIN_SLUG__ CXX)
|
|
3
|
+
|
|
4
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
5
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
6
|
+
|
|
7
|
+
# The executable name must match runtime.entry in manifest.json.
|
|
8
|
+
add_executable(__PLUGIN_SLUG__ main.cpp)
|
|
9
|
+
|
|
10
|
+
# TODO(cpp-sdk): link the rainbow cpp SDK dynamically (the robot provides the
|
|
11
|
+
# .so at runtime — do NOT bundle/static-link it). Once the SDK ships an apt
|
|
12
|
+
# package with CMake config, enable:
|
|
13
|
+
# find_package(rainbow_cpp_sdk REQUIRED)
|
|
14
|
+
# target_link_libraries(__PLUGIN_SLUG__ PRIVATE rainbow::cpp_sdk)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// __PLUGIN_NAME__ — C++ plugin back end.
|
|
2
|
+
//
|
|
3
|
+
// The C++ plugin SDK is not available yet, so this is a build/run skeleton.
|
|
4
|
+
// Once the SDK lands, connect to the plugin gateway here (the host exposes it
|
|
5
|
+
// at the RB_PLUGIN_GATEWAY / RB_PLUGIN_ID environment variables) and wire up
|
|
6
|
+
// pub / sub / serve, mirroring the Python rainbow.rb_sdk.RBPluginCall API.
|
|
7
|
+
|
|
8
|
+
#include <chrono>
|
|
9
|
+
#include <cstdlib>
|
|
10
|
+
#include <iostream>
|
|
11
|
+
#include <thread>
|
|
12
|
+
|
|
13
|
+
int main() {
|
|
14
|
+
const char* plugin_id = std::getenv("RB_PLUGIN_ID");
|
|
15
|
+
std::cout << "[__PLUGIN_NAME__] backend ready"
|
|
16
|
+
<< (plugin_id ? std::string(" (id=") + plugin_id + ")" : "")
|
|
17
|
+
<< std::endl;
|
|
18
|
+
|
|
19
|
+
// TODO: connect to the plugin gateway and register handlers once the
|
|
20
|
+
// C++ SDK is available. For now, just keep the process alive.
|
|
21
|
+
while (true) {
|
|
22
|
+
std::this_thread::sleep_for(std::chrono::seconds(1));
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.16)
|
|
2
|
+
project(__PLUGIN_SLUG__)
|
|
3
|
+
|
|
4
|
+
if(NOT CMAKE_CXX_STANDARD)
|
|
5
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
6
|
+
endif()
|
|
7
|
+
|
|
8
|
+
find_package(ament_cmake REQUIRED)
|
|
9
|
+
find_package(rclcpp REQUIRED)
|
|
10
|
+
|
|
11
|
+
add_executable(__PLUGIN_SLUG___node src/node.cpp)
|
|
12
|
+
ament_target_dependencies(__PLUGIN_SLUG___node rclcpp)
|
|
13
|
+
|
|
14
|
+
install(TARGETS __PLUGIN_SLUG___node
|
|
15
|
+
DESTINATION lib/${PROJECT_NAME})
|
|
16
|
+
|
|
17
|
+
ament_package()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<?xml version="1.0"?>
|
|
2
|
+
<package format="3">
|
|
3
|
+
<name>__PLUGIN_SLUG__</name>
|
|
4
|
+
<version>0.0.1</version>
|
|
5
|
+
<description>__PLUGIN_NAME__ ROS2 node (rclcpp)</description>
|
|
6
|
+
<maintainer email="todo@example.com">maintainer</maintainer>
|
|
7
|
+
<license>Proprietary</license>
|
|
8
|
+
<buildtool_depend>ament_cmake</buildtool_depend>
|
|
9
|
+
<depend>rclcpp</depend>
|
|
10
|
+
<export><build_type>ament_cmake</build_type></export>
|
|
11
|
+
</package>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// __PLUGIN_NAME__ — ROS2 node (rclcpp).
|
|
2
|
+
//
|
|
3
|
+
// The node is launched with `--ros-args -r __ns:=/{slug}` automatically by
|
|
4
|
+
// dev:back. ROS2 topics are bridged to the plugin communication layer, so a
|
|
5
|
+
// plain rclcpp node is all you need — relative topic names get the namespace
|
|
6
|
+
// prefix applied automatically.
|
|
7
|
+
#include "rclcpp/rclcpp.hpp"
|
|
8
|
+
|
|
9
|
+
class __PLUGIN_CLASS__Node : public rclcpp::Node {
|
|
10
|
+
public:
|
|
11
|
+
__PLUGIN_CLASS__Node() : Node("__PLUGIN_SLUG___node") {
|
|
12
|
+
RCLCPP_INFO(this->get_logger(), "__PLUGIN_NAME__ node started");
|
|
13
|
+
// create publishers / subscribers / services here
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
int main(int argc, char** argv) {
|
|
18
|
+
rclcpp::init(argc, argv);
|
|
19
|
+
rclcpp::spin(std::make_shared<__PLUGIN_CLASS__Node>());
|
|
20
|
+
rclcpp::shutdown();
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainbow-robotics/muscat-plugin-kit",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@types/react-dom": "^19.0.4"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@rainbow-robotics/preview-host": "0.1.2-dev.
|
|
34
|
+
"@rainbow-robotics/preview-host": "0.1.2-dev.25",
|
|
35
35
|
"socket.io": "^4.8.1"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|