@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.23 → 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 +4 -5
- package/cli/bin/create-muscat-plugin.mjs +1 -1
- package/cli/bin/muscat-pk.mjs +4 -8
- 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 +84 -11
- package/cli/src/cpp.mjs +51 -0
- package/cli/src/dev.mjs +12 -6
- package/cli/src/devBack.mjs +145 -46
- package/cli/src/devUi.mjs +15 -11
- package/cli/src/manifest.mjs +33 -4
- package/cli/src/new.mjs +87 -57
- 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 +49 -25
- package/cli/templates/common/manifest.json +4 -1
- package/cli/templates/common/requirements.txt +3 -3
- 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/src/pack.mjs +0 -58
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/devUi.mjs
CHANGED
|
@@ -13,6 +13,10 @@ const PREVIEW_PKG = "@rainbow-robotics/preview-host";
|
|
|
13
13
|
const CACHE_DIR = resolve(homedir(), ".muscat-pk");
|
|
14
14
|
const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
|
|
15
15
|
|
|
16
|
+
// Dev builds go to a throwaway dir, not dist/ — dist/ is reserved for
|
|
17
|
+
// build/pack output (the shippable artifact).
|
|
18
|
+
const DEV_OUT = ".muscat-dev";
|
|
19
|
+
|
|
16
20
|
function findNodeBin() {
|
|
17
21
|
if (typeof Bun === "undefined") return process.execPath;
|
|
18
22
|
for (const c of ["node", "node20", "node18"]) {
|
|
@@ -52,14 +56,14 @@ function findPreviewHostRoot() {
|
|
|
52
56
|
return CACHE_ROOT;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
// Write sandbox.html + copy manifest into
|
|
59
|
+
// Write sandbox.html + copy manifest into the dev out dir (vite doesn't generate these).
|
|
56
60
|
function stageDevAssets(pluginRoot) {
|
|
57
|
-
const
|
|
58
|
-
mkdirSync(
|
|
59
|
-
writeFileSync(resolve(
|
|
61
|
+
const outDir = resolve(pluginRoot, DEV_OUT);
|
|
62
|
+
mkdirSync(outDir, { recursive: true });
|
|
63
|
+
writeFileSync(resolve(outDir, "sandbox.html"), SANDBOX_HTML, "utf8");
|
|
60
64
|
const manifestSrc = resolve(pluginRoot, "manifest.json");
|
|
61
|
-
if (existsSync(manifestSrc)) copyFileSync(manifestSrc, resolve(
|
|
62
|
-
return
|
|
65
|
+
if (existsSync(manifestSrc)) copyFileSync(manifestSrc, resolve(outDir, "manifest.json"));
|
|
66
|
+
return outDir;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
// Resolve vite bin path from the plugin root, hoisting-aware.
|
|
@@ -79,8 +83,8 @@ function spawnViteWatch(pluginRoot, node) {
|
|
|
79
83
|
const viteBin = findViteBin(pluginRoot);
|
|
80
84
|
if (!node || !viteBin || !existsSync(viteBin)) return null;
|
|
81
85
|
|
|
82
|
-
log.step(
|
|
83
|
-
return spawn(node, [viteBin, "build", "--watch", "--mode", "development"], {
|
|
86
|
+
log.step(`vite build --watch (ui/content.tsx → ${DEV_OUT}/content.js)`);
|
|
87
|
+
return spawn(node, [viteBin, "build", "--watch", "--mode", "development", "--outDir", DEV_OUT], {
|
|
84
88
|
cwd: pluginRoot,
|
|
85
89
|
stdio: "inherit",
|
|
86
90
|
});
|
|
@@ -106,14 +110,14 @@ export async function runDevUi(args = []) {
|
|
|
106
110
|
|
|
107
111
|
await checkKitUpdate();
|
|
108
112
|
const pluginRoot = findPluginRoot();
|
|
109
|
-
const
|
|
113
|
+
const outDir = stageDevAssets(pluginRoot);
|
|
110
114
|
|
|
111
115
|
const node = findNodeBin();
|
|
112
116
|
const viteProc = spawnViteWatch(pluginRoot, node);
|
|
113
117
|
|
|
114
118
|
if (viteProc) {
|
|
115
119
|
log.step("waiting for initial vite build…");
|
|
116
|
-
await waitForInitialBuild(
|
|
120
|
+
await waitForInitialBuild(outDir);
|
|
117
121
|
} else {
|
|
118
122
|
log.warn("vite not found — UI will not be bundled. Run `npm install` in the plugin project.");
|
|
119
123
|
}
|
|
@@ -133,7 +137,7 @@ export async function runDevUi(args = []) {
|
|
|
133
137
|
log.step(`starting preview-host on :${port}`);
|
|
134
138
|
const child = spawn(
|
|
135
139
|
node ?? process.execPath,
|
|
136
|
-
[serveEntry, "--plugin",
|
|
140
|
+
[serveEntry, "--plugin", outDir, "--port", String(port)],
|
|
137
141
|
{ stdio: "inherit" },
|
|
138
142
|
);
|
|
139
143
|
|
package/cli/src/manifest.mjs
CHANGED
|
@@ -4,9 +4,10 @@ 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
|
+
const TARGET_CATEGORIES = ["manipulate", "amr", "rby1", "vision"];
|
|
10
11
|
|
|
11
12
|
function validateI18nLabel(value, field, errors) {
|
|
12
13
|
if (value === undefined || value === null) {
|
|
@@ -15,7 +16,7 @@ function validateI18nLabel(value, field, errors) {
|
|
|
15
16
|
}
|
|
16
17
|
if (typeof value === "string") return;
|
|
17
18
|
if (typeof value === "object") {
|
|
18
|
-
if (typeof value.en !== "string"
|
|
19
|
+
if (typeof value.en !== "string")
|
|
19
20
|
errors.push(`${field}: "en" is required when using object form`);
|
|
20
21
|
const unknown = Object.keys(value).filter((k) => !I18N_LANGS.includes(k));
|
|
21
22
|
if (unknown.length > 0)
|
|
@@ -65,6 +66,8 @@ function validateRuntime(runtime, errors) {
|
|
|
65
66
|
errors.push(`runtime.type must be one of ${RUNTIME_TYPES.map((t) => `"${t}"`).join(" | ")}`);
|
|
66
67
|
if (runtime.type === "python" && !runtime.entry)
|
|
67
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)");
|
|
68
71
|
if (runtime.type === "ros2") {
|
|
69
72
|
if (!runtime.package) errors.push("runtime.package is required for ros2 runtime");
|
|
70
73
|
if (!runtime.launch) errors.push("runtime.launch is required for ros2 runtime");
|
|
@@ -133,8 +136,34 @@ export function validateManifest(manifest) {
|
|
|
133
136
|
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
|
|
134
137
|
errors.push(`"${key}" is required`);
|
|
135
138
|
}
|
|
136
|
-
if (typeof manifest.author !== "string")
|
|
137
|
-
errors.push(`"author" is required
|
|
139
|
+
if (typeof manifest.author !== "string" || manifest.author.length === 0)
|
|
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`);
|
|
145
|
+
|
|
146
|
+
const hasModels = manifest.targetModels !== undefined && manifest.targetModels !== null;
|
|
147
|
+
const hasCategorys = manifest.targetCategorys !== undefined && manifest.targetCategorys !== null;
|
|
148
|
+
if (!hasModels && !hasCategorys) {
|
|
149
|
+
errors.push(`"targetModels" or "targetCategorys" is required`);
|
|
150
|
+
} else {
|
|
151
|
+
if (hasModels) {
|
|
152
|
+
if (manifest.targetModels !== "all" && (!Array.isArray(manifest.targetModels) || manifest.targetModels.length === 0))
|
|
153
|
+
errors.push(`"targetModels" must be "all" or a non-empty array of model strings`);
|
|
154
|
+
else if (Array.isArray(manifest.targetModels) && manifest.targetModels.some((m) => typeof m !== "string"))
|
|
155
|
+
errors.push(`"targetModels": all entries must be strings`);
|
|
156
|
+
}
|
|
157
|
+
if (hasCategorys) {
|
|
158
|
+
if (manifest.targetCategorys !== "all" && (!Array.isArray(manifest.targetCategorys) || manifest.targetCategorys.length === 0))
|
|
159
|
+
errors.push(`"targetCategorys" must be "all" or a non-empty array`);
|
|
160
|
+
else if (Array.isArray(manifest.targetCategorys)) {
|
|
161
|
+
const invalid = manifest.targetCategorys.filter((c) => !TARGET_CATEGORIES.includes(c));
|
|
162
|
+
if (invalid.length > 0)
|
|
163
|
+
errors.push(`"targetCategorys": invalid value(s) ${invalid.map((c) => `"${c}"`).join(", ")} — allowed: ${TARGET_CATEGORIES.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
138
167
|
validateI18nLabel(manifest.description, "description", errors);
|
|
139
168
|
|
|
140
169
|
validateIcons(manifest.icons, errors);
|
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", ".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,59 +223,27 @@ 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}`);
|
|
199
233
|
log.info(` muscat-pk dev # preview in mini-Muscat + back end`);
|
|
200
|
-
log.info(` muscat-pk
|
|
234
|
+
log.info(` muscat-pk build # build + distributable ZIP\n`);
|
|
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
|
+
}
|