@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.
- package/README.md +95 -16
- package/cli/README.md +4 -5
- package/cli/bin/create-muscat-plugin.mjs +1 -1
- package/cli/bin/muscat-pk.mjs +16 -10
- package/cli/docker/Dockerfile.bridge +17 -0
- package/cli/docker/Dockerfile.cpp +22 -0
- package/cli/docker/Dockerfile.dev +14 -0
- package/cli/docker/Dockerfile.python +20 -0
- package/cli/docker/bridge-package.json +8 -0
- package/cli/src/bridge.mjs +92 -0
- package/cli/src/build.mjs +406 -36
- package/cli/src/check-package.mjs +2 -1
- package/cli/src/cpp.mjs +65 -0
- package/cli/src/dev.mjs +16 -3
- package/cli/src/devBack.mjs +246 -49
- package/cli/src/devUi.mjs +118 -15
- package/cli/src/esbuild.mjs +51 -10
- package/cli/src/manifest.mjs +189 -0
- package/cli/src/new.mjs +152 -27
- package/cli/src/preview.mjs +91 -0
- package/cli/src/python.mjs +112 -0
- package/cli/src/ros2.mjs +66 -0
- package/cli/src/socketBridge.mjs +129 -0
- package/cli/src/type.mjs +114 -0
- package/cli/src/update.mjs +267 -30
- package/cli/src/util.mjs +65 -4
- package/cli/templates/common/README.md +382 -9
- package/cli/templates/common/manifest.json +13 -13
- package/cli/templates/common/package.json +5 -11
- package/cli/templates/common/py/main.py +88 -0
- package/cli/templates/common/requirements.txt +4 -0
- package/cli/templates/common/tsconfig.json +1 -1
- package/cli/templates/common/{src → ui}/content.tsx +3 -2
- package/cli/templates/common/vite.config.ts +22 -13
- package/cli/templates/cpp/cpp/CMakeLists.txt +14 -0
- package/cli/templates/cpp/cpp/main.cpp +25 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/__init__.py +0 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/resource/__PLUGIN_SLUG__ +0 -0
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/CMakeLists.txt +17 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -0
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +22 -0
- package/package.json +4 -4
- package/cli/src/pack.mjs +0 -58
- package/cli/templates/sdk/main.py +0 -21
- package/cli/templates/sdk/requirements.txt +0 -1
package/cli/src/build.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// muscat-pk build — bundle
|
|
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 {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
94
|
-
|
|
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
|
}
|
package/cli/src/cpp.mjs
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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);
|