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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +95 -16
  2. package/cli/README.md +4 -5
  3. package/cli/bin/create-muscat-plugin.mjs +1 -1
  4. package/cli/bin/muscat-pk.mjs +16 -10
  5. package/cli/docker/Dockerfile.bridge +17 -0
  6. package/cli/docker/Dockerfile.cpp +22 -0
  7. package/cli/docker/Dockerfile.dev +14 -0
  8. package/cli/docker/Dockerfile.python +20 -0
  9. package/cli/docker/bridge-package.json +8 -0
  10. package/cli/src/bridge.mjs +92 -0
  11. package/cli/src/build.mjs +406 -36
  12. package/cli/src/check-package.mjs +2 -1
  13. package/cli/src/cpp.mjs +65 -0
  14. package/cli/src/dev.mjs +16 -3
  15. package/cli/src/devBack.mjs +246 -49
  16. package/cli/src/devUi.mjs +118 -15
  17. package/cli/src/esbuild.mjs +51 -10
  18. package/cli/src/manifest.mjs +189 -0
  19. package/cli/src/new.mjs +152 -27
  20. package/cli/src/preview.mjs +91 -0
  21. package/cli/src/python.mjs +112 -0
  22. package/cli/src/ros2.mjs +66 -0
  23. package/cli/src/socketBridge.mjs +129 -0
  24. package/cli/src/type.mjs +114 -0
  25. package/cli/src/update.mjs +267 -30
  26. package/cli/src/util.mjs +65 -4
  27. package/cli/templates/common/README.md +382 -9
  28. package/cli/templates/common/manifest.json +13 -13
  29. package/cli/templates/common/package.json +5 -11
  30. package/cli/templates/common/py/main.py +88 -0
  31. package/cli/templates/common/requirements.txt +4 -0
  32. package/cli/templates/common/tsconfig.json +1 -1
  33. package/cli/templates/common/{src → ui}/content.tsx +3 -2
  34. package/cli/templates/common/vite.config.ts +22 -13
  35. package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
  36. package/cli/templates/cpp/cpp/main.cpp +25 -0
  37. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
  38. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
  39. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
  40. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
  41. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
  42. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
  43. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
  44. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
  45. package/package.json +4 -4
  46. package/cli/src/pack.mjs +0 -58
  47. package/cli/templates/sdk/main.py +0 -21
  48. package/cli/templates/sdk/requirements.txt +0 -1
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,185 @@
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 } from "./util.mjs";
18
+ import { assertManifest } from "./manifest.mjs";
15
19
 
