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

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 (50) 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 +386 -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/type.mjs +105 -0
  26. package/cli/src/uninstall.mjs +98 -0
  27. package/cli/src/update.mjs +259 -30
  28. package/cli/src/util.mjs +97 -7
  29. package/cli/templates/common/README.md +383 -9
  30. package/cli/templates/common/manifest.json +13 -13
  31. package/cli/templates/common/package.json +5 -11
  32. package/cli/templates/common/py/main.py +88 -0
  33. package/cli/templates/common/requirements.txt +4 -0
  34. package/cli/templates/common/tsconfig.json +1 -1
  35. package/cli/templates/common/{src → ui}/content.tsx +3 -2
  36. package/cli/templates/common/vite.config.ts +22 -13
  37. package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
  38. package/cli/templates/cpp/cpp/main.cpp +25 -0
  39. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
  40. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
  41. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
  42. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
  43. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
  44. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
  45. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
  46. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
  47. package/package.json +4 -4
  48. package/cli/src/pack.mjs +0 -58
  49. package/cli/templates/sdk/main.py +0 -21
  50. package/cli/templates/sdk/requirements.txt +0 -1
package/cli/src/build.mjs CHANGED
@@ -1,5 +1,6 @@
1
- // muscat-pk build — bundle src/content.tsx into dist/content.js and stage
2
- // manifest.json and assets/ for distribution.
1
+ // muscat-pk build — bundle ui/content.tsx into dist/content.js, stage
2
+ // manifest.json and assets/ for distribution, and zip dist/ into a
3
+ // distributable ZIP (skip with --no-zip).
3
4
  //
4
5
  // sandbox.html is generated by the CLI — plugin authors never manage it.
5
6
  // The import map always reflects the current plugin-ui entry set, so users
@@ -8,12 +9,209 @@
8
9
  // The plugin author's `@rainbow-robotics/plugin-ui` and React imports are left external.
9
10
  // Muscat/robot and preview-host provide those modules through a sandbox import
10
11
  // map, so plugin ZIPs do not carry the UI runtime.
11
- import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
- import { resolve } from "node:path";
13
- import { buildWithFallback } from "./esbuild.mjs";
14
- import { findPluginRoot, log } from "./util.mjs";
12
+ import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { basename, dirname, resolve } from "node:path";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { execSync, spawnSync } from "node:child_process";
17
+ import { ensureDocker, findPluginRoot, hasBin, humanSize, log, parseArgs } from "./util.mjs";
18
+ import { assertManifest, manifestRuntime } from "./manifest.mjs";
15
19
 
