@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.0 → 0.1.2-dev.13

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.
@@ -1,15 +1,55 @@
1
+ // When compiled with `bun --compile`, esbuild's native subprocess binary is
2
+ // not bundled. Use Bun's built-in bundler instead.
3
+ async function buildWithBun(options) {
4
+ const { entryPoints, outfile, format, minify, loader, define, external } = options;
5
+ const { dirname } = await import("node:path");
6
+ const result = await Bun.build({
7
+ entrypoints: entryPoints,
8
+ outdir: dirname(outfile),
9
+ format: format ?? "esm",
10
+ minify: minify ?? false,
11
+ loader,
12
+ define,
13
+ external,
14
+ target: "node",
15
+ throw: false,
16
+ });
17
+ if (!result.success) {
18
+ for (const log of result.logs) console.error(log.message);
19
+ throw new Error("Bun.build() failed");
20
+ }
21
+ }
22
+
23
+ async function buildWithWasm(options) {
24
+ const wasm = await import("esbuild-wasm");
25
+ return wasm.build(options);
26
+ }
27
+
28
+ function isNativeMissing(err) {
29
+ const msg = String(err?.message || err?.code || err || "");
30
+ return (
31
+ msg.includes("another platform") ||
32
+ msg.includes("Cannot find module") ||
33
+ msg.includes("not found") ||
34
+ msg.includes("No such file") ||
35
+ msg.includes("ENOENT") ||
36
+ msg.includes("MODULE_NOT_FOUND")
37
+ );
38
+ }
39
+
1
40
  export async function buildWithFallback(options) {
2
- const native = await import("esbuild");
41
+ if (typeof Bun !== "undefined") return buildWithBun(options);
42
+
43
+ // Try native esbuild (faster, no extra setup). Falls back to wasm when:
44
+ // - esbuild was not installed (removed from hard dependencies to avoid
45
+ // postinstall script blocking npm i -g on Windows)
46
+ // - binary was built for a different platform
3
47
  try {
48
+ const native = await import("esbuild");
4
49
  return await native.build(options);
5
- } catch (error) {
6
- const message = String(error?.message || error);
7
- if (!message.includes("another platform")) {
8
- throw error;
9
- }
10
-
11
- const wasm = await import("esbuild-wasm");
12
- console.warn("[muscat-pk] native esbuild binary mismatch; falling back to esbuild-wasm");
13
- return wasm.build(options);
50
+ } catch (err) {
51
+ if (!isNativeMissing(err)) throw err;
14
52
  }
53
+
54
+ return buildWithWasm(options);
15
55
  }
package/cli/src/new.mjs CHANGED
@@ -1,21 +1,21 @@
1
1
  // muscat-pk new — scaffold a new plugin project from templates.
2
- import { execSync } from "node:child_process";
2
+ import { execFileSync, execSync } from "node:child_process";
3
3
  import {
4
4
  cpSync,
5
5
  existsSync,
6
6
  mkdirSync,
7
7
  readFileSync,
8
8
  readdirSync,
9
+ renameSync,
9
10
  statSync,
10
11
  writeFileSync,
11
12
  } from "node:fs";
12
13
  import { createInterface } from "node:readline/promises";
13
14
  import { basename, resolve } from "node:path";
14
- import { parseArgs, templatesDir, log } from "./util.mjs";
15
+ import { detectPythonCmd, hasBin, kitVersion, parseArgs, templatesDir, log, venvPython } from "./util.mjs";
15
16
 
16
- const TYPES = ["sdk", "ros2", "ui"];
17
+ const TYPES = ["python", "ros2"];
17
18
  const PMS = ["npm", "yarn", "pnpm", "bun"];
18
- const DEFAULT_SDK_VERSION = "2.4.1";
19
19
 
