@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.41 → 0.1.2-dev.43

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.
@@ -11,7 +11,6 @@ import { runUpdate } from "../src/update.mjs";
11
11
  import { runUninstall } from "../src/uninstall.mjs";
12
12
  import { runSdk } from "../src/sdk.mjs";
13
13
  import { runType } from "../src/type.mjs";
14
- import { runPreview } from "../src/preview.mjs";
15
14
 
16
15
  const [, , cmd, ...args] = process.argv;
17
16
 
@@ -26,7 +25,6 @@ Commands:
26
25
  dev Run Socket.IO bridge, dev:ui, and dev:back together
27
26
  dev:ui Preview the plugin in a mini-Muscat (preview-host)
28
27
  dev:back Run the plugin back end (python: watchfiles reload / ros2: Docker)
29
- preview Preview against a real Muscat server (prompts for robot IP)
30
28
  update Update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
31
29
  uninstall Remove the installed muscat-pk CLI (npm global or S3 binary)
32
30
  sdk <list|update [version]> Manage the python rainbow-rb-sdk version
@@ -48,8 +46,6 @@ async function main() {
48
46
  return await runDevUi(args);
49
47
  case "dev:back":
50
48
  return await runDevBack(args);
51
- case "preview":
52
- return await runPreview(args);
53
49
  case "update":
54
50
  return await runUpdate(args);
55
51
  case "uninstall":
@@ -59,19 +59,16 @@ function ensureImage() {
59
59
  pruneMuskatImages();
60
60
  }
61
61
 
62
- /** Build the image, preflight the port, and start the bridge container. */
62
+ /**
63
+ * Build the image, preflight the port, and start the bridge container.
64
+ * Returns true if bridge started, false if port was already in use (caller decides).
65
+ */
63
66
  export async function startBridge() {
64
67
  assertDockerReady();
65
68
 
66
69
  // remove our own stale container first, then check the host port is free
67
70
  spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
68
- if (await isPortInUse(BRIDGE_PORT)) {
69
- throw new Error(
70
- `port ${BRIDGE_PORT} is already in use.\n` +
71
- ` Another 'muscat-pk dev' may be running, or a process is holding the port.\n` +
72
- ` Stop it, or free the port, then retry.`,
73
- );
74
- }
71
+ if (await isPortInUse(BRIDGE_PORT)) return false;
75
72
 
76
73
  ensureImage();
77
74
 
@@ -85,6 +82,7 @@ export async function startBridge() {
85
82
 
86
83
  await waitForPort(BRIDGE_PORT);
87
84
  log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
85
+ return true;
88
86
  }
89
87
 
90
88
  export function stopBridge() {
package/cli/src/build.mjs CHANGED
@@ -75,27 +75,33 @@ function isMainBranch() {
75
75
  // rclpy: arch-agnostic (pure Python) → dist/ros2_ws/install/ (built once)
76
76
  // rclcpp: arch-specific C++ binaries → dist/ros2_ws/install-{arch}/ per arch
77
77
  export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
78
- if (manifestRuntime(root) !== "ros2") return;
78
+ if (manifestRuntime(root) !== "ros2") return [];
79
79
  const { assertDockerReady, ensureImage, colconBuild, ROS2_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./ros2.mjs");
80
80
  try {
81
81
  assertDockerReady();
82
82
  } catch (e) {
83
83
  log.warn(`ros2 build skipped: ${e.message}`);
84
- return;
84
+ return [];
85
85
  }
86
86
 
87
87
  const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
88
88
  const rosClient = manifest.runtime?.rosClient;
89
89
 
90
+ const skipped = [];
90
91
  if (rosClient === "rclcpp") {
91
92
  // C++ ROS2: compile per arch so each robot picks the right install-{arch}/
92
93
  for (const arch of archs) {
93
94
  const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
94
- if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
95
+ if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); skipped.push(arch); continue; }
95
96
  const archImage = `${ROS2_DEV_IMAGE}-${arch}`;
96
- await ensureImage(archImage, { platform: dockerPlatform });
97
- colconBuild(root, { symlink: false, arch });
98
- log.ok(`dist/ros2_ws/install-${arch}/ ready`);
97
+ try {
98
+ await ensureImage(archImage, { platform: dockerPlatform });
99
+ colconBuild(root, { symlink: false, arch });
100
+ log.ok(`dist/ros2_ws/install-${arch}/ ready`);
101
+ } catch {
102
+ log.warn(`${arch} ros2 build skipped — QEMU not available`);
103
+ skipped.push(arch);
104
+ }
99
105
  }
100
106
  } else {
101
107
  // rclpy / pure Python: arch-agnostic, build once
@@ -103,6 +109,7 @@ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
103
109
  colconBuild(root, { symlink: false });
104
110
  log.ok("dist/ros2_ws/install/ ready");
105
111
  }
112
+ return skipped;
106
113
  }
107
114
 
108
115
  // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) per arch
@@ -110,13 +117,13 @@ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
110
117
  // robot-compatible executables. rb-runtime prefers dist/{arch}/ and falls back
111
118
  // to dist/{entry} (legacy). Skipped (with a warning) if Docker is unavailable.
112
119
  export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"]) {
113
- if (manifestRuntime(root) !== "cpp") return;
120
+ if (manifestRuntime(root) !== "cpp") return [];
114
121
  const { assertDockerReady, ensureImage, cmakeBuild, CPP_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./cpp.mjs");
115
122
  try {
116
123
  assertDockerReady();
117
124
  } catch (e) {
118
125
  log.warn(`cpp build skipped: ${e.message}`);
119
- return;
126
+ return [];
120
127
  }
121
128
 
122
129
  const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
@@ -125,26 +132,31 @@ export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"])
125
132
  return;
126
133
  }
127
134
 
135
+ const skipped = [];
128
136
  for (const arch of archs) {
129
- // python과 동일하게 arch별 이미지를 빌드해야 --platform 불일치를 피할 수 있다.
130
137
  const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
131
- if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
138
+ if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); skipped.push(arch); continue; }
132
139
  const archImage = `${CPP_DEV_IMAGE}-${arch}`;
133
- await ensureImage(archImage, { platform: dockerPlatform });
134
- cmakeBuild(root, { buildType: "Release", arch });
135
- const buildDir = `cpp/build-${arch}`;
136
- const built = resolve(root, buildDir, entry);
137
- if (existsSync(built)) {
138
- const archDir = resolve(distDir, arch);
139
- mkdirSync(archDir, { recursive: true });
140
- const dest = resolve(archDir, entry);
141
- copyFileSync(built, dest);
142
- chmodSync(dest, 0o755);
143
- log.ok(`cpp binary → dist/${arch}/${entry}`);
144
- } else {
145
- log.warn(`cpp binary not found at ${buildDir}/${entry} — skipped for ${arch}`);
140
+ try {
141
+ await ensureImage(archImage, { platform: dockerPlatform });
142
+ cmakeBuild(root, { buildType: "Release", arch });
143
+ const built = resolve(root, `cpp/build-${arch}`, entry);
144
+ if (existsSync(built)) {
145
+ const archDir = resolve(distDir, arch);
146
+ mkdirSync(archDir, { recursive: true });
147
+ const dest = resolve(archDir, entry);
148
+ copyFileSync(built, dest);
149
+ chmodSync(dest, 0o755);
150
+ log.ok(`cpp binary → dist/${arch}/${entry}`);
151
+ } else {
152
+ log.warn(`cpp binary not found at cpp/build-${arch}/${entry}`);
153
+ }
154
+ } catch {
155
+ log.warn(`${arch} cpp build skipped — QEMU not available`);
156
+ skipped.push(arch);
146
157
  }
147
158
  }
159
+ return skipped;
148
160
  }
