@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.
package/README.md CHANGED
@@ -1,27 +1,106 @@
1
1
  # @rainbow-robotics/muscat-plugin-kit
2
2
 
3
- Muscat plugin developer kit: `muscat-pk`, `create-muscat-plugin`, build/pack,
4
- and preview-host.
3
+ Muscat 플러그인 개발 CLI. 스캐폴딩, 빌드/팩, 개발 서버(UI + 백엔드)를 제공합니다.
5
4
 
6
- This package does **not** own the UI contract. Plugin source imports
7
- `@rainbow-robotics/plugin-ui/*`, and `muscat-pk build` leaves that package external so the
8
- robot/preview-host can provide its own runtime.
5
+ ## 설치
6
+
7
+ ### 방법 1 — npm (Node.js 20+ 필요)
8
+
9
+ ```bash
10
+ # 최신 stable
11
+ npm install -g @rainbow-robotics/muscat-plugin-kit
12
+
13
+ # dev 채널 (최신 개발 빌드)
14
+ npm install -g @rainbow-robotics/muscat-plugin-kit@dev
15
+
16
+ # 특정 버전
17
+ npm install -g @rainbow-robotics/muscat-plugin-kit@1.2.3
18
+ ```
19
+
20
+ ### 방법 2 — 설치 스크립트 (Node.js 없어도 됨)
21
+
22
+ macOS / Linux / WSL:
23
+
24
+ ```bash
25
+ # 최신 stable
26
+ curl -fsSL https://raw.githubusercontent.com/rainbow-robotics/web-rainbow-ui/main/scripts/install-muscat-pk.sh | sh
27
+
28
+ # dev 채널
29
+ MUSCAT_PK_TAG=dev curl -fsSL https://raw.githubusercontent.com/rainbow-robotics/web-rainbow-ui/main/scripts/install-muscat-pk.sh | sh
30
+
31
+ # 특정 버전
32
+ MUSCAT_PK_TAG=1.2.3 curl -fsSL https://raw.githubusercontent.com/rainbow-robotics/web-rainbow-ui/main/scripts/install-muscat-pk.sh | sh
33
+ ```
34
+
35
+ Windows PowerShell:
36
+
37
+ ```powershell
38
+ # 최신 stable
39
+ irm https://raw.githubusercontent.com/rainbow-robotics/web-rainbow-ui/main/scripts/install-muscat-pk.ps1 | iex
40
+
41
+ # dev 채널
42
+ $env:MUSCAT_PK_TAG="dev"; irm https://raw.githubusercontent.com/rainbow-robotics/web-rainbow-ui/main/scripts/install-muscat-pk.ps1 | iex
43
+ ```
44
+
45
+ > 설치 스크립트는 Node.js 20+가 있으면 npm을 우선 사용하고, 없으면 S3에서 self-contained 바이너리를 받습니다.
46
+
47
+ ### 방법 3 — 바이너리 직접 다운로드
48
+
49
+ ```
50
+ https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/v{버전}/{바이너리}
51
+ ```
52
+
53
+ | OS | 파일 |
54
+ |---|---|
55
+ | Linux x64 | `muscat-pk-linux-x64` |
56
+ | macOS ARM64 | `muscat-pk-darwin-arm64` |
57
+ | Windows x64 | `muscat-pk-win-x64.exe` |
58
+
59
+ 채널 포인터:
60
+
61
+ ```
62
+ # 최신 stable 버전 문자열
63
+ https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/latest-version.txt
64
+
65
+ # 최신 dev 버전 문자열
66
+ https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit/dev-version.txt
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 빠른 시작
9
72
 
10
73
  ```bash
11
- npm i -g @rainbow-robotics/muscat-plugin-kit
12
- muscat-pk new my-plugin --type sdk
74
+ muscat-pk new my-plugin # 플러그인 프로젝트 생성 (python 또는 ros2 선택)
13
75
  cd my-plugin
