@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.34 → 0.1.2-dev.36

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.
@@ -21,7 +21,7 @@ Usage: muscat-pk <command> [options]
21
21
  Commands:
22
22
  new [dir] Scaffold a new plugin project (prompts: author, email, description, type, front-package-manager)
23
23
  type change Switch backend type (python | cpp | ros2-python | ros2-cpp)
24
- build Build + lock deps + vendor wheels + zip (--no-zip: skip zip, --no-lock: skip python lock/vendor)
24
+ build Build + lock deps + vendor wheels + zip (--no-zip: skip zip, --no-lock: skip python lock/vendor, --arch x86_64|aarch64|both)
25
25
  dev Run Socket.IO bridge, dev:ui, and dev:back together
26
26
  dev:ui Preview the plugin in a mini-Muscat (preview-host)
27
27
  dev:back Run the plugin back end (python: watchfiles reload / ros2: Docker)
package/cli/src/build.mjs CHANGED
@@ -14,8 +14,8 @@ import { createRequire } from "node:module";
14
14
  import { basename, dirname, resolve } from "node:path";
15
15
  import { createInterface } from "node:readline/promises";
16
16
  import { execSync, spawnSync } from "node:child_process";
17
- import { ensureDocker, findPluginRoot, hasBin, humanSize, log } from "./util.mjs";
18
- import { assertManifest } from "./manifest.mjs";
17
+ import { ensureDocker, findPluginRoot, hasBin, humanSize, log, parseArgs } from "./util.mjs";
18
+ import { assertManifest, manifestRuntime } from "./manifest.mjs";
19
19
 
20
20
  // Find a usable node binary (for running vite as subprocess).
