@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.29 → 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.
package/cli/src/build.mjs CHANGED
@@ -85,10 +85,11 @@ export async function exportRos2Build(root) {
85
85
  log.ok("ros2_ws/install ready (will be included in the ZIP)");
86
86
  }
87
87
 
88
- // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) and copy
89
- // the binary into dist/ so the ZIP carries a robot-compatible executable.
90
- // Skipped (with a warning) if Docker is unavailable.
91
- export async function exportCppBuild(root, distDir) {
88
+ // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) per arch
89
+ // and copy each binary into dist/{arch}/{entry} so the ZIP carries relocatable
90
+ // robot-compatible executables. rb-runtime prefers dist/{arch}/ and falls back
91
+ // to dist/{entry} (legacy). Skipped (with a warning) if Docker is unavailable.
92
+ export async function exportCppBuild(root, distDir, archs = ["x86_64", "aarch64"]) {
92
93
  if (manifestRuntime(root) !== "cpp") return;
93
94
  const { assertDockerReady, ensureImage, cmakeBuild } = await import("./cpp.mjs");
94
95
  try {
@@ -98,17 +99,27 @@ export async function exportCppBuild(root, distDir) {
98
99
  return;
99
100
  }
100
101
  ensureImage();
101
- cmakeBuild(root, { buildType: "Release" });
102
102
 
103
103
  const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
104
- const built = resolve(root, "cpp/build", entry ?? "");
105
- if (entry && existsSync(built)) {
106
- const dest = resolve(distDir, entry);
107
- copyFileSync(built, dest);
108
- chmodSync(dest, 0o755);
109
- log.ok(`cpp binary → dist/${entry} (will be included in the ZIP)`);
110
- } else {
111
- log.warn(`cpp binary not found at cpp/build/${entry} — skipped`);
104
+ if (!entry) {
105
+ log.warn("manifest.json runtime.entry not set — cpp build skipped");
106
+ return;
107
+ }
108
+
109
+ for (const arch of archs) {
110
+ cmakeBuild(root, { buildType: "Release", arch });
111
+ const buildDir = `cpp/build-${arch}`;
112
+ const built = resolve(root, buildDir, entry);
113
+ if (existsSync(built)) {
114
+ const archDir = resolve(distDir, arch);
115
+ mkdirSync(archDir, { recursive: true });
116
+ const dest = resolve(archDir, entry);
117
+ copyFileSync(built, dest);
118
+ chmodSync(dest, 0o755);
119
+ log.ok(`cpp binary → dist/${arch}/${entry}`);
120
+ } else {
121
+ log.warn(`cpp binary not found at ${buildDir}/${entry} — skipped for ${arch}`);
122
+ }
112
123
  }
113
124
  }
114
125
 
@@ -258,6 +269,92 @@ function dirSize(dir) {
258
269
  return total;
259
270
  }
260
271
 
272
+ // Recursively sum directory size, skipping subdirs in `skip` list (by name).
273
+ function dirSizeExcluding(dir, skip = []) {
274
+ if (!existsSync(dir)) return 0;
275
+ let total = 0;
276
+ for (const entry of readdirSync(dir)) {
277
+ if (skip.includes(entry)) continue;
278
+ const full = resolve(dir, entry);
279
+ const st = statSync(full);
280
+ total += st.isDirectory() ? dirSize(full) : st.size;
281
+ }
282
+ return total;
283
+ }
284
+
285
+ // Read the uncompressed byte total of a .whl (zip) file by parsing its central
286
+ // directory — no external library needed, no actual extraction.
287
+ function unpackedWheelSize(whlPath) {
288
+ const buf = readFileSync(whlPath);
289
+ // Find EOCD signature (0x06054b50) scanning from end
290
+ let eocd = -1;
291
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65558); i--) {
292
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
293
+ }
294
+ if (eocd === -1) return 0;
295
+ const cdOffset = buf.readUInt32LE(eocd + 16);
296
+ const cdSize = buf.readUInt32LE(eocd + 12);
297
+ let pos = cdOffset;
298
+ let total = 0;
299
+ while (pos < cdOffset + cdSize) {
300
+ if (buf.readUInt32LE(pos) !== 0x02014b50) break; // central dir signature
301
+ total += buf.readUInt32LE(pos + 24); // uncompressed size
302
+ const fnLen = buf.readUInt16LE(pos + 28);
303
+ const extraLen = buf.readUInt16LE(pos + 30);
304
+ const commentLen = buf.readUInt16LE(pos + 32);
305
+ pos += 46 + fnLen + extraLen + commentLen;
306
+ }
307
+ return total;
308
+ }
309
+
310
+ // Calculate max(arch installedSize) across requested archs and stamp it into
311
+ // both manifest.json (root) and dist/manifest.json.
312
+ // installedSize(arch) = dist/(excl. wheels/) + ros2_ws/install/ + uncompressed arch+none-any wheels
313
+ function calcInstalledSize(pluginRoot, archs) {
314
+ const distDir = resolve(pluginRoot, "dist");
315
+ const wheelsDir = resolve(distDir, "wheels");
316
+ const ros2Install = resolve(pluginRoot, "ros2_ws/install");
317
+
318
+ const baseSize = dirSizeExcluding(distDir, ["wheels"]);
319
+ const ros2Size = existsSync(ros2Install) ? dirSize(ros2Install) : 0;
320
+
321
+ // Collect all wheel files once
322
+ const wheels = existsSync(wheelsDir)
323
+ ? readdirSync(wheelsDir).filter((f) => f.endsWith(".whl")).map((f) => resolve(wheelsDir, f))
324
+ : [];
325
+
326
+ const perArchSize = archs.map((arch) => {
327
+ const archWheels = wheels.filter((w) => {
328
+ const name = w.split("/").pop();
329
+ // none-any wheels count for every arch
330
+ if (name.endsWith("none-any.whl")) return true;
331
+ return name.includes(arch);
332
+ });
333
+ const wheelSize = archWheels.reduce((sum, w) => sum + unpackedWheelSize(w), 0);
334
+ return baseSize + ros2Size + wheelSize;
335
+ });
336
+
337
+ const installedSize = Math.max(0, ...perArchSize);
338
+
339
+ // Write to root manifest.json
340
+ const rootManifestPath = resolve(pluginRoot, "manifest.json");
341
+ if (existsSync(rootManifestPath)) {
342
+ const m = JSON.parse(readFileSync(rootManifestPath, "utf8"));
343
+ m.installedSize = installedSize;
344
+ writeFileSync(rootManifestPath, JSON.stringify(m, null, 2) + "\n");
345
+ }
346
+
347
+ // Re-stage dist/manifest.json so the ZIP carries the updated value
348
+ const distManifestPath = resolve(distDir, "manifest.json");
349
+ if (existsSync(distManifestPath)) {
350
+ const m = JSON.parse(readFileSync(distManifestPath, "utf8"));
351
+ m.installedSize = installedSize;
352
+ writeFileSync(distManifestPath, JSON.stringify(m, null, 2) + "\n");
353
+ }
354
+
355
+ log.ok(`installedSize → ${humanSize(installedSize)} (${archs.join(", ")} max, uncompressed)`);
356
+ }
357
+
261
358
  // Zip dist/ (+ ros2_ws/install) into a distributable ZIP with a size report.
262
359
  function packZip(pluginRoot) {
263
360
  const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
@@ -354,7 +451,7 @@ export async function runBuild(args) {
354
451
  if (needsLock) {
355
452
  const { lockAndVendor } = await import("./python.mjs");
356
453
  try {
357
- lockAndVendor(pluginRoot);
454
+ lockAndVendor(pluginRoot, archs);
358
455
  } catch (e) {
359
456
  log.warn(`python lock/vendor skipped: ${e.message}`);
360
457
  }
@@ -362,6 +459,7 @@ export async function runBuild(args) {
362
459
  }
363
460
 
364
461
  await exportRos2Build(root);
365
- await exportCppBuild(root, distDir);
462
+ await exportCppBuild(root, distDir, archs);
463
+ calcInstalledSize(pluginRoot, archs);
366
464
  if (!noZip) packZip(pluginRoot);
367
465
  }
package/cli/src/cpp.mjs CHANGED
@@ -32,21 +32,34 @@ export function ensureImage(image = CPP_DEV_IMAGE) {
32
32
  pruneMuskatImages();
33
33
  }
34
34
 
35
+ // Docker platform token for each target arch
36
+ const ARCH_DOCKER_PLATFORM = {
37
+ x86_64: "linux/amd64",
38
+ aarch64: "linux/arm64",
39
+ };
40
+
35
41
  /**
36
- * Configure + build cpp/ inside the container, producing cpp/build/<entry> on
37
- * the host (the workspace is mounted). buildType "Debug" for dev, "Release" for
38
- * export builds that ship in the ZIP.
42
+ * Configure + build cpp/ inside the container, producing cpp/build-{arch}/<entry>
43
+ * on the host (the workspace is mounted).
44
+ * - arch null → native (dev mode, uses cpp/build/)
45
+ * - arch "x86_64" / "aarch64" → cross via --platform (Release builds for ZIP)
46
+ * buildType "Debug" for dev, "Release" for export builds that ship in the ZIP.
39
47
  */
40
- export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug" } = {}) {
48
+ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", arch = null } = {}) {
49
+ const buildDir = arch ? `cpp/build-${arch}` : "cpp/build";
50
+ const platformFlags = arch && ARCH_DOCKER_PLATFORM[arch]
51
+ ? ["--platform", ARCH_DOCKER_PLATFORM[arch]]
52
+ : [];
41
53
  const script = [
42
- `cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=${buildType}`,
43
- "cmake --build cpp/build -j",
54
+ `cmake -S cpp -B ${buildDir} -DCMAKE_BUILD_TYPE=${buildType}`,
55
+ `cmake --build ${buildDir} -j`,
44
56
  ].join(" && ");
45
- log.step(`cmake build (${buildType}, in container)…`);
57
+ const label = arch ? `cmake build (${buildType}, ${arch}, in container)…` : `cmake build (${buildType}, in container)…`;
58
+ log.step(label);
46
59
  const r = spawnSync(
47
60
  "docker",
48
- ["run", "--rm", "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
61
+ ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
49
62
  { cwd: root, stdio: "inherit" },
50
63
  );
51
- if (r.status !== 0) throw new Error("cmake build failed.");
64
+ if (r.status !== 0) throw new Error(`cmake build failed${arch ? ` (${arch})` : ""}.`);
52
65
  }
@@ -31,16 +31,21 @@ export function ensureImage(image = PYTHON_DEV_IMAGE) {
31
31
  pruneMuskatImages();
32
32
  }
33
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
+
34
40
  /**
35
41
  * Ubuntu 24.04 + py3.12 컨테이너 안에서:
36
- * 1. uv pip compile requirements.txt → dist/requirements.lock (전이 포함 완전 lock)
37
- * 2. pip download -r dist/requirements.lock → dist/wheels/ (타겟 플랫폼 휠 벤더링)
42
+ * 1. uv pip compile requirements.txt → dist/requirements.lock (arch 무관, 1회)
43
+ * 2. archs별 pip download --platform <manylinux_tag> → dist/wheels/ (네이티브 휠 벤더링)
38
44
  *
39
- * 컨테이너 arch = 빌드 머신 arch = 로봇 arch 가 동일해야 네이티브 휠이 일치한다.
40
- * 순수 Python 패키지(py3-none-any)는 arch 무관.
45
+ * none-any 휠은 pip이 두 번째 arch에서 자동 skip → 중복 없음.
41
46
  * requirements.txt 없으면 조용히 건너뜀.
42
47
  */
43
- export function lockAndVendor(pluginRoot) {
48
+ export function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
44
49
  const reqTxt = resolve(pluginRoot, "requirements.txt");
45
50
  if (!existsSync(reqTxt)) {
46
51
  log.warn("requirements.txt not found — lock/vendor skipped");
@@ -64,18 +69,29 @@ export function lockAndVendor(pluginRoot) {
64
69
  if (lockResult.status !== 0) throw new Error("lock generation failed (uv pip compile)");
65
70
  log.ok("dist/requirements.lock ready");
66
71
 
67
- log.step("vendoring wheels (pip download)…");
68
- const vendorResult = spawnSync(
69
- "docker",
70
- [
71
- "run", "--rm",
72
- "-v", `${pluginRoot}:/plugin`,
73
- PYTHON_DEV_IMAGE,
74
- "bash", "-c",
75
- "set -e && cd /plugin && mkdir -p dist/wheels && pip3 download -r dist/requirements.lock --only-binary=:all: -d dist/wheels",
76
- ],
77
- { stdio: "inherit" },
78
- );
79
- if (vendorResult.status !== 0) throw new Error("wheel vendoring failed (pip download)");
80
- log.ok("dist/wheels/ ready");
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(", ")})`);
81
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.29",
3
+ "version": "0.1.2-dev.30",
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.29",
34
+ "@rainbow-robotics/preview-host": "0.1.2-dev.30",
35
35
  "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {