@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.3 → 0.1.2-dev.30

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.
Files changed (48) hide show
  1. package/README.md +95 -16
  2. package/cli/README.md +4 -5
  3. package/cli/bin/create-muscat-plugin.mjs +1 -1
  4. package/cli/bin/muscat-pk.mjs +16 -10
  5. package/cli/docker/Dockerfile.bridge +17 -0
  6. package/cli/docker/Dockerfile.cpp +22 -0
  7. package/cli/docker/Dockerfile.dev +14 -0
  8. package/cli/docker/Dockerfile.python +20 -0
  9. package/cli/docker/bridge-package.json +8 -0
  10. package/cli/src/bridge.mjs +92 -0
  11. package/cli/src/build.mjs +406 -36
  12. package/cli/src/check-package.mjs +2 -1
  13. package/cli/src/cpp.mjs +65 -0
  14. package/cli/src/dev.mjs +16 -3
  15. package/cli/src/devBack.mjs +246 -49
  16. package/cli/src/devUi.mjs +118 -15
  17. package/cli/src/esbuild.mjs +51 -10
  18. package/cli/src/manifest.mjs +189 -0
  19. package/cli/src/new.mjs +152 -27
  20. package/cli/src/preview.mjs +91 -0
  21. package/cli/src/python.mjs +97 -0
  22. package/cli/src/ros2.mjs +66 -0
  23. package/cli/src/socketBridge.mjs +129 -0
  24. package/cli/src/type.mjs +114 -0
  25. package/cli/src/update.mjs +267 -30
  26. package/cli/src/util.mjs +65 -4
  27. package/cli/templates/common/README.md +382 -9
  28. package/cli/templates/common/manifest.json +13 -13
  29. package/cli/templates/common/package.json +5 -11
  30. package/cli/templates/common/py/main.py +88 -0
  31. package/cli/templates/common/requirements.txt +4 -0
  32. package/cli/templates/common/tsconfig.json +1 -1
  33. package/cli/templates/common/{src → ui}/content.tsx +3 -2
  34. package/cli/templates/common/vite.config.ts +22 -13
  35. package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
  36. package/cli/templates/cpp/cpp/main.cpp +25 -0
  37. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
  38. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
  39. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
  40. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
  41. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
  42. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
  43. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
  44. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
  45. package/package.json +4 -4
  46. package/cli/src/pack.mjs +0 -58
  47. package/cli/templates/sdk/main.py +0 -21
  48. package/cli/templates/sdk/requirements.txt +0 -1
