@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.21 → 0.1.2-dev.23
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/cli/bin/muscat-pk.mjs +3 -1
- package/cli/src/build.mjs +55 -32
- package/cli/src/dev.mjs +1 -1
- package/cli/src/devBack.mjs +20 -8
- package/cli/src/devUi.mjs +81 -16
- package/cli/src/manifest.mjs +160 -0
- package/cli/src/new.mjs +13 -1
- package/cli/src/socketBridge.mjs +58 -64
- package/cli/src/util.mjs +6 -4
- package/cli/templates/common/README.md +118 -28
- package/cli/templates/common/manifest.json +28 -6
- package/cli/templates/common/package.json +2 -1
- package/cli/templates/common/requirements.txt +4 -3
- package/cli/templates/common/vite.config.ts +22 -13
- package/package.json +2 -2
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// muscat-pk — Muscat plugin developer CLI.
|
|
3
3
|
// Subcommands: new, build, pack, dev, dev:ui, dev:back, update, sdk.
|
|
4
|
+
import { log } from "../src/util.mjs";
|
|
4
5
|
import { runNew } from "../src/new.mjs";
|
|
5
6
|
import { runBuild } from "../src/build.mjs";
|
|
6
7
|
import { runPack } from "../src/pack.mjs";
|
|
@@ -67,7 +68,8 @@ async function main() {
|
|
|
67
68
|
process.exitCode = 1;
|
|
68
69
|
}
|
|
69
70
|
} catch (err) {
|
|
70
|
-
console.error(
|
|
71
|
+
console.error();
|
|
72
|
+
log.error(err?.message || String(err));
|
|
71
73
|
process.exitCode = 1;
|
|
72
74
|
}
|
|
73
75
|
}
|
package/cli/src/build.mjs
CHANGED
|
@@ -8,11 +8,49 @@
|
|
|
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, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
-
import {
|
|
11
|
+
import { cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
13
14
|
import { spawnSync } from "node:child_process";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
15
|
+
import { findPluginRoot, hasBin, log } from "./util.mjs";
|
|
16
|
+
import { assertManifest } from "./manifest.mjs";
|
|
17
|
+
|
|
18
|
+
// Find a usable node binary (for running vite as subprocess).
|
|
19
|
+
function findNodeBin() {
|
|
20
|
+
if (typeof Bun === "undefined") return process.execPath; // already node
|
|
21
|
+
for (const c of ["node", "node20", "node18"]) {
|
|
22
|
+
if (hasBin(c)) return c;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resolve vite bin path from the plugin root, hoisting-aware.
|
|
28
|
+
function findViteBin(pluginRoot) {
|
|
29
|
+
try {
|
|
30
|
+
const req = createRequire(resolve(pluginRoot, "package.json"));
|
|
31
|
+
const vitePkg = req.resolve("vite/package.json");
|
|
32
|
+
return resolve(dirname(vitePkg), "bin", "vite.js");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Run vite build in the plugin project.
|
|
39
|
+
// Returns true on success, false if vite/node not available.
|
|
40
|
+
function runViteBuild(pluginRoot, { mode = "production" } = {}) {
|
|
41
|
+
const node = findNodeBin();
|
|
42
|
+
if (!node) return false;
|
|
43
|
+
const viteBin = findViteBin(pluginRoot);
|
|
44
|
+
if (!viteBin || !existsSync(viteBin)) return false;
|
|
45
|
+
|
|
46
|
+
log.step(`vite build (mode: ${mode})`);
|
|
47
|
+
const r = spawnSync(node, [viteBin, "build", "--mode", mode], {
|
|
48
|
+
cwd: pluginRoot,
|
|
49
|
+
stdio: "inherit",
|
|
50
|
+
});
|
|
51
|
+
if (r.status !== 0) throw new Error("vite build failed");
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
16
54
|
|
|
17
55
|
function isMainBranch() {
|
|
18
56
|
const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
@@ -109,7 +147,7 @@ export async function pinRequirements(root, { stableOnly = false } = {}) {
|
|
|
109
147
|
if (pinned > 0) writeFileSync(reqPath, out.join("\n"));
|
|
110
148
|
}
|
|
111
149
|
|
|
112
|
-
const SANDBOX_HTML = `<!doctype html>
|
|
150
|
+
export const SANDBOX_HTML = `<!doctype html>
|
|
113
151
|
<html lang="en">
|
|
114
152
|
<head>
|
|
115
153
|
<meta charset="UTF-8" />
|
|
@@ -135,39 +173,17 @@ const SANDBOX_HTML = `<!doctype html>
|
|
|
135
173
|
</html>
|
|
136
174
|
`;
|
|
137
175
|
|
|
138
|
-
const HOST_PROVIDED_EXTERNALS = [
|
|
139
|
-
"@rainbow-robotics/plugin-ui",
|
|
140
|
-
"@rainbow-robotics/plugin-ui/*",
|
|
141
|
-
"react",
|
|
142
|
-
"react/jsx-runtime",
|
|
143
|
-
"react/jsx-dev-runtime",
|
|
144
|
-
"react-dom",
|
|
145
|
-
"react-dom/*",
|
|
146
|
-
];
|
|
147
|
-
|
|
148
176
|
export async function buildPlugin({ root, minify = false } = {}) {
|
|
149
177
|
const pluginRoot = root ?? findPluginRoot();
|
|
150
|
-
|
|
151
|
-
if (!existsSync(entry)) {
|
|
152
|
-
throw new Error(`missing entry: ${entry}`);
|
|
153
|
-
}
|
|
178
|
+
assertManifest(pluginRoot);
|
|
154
179
|
const distDir = resolve(pluginRoot, "dist");
|
|
155
180
|
rmSync(distDir, { recursive: true, force: true });
|
|
156
181
|
mkdirSync(distDir, { recursive: true });
|
|
157
182
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
bundle: true,
|
|
163
|
-
format: "esm",
|
|
164
|
-
jsx: "automatic",
|
|
165
|
-
minify,
|
|
166
|
-
loader: { ".tsx": "tsx", ".ts": "ts", ".svg": "dataurl" },
|
|
167
|
-
define: { "process.env.NODE_ENV": JSON.stringify(minify ? "production" : "development") },
|
|
168
|
-
external: HOST_PROVIDED_EXTERNALS,
|
|
169
|
-
logLevel: "warning",
|
|
170
|
-
});
|
|
183
|
+
const viteOk = runViteBuild(pluginRoot, { mode: minify ? "production" : "development" });
|
|
184
|
+
if (!viteOk) {
|
|
185
|
+
log.warn("vite not found — UI bundle skipped. Install Node.js and run: npm install");
|
|
186
|
+
}
|
|
171
187
|
|
|
172
188
|
// Stage distribution files.
|
|
173
189
|
const manifestSrc = resolve(pluginRoot, "manifest.json");
|
|
@@ -182,6 +198,13 @@ export async function buildPlugin({ root, minify = false } = {}) {
|
|
|
182
198
|
cpSync(assetsSrc, resolve(distDir, "assets"), { recursive: true });
|
|
183
199
|
}
|
|
184
200
|
|
|
201
|
+
// README.md and README.[lang].md → dist/
|
|
202
|
+
for (const f of readdirSync(pluginRoot)) {
|
|
203
|
+
if (/^README(\.[a-z]{2}(-[A-Z]{2})?)?\.md$/i.test(f)) {
|
|
204
|
+
copyFileSync(resolve(pluginRoot, f), resolve(distDir, f));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
185
208
|
// Backend runtime files — copy entire py/ into dist/, then requirements.txt from root.
|
|
186
209
|
const pySrc = resolve(pluginRoot, "py");
|
|
187
210
|
if (existsSync(pySrc)) {
|
package/cli/src/dev.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { log } from "./util.mjs";
|
|
|
6
6
|
|
|
7
7
|
export async function runDev(args = []) {
|
|
8
8
|
log.step("starting Socket.IO bridge + dev:ui + dev:back");
|
|
9
|
-
const bridge = startSocketBridge();
|
|
9
|
+
const bridge = await startSocketBridge();
|
|
10
10
|
const cleanup = () => bridge.close();
|
|
11
11
|
process.once("SIGINT", cleanup);
|
|
12
12
|
process.once("SIGTERM", cleanup);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readFileSync, watch } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { detectPythonCmd, hasBin, findPluginRoot, log, venvPython } from "./util.mjs";
|
|
11
|
+
import { readManifest as readManifestSchema, validateManifest } from "./manifest.mjs";
|
|
11
12
|
import {
|
|
12
13
|
ROS2_DEV_IMAGE,
|
|
13
14
|
assertDockerReady,
|
|
@@ -39,19 +40,22 @@ function runPython(root) {
|
|
|
39
40
|
spawnSync("uv", ["venv", venv, "--python", "3.12", "--seed"], { cwd: root, stdio: "inherit" });
|
|
40
41
|
} else if (hasBin("conda")) {
|
|
41
42
|
spawnSync("conda", ["create", "-p", venv, "python=3.12", "-y", "--quiet"], { cwd: root, stdio: "inherit" });
|
|
42
|
-
} else if (hasBin("python3.12")
|
|
43
|
-
|
|
44
|
-
if (!is312) log.warn("python3.12 not found; using fallback (robot targets 3.12).");
|
|
45
|
-
spawnSync(cmd[0], [...cmd.slice(1), "-m", "venv", venv], { cwd: root, stdio: "inherit" });
|
|
43
|
+
} else if (hasBin("python3.12")) {
|
|
44
|
+
spawnSync("python3.12", ["-m", "venv", venv], { cwd: root, stdio: "inherit" });
|
|
46
45
|
} else {
|
|
47
|
-
throw new Error(
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Python 3.12 not found.\n" +
|
|
48
|
+
" The rainbow SDK requires Python >=3.12,<3.13.\n" +
|
|
49
|
+
" Install Python 3.12: https://www.python.org/downloads/release/python-3120/\n" +
|
|
50
|
+
" Or use uv (recommended): https://docs.astral.sh/uv/getting-started/installation/"
|
|
51
|
+
);
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
const reqs = resolve(root, "requirements.txt");
|
|
52
56
|
if (existsSync(reqs)) {
|
|
53
57
|
log.step("pip install -r requirements.txt…");
|
|
54
|
-
spawnSync(py, ["-m", "pip", "install", "-q", "-r", reqs], { cwd: root, stdio: "inherit" });
|
|
58
|
+
spawnSync(py, ["-m", "pip", "install", "-q", "--no-cache-dir", "-r", reqs], { cwd: root, stdio: "inherit" });
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
log.step("pip install -q watchfiles (dev reloader)…");
|
|
@@ -61,10 +65,10 @@ function runPython(root) {
|
|
|
61
65
|
const backEnv = { ...process.env, ...(pluginId ? { RB_PLUGIN_ID: pluginId } : {}) };
|
|
62
66
|
if (pluginId) log.step(`RB_PLUGIN_ID=${pluginId}`);
|
|
63
67
|
|
|
64
|
-
log.step("watchfiles →
|
|
68
|
+
log.step("watchfiles → py/main.py (reload on any .py change in py/)");
|
|
65
69
|
const child = spawn(
|
|
66
70
|
py,
|
|
67
|
-
["-m", "watchfiles", "--filter", "python", "--target-type", "command",
|
|
71
|
+
["-m", "watchfiles", "--filter", "python", "--target-type", "command", `${py} py/main.py`, "py"],
|
|
68
72
|
{ cwd: root, stdio: "inherit", env: backEnv },
|
|
69
73
|
);
|
|
70
74
|
const cleanup = () => { if (!child.killed) child.kill(); };
|
|
@@ -143,8 +147,16 @@ function runRos2(root) {
|
|
|
143
147
|
process.on("SIGTERM", cleanup);
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
function warnManifest(root) {
|
|
151
|
+
const manifest = readManifestSchema(root);
|
|
152
|
+
if (!manifest || Object.keys(manifest).length === 0) return;
|
|
153
|
+
const errors = validateManifest(manifest);
|
|
154
|
+
for (const e of errors) log.warn(`manifest.json: ${e}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
146
157
|
export async function runDevBack() {
|
|
147
158
|
const root = findPluginRoot();
|
|
159
|
+
warnManifest(root);
|
|
148
160
|
const type = manifestRuntime(root);
|
|
149
161
|
if (type === "ros2") return runRos2(root);
|
|
150
162
|
return runPython(root);
|
package/cli/src/devUi.mjs
CHANGED
|
@@ -1,38 +1,37 @@
|
|
|
1
1
|
// muscat-pk dev:ui — preview the plugin inside a mini-Muscat (preview-host).
|
|
2
|
+
// UI bundling: vite build --watch (uses plugin project's own node_modules/vite).
|
|
3
|
+
// Falls back gracefully if Node.js or vite is not available.
|
|
2
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
5
|
import { createRequire } from "node:module";
|
|
4
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
7
|
import { homedir } from "node:os";
|
|
6
8
|
import { dirname, resolve } from "node:path";
|
|
7
|
-
import {
|
|
9
|
+
import { SANDBOX_HTML } from "./build.mjs";
|
|
8
10
|
import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log } from "./util.mjs";
|
|
9
11
|
|
|
10
12
|
const PREVIEW_PKG = "@rainbow-robotics/preview-host";
|
|
11
13
|
const CACHE_DIR = resolve(homedir(), ".muscat-pk");
|
|
12
14
|
const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
function findNodeBin() {
|
|
17
|
+
if (typeof Bun === "undefined") return process.execPath;
|
|
18
|
+
for (const c of ["node", "node20", "node18"]) {
|
|
19
|
+
if (hasBin(c)) return c;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
21
24
|
function findPreviewHostRoot() {
|
|
22
|
-
// 1. npm/yarn 설치 경로
|
|
23
25
|
try {
|
|
24
26
|
const req = createRequire(import.meta.url);
|
|
25
27
|
return dirname(req.resolve(`${PREVIEW_PKG}/package.json`));
|
|
26
28
|
} catch {}
|
|
27
29
|
|
|
28
|
-
// 2. 모노레포 sibling
|
|
29
30
|
const sibling = resolve(kitRoot, "../preview-host");
|
|
30
31
|
if (existsSync(resolve(sibling, "package.json"))) return sibling;
|
|
31
32
|
|
|
32
|
-
// 3. 캐시
|
|
33
33
|
if (existsSync(resolve(CACHE_ROOT, "package.json"))) return CACHE_ROOT;
|
|
34
34
|
|
|
35
|
-
// 4. npm 자동 설치 → 캐시
|
|
36
35
|
if (!hasBin("npm")) {
|
|
37
36
|
throw new Error(
|
|
38
37
|
`preview-host를 찾을 수 없습니다.\n` +
|
|
@@ -53,19 +52,78 @@ function findPreviewHostRoot() {
|
|
|
53
52
|
return CACHE_ROOT;
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
// Write sandbox.html + copy manifest into dist/ (vite doesn't generate these).
|
|
56
|
+
function stageDevAssets(pluginRoot) {
|
|
57
|
+
const distDir = resolve(pluginRoot, "dist");
|
|
58
|
+
mkdirSync(distDir, { recursive: true });
|
|
59
|
+
writeFileSync(resolve(distDir, "sandbox.html"), SANDBOX_HTML, "utf8");
|
|
60
|
+
const manifestSrc = resolve(pluginRoot, "manifest.json");
|
|
61
|
+
if (existsSync(manifestSrc)) copyFileSync(manifestSrc, resolve(distDir, "manifest.json"));
|
|
62
|
+
return distDir;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve vite bin path from the plugin root, hoisting-aware.
|
|
66
|
+
function findViteBin(pluginRoot) {
|
|
67
|
+
try {
|
|
68
|
+
const req = createRequire(resolve(pluginRoot, "package.json"));
|
|
69
|
+
const vitePkg = req.resolve("vite/package.json");
|
|
70
|
+
return resolve(dirname(vitePkg), "bin", "vite.js");
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Spawn vite build --watch as a background process.
|
|
77
|
+
// Returns the child process, or null if vite/node not available.
|
|
78
|
+
function spawnViteWatch(pluginRoot, node) {
|
|
79
|
+
const viteBin = findViteBin(pluginRoot);
|
|
80
|
+
if (!node || !viteBin || !existsSync(viteBin)) return null;
|
|
81
|
+
|
|
82
|
+
log.step("vite build --watch (ui/content.tsx → dist/content.js)");
|
|
83
|
+
return spawn(node, [viteBin, "build", "--watch", "--mode", "development"], {
|
|
84
|
+
cwd: pluginRoot,
|
|
85
|
+
stdio: "inherit",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Wait until dist/content.js exists (vite initial build completes).
|
|
90
|
+
function waitForInitialBuild(distDir, timeoutMs = 30_000) {
|
|
91
|
+
return new Promise((res, rej) => {
|
|
92
|
+
const contentJs = resolve(distDir, "content.js");
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
const check = () => {
|
|
95
|
+
if (existsSync(contentJs)) return res();
|
|
96
|
+
if (Date.now() - start > timeoutMs) return rej(new Error("vite initial build timed out"));
|
|
97
|
+
setTimeout(check, 300);
|
|
98
|
+
};
|
|
99
|
+
check();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
56
103
|
export async function runDevUi(args = []) {
|
|
57
104
|
const portIdx = args.indexOf("--port");
|
|
58
105
|
const port = portIdx !== -1 ? args[portIdx + 1] : "4180";
|
|
59
106
|
|
|
60
107
|
await checkKitUpdate();
|
|
61
|
-
const
|
|
108
|
+
const pluginRoot = findPluginRoot();
|
|
109
|
+
const distDir = stageDevAssets(pluginRoot);
|
|
110
|
+
|
|
111
|
+
const node = findNodeBin();
|
|
112
|
+
const viteProc = spawnViteWatch(pluginRoot, node);
|
|
113
|
+
|
|
114
|
+
if (viteProc) {
|
|
115
|
+
log.step("waiting for initial vite build…");
|
|
116
|
+
await waitForInitialBuild(distDir);
|
|
117
|
+
} else {
|
|
118
|
+
log.warn("vite not found — UI will not be bundled. Run `npm install` in the plugin project.");
|
|
119
|
+
}
|
|
62
120
|
|
|
63
121
|
const previewRoot = findPreviewHostRoot();
|
|
64
122
|
const serveEntry = resolve(previewRoot, "serve.mjs");
|
|
65
123
|
if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
|
|
66
124
|
log.step("building preview-host bundle (first run)…");
|
|
67
125
|
await new Promise((res, rej) => {
|
|
68
|
-
const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
|
|
126
|
+
const p = spawn(node ?? process.execPath, [resolve(previewRoot, "build.mjs")], {
|
|
69
127
|
stdio: "inherit",
|
|
70
128
|
});
|
|
71
129
|
p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
|
|
@@ -74,10 +132,17 @@ export async function runDevUi(args = []) {
|
|
|
74
132
|
|
|
75
133
|
log.step(`starting preview-host on :${port}`);
|
|
76
134
|
const child = spawn(
|
|
77
|
-
process.execPath,
|
|
135
|
+
node ?? process.execPath,
|
|
78
136
|
[serveEntry, "--plugin", distDir, "--port", String(port)],
|
|
79
137
|
{ stdio: "inherit" },
|
|
80
138
|
);
|
|
139
|
+
|
|
140
|
+
const cleanup = () => {
|
|
141
|
+
if (viteProc && !viteProc.killed) viteProc.kill();
|
|
142
|
+
if (!child.killed) child.kill();
|
|
143
|
+
};
|
|
144
|
+
process.once("SIGINT", cleanup);
|
|
145
|
+
process.once("SIGTERM", cleanup);
|
|
81
146
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
82
147
|
}
|
|
83
148
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// manifest.mjs — manifest.json schema definition and validation
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CONTRIBUTION_POINTS = ["program.command", "toolbar", "move.rnb"];
|
|
6
|
+
const ICON_SIZES = ["16", "48", "128"];
|
|
7
|
+
const RUNTIME_TYPES = ["none", "python", "ros2"];
|
|
8
|
+
const ROS_CLIENTS = ["rclpy", "rclcpp"];
|
|
9
|
+
const I18N_LANGS = ["ko", "en", "ja", "zh", "vi", "th", "es"];
|
|
10
|
+
|
|
11
|
+
function validateI18nLabel(value, field, errors) {
|
|
12
|
+
if (value === undefined || value === null) {
|
|
13
|
+
errors.push(`${field} is required`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (typeof value === "string") return;
|
|
17
|
+
if (typeof value === "object") {
|
|
18
|
+
if (typeof value.en !== "string" || value.en.length === 0)
|
|
19
|
+
errors.push(`${field}: "en" is required when using object form`);
|
|
20
|
+
const unknown = Object.keys(value).filter((k) => !I18N_LANGS.includes(k));
|
|
21
|
+
if (unknown.length > 0)
|
|
22
|
+
errors.push(`${field}: unsupported language key(s) — ${unknown.map((k) => `"${k}"`).join(", ")} (allowed: ${I18N_LANGS.join(", ")})`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
errors.push(`${field}: must be a string or { en (required), ko, ja, zh, vi, th, es }`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readManifest(root) {
|
|
29
|
+
const path = resolve(root, "manifest.json");
|
|
30
|
+
if (!existsSync(path)) return {};
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateIcons(icons, errors) {
|
|
39
|
+
if (icons === undefined || icons === null) {
|
|
40
|
+
errors.push("icons is required");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (typeof icons === "string") {
|
|
44
|
+
if (!icons.toLowerCase().endsWith(".svg"))
|
|
45
|
+
errors.push(`icons: non-SVG images (PNG, JPG, etc.) must use the { "16": "...", "48": "...", "128": "..." } object form`);
|
|
46
|
+
} else if (typeof icons === "object") {
|
|
47
|
+
const missing = ICON_SIZES.filter((s) => typeof icons[s] !== "string" || icons[s].length === 0);
|
|
48
|
+
if (missing.length > 0)
|
|
49
|
+
errors.push(`icons: missing size(s) ${missing.map((s) => `"${s}"`).join(", ")} — all three (16, 48, 128) are required for image files`);
|
|
50
|
+
} else {
|
|
51
|
+
errors.push(`icons: must be a string (SVG path) or { "16", "48", "128" } object (PNG/JPG)`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateRuntime(runtime, errors) {
|
|
56
|
+
if (runtime === undefined || runtime === null) {
|
|
57
|
+
errors.push("runtime is required");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (typeof runtime !== "object") {
|
|
61
|
+
errors.push("runtime must be an object");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!RUNTIME_TYPES.includes(runtime.type))
|
|
65
|
+
errors.push(`runtime.type must be one of ${RUNTIME_TYPES.map((t) => `"${t}"`).join(" | ")}`);
|
|
66
|
+
if (runtime.type === "python" && !runtime.entry)
|
|
67
|
+
errors.push("runtime.entry is required for python runtime");
|
|
68
|
+
if (runtime.type === "ros2") {
|
|
69
|
+
if (!runtime.package) errors.push("runtime.package is required for ros2 runtime");
|
|
70
|
+
if (!runtime.launch) errors.push("runtime.launch is required for ros2 runtime");
|
|
71
|
+
if (!ROS_CLIENTS.includes(runtime.rosClient))
|
|
72
|
+
errors.push(`runtime.rosClient must be one of ${ROS_CLIENTS.map((c) => `"${c}"`).join(" | ")}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateCommandInfo(info, prefix, errors) {
|
|
77
|
+
if (typeof info.name !== "string" || info.name.length === 0)
|
|
78
|
+
errors.push(`${prefix}.commandInfo.name is required`);
|
|
79
|
+
if (typeof info.icon !== "string" || info.icon.length === 0)
|
|
80
|
+
errors.push(`${prefix}.commandInfo.icon is required`);
|
|
81
|
+
if (info.iconClassName !== undefined && typeof info.iconClassName !== "string")
|
|
82
|
+
errors.push(`${prefix}.commandInfo.iconClassName must be a string`);
|
|
83
|
+
if (info.funcName !== undefined && typeof info.funcName !== "string")
|
|
84
|
+
errors.push(`${prefix}.commandInfo.funcName must be a string`);
|
|
85
|
+
if (info.args !== undefined && (typeof info.args !== "object" || Array.isArray(info.args)))
|
|
86
|
+
errors.push(`${prefix}.commandInfo.args must be an object`);
|
|
87
|
+
for (const key of ["disabled", "syncGroupId", "disableCopy", "disableDelete", "requireChildren", "requireParent"]) {
|
|
88
|
+
if (info[key] !== undefined && typeof info[key] !== "boolean")
|
|
89
|
+
errors.push(`${prefix}.commandInfo.${key} must be a boolean`);
|
|
90
|
+
}
|
|
91
|
+
for (const key of ["allowChildren", "allowParents", "notAllowChildren"]) {
|
|
92
|
+
if (info[key] !== undefined && (!Array.isArray(info[key]) || info[key].some((v) => typeof v !== "string")))
|
|
93
|
+
errors.push(`${prefix}.commandInfo.${key} must be a string[]`);
|
|
94
|
+
}
|
|
95
|
+
if (info.childrenCommand !== undefined && typeof info.childrenCommand !== "string")
|
|
96
|
+
errors.push(`${prefix}.commandInfo.childrenCommand must be a string`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateContributes(contributes, errors) {
|
|
100
|
+
if (!Array.isArray(contributes) || contributes.length === 0) {
|
|
101
|
+
errors.push("contributes is required and must be a non-empty array");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!contributes.some((c) => c.point === "toolbar"))
|
|
105
|
+
errors.push(`contributes: at least one "toolbar" entry is required`);
|
|
106
|
+
|
|
107
|
+
const uiValues = contributes.map((c) => c.ui).filter(Boolean);
|
|
108
|
+
const duplicateUi = uiValues.filter((v, i) => uiValues.indexOf(v) !== i);
|
|
109
|
+
if (duplicateUi.length > 0)
|
|
110
|
+
errors.push(`contributes: "ui" values must be unique across all entries (duplicates: ${[...new Set(duplicateUi)].map((v) => `"${v}"`).join(", ")})`);
|
|
111
|
+
|
|
112
|
+
contributes.forEach((c, i) => {
|
|
113
|
+
validateI18nLabel(c.label, `contributes[${i}].label`, errors);
|
|
114
|
+
if (c.point === "program.command") {
|
|
115
|
+
if (!c.commandInfo)
|
|
116
|
+
errors.push(`contributes[${i}]: "commandInfo" is required when point is "program.command"`);
|
|
117
|
+
else
|
|
118
|
+
validateCommandInfo(c.commandInfo, `contributes[${i}]`, errors);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate manifest.json. Returns an array of error messages (empty = valid).
|
|
125
|
+
*/
|
|
126
|
+
export function validateManifest(manifest) {
|
|
127
|
+
const errors = [];
|
|
128
|
+
|
|
129
|
+
if (typeof manifest !== "object" || manifest === null)
|
|
130
|
+
return ["manifest.json is not a valid JSON object"];
|
|
131
|
+
|
|
132
|
+
for (const key of ["id", "name", "version", "minMuscatVersion"]) {
|
|
133
|
+
if (typeof manifest[key] !== "string" || manifest[key].length === 0)
|
|
134
|
+
errors.push(`"${key}" is required`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof manifest.author !== "string")
|
|
137
|
+
errors.push(`"author" is required (empty string is allowed)`);
|
|
138
|
+
validateI18nLabel(manifest.description, "description", errors);
|
|
139
|
+
|
|
140
|
+
validateIcons(manifest.icons, errors);
|
|
141
|
+
validateRuntime(manifest.runtime, errors);
|
|
142
|
+
validateContributes(manifest.contributes, errors);
|
|
143
|
+
|
|
144
|
+
return errors;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read and validate manifest.json. Throws if any errors are found.
|
|
149
|
+
*/
|
|
150
|
+
export function assertManifest(root) {
|
|
151
|
+
const manifest = readManifest(root);
|
|
152
|
+
if (manifest === null)
|
|
153
|
+
throw new Error("manifest.json parse failed — check JSON syntax");
|
|
154
|
+
if (Object.keys(manifest).length === 0)
|
|
155
|
+
return manifest;
|
|
156
|
+
const errors = validateManifest(manifest);
|
|
157
|
+
if (errors.length > 0)
|
|
158
|
+
throw new Error(`manifest.json errors:\n${errors.map((e) => ` • ${e}`).join("\n")}`);
|
|
159
|
+
return manifest;
|
|
160
|
+
}
|
package/cli/src/new.mjs
CHANGED
|
@@ -107,9 +107,21 @@ export async function runNew(args) {
|
|
|
107
107
|
}
|
|
108
108
|
type = TYPES.includes(type) ? type : "python";
|
|
109
109
|
|
|
110
|
+
// Auto-detect yarn workspace: if .yarnrc.yml exists anywhere up the tree, default to yarn.
|
|
111
|
+
function isInsideYarnWorkspace(dir) {
|
|
112
|
+
let cur = dir;
|
|
113
|
+
while (true) {
|
|
114
|
+
if (existsSync(resolve(cur, ".yarnrc.yml"))) return true;
|
|
115
|
+
const parent = resolve(cur, "..");
|
|
116
|
+
if (parent === cur) return false;
|
|
117
|
+
cur = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
110
121
|
let pm = opts.pm;
|
|
111
122
|
if (!pm && !opts.yes) {
|
|
112
|
-
|
|
123
|
+
const defaultPm = isInsideYarnWorkspace(process.cwd()) ? "yarn" : "npm";
|
|
124
|
+
pm = await prompt(`Package manager (${PMS.join("/")}) [${defaultPm}]: `, defaultPm);
|
|
113
125
|
}
|
|
114
126
|
pm = PMS.includes(pm) ? pm : "npm";
|
|
115
127
|
|
package/cli/src/socketBridge.mjs
CHANGED
|
@@ -7,22 +7,22 @@ const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
|
|
|
7
7
|
|
|
8
8
|
export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
|
|
9
9
|
const httpServer = createServer();
|
|
10
|
-
const io = new Server(httpServer, {
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const io = new Server(httpServer, { cors: { origin: "*" } });
|
|
11
|
+
|
|
12
|
+
// topic → socketId (serve_register로 등록된 queryable)
|
|
13
13
|
const queryables = new Map();
|
|
14
|
-
|
|
14
|
+
// call_id → { timer, resolve } (action → serve_call → serve_reply 대기)
|
|
15
|
+
const pending = new Map();
|
|
15
16
|
|
|
16
17
|
function cleanupSocket(socket) {
|
|
17
18
|
for (const [topic, sid] of queryables) {
|
|
18
19
|
if (sid === socket.id) queryables.delete(topic);
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
pending.
|
|
25
|
-
pendingReplies.delete(id);
|
|
21
|
+
for (const [id, p] of pending) {
|
|
22
|
+
if (p.sid !== socket.id) continue;
|
|
23
|
+
clearTimeout(p.timer);
|
|
24
|
+
p.resolve(undefined);
|
|
25
|
+
pending.delete(id);
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -38,12 +38,9 @@ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
socket.on("pub", (data = {}) => {
|
|
41
|
-
const topic = data
|
|
41
|
+
const { topic, payload } = data;
|
|
42
42
|
if (!topic) return;
|
|
43
|
-
io.to(`t:${topic}`).emit("msg", {
|
|
44
|
-
topic,
|
|
45
|
-
payload: data.payload,
|
|
46
|
-
});
|
|
43
|
+
io.to(`t:${topic}`).emit("msg", { topic, payload });
|
|
47
44
|
});
|
|
48
45
|
|
|
49
46
|
socket.on("serve_register", (data = {}) => {
|
|
@@ -52,71 +49,68 @@ export function startSocketBridge({ port = 10000, host = "0.0.0.0" } = {}) {
|
|
|
52
49
|
});
|
|
53
50
|
|
|
54
51
|
socket.on("serve_unregister", (data = {}) => {
|
|
55
|
-
|
|
56
|
-
if (!topic) return;
|
|
57
|
-
if (queryables.get(topic) === socket.id) queryables.delete(topic);
|
|
52
|
+
if (queryables.get(data.topic) === socket.id) queryables.delete(data.topic);
|
|
58
53
|
});
|
|
59
54
|
|
|
60
55
|
socket.on("serve_reply", (data = {}) => {
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
63
|
-
clearTimeout(
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
const p = pending.get(data.id);
|
|
57
|
+
if (!p) return;
|
|
58
|
+
clearTimeout(p.timer);
|
|
59
|
+
p.resolve(data.payload);
|
|
60
|
+
pending.delete(data.id);
|
|
66
61
|
});
|
|
67
62
|
|
|
68
63
|
socket.on("action", (data = {}, ack) => {
|
|
69
|
-
const topic = data
|
|
70
|
-
const
|
|
71
|
-
const target =
|
|
72
|
-
|
|
64
|
+
const { topic, payload } = data;
|
|
65
|
+
const targetSid = queryables.get(topic);
|
|
66
|
+
const target = targetSid && io.sockets.sockets.get(targetSid);
|
|
73
67
|
if (!target) {
|
|
74
68
|
if (typeof ack === "function") ack(undefined);
|
|
75
69
|
return;
|
|
76
70
|
}
|
|
77
|
-
|
|
78
71
|
const id = randomUUID();
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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);
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
pending.delete(id);
|
|
74
|
+
if (typeof ack === "function") ack(undefined);
|
|
75
|
+
}, DEFAULT_ACTION_TIMEOUT_MS);
|
|
76
|
+
pending.set(id, {
|
|
77
|
+
sid: targetSid,
|
|
78
|
+
timer,
|
|
79
|
+
resolve: (v) => { if (typeof ack === "function") ack(v); },
|
|
100
80
|
});
|
|
81
|
+
target.emit("serve_call", { id, topic, payload });
|
|
101
82
|
});
|
|
102
83
|
|
|
103
84
|
socket.on("disconnect", () => cleanupSocket(socket));
|
|
104
85
|
});
|
|
105
86
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
io.close();
|
|
118
|
-
httpServer.close();
|
|
119
|
-
};
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
httpServer.once("error", (err) => {
|
|
89
|
+
if (err.code === "EADDRINUSE") {
|
|
90
|
+
log.warn(`Socket.IO bridge already running on :${port} — skipping`);
|
|
91
|
+
io.close();
|
|
92
|
+
httpServer.close();
|
|
93
|
+
resolve({ close: () => {} });
|
|
94
|
+
} else {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
120
98
|
|
|
121
|
-
|
|
99
|
+
httpServer.listen(port, host, () => {
|
|
100
|
+
log.step(`Socket.IO bridge listening on ws://127.0.0.1:${port}`);
|
|
101
|
+
|
|
102
|
+
const close = () => {
|
|
103
|
+
for (const p of pending.values()) {
|
|
104
|
+
clearTimeout(p.timer);
|
|
105
|
+
p.resolve(undefined);
|
|
106
|
+
}
|
|
107
|
+
pending.clear();
|
|
108
|
+
queryables.clear();
|
|
109
|
+
io.close();
|
|
110
|
+
httpServer.close();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
resolve({ close });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
122
116
|
}
|
package/cli/src/util.mjs
CHANGED
|
@@ -79,14 +79,16 @@ export function findPluginRoot(start = process.cwd()) {
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
const RED = "\x1b[31m";
|
|
82
83
|
const ORANGE = "\x1b[33m";
|
|
83
84
|
const RESET = "\x1b[0m";
|
|
84
85
|
|
|
85
86
|
export const log = {
|
|
86
|
-
info:
|
|
87
|
-
step:
|
|
88
|
-
ok:
|
|
89
|
-
warn:
|
|
87
|
+
info: (...m) => console.log(...m),
|
|
88
|
+
step: (...m) => console.log("›", ...m),
|
|
89
|
+
ok: (...m) => console.log("✓", ...m),
|
|
90
|
+
warn: (...m) => console.warn(`${ORANGE}⚠${RESET}`, ...m.map(s => `${ORANGE}${s}${RESET}`)),
|
|
91
|
+
error: (...m) => console.error(`${RED}✖${RESET}`, ...m.map(s => `${RED}${s}${RESET}`)),
|
|
90
92
|
};
|
|
91
93
|
|
|
92
94
|
export function humanSize(bytes) {
|
|
@@ -17,12 +17,14 @@ muscat-pk pack # build a distributable ZIP
|
|
|
17
17
|
|
|
18
18
|
```
|
|
19
19
|
__PLUGIN_DIR__/
|
|
20
|
-
├─
|
|
20
|
+
├─ ui/
|
|
21
21
|
│ └─ content.tsx # plugin UI entry point
|
|
22
|
+
├─ py/
|
|
23
|
+
│ └─ main.py # Python backend entry point
|
|
22
24
|
├─ assets/
|
|
23
25
|
│ └─ icon.svg # icon referenced from manifest
|
|
24
26
|
├─ manifest.json # metadata, contribution points, runtime config
|
|
25
|
-
├─
|
|
27
|
+
├─ README.md # this file (copied to dist/ on build)
|
|
26
28
|
└─ requirements.txt # Python deps (auto-pinned to latest on pack)
|
|
27
29
|
```
|
|
28
30
|
|
|
@@ -30,7 +32,7 @@ __PLUGIN_DIR__/
|
|
|
30
32
|
|
|
31
33
|
---
|
|
32
34
|
|
|
33
|
-
## Frontend (`
|
|
35
|
+
## Frontend (`ui/content.tsx`)
|
|
34
36
|
|
|
35
37
|
### useRb() hook
|
|
36
38
|
|
|
@@ -97,12 +99,11 @@ connect(App);
|
|
|
97
99
|
|
|
98
100
|
---
|
|
99
101
|
|
|
100
|
-
## Backend (`main.py`)
|
|
102
|
+
## Backend (`py/main.py`)
|
|
101
103
|
|
|
102
104
|
The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
should contain plugin logic only and should not start its own server.
|
|
105
|
+
`muscat-pk dev` starts the local Socket.IO bridge; `py/main.py` should contain plugin
|
|
106
|
+
logic only and must not start its own server.
|
|
106
107
|
|
|
107
108
|
### Python plugin
|
|
108
109
|
|
|
@@ -148,7 +149,6 @@ if __name__ == "__main__":
|
|
|
148
149
|
### ROS2 plugin
|
|
149
150
|
|
|
150
151
|
The node is launched with `--ros-args -r __ns:=/{slug}` automatically by `dev:back`.
|
|
151
|
-
ROS2 topics are bridged to the plugin communication layer — no extra code needed.
|
|
152
152
|
|
|
153
153
|
```python
|
|
154
154
|
# ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py
|
|
@@ -173,30 +173,116 @@ class __PLUGIN_CLASS__Node(Node):
|
|
|
173
173
|
|
|
174
174
|
```jsonc
|
|
175
175
|
{
|
|
176
|
-
|
|
176
|
+
// unique plugin ID — reverse-domain format recommended
|
|
177
|
+
"id": "com.example.my-plugin",
|
|
177
178
|
"name": "My Plugin",
|
|
178
179
|
"version": "1.0.0",
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
|
|
181
|
+
// minimum Muscat version required to load this plugin
|
|
182
|
+
"minMuscatVersion": "1.0.0",
|
|
183
|
+
|
|
184
|
+
// plugin author (empty string is fine)
|
|
185
|
+
"author": "Your Name",
|
|
186
|
+
|
|
187
|
+
// displayed in the Muscat plugin store / info panel
|
|
188
|
+
// string or { en (required), ko, ja, zh, vi, th, es }
|
|
189
|
+
"description": { "en": "Plugin description", "ko": "플러그인 설명" },
|
|
190
|
+
|
|
191
|
+
// SVG string (single file) or PNG/JPG size map (all three sizes required)
|
|
192
|
+
"icons": "assets/icon.svg",
|
|
193
|
+
// "icons": { "16": "assets/icon-16.png", "48": "assets/icon-48.png", "128": "assets/icon-128.png" },
|
|
181
194
|
|
|
182
195
|
"contributes": [
|
|
183
|
-
|
|
184
|
-
{
|
|
185
|
-
|
|
196
|
+
// program.command — adds a step node to the program tree
|
|
197
|
+
{
|
|
198
|
+
"point": "program.command",
|
|
199
|
+
"icon": "assets/icon.svg",
|
|
200
|
+
"label": { "ko": "내 플러그인", "en": "My Plugin" },
|
|
201
|
+
"ui": "program/content", // must be unique across all contributions
|
|
202
|
+
"commandInfo": {
|
|
203
|
+
"name": "My Plugin",
|
|
204
|
+
"icon": "assets/icon.svg",
|
|
205
|
+
"funcName": "plugin.run",
|
|
206
|
+
"args": {}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
// toolbar — adds a button to the top toolbar (required)
|
|
210
|
+
{
|
|
211
|
+
"point": "toolbar",
|
|
212
|
+
"icon": "assets/icon.svg",
|
|
213
|
+
"label": { "ko": "내 플러그인", "en": "My Plugin" },
|
|
214
|
+
"ui": "content", // must be unique across all contributions
|
|
215
|
+
"opens": "popover"
|
|
216
|
+
},
|
|
217
|
+
// move.rnb — adds a panel to the Move screen right-side area
|
|
218
|
+
{
|
|
219
|
+
"point": "move.rnb",
|
|
220
|
+
"icon": "assets/icon.svg",
|
|
221
|
+
"label": { "ko": "내 플러그인", "en": "My Plugin" },
|
|
222
|
+
"ui": "move/content" // must be unique across all contributions
|
|
223
|
+
}
|
|
186
224
|
],
|
|
187
225
|
|
|
188
|
-
// "python" | "ros2"
|
|
226
|
+
// "none" | "python" | "ros2"
|
|
189
227
|
"runtime": { "type": "python", "entry": "main.py" }
|
|
190
228
|
}
|
|
191
229
|
```
|
|
192
230
|
|
|
193
231
|
**Contribution points:**
|
|
194
232
|
|
|
195
|
-
| point | Where it appears |
|
|
196
|
-
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
233
|
+
| point | Where it appears | `commandInfo` required |
|
|
234
|
+
|---|---|:---:|
|
|
235
|
+
| `toolbar` | Top toolbar button (must have at least one) | — |
|
|
236
|
+
| `program.command` | Program tree step node | ✓ |
|
|
237
|
+
| `move.rnb` | Move screen right-side panel | — |
|
|
238
|
+
|
|
239
|
+
**`commandInfo` fields** (for `program.command`):
|
|
240
|
+
|
|
241
|
+
| field | type | description |
|
|
242
|
+
|---|---|---|
|
|
243
|
+
| `name` | `string` | Display name of the step node |
|
|
244
|
+
| `icon` | `string` | Icon path |
|
|
245
|
+
| `iconClassName` | `string?` | Optional CSS class for the icon |
|
|
246
|
+
| `funcName` | `string?` | Backend function name to call |
|
|
247
|
+
| `args` | `Record<string, any>?` | Default arguments |
|
|
248
|
+
| `disabled` | `boolean?` | Whether the node is disabled |
|
|
249
|
+
| `allowChildren` | `string[]?` | Allowed child commands |
|
|
250
|
+
| `allowParents` | `string[]?` | Allowed parent commands |
|
|
251
|
+
| `notAllowChildren` | `string[]?` | Disallowed child commands |
|
|
252
|
+
| `syncGroupId` | `boolean?` | Sync group ID across children |
|
|
253
|
+
| `disableCopy` | `boolean?` | Prevent copy |
|
|
254
|
+
| `disableDelete` | `boolean?` | Prevent delete |
|
|
255
|
+
| `requireChildren` | `boolean?` | Whether a child command is required |
|
|
256
|
+
| `requireParent` | `boolean?` | Whether a parent command is required |
|
|
257
|
+
| `childrenCommand` | `string?` | Default command type for children |
|
|
258
|
+
|
|
259
|
+
**Validation rules:**
|
|
260
|
+
- `id`, `name`, `version`, `minMuscatVersion` — required non-empty strings
|
|
261
|
+
- `author` — required (empty string is fine)
|
|
262
|
+
- `description` — required (`string` or `{ en (required), ko?, ja?, zh?, vi?, th?, es? }`)
|
|
263
|
+
- `icons` — SVG: single string path ending in `.svg`; PNG/JPG: object with `"16"`, `"48"`, `"128"` all present
|
|
264
|
+
- `contributes` — required array; must contain at least one `toolbar` entry
|
|
265
|
+
- `ui` — each contribution must have a unique value
|
|
266
|
+
- `commandInfo` — required when `point` is `"program.command"`
|
|
267
|
+
|
|
268
|
+
`muscat-pk build` throws on any validation error. `muscat-pk dev` prints warnings.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Localized documentation
|
|
273
|
+
|
|
274
|
+
You can ship per-language README files alongside the main `README.md`. Place them in the project root:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
README.md # default / English fallback
|
|
278
|
+
README.ko.md # Korean
|
|
279
|
+
README.en.md # explicit English (optional)
|
|
280
|
+
README.ja.md # Japanese
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
All `README*.md` files found at the project root are automatically copied to `dist/` on
|
|
284
|
+
`muscat-pk build` / `muscat-pk pack`. Muscat displays the file matching the user's locale,
|
|
285
|
+
falling back to `README.md` if none matches.
|
|
200
286
|
|
|
201
287
|
---
|
|
202
288
|
|
|
@@ -211,13 +297,11 @@ numpy==1.26.4
|
|
|
211
297
|
opencv-python
|
|
212
298
|
```
|
|
213
299
|
|
|
214
|
-
|
|
300
|
+
Run `muscat-pk dev` or `muscat-pk dev:back` — dependencies are installed automatically on every startup.
|
|
215
301
|
|
|
216
302
|
- **Python plugin**: installed into `.venv` via `pip install -r requirements.txt`
|
|
217
303
|
- **ROS2 plugin**: installed inside the Docker container via `pip install -r requirements.txt`
|
|
218
304
|
|
|
219
|
-
No extra steps needed.
|
|
220
|
-
|
|
221
305
|
> `muscat-pk build` / `muscat-pk pack` only **pins** versions in `requirements.txt` — it does not run `pip install`.
|
|
222
306
|
|
|
223
307
|
---
|
|
@@ -240,8 +324,9 @@ muscat-pk type change --to python # ros2 → python
|
|
|
240
324
|
muscat-pk pack
|
|
241
325
|
```
|
|
242
326
|
|
|
243
|
-
- Builds `
|
|
327
|
+
- Builds `ui/content.tsx` → `dist/content.js`
|
|
244
328
|
- Pins `requirements.txt` to latest versions (stable-only on `main` branch)
|
|
329
|
+
- Copies `assets/`, `manifest.json`, and all `README*.md` files to `dist/`
|
|
245
330
|
- ROS2: runs `colcon build` inside the Jazzy container; includes `ros2_ws/install/`
|
|
246
331
|
- Output: `__PLUGIN_DIR__-{version}.zip`
|
|
247
332
|
|
|
@@ -249,10 +334,15 @@ ZIP layout:
|
|
|
249
334
|
|
|
250
335
|
```
|
|
251
336
|
__PLUGIN_DIR__-1.0.0.zip
|
|
252
|
-
├─ dist/
|
|
253
|
-
├─
|
|
254
|
-
├─
|
|
255
|
-
|
|
337
|
+
├─ dist/
|
|
338
|
+
│ ├─ content.js # built UI
|
|
339
|
+
│ ├─ manifest.json
|
|
340
|
+
│ ├─ assets/
|
|
341
|
+
│ ├─ README.md
|
|
342
|
+
│ └─ README.ko.md # if present
|
|
343
|
+
├─ py/
|
|
344
|
+
│ └─ main.py
|
|
345
|
+
└─ requirements.txt # pinned
|
|
256
346
|
```
|
|
257
347
|
|
|
258
348
|
---
|
|
@@ -2,16 +2,38 @@
|
|
|
2
2
|
"id": "__PLUGIN_ID__",
|
|
3
3
|
"name": "__PLUGIN_NAME__",
|
|
4
4
|
"version": "1.0.0",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
5
|
+
"minMuscatVersion": "1.0.0",
|
|
6
|
+
"author": "",
|
|
7
|
+
"description": {
|
|
8
|
+
"ko": "",
|
|
9
|
+
"en": ""
|
|
10
|
+
},
|
|
11
|
+
"icons": "assets/icon.svg",
|
|
7
12
|
"contributes": [
|
|
8
|
-
{ "point": "program.panel", "label": "__PLUGIN_NAME__", "icon": "assets/icon.svg" },
|
|
9
|
-
{ "point": "lnb", "label": "__PLUGIN_NAME__", "icon": "assets/icon.svg" },
|
|
10
13
|
{
|
|
11
|
-
"point": "
|
|
12
|
-
"label": "__PLUGIN_NAME__",
|
|
14
|
+
"point": "program.command",
|
|
13
15
|
"icon": "assets/icon.svg",
|
|
16
|
+
"label": { "ko": "__PLUGIN_NAME__", "en": "__PLUGIN_NAME__" },
|
|
17
|
+
"ui": "program/content",
|
|
18
|
+
"commandInfo": {
|
|
19
|
+
"name": "__PLUGIN_NAME__",
|
|
20
|
+
"icon": "assets/icon.svg",
|
|
21
|
+
"funcName": "plugin.run",
|
|
22
|
+
"args": {}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"point": "toolbar",
|
|
27
|
+
"icon": "assets/icon.svg",
|
|
28
|
+
"label": { "ko": "__PLUGIN_NAME__", "en": "__PLUGIN_NAME__" },
|
|
29
|
+
"ui": "content",
|
|
14
30
|
"opens": "popover"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"point": "move.rnb",
|
|
34
|
+
"icon": "assets/icon.svg",
|
|
35
|
+
"label": { "ko": "__PLUGIN_NAME__", "en": "__PLUGIN_NAME__" },
|
|
36
|
+
"ui": "move/content"
|
|
15
37
|
}
|
|
16
38
|
],
|
|
17
39
|
"runtime": { "type": "__RUNTIME_TYPE__", "entry": "main.py" }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
rainbow-rb-sdk==0.0.9.
|
|
2
|
-
rainbow-rb-flat-buffers==0.0.9.
|
|
3
|
-
rainbow-rb-zenoh==0.0.9.
|
|
1
|
+
rainbow-rb-sdk==0.0.9.dev42
|
|
2
|
+
rainbow-rb-flat-buffers==0.0.9.dev42
|
|
3
|
+
rainbow-rb-zenoh==0.0.9.dev42
|
|
4
|
+
requests==2.34.2
|
|
@@ -1,29 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync } from "fs";
|
|
2
|
+
import { basename, extname, resolve } from "path";
|
|
2
3
|
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { defineConfig } from "vite";
|
|
3
5
|
|
|
4
6
|
// Plugin UI and React are provided by Muscat/preview-host via import map.
|
|
5
7
|
// They must NOT be bundled into the plugin ZIP.
|
|
6
8
|
const HOST_PROVIDED_EXTERNALS = [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"react/jsx-runtime",
|
|
11
|
-
"react/jsx-dev-runtime",
|
|
12
|
-
"react-dom",
|
|
13
|
-
/^react-dom\//,
|
|
9
|
+
/^@rainbow-robotics\/plugin-ui(\/|$)/,
|
|
10
|
+
/^react(\/|$)/,
|
|
11
|
+
/^react-dom(\/|$)/,
|
|
14
12
|
];
|
|
15
13
|
|
|
14
|
+
// All top-level .tsx/.ts files in ui/ become separate entry points.
|
|
15
|
+
// Subdirectories (components/, hooks/, etc.) are imported by entries and bundled in.
|
|
16
|
+
const uiEntries = Object.fromEntries(
|
|
17
|
+
readdirSync("ui")
|
|
18
|
+
.filter((f) => /\.(tsx?|jsx?)$/.test(f))
|
|
19
|
+
.map((f) => [basename(f, extname(f)), resolve("ui", f)]),
|
|
20
|
+
);
|
|
21
|
+
|
|
16
22
|
export default defineConfig({
|
|
17
23
|
plugins: [react()],
|
|
18
24
|
build: {
|
|
19
|
-
lib: {
|
|
20
|
-
entry: "ui/content.tsx",
|
|
21
|
-
formats: ["es"],
|
|
22
|
-
fileName: "content",
|
|
23
|
-
},
|
|
24
25
|
outDir: "dist",
|
|
26
|
+
emptyOutDir: false,
|
|
25
27
|
rollupOptions: {
|
|
28
|
+
input: uiEntries,
|
|
26
29
|
external: HOST_PROVIDED_EXTERNALS,
|
|
30
|
+
output: {
|
|
31
|
+
format: "es",
|
|
32
|
+
entryFileNames: "[name].js",
|
|
33
|
+
chunkFileNames: "chunks/[name]-[hash].js",
|
|
34
|
+
assetFileNames: "assets/[name]-[hash][extname]",
|
|
35
|
+
},
|
|
27
36
|
},
|
|
28
37
|
},
|
|
29
38
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainbow-robotics/muscat-plugin-kit",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.23",
|
|
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.
|
|
34
|
+
"@rainbow-robotics/preview-host": "0.1.2-dev.23",
|
|
35
35
|
"socket.io": "^4.8.1"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|