20
20
  async function prompt(question, fallback) {
21
21
  if (!process.stdin.isTTY) return fallback;
@@ -25,7 +25,7 @@ async function prompt(question, fallback) {
25
25
  return answer || fallback;
26
26
  }
27
27
 
28
- function applyTokens(dir, tokens) {
28
+ export function applyTokens(dir, tokens) {
29
29
  for (const entry of readdirSync(dir)) {
30
30
  const full = resolve(dir, entry);
31
31
  if (statSync(full).isDirectory()) {
@@ -45,12 +45,46 @@ function applyTokens(dir, tokens) {
45
45
  }
46
46
  }
47
47
 
48
+ // Derive naming tokens from a plugin directory name.
49
+ export function deriveNames(target) {
50
+ const base = basename(target);
51
+ const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, "_");
52
+ const pluginName = base.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
53
+ return {
54
+ dir: base,
55
+ slug,
56
+ pluginName,
57
+ pluginClass: pluginName.replace(/\s+/g, ""),
58
+ pluginId: `com.example.${base.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
59
+ };
60
+ }
61
+
62
+ // Recursively rename any file/dir whose name contains __PLUGIN_SLUG__.
63
+ function renameSlugPaths(dir, slug) {
64
+ for (const entry of readdirSync(dir)) {
65
+ let full = resolve(dir, entry);
66
+ if (entry.includes("__PLUGIN_SLUG__")) {
67
+ full = resolve(dir, entry.split("__PLUGIN_SLUG__").join(slug));
68
+ renameSync(resolve(dir, entry), full);
69
+ }
70
+ if (statSync(full).isDirectory()) renameSlugPaths(full, slug);
71
+ }
72
+ }
73
+
74
+ // Copy the ros2_ws/ overlay into a plugin and substitute the package slug into
75
+ // every path segment. The dev Dockerfile is NOT scaffolded here — it lives in
76
+ // the kit (cli/docker/) so authors can't edit it. Caller runs applyTokens after.
77
+ export function overlayRos2(target, slug) {
78
+ cpSync(resolve(templatesDir, "ros2"), target, { recursive: true });
79
+ renameSlugPaths(resolve(target, "ros2_ws"), slug);
80
+ }
81
+
48
82
  export async function runNew(args) {
49
83
  const opts = parseArgs(args, { booleans: ["yes", "no-install", "help"] });
50
84
  if (opts.help) {
51
85
  console.log(
52
- "muscat-pk new [dir] [--type sdk|ros2|ui] [--pm npm|yarn|pnpm|bun]\n" +
53
- " [--sdk-version <v>] [--yes] [--no-install]",
86
+ "muscat-pk new [dir] [--type python|ros2] [--pm npm|yarn|pnpm|bun]\n" +
87
+ " [--yes] [--no-install]",
54
88
  );
55
89
  return;
56
90
  }
@@ -65,9 +99,13 @@ export async function runNew(args) {
65
99
 
66
100
  let type = opts.type;
67
101
  if (!type && !opts.yes) {
68
- type = await prompt(`Plugin type (${TYPES.join("/")}) [sdk]: `, "sdk");
102
+ console.log("\n플러그인 타입을 선택하세요:");
103
+ console.log(" 1) Python");
104
+ console.log(" 2) ROS2(rclpy)");
105
+ const choice = await prompt("\n선택 [1/2, default: 1]: ", "1");
106
+ type = choice === "2" ? "ros2" : "python";
69
107
  }
70
- type = TYPES.includes(type) ? type : "sdk";
108
+ type = TYPES.includes(type) ? type : "python";
71
109
 
72
110
  let pm = opts.pm;
73
111
  if (!pm && !opts.yes) {
@@ -75,32 +113,25 @@ export async function runNew(args) {
75
113
  }
76
114
  pm = PMS.includes(pm) ? pm : "npm";
77
115
 
78
- const sdkVersion = opts["sdk-version"] || DEFAULT_SDK_VERSION;
79
- const pluginName = basename(target)
80
- .replace(/[-_]/g, " ")
81
- .replace(/\b\w/g, (c) => c.toUpperCase());
82
- const pluginId = `com.example.${basename(target).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
116
+ const { dir, slug, pluginName, pluginClass, pluginId } = deriveNames(target);
83
117
 
84
118
  mkdirSync(target, { recursive: true });
85
119
 
120
+ // common(UI + main.py + requirements.txt + manifest/config)은 항상 포함.
121
+ // ros2면 ros2_ws/ 오버레이만 추가로 얹는다. python은 ros2_ws/가 없을 뿐 구조는 동일.
86
122
  log.step(`scaffolding ${type} plugin → ${target}`);
87
123
  cpSync(resolve(templatesDir, "common"), target, { recursive: true });
88
- if (type === "sdk") {
89
- cpSync(resolve(templatesDir, "sdk"), target, { recursive: true });
90
- } else if (type === "ros2") {
91
- if (existsSync(resolve(templatesDir, "ros2-rclpy"))) {
92
- cpSync(resolve(templatesDir, "ros2-rclpy"), target, { recursive: true });
93
- } else {
94
- log.warn("ros2 template not bundled; scaffolding UI-only base");
95
- }
96
- }
124
+ if (type === "ros2") overlayRos2(target, slug);
97
125
 
98
126
  applyTokens(target, {
99
127
  __PLUGIN_ID__: pluginId,
100
128
  __PLUGIN_NAME__: pluginName,
101
- __SDK_VERSION__: sdkVersion,
129
+ __PLUGIN_DIR__: dir,
130
+ __PLUGIN_SLUG__: slug,
131
+ __PLUGIN_CLASS__: pluginClass,
102
132
  __PM__: pm,
103
- __RUNTIME_TYPE__: type === "ui" ? "none" : type === "ros2" ? "ros2" : "python",
133
+ __RUNTIME_TYPE__: type,
134
+ __RB_VERSION__: kitVersion(),
104
135
  });
105
136
 
106
137
  // gitignore (templates can't ship a literal .gitignore via npm reliably)
@@ -118,8 +149,60 @@ export async function runNew(args) {
118
149
  }
119
150
  }
120
151
 
152
+ let noPython = false;
153
+ let venvFailed = false;
154
+
155
+ if (!opts["no-install"]) {
156
+ // Python venv (robot targets 3.12) — cross-platform.
157
+ // uv is preferred: handles uv-managed Pythons that break standard ensurepip.
158
+ try {
159
+ const venv = resolve(target, ".venv");
160
+ log.step("creating .venv + installing requirements…");
161
+ if (hasBin("uv")) {
162
+ execFileSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: target, stdio: "inherit" });
163
+ } else if (hasBin("conda")) {
164
+ execFileSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: target, stdio: "inherit" });
165
+ } else if (hasBin("python3.12") || hasBin("python3") || hasBin("python")) {
166
+ const { cmd, is312 } = detectPythonCmd();
167
+ if (!is312) log.warn("python3.12 not found; venv uses fallback python (robot targets 3.12).");
168
+ execFileSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: target, stdio: "inherit" });
169
+ } else {
170
+ noPython = true;
171
+ }
172
+ if (!noPython) {
173
+ const py = venvPython(venv);
174
+ if (existsSync(resolve(target, "requirements.txt"))) {
175
+ execFileSync(py, ["-m", "pip", "install", "-q", "-r", "requirements.txt"], { cwd: target, stdio: "inherit" });
176
+ }
177
+ }
178
+ } catch {
179
+ venvFailed = true;
180
+ }
181
+ } else {
182
+ noPython = !hasBin("uv") && !hasBin("conda") && !hasBin("python3.12") && !hasBin("python3") && !hasBin("python");
183
+ }
184
+
121
185
  log.ok(`created ${targetName} (type: ${type}, pm: ${pm})`);