16
- const SANDBOX_HTML = `<!doctype html>
20
+ // Find a usable JS runtime for running vite as a subprocess.
21
+ // In binary (Bun) mode, try `bun` first (it can run vite directly), then node.
22
+ // In node mode, use the current process.
23
+ function findJsRuntime() {
24
+ if (typeof Bun === "undefined") return { bin: process.execPath, isBun: false };
25
+ for (const c of ["bun", "node", "node20", "node18"]) {
26
+ if (hasBin(c)) return { bin: c, isBun: c === "bun" };
27
+ }
28
+ return null;
29
+ }
30
+
31
+ // Resolve vite bin path from the plugin root, hoisting-aware.
32
+ function findViteBin(pluginRoot) {
33
+ try {
34
+ const req = createRequire(resolve(pluginRoot, "package.json"));
35
+ const vitePkg = req.resolve("vite/package.json");
36
+ return resolve(dirname(vitePkg), "bin", "vite.js");
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // Run vite build in the plugin project. Throws if runtime or vite not found.
43
+ function runViteBuild(pluginRoot, { mode = "production" } = {}) {
44
+ const runtime = findJsRuntime();
45
+ if (!runtime) {
46
+ throw new Error(
47
+ "Node.js 20+ (or Bun) is required to build the plugin UI.\n" +
48
+ " Install Node.js: https://nodejs.org\n" +
49
+ " Then run: npm install (inside the plugin directory)",
50
+ );
51
+ }
52
+ const viteBin = findViteBin(pluginRoot);
53
+ if (!viteBin || !existsSync(viteBin)) {
54
+ throw new Error(
55
+ "vite not found in this plugin's node_modules.\n" +
56
+ ` Run: npm install (inside ${pluginRoot})`,
57
+ );
58
+ }
59
+
60
+ log.step(`vite build (mode: ${mode})`);
61
+ // bun can run vite directly; node needs the explicit bin path
62
+ const args = runtime.isBun ? ["run", viteBin, "build", "--mode", mode]
63
+ : [viteBin, "build", "--mode", mode];
64
+ const r = spawnSync(runtime.bin, args, { cwd: pluginRoot, stdio: "inherit" });
65
+ if (r.status !== 0) throw new Error("vite build failed");
66
+ }
67
+
68
+ function isMainBranch() {
69
+ const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
70
+ const branch = r.stdout?.trim();
71
+ return branch === "main" || branch === "master";
72
+ }
73
+
74
+ // For ros2 plugins, compile the colcon workspace inside the Jazzy + Python 3.12 container.
75
+ // rclpy: arch-agnostic (pure Python) → dist/ros2_ws/install/ (built once)
76
+ // rclcpp: arch-specific C++ binaries → dist/ros2_ws/install-{arch}/ per arch
77
+ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
78
+ if (manifestRuntime(root) !== "ros2") return;
79
+ const { assertDockerReady, ensureImage, colconBuild, ROS2_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./ros2.mjs");
80
+ try {
81
+ assertDockerReady();
82
+ } catch (e) {
83
+ log.warn(`ros2 build skipped: ${e.message}`);
84
+ return;
85
+ }
86
+
87
+ const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
88
+ const rosClient = manifest.runtime?.rosClient;
89
+
90
+ if (rosClient === "rclcpp") {
91
+ // C++ ROS2: compile per arch so each robot picks the right install-{arch}/
92
+ for (const arch of archs) {
93
+ const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
94
+ if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
95
+ const archImage = `${ROS2_DEV_IMAGE}-${arch}`;
96
+ await ensureImage(archImage, { platform: dockerPlatform });
97
+ colconBuild(root, { symlink: false, arch });
98
+ log.ok(`dist/ros2_ws/install-${arch}/ ready`);
99
+ }
100
+ } else {
101
+ // rclpy / pure Python: arch-agnostic, build once
102
+ await ensureImage();
103
+ colconBuild(root, { symlink: false });
104
+ log.ok("dist/ros2_ws/install/ ready");
105
+ }
106
+ }
107
+
108
+ // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) per arch
109
+ // and copy each binary into dist/{arch}/{entry} so the ZIP carries relocatable
110
+ // robot-compatible executables. rb-runtime prefers dist/{arch}/ and falls back
111
+ // to dist/{entry} (legacy). Skipped (with a warning) if Docker is unavailable.
112
+ export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"]) {
113
+ if (manifestRuntime(root) !== "cpp") return;
114
+ const { assertDockerReady, ensureImage, cmakeBuild, CPP_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./cpp.mjs");
115
+ try {
116
+ assertDockerReady();
117
+ } catch (e) {
118
+ log.warn(`cpp build skipped: ${e.message}`);
119
+ return;
120
+ }
121
+
122
+ const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
123
+ if (!entry) {
124
+ log.warn("manifest.json runtime.entry not set — cpp build skipped");
125
+ return;
126
+ }
127
+
128
+ for (const arch of archs) {
129
+ // python과 동일하게 arch별 이미지를 빌드해야 --platform 불일치를 피할 수 있다.
130
+ const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
131
+ if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
132
+ const archImage = `${CPP_DEV_IMAGE}-${arch}`;
133
+ await ensureImage(archImage, { platform: dockerPlatform });
134
+ cmakeBuild(root, { buildType: "Release", arch });
135
+ const buildDir = `cpp/build-${arch}`;
136
+ const built = resolve(root, buildDir, entry);
137
+ if (existsSync(built)) {
138
+ const archDir = resolve(distDir, arch);
139
+ mkdirSync(archDir, { recursive: true });
140
+ const dest = resolve(archDir, entry);
141
+ copyFileSync(built, dest);
142
+ chmodSync(dest, 0o755);
143
+ log.ok(`cpp binary → dist/${arch}/${entry}`);
144
+ } else {
145
+ log.warn(`cpp binary not found at ${buildDir}/${entry} — skipped for ${arch}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
151
+
152
+ function isPrerelease(version) {
153
+ return PRERELEASE_RE.test(version);
154
+ }
155
+
156
+ // Resolve the latest version of a PyPI package.
157
+ // stableOnly=true → skips pre-release (dev/a/b/rc) versions.
158
+ async function latestPypiVersion(pkg, { stableOnly = false } = {}) {
159
+ const res = await fetch(`https://pypi.org/pypi/${pkg}/json`, {
160
+ signal: AbortSignal.timeout(5000),
161
+ });
162
+ if (!res.ok) throw new Error(`pypi ${pkg}: ${res.status}`);
163
+ const data = await res.json();
164
+
165
+ if (!stableOnly) return data.info.version;
166
+
167
+ // data.info.version is latest stable when stable releases exist.
168
+ // If it's still a pre-release, scan all releases for the newest stable.
169
+ if (!isPrerelease(data.info.version)) return data.info.version;
170
+
171
+ const stable = Object.keys(data.releases)
172
+ .filter((v) => !isPrerelease(v))
173
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
174
+ .at(-1);
175
+
176
+ if (stable) return stable;
177
+
178
+ // No stable release exists — fall back with a warning.
179
+ log.warn(` ${pkg}: no stable release found, using ${data.info.version}`);
180
+ return data.info.version;
181
+ }
182
+
183
+ // Pin unpinned (no version specifier) entries in requirements.txt to the
184
+ // latest PyPI release. Runs on explicit `build` only — keeps the shipped
185
+ // plugin reproducible without churning the source file on every dev rebuild.
186
+ // stableOnly=true is used on main-branch builds to avoid shipping dev versions.
187
+ export async function pinRequirements(root, { stableOnly = false } = {}) {
188
+ const reqPath = resolve(root, "requirements.txt");
189
+ if (!existsSync(reqPath)) return;
190
+
191
+ const lines = readFileSync(reqPath, "utf8").split("\n");
192
+ const out = [];
193
+ let pinned = 0;
194
+ for (const line of lines) {
195
+ const name = line.trim();
196
+ // skip blanks, comments, and already-specified versions
197
+ if (!name || name.startsWith("#") || /[=<>~!]/.test(name)) {
198
+ out.push(line);
199
+ continue;
200
+ }
201
+ try {
202
+ const v = await latestPypiVersion(name, { stableOnly });
203
+ out.push(`${name}==${v}`);
204
+ pinned++;
205
+ log.info(` pinned ${name}==${v}`);
206
+ } catch {
207
+ out.push(line);
208
+ log.warn(` could not resolve ${name} (offline?) — left unpinned`);
209
+ }
210
+ }
211
+ if (pinned > 0) writeFileSync(reqPath, out.join("\n"));
212
+ }
213
+
214
+ export const SANDBOX_HTML = `<!doctype html>
17
215
  <html lang="en">
18
216
  <head>
19
217
  <meta charset="UTF-8" />
@@ -39,39 +237,20 @@ const SANDBOX_HTML = `<!doctype html>
39
237
  </html>