16
- const SANDBOX_HTML = `<!doctype html>
20
+ // Find a usable node binary (for running vite as subprocess).
21
+ function findNodeBin() {
22
+ if (typeof Bun === "undefined") return process.execPath; // already node
23
+ for (const c of ["node", "node20", "node18"]) {
24
+ if (hasBin(c)) return c;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // Resolve vite bin path from the plugin root, hoisting-aware.
30
+ function findViteBin(pluginRoot) {
31
+ try {
32
+ const req = createRequire(resolve(pluginRoot, "package.json"));
33
+ const vitePkg = req.resolve("vite/package.json");
34
+ return resolve(dirname(vitePkg), "bin", "vite.js");
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // Run vite build in the plugin project.
41
+ // Returns true on success, false if vite/node not available.
42
+ function runViteBuild(pluginRoot, { mode = "production" } = {}) {
43
+ const node = findNodeBin();
44
+ if (!node) return false;
45
+ const viteBin = findViteBin(pluginRoot);
46
+ if (!viteBin || !existsSync(viteBin)) return false;
47
+
48
+ log.step(`vite build (mode: ${mode})`);
49
+ const r = spawnSync(node, [viteBin, "build", "--mode", mode], {
50
+ cwd: pluginRoot,
51
+ stdio: "inherit",
52
+ });
53
+ if (r.status !== 0) throw new Error("vite build failed");
54
+ return true;
55
+ }
56
+
57
+ function isMainBranch() {
58
+ const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
59
+ const branch = r.stdout?.trim();
60
+ return branch === "main" || branch === "master";
61
+ }
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) {
75
+ if (manifestRuntime(root) !== "ros2") return;
76
+ const { assertDockerReady, ensureImage, colconBuild } = await import("./ros2.mjs");
77
+ try {
78
+ assertDockerReady();
79
+ } catch (e) {
80
+ log.warn(`ros2 build skipped: ${e.message}`);
81
+ return;
82
+ }
83
+ ensureImage();
84
+ colconBuild(root, { symlink: false });
85
+ log.ok("ros2_ws/install ready (will be included in the ZIP)");
86
+ }
87
+
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"]) {
93
+ if (manifestRuntime(root) !== "cpp") return;
94
+ const { assertDockerReady, ensureImage, cmakeBuild } = await import("./cpp.mjs");
95
+ try {
96
+ assertDockerReady();
97
+ } catch (e) {
98
+ log.warn(`cpp build skipped: ${e.message}`);
99
+ return;
100
+ }
101
+ ensureImage();
102
+
103
+ const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
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
+ }
123
+ }
124
+ }
125
+
126
+ const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
127
+
128
+ function isPrerelease(version) {
129
+ return PRERELEASE_RE.test(version);
130
+ }
131
+
132
+ // Resolve the latest version of a PyPI package.
133
+ // stableOnly=true → skips pre-release (dev/a/b/rc) versions.
134
+ async function latestPypiVersion(pkg, { stableOnly = false } = {}) {
135
+ const res = await fetch(`https://pypi.org/pypi/${pkg}/json`, {
136
+ signal: AbortSignal.timeout(5000),
137
+ });
138
+ if (!res.ok) throw new Error(`pypi ${pkg}: ${res.status}`);
139
+ const data = await res.json();
140
+
141
+ if (!stableOnly) return data.info.version;
142
+
143
+ // data.info.version is latest stable when stable releases exist.
144
+ // If it's still a pre-release, scan all releases for the newest stable.
145
+ if (!isPrerelease(data.info.version)) return data.info.version;
146
+
147
+ const stable = Object.keys(data.releases)
148
+ .filter((v) => !isPrerelease(v))
149
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
150
+ .at(-1);
151
+
152
+ if (stable) return stable;
153
+
154
+ // No stable release exists — fall back with a warning.
155
+ log.warn(` ${pkg}: no stable release found, using ${data.info.version}`);
156
+ return data.info.version;
157
+ }
158
+
159
+ // Pin unpinned (no version specifier) entries in requirements.txt to the
160
+ // latest PyPI release. Runs on explicit `build` only — keeps the shipped
161
+ // plugin reproducible without churning the source file on every dev rebuild.
162
+ // stableOnly=true is used on main-branch builds to avoid shipping dev versions.
163
+ export async function pinRequirements(root, { stableOnly = false } = {}) {
164
+ const reqPath = resolve(root, "requirements.txt");
165
+ if (!existsSync(reqPath)) return;
166
+
167
+ const lines = readFileSync(reqPath, "utf8").split("\n");
168
+ const out = [];
169
+ let pinned = 0;
170
+ for (const line of lines) {
171
+ const name = line.trim();
172
+ // skip blanks, comments, and already-specified versions
173
+ if (!name || name.startsWith("#") || /[=<>~!]/.test(name)) {
174
+ out.push(line);
175
+ continue;
176
+ }
177
+ try {
178
+ const v = await latestPypiVersion(name, { stableOnly });
179
+ out.push(`${name}==${v}`);
180
+ pinned++;
181
+ log.info(` pinned ${name}==${v}`);
182
+ } catch {
183
+ out.push(line);
184
+ log.warn(` could not resolve ${name} (offline?) — left unpinned`);
185
+ }
186
+ }
187
+ if (pinned > 0) writeFileSync(reqPath, out.join("\n"));
188
+ }
189
+
190
+ export const SANDBOX_HTML = `<!doctype html>
17
191
  <html lang="en">
18
192
  <head>
19
193
  <meta charset="UTF-8" />
@@ -39,39 +213,17 @@ const SANDBOX_HTML = `<!doctype html>
39
213
  </html>
