@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.4 → 0.1.2-dev.40

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.
Files changed (50) hide show
  1. package/README.md +116 -16
  2. package/cli/README.md +5 -5
  3. package/cli/bin/create-muscat-plugin.mjs +1 -1
  4. package/cli/bin/muscat-pk.mjs +20 -10
  5. package/cli/bundle-templates.mjs +28 -0
  6. package/cli/docker/Dockerfile.bridge +17 -0
  7. package/cli/docker/Dockerfile.cpp +22 -0
  8. package/cli/docker/Dockerfile.dev +14 -0
  9. package/cli/docker/Dockerfile.python +20 -0
  10. package/cli/docker/bridge-package.json +8 -0
  11. package/cli/src/bridge.mjs +92 -0
  12. package/cli/src/build.mjs +386 -36
  13. package/cli/src/check-package.mjs +8 -23
  14. package/cli/src/cpp.mjs +61 -0
  15. package/cli/src/dev.mjs +16 -3
  16. package/cli/src/devBack.mjs +239 -53
  17. package/cli/src/devUi.mjs +118 -15
  18. package/cli/src/esbuild.mjs +51 -10
  19. package/cli/src/manifest.mjs +193 -0
  20. package/cli/src/new.mjs +168 -37
  21. package/cli/src/preview.mjs +91 -0
  22. package/cli/src/python.mjs +122 -0
  23. package/cli/src/ros2.mjs +90 -0
  24. package/cli/src/socketBridge.mjs +129 -0
  25. package/cli/src/type.mjs +105 -0
  26. package/cli/src/uninstall.mjs +98 -0
  27. package/cli/src/update.mjs +259 -30
  28. package/cli/src/util.mjs +97 -7
  29. package/cli/templates/common/README.md +383 -9
  30. package/cli/templates/common/manifest.json +13 -13
  31. package/cli/templates/common/package.json +5 -11
  32. package/cli/templates/common/py/main.py +88 -0
  33. package/cli/templates/common/requirements.txt +4 -0
  34. package/cli/templates/common/tsconfig.json +1 -1
  35. package/cli/templates/common/{src → ui}/content.tsx +3 -2
  36. package/cli/templates/common/vite.config.ts +22 -13
  37. package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
  38. package/cli/templates/cpp/cpp/main.cpp +25 -0
  39. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
  40. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
  41. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
  42. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
  43. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
  44. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
  45. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
  46. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
  47. package/package.json +4 -4
  48. package/cli/src/pack.mjs +0 -58
  49. package/cli/templates/sdk/main.py +0 -21
  50. package/cli/templates/sdk/requirements.txt +0 -1