@@ -0,0 +1,189 @@
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", "cpp", "ros2"];
8
+ const ROS_CLIENTS = ["rclpy", "rclcpp"];
9
+ const I18N_LANGS = ["ko", "en", "ja", "zh", "vi", "th", "es"];
10
+ const TARGET_CATEGORIES = ["manipulate", "amr", "rby1", "vision"];
11
+
12
+ function validateI18nLabel(value, field, errors) {
13
+ if (value === undefined || value === null) {
14
+ errors.push(`${field} is required`);
15
+ return;
16
+ }
17
+ if (typeof value === "string") return;
18
+ if (typeof value === "object") {
19
+ if (typeof value.en !== "string")
20
+ errors.push(`${field}: "en" is required when using object form`);
21
+ const unknown = Object.keys(value).filter((k) => !I18N_LANGS.includes(k));
22
+ if (unknown.length > 0)
23
+ errors.push(`${field}: unsupported language key(s) — ${unknown.map((k) => `"${k}"`).join(", ")} (allowed: ${I18N_LANGS.join(", ")})`);
24
+ return;
25
+ }
26
+ errors.push(`${field}: must be a string or { en (required), ko, ja, zh, vi, th, es }`);
27
+ }
28
+
29
+ export function readManifest(root) {
30
+ const path = resolve(root, "manifest.json");
31
+ if (!existsSync(path)) return {};
32
+ try {
33
+ return JSON.parse(readFileSync(path, "utf8"));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function validateIcons(icons, errors) {
40
+ if (icons === undefined || icons === null) {
41
+ errors.push("icons is required");
42
+ return;
43
+ }
44
+ if (typeof icons === "string") {
45
+ if (!icons.toLowerCase().endsWith(".svg"))
46
+ errors.push(`icons: non-SVG images (PNG, JPG, etc.) must use the { "16": "...", "48": "...", "128": "..." } object form`);
47
+ } else if (typeof icons === "object") {
48
+ const missing = ICON_SIZES.filter((s) => typeof icons[s] !== "string" || icons[s].length === 0);
49
+ if (missing.length > 0)
50
+ errors.push(`icons: missing size(s) ${missing.map((s) => `"${s}"`).join(", ")} — all three (16, 48, 128) are required for image files`);
51
+ } else {
52
+ errors.push(`icons: must be a string (SVG path) or { "16", "48", "128" } object (PNG/JPG)`);
53
+ }
54
+ }
55
+
56
+ function validateRuntime(runtime, errors) {
57
+ if (runtime === undefined || runtime === null) {
58
+ errors.push("runtime is required");
59
+ return;
60
+ }
61
+ if (typeof runtime !== "object") {
62
+ errors.push("runtime must be an object");
63
+ return;
64
+ }
65
+ if (!RUNTIME_TYPES.includes(runtime.type))
66
+ errors.push(`runtime.type must be one of ${RUNTIME_TYPES.map((t) => `"${t}"`).join(" | ")}`);
67
+ if (runtime.type === "python" && !runtime.entry)
68
+ errors.push("runtime.entry is required for python runtime");
69
+ if (runtime.type === "cpp" && !runtime.entry)
70
+ errors.push("runtime.entry is required for cpp runtime (the built executable name)");
71
+ if (runtime.type === "ros2") {
72
+ if (!runtime.package) errors.push("runtime.package is required for ros2 runtime");
73
+ if (!runtime.launch) errors.push("runtime.launch is required for ros2 runtime");
74
+ if (!ROS_CLIENTS.includes(runtime.rosClient))
75
+ errors.push(`runtime.rosClient must be one of ${ROS_CLIENTS.map((c) => `"${c}"`).join(" | ")}`);
76
+ }
77
+ }
78
+
79
+ function validateCommandInfo(info, prefix, errors) {
80
+ if (typeof info.name !== "string" || info.name.length === 0)
81
+ errors.push(`${prefix}.commandInfo.name is required`);
82
+ if (typeof info.icon !== "string" || info.icon.length === 0)
83
+ errors.push(`${prefix}.commandInfo.icon is required`);
84
+ if (info.iconClassName !== undefined && typeof info.iconClassName !== "string")
85
+ errors.push(`${prefix}.commandInfo.iconClassName must be a string`);
86
+ if (info.funcName !== undefined && typeof info.funcName !== "string")
87
+ errors.push(`${prefix}.commandInfo.funcName must be a string`);
88
+ if (info.args !== undefined && (typeof info.args !== "object" || Array.isArray(info.args)))
89
+ errors.push(`${prefix}.commandInfo.args must be an object`);
90
+ for (const key of ["disabled", "syncGroupId", "disableCopy", "disableDelete", "requireChildren", "requireParent"]) {
91
+ if (info[key] !== undefined && typeof info[key] !== "boolean")
92
+ errors.push(`${prefix}.commandInfo.${key} must be a boolean`);
93
+ }
94
+ for (const key of ["allowChildren", "allowParents", "notAllowChildren"]) {
95
+ if (info[key] !== undefined && (!Array.isArray(info[key]) || info[key].some((v) => typeof v !== "string")))
96
+ errors.push(`${prefix}.commandInfo.${key} must be a string[]`);
97
+ }
98
+ if (info.childrenCommand !== undefined && typeof info.childrenCommand !== "string")
99
+ errors.push(`${prefix}.commandInfo.childrenCommand must be a string`);
100
+ }
101
+
102
+ function validateContributes(contributes, errors) {
103
+ if (!Array.isArray(contributes) || contributes.length === 0) {
104
+ errors.push("contributes is required and must be a non-empty array");
105
+ return;
106
+ }
107
+ if (!contributes.some((c) => c.point === "toolbar"))
108
+ errors.push(`contributes: at least one "toolbar" entry is required`);
109
+
110
+ const uiValues = contributes.map((c) => c.ui).filter(Boolean);
111
+ const duplicateUi = uiValues.filter((v, i) => uiValues.indexOf(v) !== i);
112
+ if (duplicateUi.length > 0)
113
+ errors.push(`contributes: "ui" values must be unique across all entries (duplicates: ${[...new Set(duplicateUi)].map((v) => `"${v}"`).join(", ")})`);
114
+
115
+ contributes.forEach((c, i) => {
116
+ validateI18nLabel(c.label, `contributes[${i}].label`, errors);
117
+ if (c.point === "program.command") {
118
+ if (!c.commandInfo)
119
+ errors.push(`contributes[${i}]: "commandInfo" is required when point is "program.command"`);
120
+ else
121
+ validateCommandInfo(c.commandInfo, `contributes[${i}]`, errors);
122
+ }
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Validate manifest.json. Returns an array of error messages (empty = valid).
128
+ */
129
+ export function validateManifest(manifest) {
130
+ const errors = [];
131
+
132
+ if (typeof manifest !== "object" || manifest === null)
133
+ return ["manifest.json is not a valid JSON object"];
134
+
135
+ for (const key of ["id", "name", "version", "minMuscatVersion"]) {
136
+ if (typeof manifest[key] !== "string" || manifest[key].length === 0)
137
+ errors.push(`"${key}" is required`);
138
+ }
139
+ if (typeof manifest.author !== "string" || manifest.author.length === 0)
140
+ errors.push(`"author" is required`);
141
+ if (typeof manifest.email !== "string" || manifest.email.length === 0)
142
+ errors.push(`"email" is required`);
143
+ if (manifest.company !== undefined && manifest.company !== null && typeof manifest.company !== "string")
144
+ errors.push(`"company" must be a string`);
145
+
146
+ const hasModels = manifest.targetModels !== undefined && manifest.targetModels !== null;
147
+ const hasCategorys = manifest.targetCategorys !== undefined && manifest.targetCategorys !== null;
148
+ if (!hasModels && !hasCategorys) {
149
+ errors.push(`"targetModels" or "targetCategorys" is required`);
150
+ } else {
151
+ if (hasModels) {
152
+ if (manifest.targetModels !== "all" && (!Array.isArray(manifest.targetModels) || manifest.targetModels.length === 0))
153
+ errors.push(`"targetModels" must be "all" or a non-empty array of model strings`);
154
+ else if (Array.isArray(manifest.targetModels) && manifest.targetModels.some((m) => typeof m !== "string"))
155
+ errors.push(`"targetModels": all entries must be strings`);
156
+ }
157
+ if (hasCategorys) {
158
+ if (manifest.targetCategorys !== "all" && (!Array.isArray(manifest.targetCategorys) || manifest.targetCategorys.length === 0))
159
+ errors.push(`"targetCategorys" must be "all" or a non-empty array`);
160
+ else if (Array.isArray(manifest.targetCategorys)) {
161
+ const invalid = manifest.targetCategorys.filter((c) => !TARGET_CATEGORIES.includes(c));
162
+ if (invalid.length > 0)
163
+ errors.push(`"targetCategorys": invalid value(s) ${invalid.map((c) => `"${c}"`).join(", ")} — allowed: ${TARGET_CATEGORIES.join(", ")}`);
164
+ }
165
+ }
166
+ }
167
+ validateI18nLabel(manifest.description, "description", errors);
168
+
169
+ validateIcons(manifest.icons, errors);
170
+ validateRuntime(manifest.runtime, errors);
171
+ validateContributes(manifest.contributes, errors);
172
+
173
+ return errors;
174
+ }
175
+
176
+ /**
177
+ * Read and validate manifest.json. Throws if any errors are found.
178
+ */
179
+ export function assertManifest(root) {
180
+ const manifest = readManifest(root);
181
+ if (manifest === null)
182
+ throw new Error("manifest.json parse failed — check JSON syntax");
183
+ if (Object.keys(manifest).length === 0)
184
+ return manifest;
185
+ const errors = validateManifest(manifest);
186
+ if (errors.length > 0)
187
+ throw new Error(`manifest.json errors:\n${errors.map((e) => ` • ${e}`).join("\n")}`);
188
+ return manifest;
189
+ }
package/cli/src/new.mjs CHANGED
@@ -6,16 +6,40 @@ import {
6
6
  mkdirSync,
7
7
  readFileSync,
8
8
  readdirSync,
9
+ renameSync,
9
10
  statSync,
10
11
  writeFileSync,
11
12
  } from "node:fs";
12
13
  import { createInterface } from "node:readline/promises";
13
14
  import { basename, resolve } from "node:path";
14
- import { parseArgs, templatesDir, log } from "./util.mjs";
15
+ import { rmSync } from "node:fs";
16
+ import { hasBin, kitVersion, parseArgs, templatesDir, log } from "./util.mjs";
15
17
 
16
- const TYPES = ["sdk", "ros2", "ui"];
18
+ // `--type` values map to a runtime descriptor. ros2 has a python (rclpy) and a
19
+ // C++ (rclcpp) flavor; we expose them as distinct type choices for clarity.
20
+ export const TYPE_CHOICES = {
21
+ python: { type: "python" },
22
+ cpp: { type: "cpp" },
23
+ ros2: { type: "ros2", rosClient: "rclpy" },
24
+ "ros2-cpp":{ type: "ros2", rosClient: "rclcpp" },
25
+ };
26
+ const TYPES = Object.keys(TYPE_CHOICES);
17
27
  const PMS = ["npm", "yarn", "pnpm", "bun"];
18
- const DEFAULT_SDK_VERSION = "2.4.1";
28
+
29
+ // Build the manifest runtime object for a given type choice + plugin slug.
30
+ export function runtimeForChoice(choice, slug) {
31
+ switch (choice.type) {
32
+ case "cpp":
33
+ // entry = built executable name (matches add_executable in cpp/CMakeLists.txt)
34
+ return { type: "cpp", entry: slug };
35
+ case "ros2":
36
+ // package = colcon package name; launch = node executable run on the robot
37
+ return { type: "ros2", rosClient: choice.rosClient, package: slug, launch: `${slug}_node` };
38
+ case "python":
39
+ default:
40
+ return { type: "python", entry: "main.py" };
41
+ }
42
+ }
19
43
 
20
44
  async function prompt(question, fallback) {
21
45
  if (!process.stdin.isTTY) return fallback;
@@ -25,7 +49,7 @@ async function prompt(question, fallback) {
25
49
  return answer || fallback;
26
50
  }
27
51
 
28
- function applyTokens(dir, tokens) {
52
+ export function applyTokens(dir, tokens) {
29
53
  for (const entry of readdirSync(dir)) {
30
54
  const full = resolve(dir, entry);
31
55
  if (statSync(full).isDirectory()) {
@@ -45,12 +69,55 @@ function applyTokens(dir, tokens) {
45
69
  }
46
70
  }
47
71
 
72
+ // Derive naming tokens from a plugin directory name.
73
+ export function deriveNames(target) {
74
+ const base = basename(target);
75
+ const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, "_");
76
+ const pluginName = base.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
77
+ return {
78
+ dir: base,
79
+ slug,
80
+ pluginName,
81
+ pluginClass: pluginName.replace(/\s+/g, ""),
82
+ pluginId: `com.example.${base.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
83
+ };
84
+ }
85
+
86
+ // Recursively rename any file/dir whose name contains __PLUGIN_SLUG__.
87
+ function renameSlugPaths(dir, slug) {
88
+ for (const entry of readdirSync(dir)) {
89
+ let full = resolve(dir, entry);
90
+ if (entry.includes("__PLUGIN_SLUG__")) {
91
+ full = resolve(dir, entry.split("__PLUGIN_SLUG__").join(slug));
92
+ renameSync(resolve(dir, entry), full);
93
+ }
94
+ if (statSync(full).isDirectory()) renameSlugPaths(full, slug);
95
+ }
96
+ }
97
+
98
+ // Copy the ros2_ws/ overlay into a plugin and substitute the package slug into
99
+ // every path segment. The dev Dockerfile is NOT scaffolded here — it lives in
100
+ // the kit (cli/docker/) so authors can't edit it. Caller runs applyTokens after.
101
+ // rclpy → templates/ros2 (ament_python)
102
+ // rclcpp → templates/ros2-cpp (ament_cmake)
103
+ export function overlayRos2(target, slug, rosClient = "rclpy") {
104
+ const tmpl = rosClient === "rclcpp" ? "ros2-cpp" : "ros2";
105
+ cpSync(resolve(templatesDir, tmpl), target, { recursive: true });
106
+ renameSlugPaths(resolve(target, "ros2_ws"), slug);
107
+ }
108
+
109
+ // Copy the cpp/ overlay (CMake project) into a plugin. Caller runs applyTokens after.
110
+ export function overlayCpp(target) {
111
+ cpSync(resolve(templatesDir, "cpp"), target, { recursive: true });
112
+ }
113
+
48
114
  export async function runNew(args) {
49
115
  const opts = parseArgs(args, { booleans: ["yes", "no-install", "help"] });
50
116
  if (opts.help) {
51
117
  console.log(
52
- "muscat-pk new [dir] [--type sdk|ros2|ui] [--pm npm|yarn|pnpm|bun]\n" +
53
- " [--sdk-version <v>] [--yes] [--no-install]",
118
+ "muscat-pk new [dir] [--type python|cpp|ros2|ros2-cpp] [--pm npm|yarn|pnpm|bun]\n" +
119
+ " [--author <name>] [--email <email>] [--description <text>]\n" +
120
+ " [--yes] [--no-install]",
54
121
  );
55
122
  return;
56
123
  }
@@ -63,50 +130,88 @@ export async function runNew(args) {
63
130
  throw new Error(`directory not empty: ${target}`);
64
131
  }
65
132
 
133
+ let authorArg = opts.author;
134
+ let emailArg = opts.email;
135
+ let descriptionArg = opts.description;
136
+
137
+ if (!opts.yes) {
138
+ if (!authorArg) authorArg = await prompt("Author name: ", "");
139
+ if (!emailArg) emailArg = await prompt("Email (required): ", "");
140
+ if (!descriptionArg) descriptionArg = await prompt("Description (English): ", "");
141
+ }
142
+
66
143
  let type = opts.type;
67
144
  if (!type && !opts.yes) {
68
- type = await prompt(`Plugin type (${TYPES.join("/")}) [sdk]: `, "sdk");
145
+ console.log("\n플러그인 타입을 선택하세요:");
146
+ console.log(" 1) Python");
147
+ console.log(" 2) C++");
148
+ console.log(" 3) ROS2 (rclpy / Python)");
149
+ console.log(" 4) ROS2 (rclcpp / C++)");
150
+ const choice = await prompt("\n선택 [1/2/3/4, default: 1]: ", "1");
151
+ type = { "2": "cpp", "3": "ros2", "4": "ros2-cpp" }[choice] ?? "python";
152
+ }
153
+ type = TYPES.includes(type) ? type : "python";
154
+ const runtimeChoice = TYPE_CHOICES[type];
155
+
156
+ // Auto-detect yarn workspace: if .yarnrc.yml exists anywhere up the tree, default to yarn.
157
+ function isInsideYarnWorkspace(dir) {
158
+ let cur = dir;
159
+ while (true) {
160
+ if (existsSync(resolve(cur, ".yarnrc.yml"))) return true;
161
+ const parent = resolve(cur, "..");
162
+ if (parent === cur) return false;
163
+ cur = parent;
164
+ }
69
165
  }
70
- type = TYPES.includes(type) ? type : "sdk";
71
166
 
72
167
  let pm = opts.pm;
73
168
  if (!pm && !opts.yes) {
74
- pm = await prompt(`Package manager (${PMS.join("/")}) [npm]: `, "npm");
169
+ const defaultPm = isInsideYarnWorkspace(process.cwd()) ? "yarn" : "npm";
170
+ pm = await prompt(`Front-end package manager (${PMS.join("/")}) [${defaultPm}]: `, defaultPm);
75
171
  }
76
172
  pm = PMS.includes(pm) ? pm : "npm";
77
173
 
78
- const sdkVersion = opts["sdk-version"] || DEFAULT_SDK_VERSION;
79
- const pluginName = basename(target)
80
- .replace(/[-_]/g, " ")
81
- .replace(/\b\w/g, (c) => c.toUpperCase());
82
- const pluginId = `com.example.${basename(target).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
174
+ const { dir, slug, pluginName, pluginClass, pluginId } = deriveNames(target);
83
175
 
84
176
  mkdirSync(target, { recursive: true });
85
177
 
178
+ // common(UI + manifest/config)은 항상 포함. 백엔드 오버레이는 타입별로 얹는다.
179
+ // python → common의 py/ 사용
180
+ // cpp → cpp/ 오버레이, py/·requirements.txt 제거
181
+ // ros2(*) → ros2_ws/ 오버레이 (rclpy=ament_python, rclcpp=ament_cmake)
86
182
  log.step(`scaffolding ${type} plugin → ${target}`);
87
183
  cpSync(resolve(templatesDir, "common"), target, { recursive: true });
88
- if (type === "sdk") {
89
- cpSync(resolve(templatesDir, "sdk"), target, { recursive: true });
90
- } else if (type === "ros2") {
91
- if (existsSync(resolve(templatesDir, "ros2-rclpy"))) {
92
- cpSync(resolve(templatesDir, "ros2-rclpy"), target, { recursive: true });
93
- } else {
94
- log.warn("ros2 template not bundled; scaffolding UI-only base");
95
- }
184
+ if (runtimeChoice.type === "ros2") overlayRos2(target, slug, runtimeChoice.rosClient);
185
+ if (runtimeChoice.type === "cpp") {
186
+ overlayCpp(target);
187
+ rmSync(resolve(target, "py"), { recursive: true, force: true });
188
+ rmSync(resolve(target, "requirements.txt"), { force: true });
96
189
  }
97
190
 
98
191
  applyTokens(target, {
99
192
  __PLUGIN_ID__: pluginId,
100
193
  __PLUGIN_NAME__: pluginName,
101
- __SDK_VERSION__: sdkVersion,
194
+ __PLUGIN_DIR__: dir,
195
+ __PLUGIN_SLUG__: slug,
196
+ __PLUGIN_CLASS__: pluginClass,
197
+ __PLUGIN_AUTHOR__: authorArg || "",
198
+ __PLUGIN_EMAIL__: emailArg || "",
102
199
  __PM__: pm,
103
- __RUNTIME_TYPE__: type === "ui" ? "none" : type === "ros2" ? "ros2" : "python",
200
+ __RUNTIME_TYPE__: runtimeChoice.type,
201
+ __RB_VERSION__: kitVersion(),
104
202
  });
105
203
 
204
+ // Write the correct runtime block into manifest.json (per-type fields).
205
+ const manifestPath = resolve(target, "manifest.json");
206
+ const manifestJson = JSON.parse(readFileSync(manifestPath, "utf8"));
207
+ manifestJson.runtime = runtimeForChoice(runtimeChoice, slug);
208
+ if (descriptionArg) manifestJson.description = { en: descriptionArg };
209
+ writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 2) + "\n");
210
+
106
211
  // gitignore (templates can't ship a literal .gitignore via npm reliably)
107
212
  writeFileSync(
108
213
  resolve(target, ".gitignore"),
109
- ["node_modules", "dist", ".venv", "*.zip", "ros2_ws/install", ""].join("\n"),
214
+ ["node_modules", "dist", ".muscat-dev", ".venv", "*.zip", "ros2_ws/install", "cpp/build", ""].join("\n"),
110
215
  );
111
216
 
112
217
  if (!opts["no-install"]) {
@@ -118,8 +223,28 @@ export async function runNew(args) {
118
223
  }
119
224
  }
120
225
 
226
+ // The back end (python / cpp / ros2) runs in a Docker container that matches
227
+ // the robot — no local Python/toolchain setup needed. Only `none` skips it.
228
+ const needsDocker = runtimeChoice.type !== "none";
229
+ const noDocker = needsDocker && !hasBin("docker");
230
+
121
231
  log.ok(`created ${targetName} (type: ${type}, pm: ${pm})`);
122
232
  log.info(`\n cd ${targetName}`);
123
- log.info(` ${pm} run dev # preview in mini-Muscat + back end`);
124
- log.info(` ${pm} run pack # build a distributable ZIP\n`);
233
+ log.info(` muscat-pk dev # preview in mini-Muscat + back end`);
234
+ log.info(` muscat-pk build # build + distributable ZIP\n`);
235
+
236
+ // ── post-scaffold environment warnings ───────────────────────────────────
237
+ const noNode = !hasBin("node");
238
+ if (noNode || noDocker) {
239
+ log.info("");
240
+ if (noNode) {
241
+ log.warn("Node.js not found. Install Node.js 20+ to run JS deps and dev:ui.");
242
+ log.warn(" https://nodejs.org");
243
+ }
244
+ if (noDocker) {
245
+ log.warn("Docker not found. The plugin back end runs in a container (matches the robot env).");
246
+ log.warn(" https://docs.docker.com/get-docker/");
247
+ }
248
+ log.info("");
249
+ }
125
250
  }
@@ -0,0 +1,91 @@
1
+ // muscat-pk preview — build the plugin and open preview-host connected to a
2
+ // real Muscat server (robot or local). No local Socket.IO server is started.
3
+ import { createInterface } from "node:readline";
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { buildPlugin } from "./build.mjs";
8
+ import { findPreviewHostRoot } from "./devUi.mjs";
9
+ import { checkKitUpdate, log, parseArgs } from "./util.mjs";
10
+
11
+ function prompt(question, defaultValue) {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise((res) => {
14
+ rl.question(question, (answer) => {
15
+ rl.close();
16
+ res(answer.trim() || defaultValue);
17
+ });
18
+ });
19
+ }
20
+
21
+ export async function runPreview(args = []) {
22
+ const opts = parseArgs(args, { booleans: ["help"] });
23
+
24
+ if (opts.help) {
25
+ console.log(
26
+ [
27
+ "",
28
+ "Usage: muscat-pk preview [options]",
29
+ "",
30
+ " 빌드된 플러그인을 실제 Muscat 서버에 연결해 미리봅니다.",
31
+ " Socket.IO 서버를 직접 띄우지 않습니다.",
32
+ "",
33
+ "Options:",
34
+ " --host <ip> Muscat 서버 IP 또는 hostname (default: localhost)",
35
+ " --port <n> preview-host 로컬 포트 (default: 4180)",
36
+ " --ws-port <n> Muscat Socket.IO 포트 (default: 10000)",
37
+ "",
38
+ ].join("\n"),
39
+ );
40
+ return;
41
+ }
42
+
43
+ await checkKitUpdate();
44
+
45
+ // ── 로봇 IP 결정 ────────────────────────────────────────────────────────────
46
+ let robotHost = opts.host ?? "";
47
+ if (!robotHost) {
48
+ robotHost = await prompt(
49
+ "Muscat 서버 IP 또는 hostname [localhost]: ",
50
+ "localhost",
51
+ );
52
+ }
53
+ const wsPort = opts["ws-port"] ?? "10000";
54
+ const socketUrl = `ws://${robotHost}:${wsPort}`;
55
+
56
+ const previewPort = opts.port ?? "4180";
57
+
58
+ log.step(`Muscat 서버: ${socketUrl}`);
59
+
60
+ // ── 플러그인 빌드 ────────────────────────────────────────────────────────────
61
+ const { distDir } = await buildPlugin();
62
+
63
+ // ── preview-host 실행 ────────────────────────────────────────────────────────
64
+ const previewRoot = findPreviewHostRoot();
65
+ const serveEntry = resolve(previewRoot, "serve.mjs");
66
+
67
+ if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
68
+ log.step("preview-host 번들 빌드 중 (최초 1회)…");
69
+ await new Promise((res, rej) => {
70
+ const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
71
+ stdio: "inherit",
72
+ });
73
+ p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
74
+ });
75
+ }
76
+
77
+ log.step(`preview-host 시작 → http://localhost:${previewPort}`);
78
+ log.step(`Socket.IO URL: ${socketUrl}`);
79
+
80
+ const child = spawn(
81
+ process.execPath,
82
+ [
83
+ serveEntry,
84
+ "--plugin", distDir,
85
+ "--port", String(previewPort),
86
+ "--socket-url", socketUrl,
87
+ ],
88
+ { stdio: "inherit" },
89
+ );
90
+ child.on("exit", (code) => process.exit(code ?? 0));
91
+ }
@@ -0,0 +1,97 @@
1
+ // Shared Python Docker helpers (Ubuntu 24.04 + Python 3.12 — matches the robot).
2
+ // Used by dev:back (hot-reload) and build (lock + wheel vendor).
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
7
+
8
+ export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
9
+
10
+ // Dockerfile lives inside the kit — plugin authors never see or edit it.
11
+ const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.python");
12
+
13
+ export function assertDockerReady() {
14
+ if (!hasBin("docker")) {
15
+ throw new Error("Docker not found. The Python backend runs in a container (Ubuntu 24.04 + Python 3.12).");
16
+ }
17
+ if (!existsSync(DOCKERFILE)) {
18
+ throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
19
+ }
20
+ }
21
+
22
+ /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
23
+ export function ensureImage(image = PYTHON_DEV_IMAGE) {
24
+ log.step(`docker build -t ${image} (first run is slow, then cached)…`);
25
+ const r = spawnSync(
26
+ "docker",
27
+ ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
28
+ { stdio: "inherit" },
29
+ );
30
+ if (r.status !== 0) throw new Error("docker build failed.");
31
+ pruneMuskatImages();
32
+ }
33
+
34
+ // manylinux tag that matches each target arch for pip --platform
35
+ const ARCH_PLATFORM = {
36
+ x86_64: "manylinux2014_x86_64",
37
+ aarch64: "manylinux2014_aarch64",
38
+ };
39
+
40
+ /**
41
+ * Ubuntu 24.04 + py3.12 컨테이너 안에서:
42
+ * 1. uv pip compile requirements.txt → dist/requirements.lock (arch 무관, 1회)
43
+ * 2. archs별 pip download --platform <manylinux_tag> → dist/wheels/ (네이티브 휠 벤더링)
44
+ *
45
+ * none-any 휠은 pip이 두 번째 arch에서 자동 skip → 중복 없음.
46
+ * requirements.txt 없으면 조용히 건너뜀.
47
+ */
48
+ export function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
49
+ const reqTxt = resolve(pluginRoot, "requirements.txt");
50
+ if (!existsSync(reqTxt)) {
51
+ log.warn("requirements.txt not found — lock/vendor skipped");
52
+ return;
53
+ }
54
+
55
+ ensureImage();
56
+
57
+ log.step("generating lock (uv pip compile)…");
58
+ const lockResult = spawnSync(
59
+ "docker",
60
+ [
61
+ "run", "--rm",
62
+ "-v", `${pluginRoot}:/plugin`,
63
+ PYTHON_DEV_IMAGE,
64
+ "bash", "-c",
65
+ "set -e && cd /plugin && mkdir -p dist && uv pip compile requirements.txt --python-version 3.12 -o dist/requirements.lock",
66
+ ],
67
+ { stdio: "inherit" },
68
+ );
69
+ if (lockResult.status !== 0) throw new Error("lock generation failed (uv pip compile)");
70
+ log.ok("dist/requirements.lock ready");
71
+
72
+ // arch별 휠 벤더링 — QEMU 불필요 (download only, --platform 고정)
73
+ for (const arch of archs) {
74
+ const platform = ARCH_PLATFORM[arch];
75
+ if (!platform) {
76
+ log.warn(`unknown arch '${arch}' — skipped`);
77
+ continue;
78
+ }
79
+ log.step(`vendoring wheels for ${arch} (${platform})…`);
80
+ const r = spawnSync(
81
+ "docker",
82
+ [
83
+ "run", "--rm",
84
+ "-v", `${pluginRoot}:/plugin`,
85
+ PYTHON_DEV_IMAGE,
86
+ "bash", "-c",
87
+ `set -e && cd /plugin && mkdir -p dist/wheels && ` +
88
+ `pip3 download -r dist/requirements.lock ` +
89
+ `--only-binary=:all: --platform ${platform} --python-version 312 --implementation cp ` +
90
+ `-d dist/wheels`,
91
+ ],
92
+ { stdio: "inherit" },
93
+ );
94
+ if (r.status !== 0) throw new Error(`wheel vendoring failed for ${arch}`);
95
+ }
96
+ log.ok(`dist/wheels/ ready (${archs.join(", ")})`);
97
+ }