40
238
  `;
41
239
 
42
- const HOST_PROVIDED_EXTERNALS = [
43
- "@rainbow-robotics/plugin-ui",
44
- "@rainbow-robotics/plugin-ui/*",
45
- "react",
46
- "react/jsx-runtime",
47
- "react/jsx-dev-runtime",
48
- "react-dom",
49
- "react-dom/*",
50
- ];
51
-
52
240
  export async function buildPlugin({ root, minify = false } = {}) {
53
241
  const pluginRoot = root ?? findPluginRoot();
54
- const entry = resolve(pluginRoot, "src/content.tsx");
55
- if (!existsSync(entry)) {
56
- throw new Error(`missing entry: ${entry}`);
57
- }
242
+ assertManifest(pluginRoot);
58
243
  const distDir = resolve(pluginRoot, "dist");
59
244
  rmSync(distDir, { recursive: true, force: true });
60
245
  mkdirSync(distDir, { recursive: true });
61
246
 
62
- log.step("bundling src/content.tsx → dist/content.js");
63
- await buildWithFallback({
64
- entryPoints: [entry],
65
- outfile: resolve(distDir, "content.js"),
66
- bundle: true,
67
- format: "esm",
68
- jsx: "automatic",
69
- minify,
70
- loader: { ".tsx": "tsx", ".ts": "ts", ".svg": "dataurl" },
71
- define: { "process.env.NODE_ENV": JSON.stringify(minify ? "production" : "development") },
72
- external: HOST_PROVIDED_EXTERNALS,
73
- logLevel: "warning",
74
- });
247
+ const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
248
+ const contributesUi = (manifest.contributes ?? []).some((c) => c.ui);
249
+ const uiDir = resolve(pluginRoot, "ui");
250
+ const hasUiFiles = existsSync(uiDir) && readdirSync(uiDir).some((f) => /\.[tj]sx?$/.test(f));
251
+ if (contributesUi || hasUiFiles) {
252
+ runViteBuild(pluginRoot, { mode: minify ? "production" : "development" });
253
+ }
75
254
 
76
255
  // Stage distribution files.
77
256
  const manifestSrc = resolve(pluginRoot, "manifest.json");
@@ -80,16 +259,187 @@ export async function buildPlugin({ root, minify = false } = {}) {
80
259
  // Generate sandbox.html — plugin authors don't manage this file.
81
260
  writeFileSync(resolve(distDir, "sandbox.html"), SANDBOX_HTML, "utf8");
82
261
  log.step("generated dist/sandbox.html");
262
+
83
263
  const assetsSrc = resolve(pluginRoot, "assets");
84
264
  if (existsSync(assetsSrc)) {
85
265
  cpSync(assetsSrc, resolve(distDir, "assets"), { recursive: true });
86
266
  }
87
267
 
268
+ // README.md, README.[lang].md, CHANGELOG.md → dist/
269
+ for (const f of readdirSync(pluginRoot)) {
270
+ if (/^README(\.[a-z]{2}(-[A-Z]{2})?)?\.md$/i.test(f) || /^CHANGELOG\.md$/i.test(f)) {
271
+ copyFileSync(resolve(pluginRoot, f), resolve(distDir, f));
272
+ }
273
+ }
274
+
275
+ // Backend runtime files — copy entire py/ into dist/, then requirements.txt from root.
276
+ const pySrc = resolve(pluginRoot, "py");
277
+ if (existsSync(pySrc)) {
278
+ cpSync(pySrc, distDir, { recursive: true });
279
+ }
280
+ const rootReqs = resolve(pluginRoot, "requirements.txt");
281
+ if (existsSync(rootReqs)) {
282
+ copyFileSync(rootReqs, resolve(distDir, "requirements.txt"));
283
+ }
284
+
88
285
  log.ok(`built → ${distDir}`);
89
286
  return { pluginRoot, distDir };
90
287
  }
91
288
 
289
+ function dirSize(dir, skip = []) {
290
+ if (!existsSync(dir)) return 0;
291
+ let total = 0;
292
+ for (const entry of readdirSync(dir)) {
293
+ if (skip.includes(entry)) continue;
294
+ const full = resolve(dir, entry);
295
+ const st = statSync(full);
296
+ total += st.isDirectory() ? dirSize(full) : st.size;
297
+ }
298
+ return total;
299
+ }
300
+
301
+ // Read the uncompressed byte total of a .whl (zip) file by parsing its central
302
+ // directory — no external library needed, no actual extraction.
303
+
304
+ // installedSize = "이 플러그인 하나를 지우면 되찾는 용량"
305
+ // = dist/(wheels/ 제외)
306
+ // ros2_ws/install* 은 이제 dist/ 안에 있으므로 별도 합산 불필요.
307
+ // 휠은 로봇 공유 스토어(store/py/)에 언팩되므로 포함하지 않는다.
308
+ function calcInstalledSize(pluginRoot) {
309
+ const distDir = resolve(pluginRoot, "dist");
310
+
311
+ const installedSize = dirSize(distDir, ["wheels"]);
312
+
313
+ // Write to root manifest.json
314
+ const rootManifestPath = resolve(pluginRoot, "manifest.json");
315
+ if (existsSync(rootManifestPath)) {
316
+ const m = JSON.parse(readFileSync(rootManifestPath, "utf8"));
317
+ m.installedSize = installedSize;
318
+ writeFileSync(rootManifestPath, JSON.stringify(m, null, 2) + "\n");
319
+ }
320
+
321
+ // Re-stage dist/manifest.json so the ZIP carries the updated value
322
+ const distManifestPath = resolve(distDir, "manifest.json");
323
+ if (existsSync(distManifestPath)) {
324
+ const m = JSON.parse(readFileSync(distManifestPath, "utf8"));
325
+ m.installedSize = installedSize;
326
+ writeFileSync(distManifestPath, JSON.stringify(m, null, 2) + "\n");
327
+ }
328
+
329
+ log.ok(`installedSize → ${humanSize(installedSize)}`);
330
+ }
331
+
332
+ // Zip dist/ into a distributable ZIP with a size report.
333
+ // ros2_ws/install* is now inside dist/, so only "dist" is needed.
334
+ function packZip(pluginRoot) {
335
+ const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
336
+ const zipName = `${basename(pluginRoot)}-${manifest.version || "0.0.0"}.zip`;
337
+ const zipPath = resolve(pluginRoot, zipName);
338
+ rmSync(zipPath, { force: true });
339
+
340
+ const entries = ["dist"];
341
+
342
+ log.step(`packing ${entries.join(", ")} → ${zipName}`);
343
+ try {
344
+ execSync(`zip -r -q "${zipPath}" ${entries.map((e) => `"${e}"`).join(" ")}`, {
345
+ cwd: pluginRoot,
346
+ stdio: "inherit",
347
+ });
348
+ } catch {
349
+ throw new Error("`zip` not found. Install zip or pack manually.");
350
+ }
351
+
352
+ const zipBytes = statSync(zipPath).size;
353
+ log.ok(`${zipName} ${humanSize(zipBytes)}`);
354
+ for (const e of entries) {
355
+ const full = resolve(pluginRoot, e);
356
+ const size = statSync(full).isDirectory() ? dirSize(full) : statSync(full).size;
357
+ log.info(` ├─ ${e} ${humanSize(size)}`);
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Ask which target arch(es) to build for.
363
+ * Non-TTY (CI): auto-selects current machine arch.
364
+ * @returns {Promise<string[]>} e.g. ['amd64'] | ['arm64'] | ['amd64','arm64']
365
+ */
366
+ async function promptArch(args = []) {
367
+ const archFlag = parseArgs(args).arch ?? null;
368
+ if (archFlag) {
369
+ if (archFlag === "both") return ["amd64", "arm64"];
370
+ if (archFlag === "amd64" || archFlag === "x86_64") return ["amd64"];
371
+ if (archFlag === "arm64" || archFlag === "aarch64") return ["arm64"];
372
+ throw new Error(`unknown --arch value '${archFlag}' (valid: amd64, arm64, both)`);
373
+ }
374
+ if (!process.stdin.isTTY) {
375
+ const { arch } = await import("node:os");
376
+ const machine = arch() === "arm64" ? "arm64" : "amd64";
377
+ log.info(`arch auto-selected (non-TTY): ${machine}`);
378
+ return [machine];
379
+ }
380
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
381
+ process.stdout.write(
382
+ "\n Target arch:\n" +
383
+ " 1) amd64\n" +
384
+ " 2) arm64\n" +
385
+ " 3) Both (fat ZIP)\n",
386
+ );
387
+ const answer = (await rl.question(" Select [1/2/3, default: 3]: ")).trim() || "3";
388
+ rl.close();
389
+ if (answer === "1") return ["amd64"];
390
+ if (answer === "2") return ["arm64"];
391
+ return ["amd64", "arm64"];
392
+ }
393
+
394
+ async function promptVersion(root) {
395
+ if (!process.stdin.isTTY) return;
396
+ const manifestPath = resolve(root, "manifest.json");
397
+ if (!existsSync(manifestPath)) return;
398
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
399
+ const current = manifest.version ?? "1.0.0";
400
+
401
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
402
+ const input = (await rl.question(` Version (current: ${current}, leave blank to keep): `)).trim();
403
+ rl.close();
404
+
405
+ if (!input) return;
406
+ manifest.version = input;
407
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
408
+ log.ok(`manifest.json version → ${input}`);
409
+ }
410
+
92
411
  export async function runBuild(args) {
93
- const minify = args.includes("--minify");
94
- await buildPlugin({ minify });
412
+ const noZip = args.includes("--no-zip");
413
+ const noLock = args.includes("--no-lock");
414
+ const root = findPluginRoot();
415
+ const rt = manifestRuntime(root);
416
+
417
+ // All runtimes except "none" need Docker
418
+ if (rt !== "none") ensureDocker();
419
+
420
+ await promptVersion(root);
421
+ const archs = await promptArch(args);
422
+
423
+ const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
424
+
425
+ // python / rclpy: lock + wheel vendor in Ubuntu 24.04 container
426
+ if (!noLock) {
427
+ const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime ?? {};
428
+ const needsLock =
429
+ manifest.type === "python" ||
430
+ (manifest.type === "ros2" && manifest.rosClient === "rclpy");
431
+ if (needsLock) {
432
+ const { lockAndVendor } = await import("./python.mjs");
433
+ try {
434
+ await lockAndVendor(pluginRoot, archs);
435
+ } catch (e) {
436
+ log.warn(`python lock/vendor skipped: ${e.message}`);
437
+ }
438
+ }
439
+ }
440
+
441
+ await exportRos2Build(root, archs);
442
+ await exportCppBuild(root, distDir, archs);
443
+ calcInstalledSize(pluginRoot);
444
+ if (!noZip) packZip(pluginRoot);
95
445
  }
@@ -1,28 +1,13 @@
1
- import { execFileSync } from "node:child_process";
2
- import { readdirSync, statSync } from "node:fs";
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
7
- const roots = [resolve(root, "cli"), resolve(root, "preview-host")];
8
-
9
- function* mjsFiles(dir) {
10
- for (const entry of readdirSync(dir)) {
11
- const full = resolve(dir, entry);
12
- const stat = statSync(full);
13
- if (stat.isDirectory()) {
14
- if (entry === "dist" || entry === "runtime" || entry === "templates") {
15
- continue;
16
- }
17
- yield* mjsFiles(full);
18
- } else if (entry.endsWith(".mjs")) {
19
- yield full;
20
- }
21
- }
22
- }
23
-
24
- for (const dir of roots) {
25
- for (const file of mjsFiles(dir)) {
26
- execFileSync(process.execPath, ["--check", file], { stdio: "inherit" });
27
- }
7
+ for (const dir of ["cli", "preview-host"]) {
8
+ if (!existsSync(resolve(root, dir))) continue;
9
+ execSync(
10
+ `find ${dir} -name '*.mjs' ! -path '*/dist/*' ! -path '*/runtime/*' ! -path '*/templates/*' -exec node --check {} +`,
11
+ { cwd: root, stdio: "inherit" },
12
+ );
28
13
  }
@@ -0,0 +1,61 @@
1
+ // Shared C++ Docker helpers (Ubuntu 24.04 + build-essential + cmake).
2
+ // Matches the robot OS so the compiled binary is relocatable to the robot.
3
+ // Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
4
+ import { spawnSync } from "node:child_process";
5
+ import { dirname } from "node:path";
6
+ import { hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
+
8
+ export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
9
+
10
+ export function assertDockerReady() {
11
+ if (!hasBin("docker")) {
12
+ throw new Error("Docker not found. C++ builds need Docker (Ubuntu 24.04 + cmake container).");
13
+ }
14
+ }
15
+
16
+ /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
17
+ export async function ensureImage(image = CPP_DEV_IMAGE, { platform = null } = {}) {
18
+ const dockerfile = await resolveDockerfile("Dockerfile.cpp");
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 token for each target arch
31
+ export const ARCH_DOCKER_PLATFORM = {
32
+ amd64: "linux/amd64",
33
+ arm64: "linux/arm64",
34
+ };
35
+
36
+ /**
37
+ * Configure + build cpp/ inside the container, producing cpp/build-{arch}/<entry>
38
+ * on the host (the workspace is mounted).
39
+ * - arch null → native (dev mode, uses cpp/build/)
40
+ * - arch "x86_64" / "aarch64" → cross via --platform (Release builds for ZIP)
41
+ * buildType "Debug" for dev, "Release" for export builds that ship in the ZIP.
42
+ */
43
+ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", arch = null } = {}) {
44
+ const buildDir = arch ? `cpp/build-${arch}` : "cpp/build";
45
+ const dockerPlatform = arch ? ARCH_DOCKER_PLATFORM[arch] : null;
46
+ const platformFlags = dockerPlatform ? ["--platform", dockerPlatform] : [];
47
+ // arch 빌드 시 arch별 이미지 사용 (로컬 이미지와 platform 불일치 방지)
48
+ const actualImage = arch ? `${CPP_DEV_IMAGE}-${arch}` : image;
49
+ const script = [
50
+ `cmake -S cpp -B ${buildDir} -DCMAKE_BUILD_TYPE=${buildType}`,
51
+ `cmake --build ${buildDir} -j`,
52
+ ].join(" && ");
53
+ const label = arch ? `cmake build (${buildType}, ${arch}, in container)…` : `cmake build (${buildType}, in container)…`;
54
+ log.step(label);
55
+ const r = spawnSync(
56
+ "docker",
57
+ ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
58
+ { cwd: root, stdio: "inherit" },
59
+ );
60
+ if (r.status !== 0) throw new Error(`cmake build failed${arch ? ` (${arch})` : ""}.`);
61
+ }
package/cli/src/dev.mjs CHANGED
@@ -1,10 +1,23 @@
1
- // muscat-pk dev — run dev:ui and dev:back together.
1
+ // muscat-pk dev — run the Socket.IO bridge (Docker), dev:ui, and dev:back together.
2
2
  import { runDevUi } from "./devUi.mjs";
3
3
  import { runDevBack } from "./devBack.mjs";
4
- import { log } from "./util.mjs";
4
+ import { startBridge, stopBridge, BRIDGE_CONTAINER } from "./bridge.mjs";
5
+ import { ensureDocker, log } from "./util.mjs";
5
6
 
6
7
  export async function runDev(args = []) {
7
- log.step("starting dev:ui + dev:back");
8
+ ensureDocker();
9
+ log.step("starting Socket.IO bridge (Docker) + dev:ui + dev:back");
10
+ await startBridge();
11
+
12
+ // Tell dev:back which bridge container to share a netns with, so the backend
13
+ // reaches the bridge at localhost:10000 (no host.docker.internal).
14
+ process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
15
+
16
+ const cleanup = () => stopBridge();
17
+ process.once("SIGINT", cleanup);
18
+ process.once("SIGTERM", cleanup);
19
+ process.once("exit", cleanup);
20
+
8
21
  // dev:back runs to completion / detaches on its own; dev:ui owns the process.
9
22
  runDevBack().catch((e) => log.warn(`dev:back: ${e.message}`));
10
23
  await runDevUi(args);