149
161
 
150
162
  const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
@@ -434,6 +446,8 @@ export async function runBuild(args) {
434
446
 
435
447
  const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
436
448
 
449
+ const skippedArches = { wheels: [], ros2: [], cpp: [] };
450
+
437
451
  // python / rclpy: lock + wheel vendor in Ubuntu 24.04 container
438
452
  if (!noLock) {
439
453
  const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime ?? {};
@@ -443,15 +457,29 @@ export async function runBuild(args) {
443
457
  if (needsLock) {
444
458
  const { lockAndVendor } = await import("./python.mjs");
445
459
  try {
446
- await lockAndVendor(pluginRoot, archs);
460
+ skippedArches.wheels = await lockAndVendor(pluginRoot, archs);
447
461
  } catch (e) {
448
462
  log.warn(`python lock/vendor skipped: ${e.message}`);
463
+ skippedArches.wheels = archs;
449
464
  }
450
465
  }
451
466
  }
452
467
 
453
- await exportRos2Build(root, archs);
454
- await exportCppBuild(root, distDir, archs);
468
+ skippedArches.ros2 = await exportRos2Build(root, archs) ?? [];
469
+ skippedArches.cpp = await exportCppBuild(root, distDir, archs) ?? [];
455
470
  calcInstalledSize(pluginRoot);
456
471
  if (!noZip) packZip(pluginRoot);
472
+
473
+ // Build summary
474
+ const allSkipped = [...new Set([...skippedArches.wheels, ...skippedArches.ros2, ...skippedArches.cpp])];
475
+ if (allSkipped.length > 0) {
476
+ const line = "─".repeat(58);
477
+ console.log(`\n${line}`);
478
+ log.warn(`Cross-arch builds skipped (QEMU not available): ${allSkipped.join(", ")}`);
479
+ if (skippedArches.wheels.length) log.warn(` wheels: ${skippedArches.wheels.join(", ")}`);
480
+ if (skippedArches.ros2.length) log.warn(` ros2: ${skippedArches.ros2.join(", ")}`);
481
+ if (skippedArches.cpp.length) log.warn(` cpp: ${skippedArches.cpp.join(", ")}`);
482
+ log.warn(` Enable: docker run --privileged --rm tonistiigi/binfmt --install all`);
483
+ console.log(line);
484
+ }
457
485
  }
package/cli/src/dev.mjs CHANGED
@@ -1,19 +1,29 @@
1
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 { startBridge, stopBridge, BRIDGE_CONTAINER } from "./bridge.mjs";
5
- import { ensureDocker, log } from "./util.mjs";
4
+ import { startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
5
+ import { ensureDocker, log, prompt, promptRobotConnection } from "./util.mjs";
6
6
 
7
7
  export async function runDev(args = []) {
8
8
  ensureDocker();
9
+ const { robotIp, robotToken } = await promptRobotConnection(args);
10
+ if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
11
+ if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
9
12
  log.step("starting Socket.IO bridge (Docker) + dev:ui + dev:back");
10
- await startBridge();
13
+ const bridgeStarted = await startBridge();
11
14
 
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
+ if (!bridgeStarted) {
16
+ log.warn(`port ${BRIDGE_PORT} is already in use — can't start Socket.IO bridge.`);
17
+ const answer = await prompt(" Proceed without Socket.IO bridge? [y/N]: ");
18
+ if (answer.toLowerCase() !== "y") return;
19
+ log.info("proceeding without bridge — backend will skip network sharing.");
20
+ } else {
21
+ // Tell dev:back which bridge container to share a netns with, so the backend
22
+ // reaches the bridge at localhost:10000 (no host.docker.internal).
23
+ process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
24
+ }
15
25
 
16
- const cleanup = () => stopBridge();
26
+ const cleanup = () => { if (bridgeStarted) stopBridge(); };
17
27
  process.once("SIGINT", cleanup);
18
28
  process.once("SIGTERM", cleanup);
19
29
  process.once("exit", cleanup);
@@ -10,7 +10,7 @@
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { existsSync, watch } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import { ensureDocker, findPluginRoot, log } from "./util.mjs";
13
+ import { ensureDocker, findPluginRoot, log, promptRobotConnection } from "./util.mjs";
14
14
  import { manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
15
15
  import {
16
16
  PYTHON_DEV_IMAGE,
@@ -40,7 +40,13 @@ async function runPython(root) {
40
40
  const manifest = readManifest(root);
41
41
  const pluginId = manifest.id ?? "";
42
42
  const container = `${PYTHON_DEV_IMAGE}-run`;
43
- const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
43
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
44
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
45
+ const envFlags = [
46
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
47
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
48
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
49
+ ];
44
50
  // Share the bridge container's network namespace so localhost:10000 is the
45
51
  // bridge (set by `muscat-pk dev`). Standalone dev:back has no bridge to join.
46
52
  const bridgeContainer = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
@@ -105,7 +111,13 @@ async function runRos2(root) {
105
111
  const pluginId = manifest.id ?? "";
106
112
  const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
107
113
  const container = `${ROS2_DEV_IMAGE}-run`;
108
- const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
114
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
115
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
116
+ const envFlags = [
117
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
118
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
119
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
120
+ ];
109
121
  const docker = (args, opts = {}) =>
110
122
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
111
123
 
@@ -177,7 +189,13 @@ async function runCpp(root) {
177
189
 
178
190
  const pluginId = manifest.id ?? "";
179
191
  const container = `${CPP_DEV_IMAGE}-run`;
180
- const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
192
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
193
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
194
+ const envFlags = [
195
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
196
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
197
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
198
+ ];
181
199
  const docker = (args, opts = {}) =>
182
200
  spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
183
201
 
@@ -240,10 +258,13 @@ function warnManifest(root) {
240
258
  for (const e of errors) log.warn(`manifest.json: ${e}`);
241
259
  }
242
260
 
243
- export async function runDevBack() {
261
+ export async function runDevBack(args = []) {
244
262
  ensureDocker();
245
263
  const root = findPluginRoot();
246
264
  warnManifest(root);
265
+ const { robotIp, robotToken } = await promptRobotConnection(args);
266
+ if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
267
+ if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
247
268
  const type = manifestRuntime(root);
248
269
  if (type === "ros2") return await runRos2(root);
249
270
  if (type === "cpp") return await runCpp(root);
package/cli/src/devUi.mjs CHANGED
@@ -7,7 +7,7 @@ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { dirname, resolve } from "node:path";
9
9
  import { SANDBOX_HTML } from "./build.mjs";
10
- import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log } from "./util.mjs";
10
+ import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log, parseArgs, prompt } from "./util.mjs";
11
11
 
12
12
  const PREVIEW_PKG = "@rainbow-robotics/preview-host";
13
13
  const CACHE_DIR = resolve(homedir(), ".muscat-pk");
@@ -79,7 +79,7 @@ function findViteBin(pluginRoot) {
79
79
 
80
80
  // Spawn vite build --watch as a background process.
81
81
  // Returns the child process, or null if vite/node not available.
82
- function spawnViteWatch(pluginRoot, node) {
82
+ function spawnViteWatch(pluginRoot, node, { robotIp = "" } = {}) {
83
83
  const viteBin = findViteBin(pluginRoot);
84
84
  if (!node || !viteBin || !existsSync(viteBin)) return null;
85
85
 
@@ -87,6 +87,7 @@ function spawnViteWatch(pluginRoot, node) {
87
87
  return spawn(node, [viteBin, "build", "--watch", "--mode", "development", "--outDir", DEV_OUT], {
88
88
  cwd: pluginRoot,
89
89
  stdio: "inherit",
90
+ env: { ...process.env, ...(robotIp ? { VITE_RRS_PY_HOST: robotIp } : {}) },
90
91
  });
91
92
  }
92
93
 
@@ -105,15 +106,18 @@ function waitForInitialBuild(distDir, timeoutMs = 30_000) {
105
106
  }
106
107
 
107
108
  export async function runDevUi(args = []) {
108
- const portIdx = args.indexOf("--port");
109
- const port = portIdx !== -1 ? args[portIdx + 1] : "4180";
109
+ const parsed = parseArgs(args);
110
+ const port = parsed.port ?? "4180";
111
+ const robotIp = parsed["robot-ip"] ?? process.env.MUSCAT_ROBOT_HOST ?? await prompt(" Robot IP (skip if no robot): ");
110
112
 
111
113
  await checkKitUpdate();
112
114
  const pluginRoot = findPluginRoot();
113
115
  const outDir = stageDevAssets(pluginRoot);
114
116
 
117
+ if (robotIp) log.info(`MUSCAT_API_ORIGIN → http://${robotIp}:*`);
118
+
115
119
  const node = findNodeBin();
116
- const viteProc = spawnViteWatch(pluginRoot, node);
120
+ const viteProc = spawnViteWatch(pluginRoot, node, { robotIp });
117
121
 
118
122
  if (viteProc) {
119
123
  log.step("waiting for initial vite build…");
@@ -27,22 +27,30 @@ export async function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null }
27
27
  pruneMuskatImages();
28
28
  }
29
29
 
30
- // Docker --platform 플래그: 컨테이너를 target arch로 띄우면
31
- // pip이 glibc + arch를 자동 감지해 올바른 휠을 받는다.
32
- const ARCH_DOCKER_PLATFORM = {
33
- amd64: "linux/amd64",
34
- arm64: "linux/arm64",
30
+ // pip --platform does NOT auto-expand manylinux tags (unlike sys_tags()) — exact match only.
31
+ // Specifying all common glibc milestones ensures packages built for any Ubuntu era are found.
32
+ const PIP_PLATFORM = {
33
+ amd64: [
34
+ "manylinux_2_5_x86_64",
35
+ "manylinux_2_17_x86_64",
36
+ "manylinux_2_27_x86_64",
37
+ "manylinux_2_28_x86_64",
38
+ "manylinux_2_34_x86_64",
39
+ "manylinux_2_39_x86_64",
40
+ "linux_x86_64",
41
+ ],
42
+ arm64: [
43
+ "manylinux_2_17_aarch64",
44
+ "manylinux_2_27_aarch64",
45
+ "manylinux_2_28_aarch64",
46
+ "manylinux_2_34_aarch64",
47
+ "manylinux_2_39_aarch64",
48
+ "linux_aarch64",
49
+ ],
35
50
  };
36
51
 
37
- /**
38
- * Ubuntu 24.04 + py3.12 컨테이너 안에서:
39
- * 1. uv pip compile requirements.txt → dist/requirements.lock (arch 무관, 1회)
40
- * 2. archs별 pip download --platform <manylinux_tag> → dist/wheels/ (네이티브 휠 벤더링)
41
- *
42
- * none-any 휠은 pip이 두 번째 arch에서 자동 skip → 중복 없음.
43
- * requirements.txt 없으면 조용히 건너뜀.
44
- */
45
- export async function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
52
+ export async function lockAndVendor(pluginRoot, archs = ["amd64", "arm64"]) {
53
+ // ponytail: pip --platform used intentionally (not QEMU) — downloads wheels without running target arch code.
46
54
  const reqTxt = resolve(pluginRoot, "requirements.txt");
47
55
  if (!existsSync(reqTxt)) {
48
56
  log.warn("requirements.txt not found — lock/vendor skipped");
@@ -66,45 +74,28 @@ export async function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
66
74
  if (lockResult.status !== 0) throw new Error("lock generation failed (uv pip compile)");
67
75
  log.ok("dist/requirements.lock ready");
68
76
 
69
- // arch별 휠 벤더링 — QEMU 불필요 (download only, --platform 고정)
70
- // 한 arch가 실패해도 나머지 arch는 계속 시도한다.
71
77
  const failed = [];
72
78
  for (const arch of archs) {
73
- const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
74
- if (!dockerPlatform) {
75
- log.warn(`unknown arch '${arch}' — skipped`);
76
- continue;
77
- }
78
- // arch별 이미지 빌드 (--platform 지정) → pip이 glibc+arch 자동 감지 → 올바른 휠 선택.
79
- const archImage = `${PYTHON_DEV_IMAGE}-${arch}`;
80
- await ensureImage(archImage, { platform: dockerPlatform });
81
-
82
- log.step(`vendoring wheels for ${arch} (${dockerPlatform})…`);
83
- // 패키지별 개별 download로 private/미공개 패키지 실패 시에도 나머지 계속 진행.
84
- const script = [
85
- `set -e`,
86
- `cd /plugin`,
87
- `mkdir -p dist/wheels`,
88
- `pkgs=$(grep -v '^#' dist/requirements.lock | grep -v '^[[:space:]]' | grep -v '^$')`,
89
- `SKIP=""`,
90
- `for pkg in $pkgs; do`,
91
- ` pip3 download "$pkg" --no-deps --only-binary=:all: -d dist/wheels -q 2>&1 || SKIP="$SKIP $pkg"`,
92
- `done`,
93
- `[ -z "$SKIP" ] || echo "WARN: no wheel found (package not on PyPI or missing binary wheel):$SKIP"`,
94
- ].join("\n");
79
+ const pipPlatforms = PIP_PLATFORM[arch];
80
+ if (!pipPlatforms) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
81
+ log.step(`vendoring wheels for ${arch}…`);
95
82
  const r = spawnSync(
96
83
  "docker",
97
84
  [
98
85
  "run", "--rm",
99
- "--platform", dockerPlatform,
86
+ "-e", "PIP_ROOT_USER_ACTION=ignore",
100
87
  "-v", `${pluginRoot}:/plugin`,
101
- archImage,
102
- "bash", "-c", script,
88
+ "-w", "/plugin",
89
+ PYTHON_DEV_IMAGE,
90
+ "bash", "-c",
91
+ "mkdir -p dist/wheels && pip3 download -r dist/requirements.lock --no-deps --only-binary=:all: -d dist/wheels -q " +
92
+ "--python-version 312 --implementation cp --abi cp312 " +
93
+ pipPlatforms.map((p) => `--platform ${p}`).join(" "),
103
94
  ],
104
95
  { stdio: "inherit" },
105
96
  );
106
97
  if (r.status !== 0) {
107
- log.warn(`wheel vendoring failed for ${arch} — some packages may not have manylinux wheels`);
98
+ log.warn(`wheel vendoring failed for ${arch}`);
108
99
  failed.push(arch);
109
100
  } else {
110
101
  log.ok(`dist/wheels/ ready (${arch})`);
@@ -114,9 +105,5 @@ export async function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
114
105
  if (failed.length === archs.length) {
115
106
  throw new Error(`wheel vendoring failed for all archs: ${failed.join(", ")}`);
116
107
  }
117
- if (failed.length > 0) {
118
- log.warn(`wheel vendoring partial — failed: ${failed.join(", ")}, ok: ${archs.filter((a) => !failed.includes(a)).join(", ")}`);
119
- } else {
120
- log.ok(`dist/wheels/ ready (${archs.join(", ")})`);
121
- }
108
+ return failed;
122
109
  }
package/cli/src/util.mjs CHANGED
@@ -76,6 +76,31 @@ export async function prompt(question, fallback = "") {
76
76
  return answer || fallback;
77
77
  }
78
78
 
79
+ const LOCAL_HOSTS = new Set(["", "localhost", "127.0.0.1", "::1"]);
80
+
81
+ /**
82
+ * Ask for robot IP (and token if remote). Returns { robotIp, robotToken }.
83
+ * Skips prompts if already set in process.env (from a parent command like `dev`).
84
+ * Supports --robot-ip / --robot-token flags for non-interactive use.
85
+ */
86
+ export async function promptRobotConnection(args = []) {
87
+ const parsed = parseArgs(args);
88
+ const robotIp =
89
+ parsed["robot-ip"] ??
90
+ process.env.MUSCAT_ROBOT_HOST ??
91
+ (await prompt(" Robot IP (skip if no robot): "));
92
+
93
+ if (LOCAL_HOSTS.has(robotIp)) return { robotIp, robotToken: "" };
94
+
95
+ const robotToken =
96
+ parsed["robot-token"] ??
97
+ process.env.MUSCAT_ROBOT_TOKEN ??
98
+ (await prompt(" SDK Token (required for remote): "));
99
+
100
+ if (!robotToken) throw new Error("SDK token is required for remote robot connection.");
101
+ return { robotIp, robotToken };
102
+ }
103
+
79
104
  /** Walk up from cwd to find the plugin project root (has manifest.json). */
80
105
  export function findPluginRoot(start = process.cwd()) {
81
106
  let dir = resolve(start);
@@ -99,6 +99,26 @@ connect(App);
99
99
 
100
100
  ---
101
101
 
102
+ ## Developing directly on the robot PC
103
+
104
+ If you're running `muscat-pk dev` **on the robot PC itself**, Muscat is likely already
105
+ running its own Socket.IO bridge on port 10000. In that case the CLI detects the port
106
+ conflict and asks:
107
+
108
+ ```
109
+ ⚠ port 10000 is already in use — can't start Socket.IO bridge.
110
+ Proceed without Socket.IO bridge? [y/N]:
111
+ ```
112
+
113
+ Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
114
+ running instead of starting a new one — exactly what you want when developing on the
115
+ robot.
116
+
117
+ > If you answer **n** (or just press Enter), the CLI exits so you can free the port
118
+ > manually and retry.
119
+
120
+ ---
121
+
102
122
  ## Backend (`py/main.py`)
103
123
 
104
124
  The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
@@ -1,15 +1,19 @@
1
1
  """
2
2
  Muscat plugin backend — __PLUGIN_NAME__
3
3
 
4
- Run with:
4
+ On-robot (default):
5
5
  muscat-pk dev:back
6
6
  python main.py
7
7
 
8
- This template uses the high-level Rainbow Python SDK instead of wiring the
9
- transport layer directly.
8
+ Remote dev (from your laptop):
9
+ Set env vars before running:
10
+ MUSCAT_ROBOT_HOST=192.168.1.100
11
+ MUSCAT_ROBOT_TOKEN=<service token>
12
+ Then: muscat-pk dev:back
10
13
  """
11
14
 
12
- import time
15
+ import asyncio
16
+ import os
13
17
  from typing import Any
14
18
 
15
19
  from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
@@ -18,40 +22,30 @@ from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
18
22
  PLUGIN_NAME = "__PLUGIN_NAME__"
19
23
 
20
24
  plugin = RBPluginCall()
25
+ manipulate = RBManipulateSDK()
21
26
 
22
27
 
23
- @plugin.subscribe("event")
24
- def on_ui_event(payload: dict[str, Any] | None = None):
25
- """Handle messages published from the plugin UI with rb.pub("event", ...)."""
26
- print(f"[{PLUGIN_NAME}] event:", payload)
28
+ def register_handlers():
29
+ # Register handlers here — called after connect() so subscriptions survive
30
+ # session reconnection in remote-dev mode.
27
31
 
32
+ @plugin.subscribe("event")
33
+ def on_ui_event(payload: dict[str, Any] | None = None):
34
+ """Handle messages published from the plugin UI with rb.pub("event", ...)."""
35
+ print(f"[{PLUGIN_NAME}] event:", payload)
28
36
 
29
- @plugin.serve("grip")
30
- def grip(payload: dict[str, Any] | None = None):
31
- """Handle the default UI button: rb.action("grip", { width })."""
32
- width = (payload or {}).get("width", 20)
33
- print(f"[{PLUGIN_NAME}] grip requested: width={width}")
37
+ @plugin.serve("grip")
38
+ def grip(payload: dict[str, Any] | None = None):
39
+ """Handle the default UI button: rb.action("grip", { width })."""
40
+ width = (payload or {}).get("width", 20)
41
+ print(f"[{PLUGIN_NAME}] grip requested: width={width}")
42
+ plugin.publish("state", {"ready": True, "last_grip_width": width})
43
+ return {"ok": True, "width": width}
34
44
 
35
- plugin.publish("state", {"ready": True, "last_grip_width": width})
36
- return {"ok": True, "width": width}
37
-
38
-
39
- @plugin.serve("tool/echo")
40
- def echo(payload: dict[str, Any] | None = None):
41
- """Small query example callable from the UI with rb.action("tool/echo", ...)."""
42
- return {"echo": payload, "plugin": PLUGIN_NAME}
43
-
44
-
45
- def ping_ui_example():
46
- """
47
- Example: call a queryable served by the UI.
48
-
49
- In the frontend, register:
50
- rb.serve("tool/ping", async (payload) => ({ pong: payload }))
51
- """
52
- res = plugin.call("tool/ping", payload={"hello": "plugin"})
53
- print(f"[{PLUGIN_NAME}] ping response:", res)
54
- return res
45
+ @plugin.serve("tool/echo")
46
+ def echo(payload: dict[str, Any] | None = None):
47
+ """Small query example callable from the UI with rb.action("tool/echo", ...)."""
48
+ return {"echo": payload, "plugin": PLUGIN_NAME}
55
49
 
56
50
 
57
51
  def move_j_example():
@@ -61,8 +55,6 @@ def move_j_example():
61
55
  This function is not called automatically. Add safety checks and call it
62
56
  from your own handler when the robot is ready.
63
57
  """
64
- manipulate = RBManipulateSDK()
65
-
66
58
  return manipulate.move.call_move_j(
67
59
  robot_model="C500920",
68
60
  target={
@@ -73,16 +65,34 @@ def move_j_example():
73
65
  )
74
66
 
75
67
 
76
- def main():
68
+ async def main():
69
+ robot_host = os.getenv("MUSCAT_ROBOT_HOST")
70
+ robot_token = os.getenv("MUSCAT_ROBOT_TOKEN")
71
+
72
+ if robot_host and robot_token:
73
+ # Remote dev: connect to the robot before registering any handlers.
74
+ # connect() closes and reopens the Zenoh session, so handlers registered
75
+ # before this call would be lost.
76
+ await manipulate.connect(token=robot_token, host=robot_host)
77
+
78
+ register_handlers()
79
+
77
80
  plugin.publish("state", {"ready": True, "plugin": PLUGIN_NAME})
78
81
  print(f"[{PLUGIN_NAME}] backend ready")
79
82
 
80
83
  try:
81
84
  while True:
82
- time.sleep(1)
83
- except KeyboardInterrupt:
85
+ await asyncio.sleep(1)
86
+ except (KeyboardInterrupt, asyncio.CancelledError):
87
+ pass
88
+ finally:
89
+ if robot_host and robot_token:
90
+ await manipulate.disconnect()
84
91
  print(f"[{PLUGIN_NAME}] stopped")
85
92
 
86
93
 
87
94
  if __name__ == "__main__":
88
- main()
95
+ try:
96
+ asyncio.run(main())
97
+ except KeyboardInterrupt:
98
+ pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.41",
3
+ "version": "0.1.2-dev.43",
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.41",
34
+ "@rainbow-robotics/preview-host": "0.1.2-dev.43",
35
35
  "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {
@@ -1,91 +0,0 @@
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
- }