@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 +95 -16
- package/cli/bin/muscat-pk.mjs +10 -2
- package/cli/docker/Dockerfile.dev +13 -0
- package/cli/src/build.mjs +105 -2
- package/cli/src/check-package.mjs +2 -1
- package/cli/src/dev.mjs +9 -2
- package/cli/src/devBack.mjs +131 -43
- package/cli/src/devUi.mjs +47 -13
- package/cli/src/esbuild.mjs +50 -10
- package/cli/src/new.mjs +110 -27
- package/cli/src/pack.mjs +4 -1
- package/cli/src/preview.mjs +91 -0
- package/cli/src/ros2.mjs +65 -0
- package/cli/src/socketBridge.mjs +122 -0
- package/cli/src/type.mjs +82 -0
- package/cli/src/util.mjs +39 -2
- package/cli/templates/common/.vscode/settings.json +3 -0
- package/cli/templates/common/README.md +268 -9
- package/cli/templates/common/main.py +88 -0
- package/cli/templates/common/package.json +3 -10
- package/cli/templates/common/requirements.txt +3 -0
- package/cli/templates/common/src/content.tsx +3 -2
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
- package/package.json +5 -3
- package/cli/templates/sdk/main.py +0 -21
- package/cli/templates/sdk/requirements.txt +0 -1
package/README.md
CHANGED
|
@@ -1,27 +1,106 @@
|
|
|
1
1
|
# @rainbow-robotics/muscat-plugin-kit
|
|
2
2
|
|
|
3
|
-
Muscat
|
|
4
|
-
and preview-host.
|
|
3
|
+
Muscat 플러그인 개발 CLI. 스캐폴딩, 빌드/팩, 개발 서버(UI + 백엔드)를 제공합니다.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
muscat-pk new my-plugin --type sdk
|
|
74
|
+
muscat-pk new my-plugin # 플러그인 프로젝트 생성 (python 또는 ros2 선택)
|
|
13
75
|
cd my-plugin
|
|
14
|
-
|
|
15
|
-
muscat-pk dev
|
|
16
|
-
muscat-pk
|
|
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
|
-
|
|
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
|
-
|
|
|
101
|
+
| 패키지 | 역할 |
|
|
22
102
|
|---|---|
|
|
23
|
-
| `@rainbow-robotics/plugin-ui` |
|
|
24
|
-
| `@rainbow-robotics/muscat-plugin-kit` | CLI,
|
|
103
|
+
| `@rainbow-robotics/plugin-ui` | UI 런타임 계약: rb-* 엘리먼트, React 바인딩, sandbox connect |
|
|
104
|
+
| `@rainbow-robotics/muscat-plugin-kit` | CLI, 템플릿, 빌드/팩, 미리보기 호스트 |
|
|
25
105
|
|
|
26
|
-
|
|
27
|
-
import map for `@rainbow-robotics/plugin-ui`, `react`, and `react-dom`.
|
|
106
|
+
플러그인 ZIP에는 플러그인 코드와 에셋만 포함됩니다. `@rainbow-robotics/plugin-ui`, `react`, `react-dom`은 호스트(Muscat/로봇)의 import map에서 제공됩니다.
|
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -1,65 +1,153 @@
|
|
|
1
|
-
// muscat-pk dev:back — run the plugin back end.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
18
|
+
function readManifest(root) {
|
|
11
19
|
try {
|
|
12
|
-
|
|
13
|
-
return m.runtime?.type ?? "none";
|
|
20
|
+
return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
|
|
14
21
|
} catch {
|
|
15
|
-
return
|
|
22
|
+
return {};
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
30
|
+
// ── python ────────────────────────────────────────────────────────────────────
|
|
31
|
+
function runPython(root) {
|
|
37
32
|
const venv = resolve(root, ".venv");
|
|
38
|
-
const py =
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
16
|
-
} catch {
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
-
|
|
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 };
|