21
21
  function findNodeBin() {
@@ -60,45 +60,53 @@ function isMainBranch() {
60
60
  return branch === "main" || branch === "master";
61
61
  }
62
62
 
63
- function manifestRuntime(root) {
64
- try {
65
- return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.type ?? "python";
66
- } catch {
67
- return "python";
68
- }
69
- }
70
-
71
- // For ros2 plugins, compile the colcon workspace inside the Jazzy + Python 3.12
72
- // container so the shipped ZIP carries ros2_ws/install. Real (non-symlink) build
73
- // → relocatable on the robot. Skipped (with a warning) if Docker is unavailable.
74
- export async function exportRos2Build(root) {
63
+ // For ros2 plugins, compile the colcon workspace inside the Jazzy + Python 3.12 container.
64
+ // rclpy: arch-agnostic (pure Python) → dist/ros2_ws/install/ (built once)
65
+ // rclcpp: arch-specific C++ binaries → dist/ros2_ws/install-{arch}/ per arch
66
+ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
75
67
  if (manifestRuntime(root) !== "ros2") return;
76
- const { assertDockerReady, ensureImage, colconBuild } = await import("./ros2.mjs");
68
+ const { assertDockerReady, ensureImage, colconBuild, ROS2_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./ros2.mjs");
77
69
  try {
78
70
  assertDockerReady();
79
71
  } catch (e) {
80
72
  log.warn(`ros2 build skipped: ${e.message}`);
81
73
  return;
82
74
  }
83
- ensureImage();
84
- colconBuild(root, { symlink: false });
85
- log.ok("ros2_ws/install ready (will be included in the ZIP)");
75
+
76
+ const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
77
+ const rosClient = manifest.runtime?.rosClient;
78
+
79
+ if (rosClient === "rclcpp") {
80
+ // C++ ROS2: compile per arch so each robot picks the right install-{arch}/
81
+ for (const arch of archs) {
82
+ const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
83
+ if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
84
+ const archImage = `${ROS2_DEV_IMAGE}-${arch}`;
85
+ ensureImage(archImage, { platform: dockerPlatform });
86
+ colconBuild(root, { symlink: false, arch });
87
+ log.ok(`dist/ros2_ws/install-${arch}/ ready`);
88
+ }
89
+ } else {
90
+ // rclpy / pure Python: arch-agnostic, build once
91
+ ensureImage();
92
+ colconBuild(root, { symlink: false });
93
+ log.ok("dist/ros2_ws/install/ ready");
94
+ }
86
95
  }
87
96
 
88
97
  // For cpp plugins, compile cpp/ inside the Ubuntu container (Release) per arch
89
98
  // and copy each binary into dist/{arch}/{entry} so the ZIP carries relocatable
90
99
  // robot-compatible executables. rb-runtime prefers dist/{arch}/ and falls back
91
100
  // to dist/{entry} (legacy). Skipped (with a warning) if Docker is unavailable.
92
- export async function exportCppBuild(root, distDir, archs = ["x86_64", "aarch64"]) {
101
+ export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"]) {
93
102
  if (manifestRuntime(root) !== "cpp") return;
94
- const { assertDockerReady, ensureImage, cmakeBuild } = await import("./cpp.mjs");
103
+ const { assertDockerReady, ensureImage, cmakeBuild, CPP_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./cpp.mjs");
95
104
  try {
96
105
  assertDockerReady();
97
106
  } catch (e) {
98
107
  log.warn(`cpp build skipped: ${e.message}`);
99
108
  return;
100
109
  }
101
- ensureImage();
102
110
 
103
111
  const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
104
112
  if (!entry) {
@@ -107,6 +115,11 @@ export async function exportCppBuild(root, distDir, archs = ["x86_64", "aarch64"
107
115
  }
108
116
 
109
117
  for (const arch of archs) {
118
+ // python과 동일하게 arch별 이미지를 빌드해야 --platform 불일치를 피할 수 있다.
119
+ const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
120
+ if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
121
+ const archImage = `${CPP_DEV_IMAGE}-${arch}`;
122
+ ensureImage(archImage, { platform: dockerPlatform });
110
123
  cmakeBuild(root, { buildType: "Release", arch });
111
124
  const buildDir = `cpp/build-${arch}`;
112
125
  const built = resolve(root, buildDir, entry);
@@ -259,18 +272,7 @@ export async function buildPlugin({ root, minify = false } = {}) {
259
272
  return { pluginRoot, distDir };
260
273
  }
261
274
 
262
- function dirSize(dir) {
263
- let total = 0;
264
- for (const entry of readdirSync(dir)) {
265
- const full = resolve(dir, entry);
266
- const st = statSync(full);
267
- total += st.isDirectory() ? dirSize(full) : st.size;
268
- }
269
- return total;
270
- }
271
-
272
- // Recursively sum directory size, skipping subdirs in `skip` list (by name).
273
- function dirSizeExcluding(dir, skip = []) {
275
+ function dirSize(dir, skip = []) {
274
276
  if (!existsSync(dir)) return 0;
275
277
  let total = 0;
276
278
  for (const entry of readdirSync(dir)) {
@@ -284,57 +286,15 @@ function dirSizeExcluding(dir, skip = []) {
284
286
 
285
287
  // Read the uncompressed byte total of a .whl (zip) file by parsing its central
286
288
  // 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
289
 
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
- });
290
+ // installedSize = "이 플러그인 하나를 지우면 되찾는 용량"
291
+ // = dist/(wheels/ 제외)
292
+ // ros2_ws/install* 은 이제 dist/ 안에 있으므로 별도 합산 불필요.
293
+ // 휠은 로봇 공유 스토어(store/py/)에 언팩되므로 포함하지 않는다.
294
+ function calcInstalledSize(pluginRoot) {
295
+ const distDir = resolve(pluginRoot, "dist");
336
296
 
337
- const installedSize = Math.max(0, ...perArchSize);
297
+ const installedSize = dirSize(distDir, ["wheels"]);
338
298
 
339
299
  // Write to root manifest.json
340
300
  const rootManifestPath = resolve(pluginRoot, "manifest.json");
@@ -352,10 +312,11 @@ function calcInstalledSize(pluginRoot, archs) {
352
312
  writeFileSync(distManifestPath, JSON.stringify(m, null, 2) + "\n");
353
313
  }
354
314
 
355
- log.ok(`installedSize → ${humanSize(installedSize)} (${archs.join(", ")} max, uncompressed)`);
315
+ log.ok(`installedSize → ${humanSize(installedSize)}`);
356
316
  }
357
317
 
358
- // Zip dist/ (+ ros2_ws/install) into a distributable ZIP with a size report.
318
+ // Zip dist/ into a distributable ZIP with a size report.
319
+ // ros2_ws/install* is now inside dist/, so only "dist" is needed.
359
320
  function packZip(pluginRoot) {
360
321
  const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
361
322
  const zipName = `${basename(pluginRoot)}-${manifest.version || "0.0.0"}.zip`;
@@ -363,7 +324,6 @@ function packZip(pluginRoot) {
363
324
  rmSync(zipPath, { force: true });
364
325
 
365
326
  const entries = ["dist"];
366
- if (existsSync(resolve(pluginRoot, "ros2_ws/install"))) entries.push("ros2_ws/install");
367
327
 
368
328
  log.step(`packing ${entries.join(", ")} → ${zipName}`);
369
329
  try {
@@ -387,27 +347,34 @@ function packZip(pluginRoot) {
387
347
  /**
388
348
  * Ask which target arch(es) to build for.
389
349
  * Non-TTY (CI): auto-selects current machine arch.
390
- * @returns {Promise<string[]>} e.g. ['x86_64'] | ['aarch64'] | ['x86_64','aarch64']
350
+ * @returns {Promise<string[]>} e.g. ['amd64'] | ['arm64'] | ['amd64','arm64']
391
351
  */
392
- async function promptArch() {
352
+ async function promptArch(args = []) {
353
+ const archFlag = parseArgs(args).arch ?? null;
354
+ if (archFlag) {
355
+ if (archFlag === "both") return ["amd64", "arm64"];
356
+ if (archFlag === "amd64" || archFlag === "x86_64") return ["amd64"];
357
+ if (archFlag === "arm64" || archFlag === "aarch64") return ["arm64"];
358
+ throw new Error(`unknown --arch value '${archFlag}' (valid: amd64, arm64, both)`);
359
+ }
393
360
  if (!process.stdin.isTTY) {
394
361
  const { arch } = await import("node:os");
395
- const machine = arch() === "arm64" ? "aarch64" : "x86_64";
362
+ const machine = arch() === "arm64" ? "arm64" : "amd64";
396
363
  log.info(`arch auto-selected (non-TTY): ${machine}`);
397
364
  return [machine];
398
365
  }
399
366
  const rl = createInterface({ input: process.stdin, output: process.stdout });
400
367
  process.stdout.write(
401
368
  "\n Target arch:\n" +
402
- " 1) amd64 (x86_64)\n" +
403
- " 2) arm64 (aarch64)\n" +
369
+ " 1) amd64\n" +
370
+ " 2) arm64\n" +
404
371
  " 3) Both (fat ZIP)\n",
405
372
  );
406
373
  const answer = (await rl.question(" Select [1/2/3, default: 3]: ")).trim() || "3";
407
374
  rl.close();
408
- if (answer === "1") return ["x86_64"];
409
- if (answer === "2") return ["aarch64"];
410
- return ["x86_64", "aarch64"];
375
+ if (answer === "1") return ["amd64"];
376
+ if (answer === "2") return ["arm64"];
377
+ return ["amd64", "arm64"];
411
378
  }
412
379
 
413
380
  async function promptVersion(root) {
@@ -437,7 +404,7 @@ export async function runBuild(args) {
437
404
  if (rt !== "none") ensureDocker();
438
405
 
439
406
  await promptVersion(root);
440
- const archs = await promptArch();
407
+ const archs = await promptArch(args);
441
408
 
442
409
  const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
443
410
 
@@ -457,8 +424,8 @@ export async function runBuild(args) {
457
424
  }
458
425
  }
459
426
 
460
- await exportRos2Build(root);
427
+ await exportRos2Build(root, archs);
461
428
  await exportCppBuild(root, distDir, archs);
462
- calcInstalledSize(pluginRoot, archs);
429
+ calcInstalledSize(pluginRoot);
463
430
  if (!noZip) packZip(pluginRoot);
464
431
  }
@@ -1,29 +1,13 @@
1
- import { execFileSync } from "node:child_process";
2
- import { existsSync, 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
- if (!existsSync(dir)) continue;
26
- for (const file of mjsFiles(dir)) {
27
- execFileSync(process.execPath, ["--check", file], { stdio: "inherit" });
28
- }
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
+ );
29
13
  }
package/cli/src/cpp.mjs CHANGED
@@ -21,11 +21,12 @@ export function assertDockerReady() {
21
21
  }
22
22
 
23
23
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
24
- export function ensureImage(image = CPP_DEV_IMAGE) {
25
- log.step(`docker build -t ${image} (first run is slow, then cached)…`);
24
+ export function ensureImage(image = CPP_DEV_IMAGE, { platform = null } = {}) {
25
+ log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
26
+ const platformFlags = platform ? ["--platform", platform] : [];
26
27
  const r = spawnSync(
27
28
  "docker",
28
- ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
29
+ ["build", ...platformFlags, "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
29
30
  { stdio: "inherit" },
30
31
  );
31
32
  if (r.status !== 0) throw new Error("docker build failed.");
@@ -33,9 +34,9 @@ export function ensureImage(image = CPP_DEV_IMAGE) {
33
34
  }
34
35
 
35
36
  // Docker platform token for each target arch
36
- const ARCH_DOCKER_PLATFORM = {
37
- x86_64: "linux/amd64",
38
- aarch64: "linux/arm64",
37
+ export const ARCH_DOCKER_PLATFORM = {
38
+ amd64: "linux/amd64",
39
+ arm64: "linux/arm64",
39
40
  };
40
41
 
41
42
  /**
@@ -47,9 +48,10 @@ const ARCH_DOCKER_PLATFORM = {
47
48
  */
48
49
  export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", arch = null } = {}) {
49
50
  const buildDir = arch ? `cpp/build-${arch}` : "cpp/build";
50
- const platformFlags = arch && ARCH_DOCKER_PLATFORM[arch]
51
- ? ["--platform", ARCH_DOCKER_PLATFORM[arch]]
52
- : [];
51
+ const dockerPlatform = arch ? ARCH_DOCKER_PLATFORM[arch] : null;
52
+ const platformFlags = dockerPlatform ? ["--platform", dockerPlatform] : [];
53
+ // arch 빌드 시 arch별 이미지 사용 (로컬 이미지와 platform 불일치 방지)
54
+ const actualImage = arch ? `${CPP_DEV_IMAGE}-${arch}` : image;
53
55
  const script = [
54
56
  `cmake -S cpp -B ${buildDir} -DCMAKE_BUILD_TYPE=${buildType}`,
55
57
  `cmake --build ${buildDir} -j`,
@@ -58,7 +60,7 @@ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", a
58
60
  log.step(label);
59
61
  const r = spawnSync(
60
62
  "docker",
61
- ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
63
+ ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
62
64
  { cwd: root, stdio: "inherit" },
63
65
  );
64
66
  if (r.status !== 0) throw new Error(`cmake build failed${arch ? ` (${arch})` : ""}.`);
@@ -8,10 +8,10 @@
8
8
  // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
9
  // on source changes (rclcpp rebuilds on change).
10
10
  import { spawn, spawnSync } from "node:child_process";
11
- import { existsSync, readFileSync, watch } from "node:fs";
11
+ import { existsSync, watch } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
  import { ensureDocker, findPluginRoot, log } from "./util.mjs";
14
- import { readManifest as readManifestSchema, validateManifest } from "./manifest.mjs";
14
+ import { manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
15
15
  import {
16
16
  PYTHON_DEV_IMAGE,
17
17
  assertDockerReady as pyAssertDockerReady,
@@ -29,18 +29,6 @@ import {
29
29
  ensureImage as cppEnsureImage,
30
30
  } from "./cpp.mjs";
31
31
 
32
- function readManifest(root) {
33
- try {
34
- return JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
35
- } catch {
36
- return {};
37
- }
38
- }
39
-
40
- function manifestRuntime(root) {
41
- return readManifest(root).runtime?.type ?? "python";
42
- }
43
-
44
32
  // ── python ────────────────────────────────────────────────────────────────────
45
33
  // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
46
34
  // Under `muscat-pk dev`, it shares the bridge container's netns, so the SDK
@@ -245,7 +233,7 @@ function runCpp(root) {
245
233
  }
246
234
 
247
235
  function warnManifest(root) {
248
- const manifest = readManifestSchema(root);
236
+ const manifest = readManifest(root);
249
237
  if (!manifest || Object.keys(manifest).length === 0) return;
250
238
  const errors = validateManifest(manifest);
251
239
  for (const e of errors) log.warn(`manifest.json: ${e}`);
@@ -36,6 +36,10 @@ export function readManifest(root) {
36
36
  }
37
37
  }
38
38
 
39
+ export function manifestRuntime(root) {
40
+ return readManifest(root)?.runtime?.type ?? "python";
41
+ }
42
+
39
43
  function validateIcons(icons, errors) {
40
44
  if (icons === undefined || icons === null) {
41
45
  errors.push("icons is required");
package/cli/src/new.mjs CHANGED
@@ -10,10 +10,9 @@ import {
10
10
  statSync,
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
- import { createInterface } from "node:readline/promises";
14
13
  import { basename, resolve } from "node:path";
15
14
  import { rmSync } from "node:fs";
16
- import { hasBin, kitVersion, parseArgs, templatesDir, log } from "./util.mjs";
15
+ import { hasBin, kitVersion, parseArgs, prompt, templatesDir, log } from "./util.mjs";
17
16
 
18
17
  // `--type` values map to a runtime descriptor. ros2 has a python (rclpy) and a
19
18
  // C++ (rclcpp) flavor; we expose them as distinct type choices for clarity.
@@ -41,14 +40,6 @@ export function runtimeForChoice(choice, slug) {
41
40
  }
42
41
  }
43
42
 
44
- async function prompt(question, fallback) {
45
- if (!process.stdin.isTTY) return fallback;
46
- const rl = createInterface({ input: process.stdin, output: process.stdout });
47
- const answer = (await rl.question(question)).trim();
48
- rl.close();
49
- return answer || fallback;
50
- }
51
-
52
43
  export function applyTokens(dir, tokens) {
53
44
  for (const entry of readdirSync(dir)) {
54
45
  const full = resolve(dir, entry);
@@ -35,8 +35,8 @@ export function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null } = {})
35
35
  // Docker --platform 플래그: 컨테이너를 target arch로 띄우면
36
36
  // pip이 glibc + arch를 자동 감지해 올바른 휠을 받는다.
37
37
  const ARCH_DOCKER_PLATFORM = {
38
- x86_64: "linux/amd64",
39
- aarch64: "linux/arm64",
38
+ amd64: "linux/amd64",
39
+ arm64: "linux/arm64",
40
40
  };
41
41
 
42
42
  /**
package/cli/src/ros2.mjs CHANGED
@@ -30,12 +30,18 @@ export function assertDockerReady() {
30
30
  }
31
31
  }
32
32
 
33
+ export const ARCH_DOCKER_PLATFORM = {
34
+ amd64: "linux/amd64",
35
+ arm64: "linux/arm64",
36
+ };
37
+
33
38
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
34
- export function ensureImage(image = ROS2_DEV_IMAGE) {
35
- log.step(`docker build -t ${image} (first run is slow, then cached)…`);
39
+ export function ensureImage(image = ROS2_DEV_IMAGE, { platform = null } = {}) {
40
+ log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
41
+ const platformFlags = platform ? ["--platform", platform] : [];
36
42
  const r = spawnSync(
37
43
  "docker",
38
- ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
44
+ ["build", ...platformFlags, "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
39
45
  { stdio: "inherit" },
40
46
  );
41
47
  if (r.status !== 0) throw new Error("docker build failed.");
@@ -47,20 +53,42 @@ export function ensureImage(image = ROS2_DEV_IMAGE) {
47
53
  * (the workspace is mounted). symlink=true for dev (fast, edit-in-place);
48
54
  * symlink=false for export builds that ship in the ZIP (real copies, relocatable).
49
55
  */
50
- export function colconBuild(root, { image = ROS2_DEV_IMAGE, symlink = false } = {}) {
56
+ /**
57
+ * arch=null → dev mode, builds to ros2_ws/install/ (native)
58
+ * arch="amd64"|"arm64" → export mode, builds to ros2_ws/install-{arch}/ using arch-specific image
59
+ */
60
+ /**
61
+ * symlink=true (dev): ros2_ws/install/ — local dev workspace, not for ZIP
62
+ * symlink=false, arch=null (rclpy export): dist/ros2_ws/install/
63
+ * symlink=false, arch="amd64"|"arm64" (rclcpp export): dist/ros2_ws/install-{arch}/
64
+ */
65
+ export function colconBuild(root, { image = ROS2_DEV_IMAGE, symlink = false, arch = null } = {}) {
66
+ // paths are relative to ros2_ws/ inside the container
67
+ let installBase;
68
+ if (symlink) {
69
+ installBase = "install";
70
+ } else if (arch) {
71
+ installBase = `../dist/ros2_ws/install-${arch}`;
72
+ } else {
73
+ installBase = "../dist/ros2_ws/install";
74
+ }
51
75
  const flag = symlink ? "--symlink-install" : "";
52
76
  const script = [
53
- // --ignore-installed: don't try to uninstall debian-managed packages
54
- // (e.g. psutil), which fails with "RECORD file not found".
55
77
  "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true",
56
78
  "cd ros2_ws",
57
- `colcon build ${flag}`.trim(),
79
+ `colcon build ${flag} --install-base ${installBase}`.trim(),
58
80
  ].join(" && ");
59
- log.step(`colcon build${symlink ? " --symlink-install" : ""} (in container)…`);
81
+ const label = arch
82
+ ? `colcon build (${arch}) → dist/ros2_ws/install-${arch}/…`
83
+ : `colcon build${symlink ? " --symlink-install" : ""} (in container)…`;
84
+ log.step(label);
85
+ const actualImage = arch ? `${ROS2_DEV_IMAGE}-${arch}` : image;
86
+ const dockerPlatform = arch ? ARCH_DOCKER_PLATFORM[arch] : null;
87
+ const platformFlags = dockerPlatform ? ["--platform", dockerPlatform] : [];
60
88
  const r = spawnSync(
61
89
  "docker",
62
- ["run", "--rm", "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
90
+ ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
63
91
  { cwd: root, stdio: "inherit" },
64
92
  );
65
- if (r.status !== 0) throw new Error("colcon build failed.");
93
+ if (r.status !== 0) throw new Error(`colcon build failed${arch ? ` (${arch})` : ""}.`);
66
94
  }
package/cli/src/type.mjs CHANGED
@@ -3,9 +3,8 @@
3
3
  // Adds the matching backend overlay; never deletes existing user code
4
4
  // (e.g. ros2_ws/ or cpp/ are left in place when switching away).
5
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
- import { createInterface } from "node:readline/promises";
7
6
  import { basename, resolve } from "node:path";
8
- import { findPluginRoot, log } from "./util.mjs";
7
+ import { findPluginRoot, log, prompt } from "./util.mjs";
9
8
  import {
10
9
  TYPE_CHOICES,
11
10
  applyTokens,
@@ -17,14 +16,6 @@ import {
17
16
 
18
17
  const TYPES = Object.keys(TYPE_CHOICES);
19
18
 
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;
26
- }
27
-
28
19
  function readManifest(root) {
29
20
  const path = resolve(root, "manifest.json");
30
21
  if (!existsSync(path)) throw new Error(`manifest.json not found in ${root}`);
@@ -10,12 +10,11 @@
10
10
  //
11
11
  // After updating the CLI itself, also bumps the plugin project's local deps
12
12
  // and migrates the project structure if it's outdated (src/ → ui/, root main.py → py/).
13
- import { createInterface } from "node:readline";
14
13
  import { execSync, spawn } from "node:child_process";
15
14
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
15
  import { tmpdir } from "node:os";
17
16
  import { join, resolve } from "node:path";
18
- import { findPluginRoot, KIT_PUBLISHED_NAME, kitVersion, log, parseArgs } from "./util.mjs";
17
+ import { findPluginRoot, KIT_PUBLISHED_NAME, kitVersion, log, parseArgs, prompt } from "./util.mjs";
19
18
 
20
19
  const PLUGIN_UI_PACKAGE = "@rainbow-robotics/plugin-ui";
21
20
  const S3_BASE = "https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit";
@@ -135,13 +134,6 @@ function detectOldStructure(root) {
135
134
  return issues;
136
135
  }
137
136
 
138
- function prompt(question) {
139
- return new Promise((resolve) => {
140
- const rl = createInterface({ input: process.stdin, output: process.stdout });
141
- rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
142
- });
143
- }
144
-
145
137
  async function migrateStructure(root, issues) {
146
138
  log.warn("This plugin project's structure is outdated:");
147
139
  for (const issue of issues) log.info(" • " + issue.desc);
package/cli/src/util.mjs CHANGED
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { createInterface } from "node:readline/promises";
6
7
 
7
8
  /** True if an executable is on PATH (cross-platform). */
8
9
  export function hasBin(bin) {
@@ -65,6 +66,15 @@ export function parseArgs(args, { booleans = [] } = {}) {
65
66
  return out;
66
67
  }
67
68
 
69
+ /** Prompt user for input. Returns trimmed answer or fallback (non-TTY or empty). */
70
+ export async function prompt(question, fallback = "") {
71
+ if (!process.stdin.isTTY) return fallback;
72
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
73
+ const answer = (await rl.question(question)).trim();
74
+ rl.close();
75
+ return answer || fallback;
76
+ }
77
+
68
78
  /** Walk up from cwd to find the plugin project root (has manifest.json). */
69
79
  export function findPluginRoot(start = process.cwd()) {
70
80
  let dir = resolve(start);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-robotics/muscat-plugin-kit",
3
- "version": "0.1.2-dev.34",
3
+ "version": "0.1.2-dev.36",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -31,7 +31,7 @@
31
31
  "@types/react-dom": "^19.0.4"
32
32
  },
33
33
  "dependencies": {
34
- "@rainbow-robotics/preview-host": "0.1.2-dev.34",
34
+ "@rainbow-robotics/preview-host": "0.1.2-dev.36",
35
35
  "socket.io": "^4.8.1"
36
36
  },
37
37
  "optionalDependencies": {