14
- muscat-pk build
15
- muscat-pk dev:ui
16
- muscat-pk pack
76
+
77
+ muscat-pk dev # UI + 백엔드 동시 실행
78
+ muscat-pk dev:ui # UI 미리보기만
79
+ muscat-pk dev:back # 백엔드만 (Python watchfiles / ROS2 Docker)
80
+
81
+ muscat-pk build # dist/ 빌드
82
+ muscat-pk pack # 배포용 ZIP 생성
17
83
  ```
18
84
 
19
- Published package responsibilities:
85
+ ## 명령어 목록
86
+
87
+ | 명령 | 설명 |
88
+ |---|---|
89
+ | `muscat-pk new [dir]` | 플러그인 프로젝트 스캐폴딩 |
90
+ | `muscat-pk type change` | 백엔드 타입 전환 (python ↔ ros2) |
91
+ | `muscat-pk build` | `src/content.tsx` → `dist/content.js` 빌드 |
92
+ | `muscat-pk pack` | 빌드 후 배포용 ZIP 생성 |
93
+ | `muscat-pk dev` | UI + 백엔드 동시 개발 서버 |
94
+ | `muscat-pk dev:ui` | 미니 Muscat 미리보기 서버 |
95
+ | `muscat-pk dev:back` | 백엔드 개발 서버 (hot-reload) |
96
+ | `muscat-pk update` | kit + plugin-ui 최신 버전으로 업데이트 |
97
+ | `muscat-pk sdk <list\|update>` | Python rainbow-rb-sdk 버전 관리 |
98
+
99
+ ## 패키지 역할
20
100
 
21
- | Package | Role |
101
+ | 패키지 | 역할 |
22
102
  |---|---|
23
- | `@rainbow-robotics/plugin-ui` | Contract/runtime: rb-* elements, author React bindings, sandbox connect, host renderer |
24
- | `@rainbow-robotics/muscat-plugin-kit` | CLI, scaffold templates, build/pack, preview-host |
103
+ | `@rainbow-robotics/plugin-ui` | UI 런타임 계약: rb-* 엘리먼트, React 바인딩, sandbox connect |
104
+ | `@rainbow-robotics/muscat-plugin-kit` | CLI, 템플릿, 빌드/팩, 미리보기 호스트 |
25
105
 
26
- The plugin ZIP contains plugin code and assets only. It relies on the host's
27
- import map for `@rainbow-robotics/plugin-ui`, `react`, and `react-dom`.
106
+ 플러그인 ZIP에는 플러그인 코드와 에셋만 포함됩니다. `@rainbow-robotics/plugin-ui`, `react`, `react-dom`은 호스트(Muscat/로봇)의 import map에서 제공됩니다.
@@ -9,6 +9,8 @@ import { runDevBack } from "../src/devBack.mjs";
9
9
  import { runDev } from "../src/dev.mjs";
10
10
  import { runUpdate } from "../src/update.mjs";
11
11
  import { runSdk } from "../src/sdk.mjs";
12
+ import { runType } from "../src/type.mjs";
13
+ import { runPreview } from "../src/preview.mjs";
12
14
 
13
15
  const [, , cmd, ...args] = process.argv;
14
16
 
@@ -18,11 +20,13 @@ Usage: muscat-pk <command> [options]
18
20
 
19
21
  Commands:
20
22
  new [dir] Scaffold a new plugin project
23
+ type change Switch backend type (python <-> ros2)
21
24
  build Build src/content.tsx -> dist/content.js (+ manifest, assets)
22
25
  pack Build then create a distributable ZIP (with size report)
23
- dev Run dev:ui and dev:back together
26
+ dev Run Socket.IO bridge, dev:ui, and dev:back together
24
27
  dev:ui Preview the plugin in a mini-Muscat (preview-host)
25
- dev:back Run the plugin back end (python main.py)
28
+ dev:back Run the plugin back end (python: watchfiles reload / ros2: Docker)
29
+ preview Preview against a real Muscat server (prompts for robot IP)
26
30
  update Update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
27
31
  sdk <list|update [version]> Manage the python rainbow-rb-sdk version
28
32
 
@@ -33,6 +37,8 @@ async function main() {
33
37
  switch (cmd) {
34
38
  case "new":
35
39
  return await runNew(args);
40
+ case "type":
41
+ return await runType(args);
36
42
  case "build":
37
43
  return await runBuild(args);
38
44
  case "pack":
@@ -43,6 +49,8 @@ async function main() {
43
49
  return await runDevUi(args);
44
50
  case "dev:back":
45
51
  return await runDevBack(args);
52
+ case "preview":
53
+ return await runPreview(args);
46
54
  case "update":
47
55
  return await runUpdate(args);
48
56
  case "sdk":
@@ -0,0 +1,13 @@
1
+ # Dev-only container for the ROS2 plugin back end.
2
+ # Matches the Muscat robot backend: ROS2 Jazzy + Python 3.12 (Ubuntu 24.04).
3
+ # Used by `muscat-pk dev:back` — NOT shipped in the plugin ZIP.
4
+ FROM ros:jazzy-ros-base
5
+
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ python3-pip \
8
+ python3-colcon-common-extensions \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ WORKDIR /plugin
12
+
13
+ CMD ["bash"]
package/cli/src/build.mjs CHANGED
@@ -8,11 +8,107 @@
8
8
  // The plugin author's `@rainbow-robotics/plugin-ui` and React imports are left external.
9
9
  // Muscat/robot and preview-host provide those modules through a sandbox import
10
10
  // map, so plugin ZIPs do not carry the UI runtime.
11
- import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { cpSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
+ import { spawnSync } from "node:child_process";
13
14
  import { buildWithFallback } from "./esbuild.mjs";
14
15
  import { findPluginRoot, log } from "./util.mjs";
15
16
 
17
+ function isMainBranch() {
18
+ const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
19
+ const branch = r.stdout?.trim();
20
+ return branch === "main" || branch === "master";
21
+ }
22
+
23
+ function manifestRuntime(root) {
24
+ try {
25
+ return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.type ?? "python";
26
+ } catch {
27
+ return "python";
28
+ }
29
+ }
30
+
31
+ // For ros2 plugins, compile the colcon workspace inside the Jazzy + Python 3.12
32
+ // container so the shipped ZIP carries ros2_ws/install. Real (non-symlink) build
33
+ // → relocatable on the robot. Skipped (with a warning) if Docker is unavailable.
34
+ export async function exportRos2Build(root) {
35
+ if (manifestRuntime(root) !== "ros2") return;
36
+ const { assertDockerReady, ensureImage, colconBuild } = await import("./ros2.mjs");
37
+ try {
38
+ assertDockerReady();
39
+ } catch (e) {
40
+ log.warn(`ros2 build skipped: ${e.message}`);
41
+ return;
42
+ }
43
+ ensureImage();
44
+ colconBuild(root, { symlink: false });
45
+ log.ok("ros2_ws/install ready (will be included in the ZIP)");
46
+ }
47
+
48
+ const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
49
+
50
+ function isPrerelease(version) {
51
+ return PRERELEASE_RE.test(version);
52
+ }
53
+
54
+ // Resolve the latest version of a PyPI package.
55
+ // stableOnly=true → skips pre-release (dev/a/b/rc) versions.
56
+ async function latestPypiVersion(pkg, { stableOnly = false } = {}) {
57
+ const res = await fetch(`https://pypi.org/pypi/${pkg}/json`, {
58
+ signal: AbortSignal.timeout(5000),
59
+ });
60
+ if (!res.ok) throw new Error(`pypi ${pkg}: ${res.status}`);
61
+ const data = await res.json();
62
+
63
+ if (!stableOnly) return data.info.version;
64
+
65
+ // data.info.version is latest stable when stable releases exist.
66
+ // If it's still a pre-release, scan all releases for the newest stable.
67
+ if (!isPrerelease(data.info.version)) return data.info.version;
68
+
69
+ const stable = Object.keys(data.releases)
70
+ .filter((v) => !isPrerelease(v))
71
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
72
+ .at(-1);
73
+
74
+ if (stable) return stable;
75
+
76
+ // No stable release exists — fall back with a warning.
77
+ log.warn(` ${pkg}: no stable release found, using ${data.info.version}`);
78
+ return data.info.version;
79
+ }
80
+
81
+ // Pin unpinned (no version specifier) entries in requirements.txt to the
82
+ // latest PyPI release. Runs on explicit `build`/`pack` only — keeps the shipped
83
+ // plugin reproducible without churning the source file on every dev rebuild.
84
+ // stableOnly=true is used on main-branch builds to avoid shipping dev versions.
85
+ export async function pinRequirements(root, { stableOnly = false } = {}) {
86
+ const reqPath = resolve(root, "requirements.txt");
87
+ if (!existsSync(reqPath)) return;
88
+
89
+ const lines = readFileSync(reqPath, "utf8").split("\n");
90
+ const out = [];
91
+ let pinned = 0;
92
+ for (const line of lines) {
93
+ const name = line.trim();
94
+ // skip blanks, comments, and already-specified versions
95
+ if (!name || name.startsWith("#") || /[=<>~!]/.test(name)) {
96
+ out.push(line);
97
+ continue;
98
+ }
99
+ try {
100
+ const v = await latestPypiVersion(name, { stableOnly });
101
+ out.push(`${name}==${v}`);
102
+ pinned++;
103
+ log.info(` pinned ${name}==${v}`);
104
+ } catch {
105
+ out.push(line);
106
+ log.warn(` could not resolve ${name} (offline?) — left unpinned`);
107
+ }
108
+ }
109
+ if (pinned > 0) writeFileSync(reqPath, out.join("\n"));
110
+ }
111
+
16
112
  const SANDBOX_HTML = `<!doctype html>
17
113
  <html lang="en">
18
114
  <head>
@@ -91,5 +187,12 @@ export async function buildPlugin({ root, minify = false } = {}) {
91
187
 
92
188
  export async function runBuild(args) {
93
189
  const minify = args.includes("--minify");
94
- await buildPlugin({ minify });
190
+ const root = findPluginRoot();
191
+ if (!args.includes("--no-pin")) {
192
+ const stableOnly = isMainBranch();
193
+ log.step(`pinning requirements.txt to latest ${stableOnly ? "stable " : ""}PyPI versions`);
194
+ await pinRequirements(root, { stableOnly });
195
+ }
196
+ await buildPlugin({ root, minify });
197
+ await exportRos2Build(root);
95
198
  }
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readdirSync, statSync } from "node:fs";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -22,6 +22,7 @@ function* mjsFiles(dir) {
22
22
  }
23
23
 
24
24
  for (const dir of roots) {
25
+ if (!existsSync(dir)) continue;
25
26
  for (const file of mjsFiles(dir)) {
26
27
  execFileSync(process.execPath, ["--check", file], { stdio: "inherit" });
27
28
  }
package/cli/src/dev.mjs CHANGED
@@ -1,10 +1,17 @@
1
- // muscat-pk dev — run dev:ui and dev:back together.
1
+ // muscat-pk dev — run Socket.IO bridge, dev:ui, and dev:back together.
2
2
  import { runDevUi } from "./devUi.mjs";
3
3
  import { runDevBack } from "./devBack.mjs";
4
+ import { startSocketBridge } from "./socketBridge.mjs";
4
5
  import { log } from "./util.mjs";
5
6
 
6
7
  export async function runDev(args = []) {
7
- log.step("starting dev:ui + dev:back");
8
+ log.step("starting Socket.IO bridge + dev:ui + dev:back");
9
+ const bridge = startSocketBridge();
10
+ const cleanup = () => bridge.close();
11
+ process.once("SIGINT", cleanup);
12
+ process.once("SIGTERM", cleanup);
13
+ process.once("exit", cleanup);
14
+
8
15
  // dev:back runs to completion / detaches on its own; dev:ui owns the process.
9
16
  runDevBack().catch((e) => log.warn(`dev:back: ${e.message}`));
10
17
  await runDevUi(args);
@@ -1,65 +1,153 @@
1
- // muscat-pk dev:back — run the plugin back end.
2
- // sdk : create/refresh .venv then `python main.py`
3
- // ros2 : requires Docker (Jazzy container) — guidance only here
4
- // none : nothing to run
1
+ // muscat-pk dev:back — run the plugin back end during development.
2
+ // python : create/refresh .venv, then run main.py with watchfiles hot-reload
3
+ // (uvicorn's reloader). Watches ALL .py files, not just main.py.
4
+ // ros2 : keep a Jazzy + Python 3.12 container warm, colcon build once with
5
+ // --symlink-install, run the node, and restart on source changes —
6
+ // i.e. develop as if inside the container.
5
7
  import { spawn, spawnSync } from "node:child_process";
6
- import { existsSync, readFileSync } from "node:fs";
8
+ import { existsSync, readFileSync, watch } from "node:fs";
7
9
  import { resolve } from "node:path";
8
- import { findPluginRoot, log } from "./util.mjs";
10
+ import { detectPythonCmd, hasBin, findPluginRoot, log, venvPython } from "./util.mjs";
11
+ import {
12
+ ROS2_DEV_IMAGE,
13
+ assertDockerReady,
14
+ ensureImage,
15
+ ros2PackageName,
16
+ } from "./ros2.mjs";
9
17
 
10
- function manifestRuntime(root) {
18
+ function readManifest(root) {
11
19
  try {
12
- const m = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
13
- return m.runtime?.type ?? "none";
20
+ return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
14
21
  } catch {
15
- return "none";
22
+ return {};
16
23
  }
17
24
  }
18
25
 
19
- export async function runDevBack() {
20
- const root = findPluginRoot();
21
- const type = manifestRuntime(root);
22
-
23
- if (type === "none") {
24
- log.info("runtime: none — no back end to run.");
25
- return;
26
- }
27
-
28
- if (type === "ros2") {
29
- log.warn(
30
- "ROS2 back end runs in a Jazzy + Python 3.12 Docker container.\n" +
31
- " Ensure Docker is running, then: muscat-pk dev:back (container support TBD).",
32
- );
33
- return;
34
- }
26
+ function manifestRuntime(root) {
27
+ return readManifest(root).runtime?.type ?? "python";
28
+ }
35
29
 
36
- // python / rb_sdk
30
+ // ── python ────────────────────────────────────────────────────────────────────
31
+ function runPython(root) {
37
32
  const venv = resolve(root, ".venv");
38
- const py = process.platform === "win32"
39
- ? resolve(venv, "Scripts", "python.exe")
40
- : resolve(venv, "bin", "python");
33
+ const py = venvPython(venv);
41
34
 
42
35
  if (!existsSync(py)) {
43
- log.step("creating .venv (python3.12 preferred)…");
44
- const python = spawnSync("python3.12", ["--version"]).status === 0
45
- ? "python3.12"
46
- : "python3";
47
- if (python !== "python3.12") {
48
- log.warn("python3.12 not found; using python3 (robot targets 3.12).");
36
+ log.step("creating .venv…");
37
+ if (hasBin("uv")) {
38
+ spawnSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: root, stdio: "inherit" });
39
+ } else if (hasBin("conda")) {
40
+ spawnSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: root, stdio: "inherit" });
41
+ } else if (hasBin("python3.12") || hasBin("python3") || hasBin("python")) {
42
+ const { cmd, is312 } = detectPythonCmd();
43
+ if (!is312) log.warn("python3.12 not found; using fallback (robot targets 3.12).");
44
+ spawnSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: root, stdio: "inherit" });
45
+ } else {
46
+ throw new Error("Python not found (uv / conda / python3 none available). Install Python 3.12 to run the backend.");
49
47
  }
50
- spawnSync(python, ["-m", "venv", venv], { cwd: root, stdio: "inherit" });
51
48
  }
52
49
 
53
50
  const reqs = resolve(root, "requirements.txt");
54
51
  if (existsSync(reqs)) {
55
52
  log.step("pip install -r requirements.txt…");
56
- spawnSync(py, ["-m", "pip", "install", "-q", "-r", reqs], {
57
- cwd: root,
58
- stdio: "inherit",
59
- });
53
+ spawnSync(py, ["-m", "pip", "install", "-q", "-r", reqs], { cwd: root, stdio: "inherit" });
60
54
  }
61
55
 
62
- log.step("python main.py");
63
- const child = spawn(py, ["main.py"], { cwd: root, stdio: "inherit" });
56
+ // watchfiles = uvicorn's reloader. --filter python watches every *.py in the
57
+ // project (and ignores .venv/__pycache__/node_modules), so interdependent
58
+ // modules trigger reloads — not just main.py.
59
+ log.step("pip install -q watchfiles (dev reloader)…");
60
+ spawnSync(py, ["-m", "pip", "install", "-q", "watchfiles"], { cwd: root, stdio: "inherit" });
61
+
62
+ const pluginId = readManifest(root).id ?? "";
63
+ const backEnv = { ...process.env, ...(pluginId ? { RB_PLUGIN_ID: pluginId } : {}) };
64
+ if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
65
+
66
+ log.step("watchfiles → python main.py (reload on any .py change)");
67
+ const child = spawn(
68
+ py,
69
+ ["-m", "watchfiles", "--filter", "python", "--target-type", "command", "python main.py", "."],
70
+ { cwd: root, stdio: "inherit", env: backEnv },
71
+ );
72
+ const cleanup = () => { if (!child.killed) child.kill(); };
64
73
  child.on("exit", (code) => process.exit(code ?? 0));
74
+ process.once("SIGINT", cleanup);
75
+ process.once("SIGTERM", cleanup);
76
+ }
77
+
78
+ // ── ros2 ───────────────────────────────────────────────────────────────────────
79
+ function runRos2(root) {
80
+ assertDockerReady();
81
+ const pkg = ros2PackageName(root);
82
+ if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
83
+
84
+ ensureImage();
85
+
86
+ const pluginId = readManifest(root).id ?? "";
87
+ const container = `${ROS2_DEV_IMAGE}-run`;
88
+ const envFlags = pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : [];
89
+ const docker = (args, opts = {}) =>
90
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
91
+
92
+ // start a warm, long-lived container with the workspace mounted
93
+ docker(["rm", "-f", container], { stdio: "ignore" });
94
+ log.step("starting ROS2 dev container…");
95
+ const up = docker(
96
+ ["run", "-d", "--name", container, "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
97
+ { stdio: "inherit" },
98
+ );
99
+ if (up.status !== 0) throw new Error("failed to start dev container.");
100
+
101
+ // one-time deps + symlink build (edits to .py reflect without rebuilding)
102
+ log.step("pip install + colcon build --symlink-install (once)…");
103
+ docker(["exec", container, "bash", "-lc",
104
+ "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true; cd ros2_ws && colcon build --symlink-install"]);
105
+
106
+ let child = null;
107
+ const startNode = () => {
108
+ if (child) child.kill();
109
+ log.step(`ros2 run ${pkg} ${pkg}_node`);
110
+ child = spawn(
111
+ "docker",
112
+ ["exec", ...envFlags, container, "bash", "-lc",
113
+ `cd ros2_ws && source install/setup.bash && ros2 run ${pkg} ${pkg}_node`],
114
+ { cwd: root, stdio: "inherit" },
115
+ );
116
+ };
117
+ startNode();
118
+
119
+ // restart node on source change; rebuild only when package metadata changes
120
+ let timer = null;
121
+ const onChange = (_event, filename) => {
122
+ clearTimeout(timer);
123
+ timer = setTimeout(() => {
124
+ const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
125
+ if (structural) {
126
+ log.info("package metadata changed — colcon rebuild…");
127
+ docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
128
+ } else {
129
+ log.info("source changed — restarting node…");
130
+ }
131
+ startNode();
132
+ }, 300);
133
+ };
134
+ for (const target of ["ros2_ws/src", "main.py"]) {
135
+ const full = resolve(root, target);
136
+ if (existsSync(full)) watch(full, { recursive: true }, onChange);
137
+ }
138
+
139
+ const cleanup = () => {
140
+ if (child) child.kill();
141
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
142
+ process.exit(0);
143
+ };
144
+ process.on("SIGINT", cleanup);
145
+ process.on("SIGTERM", cleanup);
146
+ }
147
+
148
+ export async function runDevBack() {
149
+ const root = findPluginRoot();
150
+ const type = manifestRuntime(root);
151
+ if (type === "ros2") return runRos2(root);
152
+ return runPython(root);
65
153
  }
package/cli/src/devUi.mjs CHANGED
@@ -1,22 +1,56 @@
1
1
  // muscat-pk dev:ui — preview the plugin inside a mini-Muscat (preview-host).
2
- import { spawn } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync } from "node:fs";
4
+ import { existsSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
5
6
  import { dirname, resolve } from "node:path";
6
7
  import { buildPlugin } from "./build.mjs";
7
- import { checkKitUpdate, findPluginRoot, kitRoot, log } from "./util.mjs";
8
+ import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log } from "./util.mjs";
8
9
 
9
- /** Resolve @rainbow-robotics/preview-host package root.
10
- * Works in monorepo (yarn workspace hoisting) and when both packages are
11
- * installed as deps of the plugin project. */
10
+ const PREVIEW_PKG = "@rainbow-robotics/preview-host";
11
+ const CACHE_DIR = resolve(homedir(), ".muscat-pk");
12
+ const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
13
+
14
+ /**
15
+ * Resolve preview-host root in order:
16
+ * 1. Standard require (npm/yarn global install)
17
+ * 2. Monorepo sibling (dev workspace)
18
+ * 3. ~/.muscat-pk cache (previous auto-install)
19
+ * 4. Auto-install to ~/.muscat-pk via npm (binary users who have Node.js)
20
+ */
12
21
  function findPreviewHostRoot() {
22
+ // 1. npm/yarn 설치 경로
13
23
  try {
14
24
  const req = createRequire(import.meta.url);
15
- return dirname(req.resolve("@rainbow-robotics/preview-host/package.json"));
16
- } catch {
17
- // fallback: sibling package in muscat-plugin-kit/ workspace
18
- return resolve(kitRoot, "../preview-host");
25
+ return dirname(req.resolve(`${PREVIEW_PKG}/package.json`));
26
+ } catch {}
27
+
28
+ // 2. 모노레포 sibling
29
+ const sibling = resolve(kitRoot, "../preview-host");
30
+ if (existsSync(resolve(sibling, "package.json"))) return sibling;
31
+
32
+ // 3. 캐시
33
+ if (existsSync(resolve(CACHE_ROOT, "package.json"))) return CACHE_ROOT;
34
+
35
+ // 4. npm 자동 설치 → 캐시
36
+ if (!hasBin("npm")) {
37
+ throw new Error(
38
+ `preview-host를 찾을 수 없습니다.\n` +
39
+ `Node.js 20+ 를 설치하면 자동으로 준비됩니다: https://nodejs.org`,
40
+ );
41
+ }
42
+ log.step(`preview-host 설치 중 (~/.muscat-pk) — 첫 실행 시 한 번만 수행됩니다…`);
43
+ mkdirSync(CACHE_DIR, { recursive: true });
44
+ const r = spawnSync(
45
+ "npm",
46
+ ["install", "--prefix", CACHE_DIR, "--save", PREVIEW_PKG],
47
+ { stdio: "inherit" },
48
+ );
49
+ if (r.status !== 0) throw new Error("preview-host 설치 실패.");
50
+ if (!existsSync(resolve(CACHE_ROOT, "package.json"))) {
51
+ throw new Error("preview-host 설치 후에도 패키지를 찾을 수 없습니다.");
19
52
  }
53
+ return CACHE_ROOT;
20
54
  }
21
55
 
22
56
  export async function runDevUi(args = []) {
@@ -31,7 +65,7 @@ export async function runDevUi(args = []) {
31
65
  if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
32
66
  log.step("building preview-host bundle (first run)…");
33
67
  await new Promise((res, rej) => {
34
- const p = spawn("node", [resolve(previewRoot, "build.mjs")], {
68
+ const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
35
69
  stdio: "inherit",
36
70
  });
37
71
  p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
@@ -40,11 +74,11 @@ export async function runDevUi(args = []) {
40
74
 
41
75
  log.step(`starting preview-host on :${port}`);
42
76
  const child = spawn(
43
- "node",
77
+ process.execPath,
44
78
  [serveEntry, "--plugin", distDir, "--port", String(port)],
45
79
  { stdio: "inherit" },
46
80
  );
47
81
  child.on("exit", (code) => process.exit(code ?? 0));
48
82
  }
49
83
 
50
- export { findPluginRoot };
84
+ export { findPluginRoot, findPreviewHostRoot };