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

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