@@ -0,0 +1,90 @@
1
+ // Shared ROS2 Docker helpers (Jazzy + Python 3.12 — matches the robot backend).
2
+ // Used by dev:back (interactive dev loop) and build (export install/ into the ZIP).
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
+
8
+ export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
9
+
10
+ /** First package directory under ros2_ws/src (the colcon package name). */
11
+ export function ros2PackageName(root) {
12
+ const srcDir = resolve(root, "ros2_ws/src");
13
+ if (!existsSync(srcDir)) return null;
14
+ const dirs = readdirSync(srcDir, { withFileTypes: true })
15
+ .filter((d) => d.isDirectory())
16
+ .map((d) => d.name);
17
+ return dirs[0] ?? null;
18
+ }
19
+
20
+ export function assertDockerReady() {
21
+ if (!hasBin("docker")) {
22
+ throw new Error("Docker not found. ROS2 needs Docker (Jazzy + Python 3.12 container).");
23
+ }
24
+ }
25
+
26
+ export const ARCH_DOCKER_PLATFORM = {
27
+ amd64: "linux/amd64",
28
+ arm64: "linux/arm64",
29
+ };
30
+
31
+ /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
32
+ export async function ensureImage(image = ROS2_DEV_IMAGE, { platform = null } = {}) {
33
+ const dockerfile = await resolveDockerfile("Dockerfile.dev");
34
+ log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
35
+ const platformFlags = platform ? ["--platform", platform] : [];
36
+ const r = spawnSync(
37
+ "docker",
38
+ ["build", ...platformFlags, "-t", image, "-f", dockerfile, dirname(dockerfile)],
39
+ { stdio: "inherit" },
40
+ );
41
+ if (r.status !== 0) throw new Error("docker build failed.");
42
+ pruneMuskatImages();
43
+ }
44
+
45
+ /**
46
+ * Run `colcon build` inside the container, producing ros2_ws/install on the host
47
+ * (the workspace is mounted). symlink=true for dev (fast, edit-in-place);
48
+ * symlink=false for export builds that ship in the ZIP (real copies, relocatable).
49
+ */
50
+ /**
51
+ * arch=null → dev mode, builds to ros2_ws/install/ (native)
52
+ * arch="amd64"|"arm64" → export mode, builds to ros2_ws/install-{arch}/ using arch-specific image
53
+ */
54
+ /**
55
+ * symlink=true (dev): ros2_ws/install/ — local dev workspace, not for ZIP
56
+ * symlink=false, arch=null (rclpy export): dist/ros2_ws/install/
57
+ * symlink=false, arch="amd64"|"arm64" (rclcpp export): dist/ros2_ws/install-{arch}/
58
+ */
59
+ export function colconBuild(root, { image = ROS2_DEV_IMAGE, symlink = false, arch = null } = {}) {
60
+ // paths are relative to ros2_ws/ inside the container
61
+ let installBase;
62
+ if (symlink) {
63
+ installBase = "install";
64
+ } else if (arch) {
65
+ installBase = `../dist/ros2_ws/install-${arch}`;
66
+ } else {
67
+ installBase = "../dist/ros2_ws/install";
68
+ }
69
+ const flag = symlink ? "--symlink-install" : "";
70
+ const script = [
71
+ "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true",
72
+ "cd ros2_ws",
73
+ // ponytail: rm only on cross-arch; same-arch dev rebuilds keep the cache
74
+ ...(arch ? ["rm -rf build"] : []),
75
+ `colcon build ${flag} --install-base ${installBase}`.trim(),
76
+ ].join(" && ");
77
+ const label = arch
78
+ ? `colcon build (${arch}) → dist/ros2_ws/install-${arch}/…`
79
+ : `colcon build${symlink ? " --symlink-install" : ""} (in container)…`;
80
+ log.step(label);
81
+ const actualImage = arch ? `${ROS2_DEV_IMAGE}-${arch}` : image;
82
+ const dockerPlatform = arch ? ARCH_DOCKER_PLATFORM[arch] : null;
83
+ const platformFlags = dockerPlatform ? ["--platform", dockerPlatform] : [];
84
+ const r = spawnSync(
85
+ "docker",
86
+ ["run", "--rm", ...platformFlags, "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
87
+ { cwd: root, stdio: "inherit" },
88
+ );
89
+ if (r.status !== 0) throw new Error(`colcon build failed${arch ? ` (${arch})` : ""}.`);
90
+ }
@@ -0,0 +1,129 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Server } from "socket.io";
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
+ };
12
+
13
+ const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
14
+
15
+ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
16
+ const httpServer = createServer();
17
+ const io = new Server(httpServer, { cors: { origin: "*" } });
18
+
19
+ // topic → socketId (serve_register로 등록된 queryable)
20
+ const queryables = new Map();
21
+ // call_id → { timer, resolve } (action → serve_call → serve_reply 대기)
22
+ const pending = new Map();
23
+
24
+ function cleanupSocket(socket) {
25
+ for (const [topic, sid] of queryables) {
26
+ if (sid === socket.id) queryables.delete(topic);
27
+ }
28
+ for (const [id, p] of pending) {
29
+ if (p.sid !== socket.id) continue;
30
+ clearTimeout(p.timer);
31
+ p.resolve(undefined);
32
+ pending.delete(id);
33
+ }
34
+ }
35
+
36
+ io.on("connection", (socket) => {
37
+ socket.on("sub", async (data = {}) => {
38
+ if (!data.topic) return;
39
+ await socket.join(`t:${data.topic}`);
40
+ });
41
+
42
+ socket.on("unsub", async (data = {}) => {
43
+ if (!data.topic) return;
44
+ await socket.leave(`t:${data.topic}`);
45
+ });
46
+
47
+ socket.on("pub", (data = {}) => {
48
+ const { topic, payload } = data;
49
+ if (!topic) return;
50
+ io.to(`t:${topic}`).emit("msg", { topic, payload });
51
+ });
52
+
53
+ socket.on("serve_register", (data = {}) => {
54
+ if (!data.topic) return;
55
+ queryables.set(data.topic, socket.id);
56
+ });
57
+
58
+ socket.on("serve_unregister", (data = {}) => {
59
+ if (queryables.get(data.topic) === socket.id) queryables.delete(data.topic);
60
+ });
61
+
62
+ socket.on("serve_reply", (data = {}) => {
63
+ const p = pending.get(data.id);
64
+ if (!p) return;
65
+ clearTimeout(p.timer);
66
+ p.resolve(data.payload);
67
+ pending.delete(data.id);
68
+ });
69
+
70
+ socket.on("action", (data = {}, ack) => {
71
+ const { topic, payload } = data;
72
+ const targetSid = queryables.get(topic);
73
+ const target = targetSid && io.sockets.sockets.get(targetSid);
74
+ if (!target) {
75
+ if (typeof ack === "function") ack(undefined);
76
+ return;
77
+ }
78
+ const id = randomUUID();
79
+ const timer = setTimeout(() => {
80
+ pending.delete(id);
81
+ if (typeof ack === "function") ack(undefined);
82
+ }, DEFAULT_ACTION_TIMEOUT_MS);
83
+ pending.set(id, {
84
+ sid: targetSid,
85
+ timer,
86
+ resolve: (v) => { if (typeof ack === "function") ack(v); },
87
+ });
88
+ target.emit("serve_call", { id, topic, payload });
89
+ });
90
+
91
+ socket.on("disconnect", () => cleanupSocket(socket));
92
+ });
93
+
94
+ return new Promise((resolve) => {
95
+ httpServer.once("error", (err) => {
96
+ if (err.code === "EADDRINUSE") {
97
+ log.warn(`Socket.IO bridge already running on :${port} — skipping`);
98
+ io.close();
99
+ httpServer.close();
100
+ resolve({ close: () => {} });
101
+ } else {
102
+ throw err;
103
+ }
104
+ });
105
+
106
+ httpServer.listen(port, host, () => {
107
+ log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
108
+
109
+ const close = () => {
110
+ for (const p of pending.values()) {
111
+ clearTimeout(p.timer);
112
+ p.resolve(undefined);
113
+ }
114
+ pending.clear();
115
+ queryables.clear();
116
+ io.close();
117
+ httpServer.close();
118
+ };
119
+
120
+ resolve({ close });
121
+ });
122
+ });
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
+ }
@@ -0,0 +1,105 @@
1
+ // muscat-pk type change — switch an existing plugin's backend type.
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).
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { basename, resolve } from "node:path";
7
+ import { findPluginRoot, log, prompt } from "./util.mjs";
8
+ import {
9
+ TYPE_CHOICES,
10
+ applyTokens,
11
+ deriveNames,
12
+ overlayCpp,
13
+ overlayRos2,
14
+ runtimeForChoice,
15
+ } from "./new.mjs";
16
+
17
+ const TYPES = Object.keys(TYPE_CHOICES);
18
+
19
+ function readManifest(root) {
20
+ const path = resolve(root, "manifest.json");
21
+ if (!existsSync(path)) throw new Error(`manifest.json not found in ${root}`);
22
+ return { path, json: JSON.parse(readFileSync(path, "utf8")) };
23
+ }
24
+
25
+ // Derive the current `--type`-style key from a manifest runtime block.
26
+ function currentTypeKey(runtime) {
27
+ if (runtime?.type === "ros2") return runtime.rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
28
+ return runtime?.type ?? "python";
29
+ }
30
+
31
+ export async function runType(args) {
32
+ // accept `type change`, `type`, `type --to ros2-cpp`
33
+ const sub = args[0];
34
+ const toFlagIdx = args.indexOf("--to");
35
+ let to = toFlagIdx >= 0 ? args[toFlagIdx + 1] : undefined;
36
+
37
+ const root = findPluginRoot();
38
+ const { path, json } = readManifest(root);
39
+ const current = currentTypeKey(json.runtime);
40
+
41
+ if (sub && sub !== "change") {
42
+ log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|cpp|ros2|ros2-cpp]`);
43
+ }
44
+
45
+ if (!to) {
46
+ console.log(`\n현재 타입: ${current}`);
47
+ console.log("변경할 타입을 선택하세요:");
48
+ console.log(" 1) Python");
49
+ console.log(" 2) C++");
50
+ console.log(" 3) ROS2 (rclpy / Python)");
51
+ console.log(" 4) ROS2 (rclcpp / C++)");
52
+ const choice = await prompt("\n선택 [1/2/3/4]: ", "");
53
+ to = { "1": "python", "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice];
54
+ }
55
+
56
+ if (!TYPES.includes(to)) {
57
+ throw new Error(`invalid type. choose python | cpp | ros2 | ros2-cpp.`);
58
+ }
59
+ if (to === current) {
60
+ log.info(`already '${current}' — nothing to change.`);
61
+ return;
62
+ }
63
+
64
+ const runtimeChoice = TYPE_CHOICES[to];
65
+ const { slug, pluginName, pluginClass, pluginId } = deriveNames(root);
66
+
67
+ if (runtimeChoice.type === "ros2") {
68
+ if (existsSync(resolve(root, "ros2_ws"))) {
69
+ log.warn("ros2_ws/ already exists — keeping it, only flipping manifest type.");
70
+ } else {
71
+ log.step(`adding ros2_ws/ overlay (${runtimeChoice.rosClient})`);
72
+ await overlayRos2(root, slug, runtimeChoice.rosClient);
73
+ applyTokens(root, {
74
+ __PLUGIN_ID__: pluginId,
75
+ __PLUGIN_NAME__: pluginName,
76
+ __PLUGIN_SLUG__: slug,
77
+ __PLUGIN_CLASS__: pluginClass,
78
+ __RUNTIME_TYPE__: "ros2",
79
+ });
80
+ }
81
+ } else if (runtimeChoice.type === "cpp") {
82
+ if (existsSync(resolve(root, "cpp"))) {
83
+ log.warn("cpp/ already exists — keeping it, only flipping manifest type.");
84
+ } else {
85
+ log.step("adding cpp/ overlay");
86
+ await overlayCpp(root);
87
+ applyTokens(root, {
88
+ __PLUGIN_ID__: pluginId,
89
+ __PLUGIN_NAME__: pluginName,
90
+ __PLUGIN_SLUG__: slug,
91
+ __PLUGIN_CLASS__: pluginClass,
92
+ __RUNTIME_TYPE__: "cpp",
93
+ });
94
+ }
95
+ } else {
96
+ // → python. Leave any ros2_ws/ or cpp/ in place (never delete user code).
97
+ if (existsSync(resolve(root, "ros2_ws")) || existsSync(resolve(root, "cpp"))) {
98
+ log.warn("existing backend dir left in place (not deleted). Remove it manually if unused.");
99
+ }
100
+ }
101
+
102
+ json.runtime = runtimeForChoice(runtimeChoice, slug);
103
+ writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
104
+ log.ok(`${basename(root)} type: ${current} → ${to}`);
105
+ }
@@ -0,0 +1,98 @@
1
+ // muscat-pk uninstall - remove the installed CLI, whether npm-global or S3 binary.
2
+ import { execSync, spawn, spawnSync } from "node:child_process";
3
+ import { existsSync, realpathSync, rmSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { KIT_PUBLISHED_NAME, kitRoot, log, parseArgs } from "./util.mjs";
6
+
7
+ const isBinaryMode = typeof Bun !== "undefined";
8
+
9
+ function isInside(child, parent) {
10
+ const rel = relative(parent, child);
11
+ return rel === "" || (!!rel && !rel.startsWith("..") && !rel.includes(":"));
12
+ }
13
+
14
+ function npmGlobalRoot() {
15
+ return execSync("npm root -g", { encoding: "utf8" }).trim();
16
+ }
17
+
18
+ function isNpmGlobalInstall() {
19
+ try {
20
+ return isInside(realpathSync(kitRoot), realpathSync(npmGlobalRoot()));
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function uninstallNpmGlobal() {
27
+ log.step(`npm uninstall -g ${KIT_PUBLISHED_NAME}`);
28
+ execSync(`npm uninstall -g ${KIT_PUBLISHED_NAME}`, { stdio: "inherit" });
29
+ log.ok("Global npm package removed.");
30
+ }
31
+
32
+ function uninstallBinary() {
33
+ const execPath = process.execPath;
34
+ log.step(`Removing binary: ${execPath}`);
35
+
36
+ if (process.platform === "win32") {
37
+ const ps = `Start-Sleep -Milliseconds 500; Remove-Item -Force '${execPath}'`;
38
+ spawn("powershell", ["-NoProfile", "-Command", ps], {
39
+ detached: true,
40
+ stdio: "ignore",
41
+ }).unref();
42
+ log.ok("Binary will be removed after this process exits.");
43
+ return;
44
+ }
45
+
46
+ try {
47
+ rmSync(execPath, { force: true });
48
+ } catch (err) {
49
+ if (err?.code !== "EACCES" && err?.code !== "EPERM") throw err;
50
+ log.info(" sudo is required...");
51
+ const result = spawnSync("sudo", ["rm", "-f", execPath], { stdio: "inherit" });
52
+ if (result.status !== 0) throw new Error(`sudo rm failed: ${execPath}`);
53
+ }
54
+
55
+ const parent = dirname(execPath);
56
+ for (const shim of ["muscat-pk.cmd", "muscat-pk.ps1"]) {
57
+ rmSync(join(parent, shim), { force: true });
58
+ }
59
+
60
+ log.ok("Binary removed.");
61
+ }
62
+
63
+ export async function runUninstall(args = []) {
64
+ const opts = parseArgs(args, { booleans: ["help"] });
65
+
66
+ if (opts.help) {
67
+ log.info(
68
+ [
69
+ "Usage: muscat-pk uninstall",
70
+ "",
71
+ "Removes the installed muscat-pk CLI.",
72
+ " npm global install -> npm uninstall -g @rainbow-robotics/muscat-plugin-kit",
73
+ " S3 binary install -> remove the muscat-pk executable",
74
+ ].join("\n"),
75
+ );
76
+ return;
77
+ }
78
+
79
+ if (isBinaryMode) {
80
+ uninstallBinary();
81
+ return;
82
+ }
83
+
84
+ if (isNpmGlobalInstall()) {
85
+ uninstallNpmGlobal();
86
+ return;
87
+ }
88
+
89
+ const localBin = process.argv[1] || "";
90
+ if (localBin.includes(`${join("node_modules", ".bin")}`) || existsSync(join(process.cwd(), "package.json"))) {
91
+ log.warn("This muscat-pk appears to be running from a project/local install.");
92
+ log.warn("Remove it from the project package manager instead, for example:");
93
+ log.warn(` npm uninstall ${KIT_PUBLISHED_NAME}`);
94
+ return;
95
+ }
96
+
97
+ throw new Error("Could not determine install mode. Remove muscat-pk manually from your PATH.");
98
+ }