122
186
  log.info(`\n cd ${targetName}`);
123
- log.info(` ${pm} run dev # preview in mini-Muscat + back end`);
124
- log.info(` ${pm} run pack # build a distributable ZIP\n`);
187
+ log.info(` muscat-pk dev # preview in mini-Muscat + back end`);
188
+ log.info(` muscat-pk pack # build a distributable ZIP\n`);
189
+
190
+ // ── post-scaffold environment warnings ───────────────────────────────────
191
+ const noNode = !hasBin("node");
192
+ if (noNode || noPython || venvFailed) {
193
+ log.info("");
194
+ if (noNode) {
195
+ log.warn("Node.js not found. Install Node.js 20+ to run JS deps and dev:ui.");
196
+ log.warn(" https://nodejs.org");
197
+ }
198
+ if (noPython) {
199
+ log.warn("Python not found. Install Python 3.12 to run the backend.");
200
+ log.warn(" https://www.python.org/downloads/ (or: brew install python@3.12 / uv / conda)");
201
+ log.warn(` Then: cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
202
+ } else if (venvFailed) {
203
+ log.warn("venv setup failed. Create it manually:");
204
+ log.warn(` cd ${targetName} && python3.12 -m venv .venv && pip install -r requirements.txt`);
205
+ }
206
+ log.info("");
207
+ }
125
208
  }
package/cli/src/pack.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { execSync } from "node:child_process";
3
3
  import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
4
4
  import { basename, resolve } from "node:path";
5
- import { buildPlugin } from "./build.mjs";
5
+ import { buildPlugin, exportRos2Build, pinRequirements } from "./build.mjs";
6
6
  import { findPluginRoot, humanSize, log } from "./util.mjs";
7
7
 
8
8
  function dirSize(dir) {
@@ -17,7 +17,10 @@ function dirSize(dir) {
17
17
 
18
18
  export async function runPack() {
19
19
  const pluginRoot = findPluginRoot();
20
+ log.step("pinning requirements.txt to latest PyPI versions");
21
+ await pinRequirements(pluginRoot);
20
22
  await buildPlugin({ root: pluginRoot, minify: true });
23
+ await exportRos2Build(pluginRoot);
21
24
 
22
25
  const manifest = JSON.parse(
23
26
  readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"),
@@ -0,0 +1,91 @@
1
+ // muscat-pk preview — build the plugin and open preview-host connected to a
2
+ // real Muscat server (robot or local). No local Socket.IO server is started.
3
+ import { createInterface } from "node:readline";
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { buildPlugin } from "./build.mjs";
8
+ import { findPreviewHostRoot } from "./devUi.mjs";
9
+ import { checkKitUpdate, log, parseArgs } from "./util.mjs";
10
+
11
+ function prompt(question, defaultValue) {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise((res) => {
14
+ rl.question(question, (answer) => {
15
+ rl.close();
16
+ res(answer.trim() || defaultValue);
17
+ });
18
+ });
19
+ }
20
+
21
+ export async function runPreview(args = []) {
22
+ const opts = parseArgs(args, { booleans: ["help"] });
23
+
24
+ if (opts.help) {
25
+ console.log(
26
+ [
27
+ "",
28
+ "Usage: muscat-pk preview [options]",
29
+ "",
30
+ " 빌드된 플러그인을 실제 Muscat 서버에 연결해 미리봅니다.",
31
+ " Socket.IO 서버를 직접 띄우지 않습니다.",
32
+ "",
33
+ "Options:",
34
+ " --host <ip> Muscat 서버 IP 또는 hostname (default: localhost)",
35
+ " --port <n> preview-host 로컬 포트 (default: 4180)",
36
+ " --ws-port <n> Muscat Socket.IO 포트 (default: 10000)",
37
+ "",
38
+ ].join("\n"),
39
+ );
40
+ return;
41
+ }
42
+
43
+ await checkKitUpdate();
44
+
45
+ // ── 로봇 IP 결정 ────────────────────────────────────────────────────────────
46
+ let robotHost = opts.host ?? "";
47
+ if (!robotHost) {
48
+ robotHost = await prompt(
49
+ "Muscat 서버 IP 또는 hostname [localhost]: ",
50
+ "localhost",
51
+ );
52
+ }
53
+ const wsPort = opts["ws-port"] ?? "10000";
54
+ const socketUrl = `ws://${robotHost}:${wsPort}`;
55
+
56
+ const previewPort = opts.port ?? "4180";
57
+
58
+ log.step(`Muscat 서버: ${socketUrl}`);
59
+
60
+ // ── 플러그인 빌드 ────────────────────────────────────────────────────────────
61
+ const { distDir } = await buildPlugin();
62
+
63
+ // ── preview-host 실행 ────────────────────────────────────────────────────────
64
+ const previewRoot = findPreviewHostRoot();
65
+ const serveEntry = resolve(previewRoot, "serve.mjs");
66
+
67
+ if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
68
+ log.step("preview-host 번들 빌드 중 (최초 1회)…");
69
+ await new Promise((res, rej) => {
70
+ const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
71
+ stdio: "inherit",
72
+ });
73
+ p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
74
+ });
75
+ }
76
+
77
+ log.step(`preview-host 시작 → http://localhost:${previewPort}`);
78
+ log.step(`Socket.IO URL: ${socketUrl}`);
79
+
80
+ const child = spawn(
81
+ process.execPath,
82
+ [
83
+ serveEntry,
84
+ "--plugin", distDir,
85
+ "--port", String(previewPort),
86
+ "--socket-url", socketUrl,
87
+ ],
88
+ { stdio: "inherit" },
89
+ );
90
+ child.on("exit", (code) => process.exit(code ?? 0));
91
+ }
@@ -0,0 +1,65 @@
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, kitRoot, log } from "./util.mjs";
7
+
8
+ export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
9
+
10
+ // Dockerfile lives inside the kit — plugin authors never see or edit it.
11
+ // It has no COPY/ADD, so the build context is irrelevant (its own dir is used).
12
+ const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.dev");
13
+
14
+ /** First package directory under ros2_ws/src (the colcon package name). */
15
+ export function ros2PackageName(root) {
16
+ const srcDir = resolve(root, "ros2_ws/src");
17
+ if (!existsSync(srcDir)) return null;
18
+ const dirs = readdirSync(srcDir, { withFileTypes: true })
19
+ .filter((d) => d.isDirectory())
20
+ .map((d) => d.name);
21
+ return dirs[0] ?? null;
22
+ }
23
+
24
+ export function assertDockerReady() {
25
+ if (!hasBin("docker")) {
26
+ throw new Error("Docker not found. ROS2 needs Docker (Jazzy + Python 3.12 container).");
27
+ }
28
+ if (!existsSync(DOCKERFILE)) {
29
+ throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
30
+ }
31
+ }
32
+
33
+ /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
34
+ export function ensureImage(image = ROS2_DEV_IMAGE) {
35
+ log.step(`docker build -t ${image} (first run is slow, then cached)…`);
36
+ const r = spawnSync(
37
+ "docker",
38
+ ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
39
+ { stdio: "inherit" },
40
+ );
41
+ if (r.status !== 0) throw new Error("docker build failed.");
42
+ }
43
+
44
+ /**
45
+ * Run `colcon build` inside the container, producing ros2_ws/install on the host
46
+ * (the workspace is mounted). symlink=true for dev (fast, edit-in-place);
47
+ * symlink=false for export builds that ship in the ZIP (real copies, relocatable).
48
+ */
49
+ export function colconBuild(root, { image = ROS2_DEV_IMAGE, symlink = false } = {}) {
50
+ const flag = symlink ? "--symlink-install" : "";
51
+ const script = [
52
+ // --ignore-installed: don't try to uninstall debian-managed packages
53
+ // (e.g. psutil), which fails with "RECORD file not found".
54
+ "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true",
55
+ "cd ros2_ws",
56
+ `colcon build ${flag}`.trim(),
57
+ ].join(" && ");
58
+ log.step(`colcon build${symlink ? " --symlink-install" : ""} (in container)…`);
59
+ const r = spawnSync(
60
+ "docker",
61
+ ["run", "--rm", "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
62
+ { cwd: root, stdio: "inherit" },
63
+ );
64
+ if (r.status !== 0) throw new Error("colcon build failed.");
65
+ }
@@ -0,0 +1,122 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { Server } from "socket.io";
4
+ import { log } from "./util.mjs";
5
+
6
+ const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
7
+
8
+ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
9
+ const httpServer = createServer();
10
+ const io = new Server(httpServer, {
11
+ cors: { origin: "*" },
12
+ });
13
+ const queryables = new Map();
14
+ const pendingReplies = new Map();
15
+
16
+ function cleanupSocket(socket) {
17
+ for (const [topic, sid] of queryables) {
18
+ if (sid === socket.id) queryables.delete(topic);
19
+ }
20
+
21
+ for (const [id, pending] of pendingReplies) {
22
+ if (pending.sid !== socket.id) continue;
23
+ clearTimeout(pending.timer);
24
+ pending.resolve(undefined);
25
+ pendingReplies.delete(id);
26
+ }
27
+ }
28
+
29
+ io.on("connection", (socket) => {
30
+ socket.on("sub", async (data = {}) => {
31
+ if (!data.topic) return;
32
+ await socket.join(`t:${data.topic}`);
33
+ });
34
+
35
+ socket.on("unsub", async (data = {}) => {
36
+ if (!data.topic) return;
37
+ await socket.leave(`t:${data.topic}`);
38
+ });
39
+
40
+ socket.on("pub", (data = {}) => {
41
+ const topic = data.topic;
42
+ if (!topic) return;
43
+ io.to(`t:${topic}`).emit("msg", {
44
+ topic,
45
+ payload: data.payload,
46
+ });
47
+ });
48
+
49
+ socket.on("serve_register", (data = {}) => {
50
+ if (!data.topic) return;
51
+ queryables.set(data.topic, socket.id);
52
+ });
53
+
54
+ socket.on("serve_unregister", (data = {}) => {
55
+ const topic = data.topic;
56
+ if (!topic) return;
57
+ if (queryables.get(topic) === socket.id) queryables.delete(topic);
58
+ });
59
+
60
+ socket.on("serve_reply", (data = {}) => {
61
+ const pending = pendingReplies.get(data.id);
62
+ if (!pending) return;
63
+ clearTimeout(pending.timer);
64
+ pending.resolve(data.payload);
65
+ pendingReplies.delete(data.id);
66
+ });
67
+
68
+ socket.on("action", (data = {}, ack) => {
69
+ const topic = data.topic;
70
+ const sid = topic ? queryables.get(topic) : undefined;
71
+ const target = sid ? io.sockets.sockets.get(sid) : undefined;
72
+
73
+ if (!target) {
74
+ if (typeof ack === "function") ack(undefined);
75
+ return;
76
+ }
77
+
78
+ const id = randomUUID();
79
+ const result = new Promise((resolve) => {
80
+ const timer = setTimeout(() => {
81
+ pendingReplies.delete(id);
82
+ resolve(undefined);
83
+ }, DEFAULT_ACTION_TIMEOUT_MS);
84
+
85
+ pendingReplies.set(id, {
86
+ sid,
87
+ timer,
88
+ resolve,
89
+ });
90
+ });
91
+
92
+ target.emit("serve_call", {
93
+ id,
94
+ topic,
95
+ payload: data.payload,
96
+ });
97
+
98
+ result.then((payload) => {
99
+ if (typeof ack === "function") ack(payload);
100
+ });
101
+ });
102
+
103
+ socket.on("disconnect", () => cleanupSocket(socket));
104
+ });
105
+
106
+ httpServer.listen(port, host, () => {
107
+ log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
108
+ });
109
+
110
+ const close = () => {
111
+ for (const pending of pendingReplies.values()) {
112
+ clearTimeout(pending.timer);
113
+ pending.resolve(undefined);
114
+ }
115
+ pendingReplies.clear();
116
+ queryables.clear();
117
+ io.close();
118
+ httpServer.close();
119
+ };
120
+
121
+ return { close };
122
+ }
@@ -0,0 +1,82 @@
1
+ // muscat-pk type change — switch an existing plugin's backend type.
2
+ // python ↔ ros2. python→ros2 adds the ros2_ws/ overlay; ros2→python flips
3
+ // the manifest type and leaves ros2_ws/ in place (never deletes user code).
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { basename, resolve } from "node:path";
7
+ import { findPluginRoot, log } from "./util.mjs";
8
+ import { applyTokens, deriveNames, overlayRos2 } from "./new.mjs";
9
+
10
+ const TYPES = ["python", "ros2"];
11
+
12
+ async function prompt(question, fallback) {
13
+ if (!process.stdin.isTTY) return fallback;
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ const answer = (await rl.question(question)).trim();
16
+ rl.close();
17
+ return answer || fallback;
18
+ }
19
+
20
+ function readManifest(root) {
21
+ const path = resolve(root, "manifest.json");
22
+ if (!existsSync(path)) throw new Error(`manifest.json not found in ${root}`);
23
+ return { path, json: JSON.parse(readFileSync(path, "utf8")) };
24
+ }
25
+
26
+ export async function runType(args) {
27
+ // accept `type change`, `type`, `type --to ros2`
28
+ const sub = args[0];
29
+ const toFlagIdx = args.indexOf("--to");
30
+ let to = toFlagIdx >= 0 ? args[toFlagIdx + 1] : undefined;
31
+
32
+ const root = findPluginRoot();
33
+ const { path, json } = readManifest(root);
34
+ const current = json.runtime?.type ?? "python";
35
+
36
+ if (sub && sub !== "change") {
37
+ log.warn(`unknown subcommand '${sub}'. Usage: muscat-pk type change [--to python|ros2]`);
38
+ }
39
+
40
+ if (!to) {
41
+ console.log(`\n현재 타입: ${current}`);
42
+ console.log("변경할 타입을 선택하세요:");
43
+ console.log(" 1) Python");
44
+ console.log(" 2) ROS2(rclpy)");
45
+ const choice = await prompt("\n선택 [1/2]: ", "");
46
+ to = choice === "1" ? "python" : choice === "2" ? "ros2" : undefined;
47
+ }
48
+
49
+ if (!TYPES.includes(to)) {
50
+ throw new Error(`invalid type. choose python or ros2.`);
51
+ }
52
+ if (to === current) {
53
+ log.info(`already '${current}' — nothing to change.`);
54
+ return;
55
+ }
56
+
57
+ if (to === "ros2") {
58
+ const { slug, pluginName, pluginClass, pluginId } = deriveNames(root);
59
+ if (existsSync(resolve(root, "ros2_ws"))) {
60
+ log.warn("ros2_ws/ already exists — keeping it, only flipping manifest type.");
61
+ } else {
62
+ log.step("adding ros2_ws/ overlay");
63
+ overlayRos2(root, slug);
64
+ applyTokens(root, {
65
+ __PLUGIN_ID__: pluginId,
66
+ __PLUGIN_NAME__: pluginName,
67
+ __PLUGIN_SLUG__: slug,
68
+ __PLUGIN_CLASS__: pluginClass,
69
+ __RUNTIME_TYPE__: "ros2",
70
+ });
71
+ }
72
+ } else {
73
+ // ros2 → python
74
+ if (existsSync(resolve(root, "ros2_ws"))) {
75
+ log.warn("ros2_ws/ left in place (not deleted). Remove it manually if unused.");
76
+ }
77
+ }
78
+
79
+ json.runtime = { ...json.runtime, type: to };
80
+ writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
81
+ log.ok(`${basename(root)} type: ${current} → ${to}`);
82
+ }