40
214
  `;
41
215
 
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
216
  export async function buildPlugin({ root, minify = false } = {}) {
53
217
  const pluginRoot = root ?? findPluginRoot();
54
- const entry = resolve(pluginRoot, "src/content.tsx");
55
- if (!existsSync(entry)) {
56
- throw new Error(`missing entry: ${entry}`);
57
- }
218
+ assertManifest(pluginRoot);
58
219
  const distDir = resolve(pluginRoot, "dist");
59
220
  rmSync(distDir, { recursive: true, force: true });
60
221
  mkdirSync(distDir, { recursive: true });
61
222
 
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
- });
223
+ const viteOk = runViteBuild(pluginRoot, { mode: minify ? "production" : "development" });
224
+ if (!viteOk) {
225
+ log.warn("vite not found — UI bundle skipped. Install Node.js and run: npm install");
226
+ }
75
227
 
76
228
  // Stage distribution files.
77
229
  const manifestSrc = resolve(pluginRoot, "manifest.json");
@@ -80,16 +232,234 @@ export async function buildPlugin({ root, minify = false } = {}) {
80
232
  // Generate sandbox.html — plugin authors don't manage this file.
81
233
  writeFileSync(resolve(distDir, "sandbox.html"), SANDBOX_HTML, "utf8");
82
234
  log.step("generated dist/sandbox.html");
235
+
83
236
  const assetsSrc = resolve(pluginRoot, "assets");
84
237
  if (existsSync(assetsSrc)) {
85
238
  cpSync(assetsSrc, resolve(distDir, "assets"), { recursive: true });
86
239
  }
87
240
 
241
+ // README.md, README.[lang].md, CHANGELOG.md → dist/
242
+ for (const f of readdirSync(pluginRoot)) {
243
+ if (/^README(\.[a-z]{2}(-[A-Z]{2})?)?\.md$/i.test(f) || /^CHANGELOG\.md$/i.test(f)) {
244
+ copyFileSync(resolve(pluginRoot, f), resolve(distDir, f));
245
+ }
246
+ }
247
+
248
+ // Backend runtime files — copy entire py/ into dist/, then requirements.txt from root.
249
+ const pySrc = resolve(pluginRoot, "py");
250
+ if (existsSync(pySrc)) {
251
+ cpSync(pySrc, distDir, { recursive: true });
252
+ }
253
+ const rootReqs = resolve(pluginRoot, "requirements.txt");
254
+ if (existsSync(rootReqs)) {
255
+ copyFileSync(rootReqs, resolve(distDir, "requirements.txt"));
256
+ }
257
+
88
258
  log.ok(`built → ${distDir}`);
89
259
  return { pluginRoot, distDir };
90
260
  }
91
261
 
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 = []) {
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
+
358
+ // Zip dist/ (+ ros2_ws/install) into a distributable ZIP with a size report.
359
+ function packZip(pluginRoot) {
360
+ const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
361
+ const zipName = `${basename(pluginRoot)}-${manifest.version || "0.0.0"}.zip`;
362
+ const zipPath = resolve(pluginRoot, zipName);
363
+ rmSync(zipPath, { force: true });
364
+
365
+ const entries = ["dist"];
366
+ if (existsSync(resolve(pluginRoot, "ros2_ws/install"))) entries.push("ros2_ws/install");
367
+
368
+ log.step(`packing ${entries.join(", ")} → ${zipName}`);
369
+ try {
370
+ execSync(`zip -r -q "${zipPath}" ${entries.map((e) => `"${e}"`).join(" ")}`, {
371
+ cwd: pluginRoot,
372
+ stdio: "inherit",
373
+ });
374
+ } catch {
375
+ throw new Error("`zip` not found. Install zip or pack manually.");
376
+ }
377
+
378
+ const zipBytes = statSync(zipPath).size;
379
+ log.ok(`${zipName} ${humanSize(zipBytes)}`);
380
+ for (const e of entries) {
381
+ const full = resolve(pluginRoot, e);
382
+ const size = statSync(full).isDirectory() ? dirSize(full) : statSync(full).size;
383
+ log.info(` ├─ ${e} ${humanSize(size)}`);
384
+ }
385
+ log.warn("robot install checks free space via GET /plugin/capacity (507 if low).");
386
+ }
387
+
388
+ /**
389
+ * Ask which target arch(es) to build for.
390
+ * Non-TTY (CI): auto-selects current machine arch.
391
+ * @returns {Promise<string[]>} e.g. ['x86_64'] | ['aarch64'] | ['x86_64','aarch64']
392
+ */
393
+ async function promptArch() {
394
+ if (!process.stdin.isTTY) {
395
+ const { arch } = await import("node:os");
396
+ const machine = arch() === "arm64" ? "aarch64" : "x86_64";
397
+ log.info(`arch auto-selected (non-TTY): ${machine}`);
398
+ return [machine];
399
+ }
400
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
401
+ process.stdout.write(
402
+ "\n Target arch:\n" +
403
+ " 1) amd64 (x86_64)\n" +
404
+ " 2) arm64 (aarch64)\n" +
405
+ " 3) Both (fat ZIP)\n",
406
+ );
407
+ const answer = (await rl.question(" Select [1/2/3, default: 3]: ")).trim() || "3";
408
+ rl.close();
409
+ if (answer === "1") return ["x86_64"];
410
+ if (answer === "2") return ["aarch64"];
411
+ return ["x86_64", "aarch64"];
412
+ }
413
+
414
+ async function promptVersion(root) {
415
+ if (!process.stdin.isTTY) return;
416
+ const manifestPath = resolve(root, "manifest.json");
417
+ if (!existsSync(manifestPath)) return;
418
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
419
+ const current = manifest.version ?? "1.0.0";
420
+
421
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
422
+ const input = (await rl.question(` Version (current: ${current}, leave blank to keep): `)).trim();
423
+ rl.close();
424
+
425
+ if (!input) return;
426
+ manifest.version = input;
427
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
428
+ log.ok(`manifest.json version → ${input}`);
429
+ }
430
+
92
431
  export async function runBuild(args) {
93
- const minify = args.includes("--minify");
94
- await buildPlugin({ minify });
432
+ const noZip = args.includes("--no-zip");
433
+ const noLock = args.includes("--no-lock");
434
+ const root = findPluginRoot();
435
+ const rt = manifestRuntime(root);
436
+
437
+ // All runtimes except "none" need Docker
438
+ if (rt !== "none") ensureDocker();
439
+
440
+ await promptVersion(root);
441
+ const archs = await promptArch();
442
+
443
+ const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
444
+
445
+ // python / rclpy: lock + wheel vendor in Ubuntu 24.04 container
446
+ if (!noLock) {
447
+ const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime ?? {};
448
+ const needsLock =
449
+ manifest.type === "python" ||
450
+ (manifest.type === "ros2" && manifest.rosClient === "rclpy");
451
+ if (needsLock) {
452
+ const { lockAndVendor } = await import("./python.mjs");
453
+ try {
454
+ lockAndVendor(pluginRoot, archs);
455
+ } catch (e) {
456
+ log.warn(`python lock/vendor skipped: ${e.message}`);
457
+ }
458
+ }
459
+ }
460
+
461
+ await exportRos2Build(root);
462
+ await exportCppBuild(root, distDir, archs);
463
+ calcInstalledSize(pluginRoot, archs);
464
+ if (!noZip) packZip(pluginRoot);
95
465
  }
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readdirSync, statSync } from "node:fs";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
@@ -22,6 +22,7 @@ function* mjsFiles(dir) {
22
22
  }
23
23
 
24
24
  for (const dir of roots) {
25
+ if (!existsSync(dir)) continue;
25
26
  for (const file of mjsFiles(dir)) {
26
27
  execFileSync(process.execPath, ["--check", file], { stdio: "inherit" });
27
28
  }
@@ -0,0 +1,65 @@
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 { existsSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
7
+ import { hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
8
+
9
+ export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
10
+
11
+ // Dockerfile lives inside the kit — plugin authors never see or edit it.
12
+ const DOCKERFILE = resolve(kitRoot, "cli/docker/Dockerfile.cpp");
13
+
14
+ export function assertDockerReady() {
15
+ if (!hasBin("docker")) {
16
+ throw new Error("Docker not found. C++ builds need Docker (Ubuntu 24.04 + cmake container).");
17
+ }
18
+ if (!existsSync(DOCKERFILE)) {
19
+ throw new Error(`kit Dockerfile missing at ${DOCKERFILE} (reinstall muscat-plugin-kit).`);
20
+ }
21
+ }
22
+
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)…`);
26
+ const r = spawnSync(
27
+ "docker",
28
+ ["build", "-t", image, "-f", DOCKERFILE, dirname(DOCKERFILE)],
29
+ { stdio: "inherit" },
30
+ );
31
+ if (r.status !== 0) throw new Error("docker build failed.");
32
+ pruneMuskatImages();
33
+ }
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
+
41
+ /**
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.
47
+ */
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
+ : [];
53
+ const script = [
54
+ `cmake -S cpp -B ${buildDir} -DCMAKE_BUILD_TYPE=${buildType}`,
55
+ `cmake --build ${buildDir} -j`,
56
+ ].join(" && ");
57
+ const label = arch ? `cmake build (${buildType}, ${arch}, in container)…` : `cmake build (${buildType}, in container)…`;
58
+ log.step(label);
59
+ const r = spawnSync(
60
+ "docker",
61
+ ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", image, "bash", "-lc", script],
62
+ { cwd: root, stdio: "inherit" },
63
+ );
64
+ if (r.status !== 0) throw new Error(`cmake build failed${arch ? ` (${arch})` : ""}.`);
65
+ }
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);