@rainbow-robotics/muscat-plugin-kit 0.1.2-dev.41 → 0.1.2-dev.43
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/bin/muscat-pk.mjs +0 -4
- package/cli/src/bridge.mjs +6 -8
- package/cli/src/build.mjs +54 -26
- package/cli/src/dev.mjs +17 -7
- package/cli/src/devBack.mjs +26 -5
- package/cli/src/devUi.mjs +9 -5
- package/cli/src/python.mjs +34 -47
- package/cli/src/util.mjs +25 -0
- package/cli/templates/common/README.md +20 -0
- package/cli/templates/common/py/main.py +49 -39
- package/package.json +2 -2
- package/cli/src/preview.mjs +0 -91
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -11,7 +11,6 @@ import { runUpdate } from "../src/update.mjs";
|
|
|
11
11
|
import { runUninstall } from "../src/uninstall.mjs";
|
|
12
12
|
import { runSdk } from "../src/sdk.mjs";
|
|
13
13
|
import { runType } from "../src/type.mjs";
|
|
14
|
-
import { runPreview } from "../src/preview.mjs";
|
|
15
14
|
|
|
16
15
|
const [, , cmd, ...args] = process.argv;
|
|
17
16
|
|
|
@@ -26,7 +25,6 @@ Commands:
|
|
|
26
25
|
dev Run Socket.IO bridge, dev:ui, and dev:back together
|
|
27
26
|
dev:ui Preview the plugin in a mini-Muscat (preview-host)
|
|
28
27
|
dev:back Run the plugin back end (python: watchfiles reload / ros2: Docker)
|
|
29
|
-
preview Preview against a real Muscat server (prompts for robot IP)
|
|
30
28
|
update Update @rainbow-robotics/muscat-plugin-kit and @rainbow-robotics/plugin-ui
|
|
31
29
|
uninstall Remove the installed muscat-pk CLI (npm global or S3 binary)
|
|
32
30
|
sdk <list|update [version]> Manage the python rainbow-rb-sdk version
|
|
@@ -48,8 +46,6 @@ async function main() {
|
|
|
48
46
|
return await runDevUi(args);
|
|
49
47
|
case "dev:back":
|
|
50
48
|
return await runDevBack(args);
|
|
51
|
-
case "preview":
|
|
52
|
-
return await runPreview(args);
|
|
53
49
|
case "update":
|
|
54
50
|
return await runUpdate(args);
|
|
55
51
|
case "uninstall":
|
package/cli/src/bridge.mjs
CHANGED
|
@@ -59,19 +59,16 @@ function ensureImage() {
|
|
|
59
59
|
pruneMuskatImages();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Build the image, preflight the port, and start the bridge container.
|
|
64
|
+
* Returns true if bridge started, false if port was already in use (caller decides).
|
|
65
|
+
*/
|
|
63
66
|
export async function startBridge() {
|
|
64
67
|
assertDockerReady();
|
|
65
68
|
|
|
66
69
|
// remove our own stale container first, then check the host port is free
|
|
67
70
|
spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
|
|
68
|
-
if (await isPortInUse(BRIDGE_PORT))
|
|
69
|
-
throw new Error(
|
|
70
|
-
`port ${BRIDGE_PORT} is already in use.\n` +
|
|
71
|
-
` Another 'muscat-pk dev' may be running, or a process is holding the port.\n` +
|
|
72
|
-
` Stop it, or free the port, then retry.`,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
71
|
+
if (await isPortInUse(BRIDGE_PORT)) return false;
|
|
75
72
|
|
|
76
73
|
ensureImage();
|
|
77
74
|
|
|
@@ -85,6 +82,7 @@ export async function startBridge() {
|
|
|
85
82
|
|
|
86
83
|
await waitForPort(BRIDGE_PORT);
|
|
87
84
|
log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
|
|
85
|
+
return true;
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
export function stopBridge() {
|
package/cli/src/build.mjs
CHANGED
|
@@ -75,27 +75,33 @@ function isMainBranch() {
|
|
|
75
75
|
// rclpy: arch-agnostic (pure Python) → dist/ros2_ws/install/ (built once)
|
|
76
76
|
// rclcpp: arch-specific C++ binaries → dist/ros2_ws/install-{arch}/ per arch
|
|
77
77
|
export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
|
|
78
|
-
if (manifestRuntime(root) !== "ros2") return;
|
|
78
|
+
if (manifestRuntime(root) !== "ros2") return [];
|
|
79
79
|
const { assertDockerReady, ensureImage, colconBuild, ROS2_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./ros2.mjs");
|
|
80
80
|
try {
|
|
81
81
|
assertDockerReady();
|
|
82
82
|
} catch (e) {
|
|
83
83
|
log.warn(`ros2 build skipped: ${e.message}`);
|
|
84
|
-
return;
|
|
84
|
+
return [];
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8"));
|
|
88
88
|
const rosClient = manifest.runtime?.rosClient;
|
|
89
89
|
|
|
90
|
+
const skipped = [];
|
|
90
91
|
if (rosClient === "rclcpp") {
|
|
91
92
|
// C++ ROS2: compile per arch so each robot picks the right install-{arch}/
|
|
92
93
|
for (const arch of archs) {
|
|
93
94
|
const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
|
|
94
|
-
if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
|
|
95
|
+
if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); skipped.push(arch); continue; }
|
|
95
96
|
const archImage = `${ROS2_DEV_IMAGE}-${arch}`;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
try {
|
|
98
|
+
await ensureImage(archImage, { platform: dockerPlatform });
|
|
99
|
+
colconBuild(root, { symlink: false, arch });
|
|
100
|
+
log.ok(`dist/ros2_ws/install-${arch}/ ready`);
|
|
101
|
+
} catch {
|
|
102
|
+
log.warn(`${arch} ros2 build skipped — QEMU not available`);
|
|
103
|
+
skipped.push(arch);
|
|
104
|
+
}
|
|
99
105
|
}
|
|
100
106
|
} else {
|
|
101
107
|
// rclpy / pure Python: arch-agnostic, build once
|
|
@@ -103,6 +109,7 @@ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
|
|
|
103
109
|
colconBuild(root, { symlink: false });
|
|
104
110
|
log.ok("dist/ros2_ws/install/ ready");
|
|
105
111
|
}
|
|
112
|
+
return skipped;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
// For cpp plugins, compile cpp/ inside the Ubuntu container (Release) per arch
|
|
@@ -110,13 +117,13 @@ export async function exportRos2Build(root, archs = ["amd64", "arm64"]) {
|
|
|
110
117
|
// robot-compatible executables. rb-runtime prefers dist/{arch}/ and falls back
|
|
111
118
|
// to dist/{entry} (legacy). Skipped (with a warning) if Docker is unavailable.
|
|
112
119
|
export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"]) {
|
|
113
|
-
if (manifestRuntime(root) !== "cpp") return;
|
|
120
|
+
if (manifestRuntime(root) !== "cpp") return [];
|
|
114
121
|
const { assertDockerReady, ensureImage, cmakeBuild, CPP_DEV_IMAGE, ARCH_DOCKER_PLATFORM } = await import("./cpp.mjs");
|
|
115
122
|
try {
|
|
116
123
|
assertDockerReady();
|
|
117
124
|
} catch (e) {
|
|
118
125
|
log.warn(`cpp build skipped: ${e.message}`);
|
|
119
|
-
return;
|
|
126
|
+
return [];
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
const entry = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime?.entry;
|
|
@@ -125,26 +132,31 @@ export async function exportCppBuild(root, distDir, archs = ["amd64", "arm64"])
|
|
|
125
132
|
return;
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
const skipped = [];
|
|
128
136
|
for (const arch of archs) {
|
|
129
|
-
// python과 동일하게 arch별 이미지를 빌드해야 --platform 불일치를 피할 수 있다.
|
|
130
137
|
const dockerPlatform = ARCH_DOCKER_PLATFORM[arch];
|
|
131
|
-
if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
|
|
138
|
+
if (!dockerPlatform) { log.warn(`unknown arch '${arch}' — skipped`); skipped.push(arch); continue; }
|
|
132
139
|
const archImage = `${CPP_DEV_IMAGE}-${arch}`;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
try {
|
|
141
|
+
await ensureImage(archImage, { platform: dockerPlatform });
|
|
142
|
+
cmakeBuild(root, { buildType: "Release", arch });
|
|
143
|
+
const built = resolve(root, `cpp/build-${arch}`, entry);
|
|
144
|
+
if (existsSync(built)) {
|
|
145
|
+
const archDir = resolve(distDir, arch);
|
|
146
|
+
mkdirSync(archDir, { recursive: true });
|
|
147
|
+
const dest = resolve(archDir, entry);
|
|
148
|
+
copyFileSync(built, dest);
|
|
149
|
+
chmodSync(dest, 0o755);
|
|
150
|
+
log.ok(`cpp binary → dist/${arch}/${entry}`);
|
|
151
|
+
} else {
|
|
152
|
+
log.warn(`cpp binary not found at cpp/build-${arch}/${entry}`);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
log.warn(`${arch} cpp build skipped — QEMU not available`);
|
|
156
|
+
skipped.push(arch);
|
|
146
157
|
}
|
|
147
158
|
}
|
|
159
|
+
return skipped;
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
const PRERELEASE_RE = /\.(dev|a|b|rc|alpha|beta)\d*/i;
|
|
@@ -434,6 +446,8 @@ export async function runBuild(args) {
|
|
|
434
446
|
|
|
435
447
|
const { pluginRoot, distDir } = await buildPlugin({ root, minify: true });
|
|
436
448
|
|
|
449
|
+
const skippedArches = { wheels: [], ros2: [], cpp: [] };
|
|
450
|
+
|
|
437
451
|
// python / rclpy: lock + wheel vendor in Ubuntu 24.04 container
|
|
438
452
|
if (!noLock) {
|
|
439
453
|
const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf8")).runtime ?? {};
|
|
@@ -443,15 +457,29 @@ export async function runBuild(args) {
|
|
|
443
457
|
if (needsLock) {
|
|
444
458
|
const { lockAndVendor } = await import("./python.mjs");
|
|
445
459
|
try {
|
|
446
|
-
await lockAndVendor(pluginRoot, archs);
|
|
460
|
+
skippedArches.wheels = await lockAndVendor(pluginRoot, archs);
|
|
447
461
|
} catch (e) {
|
|
448
462
|
log.warn(`python lock/vendor skipped: ${e.message}`);
|
|
463
|
+
skippedArches.wheels = archs;
|
|
449
464
|
}
|
|
450
465
|
}
|
|
451
466
|
}
|
|
452
467
|
|
|
453
|
-
await exportRos2Build(root, archs);
|
|
454
|
-
await exportCppBuild(root, distDir, archs);
|
|
468
|
+
skippedArches.ros2 = await exportRos2Build(root, archs) ?? [];
|
|
469
|
+
skippedArches.cpp = await exportCppBuild(root, distDir, archs) ?? [];
|
|
455
470
|
calcInstalledSize(pluginRoot);
|
|
456
471
|
if (!noZip) packZip(pluginRoot);
|
|
472
|
+
|
|
473
|
+
// Build summary
|
|
474
|
+
const allSkipped = [...new Set([...skippedArches.wheels, ...skippedArches.ros2, ...skippedArches.cpp])];
|
|
475
|
+
if (allSkipped.length > 0) {
|
|
476
|
+
const line = "─".repeat(58);
|
|
477
|
+
console.log(`\n${line}`);
|
|
478
|
+
log.warn(`Cross-arch builds skipped (QEMU not available): ${allSkipped.join(", ")}`);
|
|
479
|
+
if (skippedArches.wheels.length) log.warn(` wheels: ${skippedArches.wheels.join(", ")}`);
|
|
480
|
+
if (skippedArches.ros2.length) log.warn(` ros2: ${skippedArches.ros2.join(", ")}`);
|
|
481
|
+
if (skippedArches.cpp.length) log.warn(` cpp: ${skippedArches.cpp.join(", ")}`);
|
|
482
|
+
log.warn(` Enable: docker run --privileged --rm tonistiigi/binfmt --install all`);
|
|
483
|
+
console.log(line);
|
|
484
|
+
}
|
|
457
485
|
}
|
package/cli/src/dev.mjs
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
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 { startBridge, stopBridge, BRIDGE_CONTAINER } from "./bridge.mjs";
|
|
5
|
-
import { ensureDocker, log } from "./util.mjs";
|
|
4
|
+
import { startBridge, stopBridge, BRIDGE_CONTAINER, BRIDGE_PORT } from "./bridge.mjs";
|
|
5
|
+
import { ensureDocker, log, prompt, promptRobotConnection } from "./util.mjs";
|
|
6
6
|
|
|
7
7
|
export async function runDev(args = []) {
|
|
8
8
|
ensureDocker();
|
|
9
|
+
const { robotIp, robotToken } = await promptRobotConnection(args);
|
|
10
|
+
if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
|
|
11
|
+
if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
|
|
9
12
|
log.step("starting Socket.IO bridge (Docker) + dev:ui + dev:back");
|
|
10
|
-
await startBridge();
|
|
13
|
+
const bridgeStarted = await startBridge();
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
if (!bridgeStarted) {
|
|
16
|
+
log.warn(`port ${BRIDGE_PORT} is already in use — can't start Socket.IO bridge.`);
|
|
17
|
+
const answer = await prompt(" Proceed without Socket.IO bridge? [y/N]: ");
|
|
18
|
+
if (answer.toLowerCase() !== "y") return;
|
|
19
|
+
log.info("proceeding without bridge — backend will skip network sharing.");
|
|
20
|
+
} else {
|
|
21
|
+
// Tell dev:back which bridge container to share a netns with, so the backend
|
|
22
|
+
// reaches the bridge at localhost:10000 (no host.docker.internal).
|
|
23
|
+
process.env.MUSCAT_PK_BRIDGE_CONTAINER = BRIDGE_CONTAINER;
|
|
24
|
+
}
|
|
15
25
|
|
|
16
|
-
const cleanup = () => stopBridge();
|
|
26
|
+
const cleanup = () => { if (bridgeStarted) stopBridge(); };
|
|
17
27
|
process.once("SIGINT", cleanup);
|
|
18
28
|
process.once("SIGTERM", cleanup);
|
|
19
29
|
process.once("exit", cleanup);
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { existsSync, watch } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import { ensureDocker, findPluginRoot, log } from "./util.mjs";
|
|
13
|
+
import { ensureDocker, findPluginRoot, log, promptRobotConnection } from "./util.mjs";
|
|
14
14
|
import { manifestRuntime, readManifest, validateManifest } from "./manifest.mjs";
|
|
15
15
|
import {
|
|
16
16
|
PYTHON_DEV_IMAGE,
|
|
@@ -40,7 +40,13 @@ async function runPython(root) {
|
|
|
40
40
|
const manifest = readManifest(root);
|
|
41
41
|
const pluginId = manifest.id ?? "";
|
|
42
42
|
const container = `${PYTHON_DEV_IMAGE}-run`;
|
|
43
|
-
const
|
|
43
|
+
const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
|
|
44
|
+
const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
|
|
45
|
+
const envFlags = [
|
|
46
|
+
...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
|
|
47
|
+
...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
|
|
48
|
+
...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
|
|
49
|
+
];
|
|
44
50
|
// Share the bridge container's network namespace so localhost:10000 is the
|
|
45
51
|
// bridge (set by `muscat-pk dev`). Standalone dev:back has no bridge to join.
|
|
46
52
|
const bridgeContainer = process.env.MUSCAT_PK_BRIDGE_CONTAINER;
|
|
@@ -105,7 +111,13 @@ async function runRos2(root) {
|
|
|
105
111
|
const pluginId = manifest.id ?? "";
|
|
106
112
|
const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
|
|
107
113
|
const container = `${ROS2_DEV_IMAGE}-run`;
|
|
108
|
-
const
|
|
114
|
+
const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
|
|
115
|
+
const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
|
|
116
|
+
const envFlags = [
|
|
117
|
+
...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
|
|
118
|
+
...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
|
|
119
|
+
...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
|
|
120
|
+
];
|
|
109
121
|
const docker = (args, opts = {}) =>
|
|
110
122
|
spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
|
|
111
123
|
|
|
@@ -177,7 +189,13 @@ async function runCpp(root) {
|
|
|
177
189
|
|
|
178
190
|
const pluginId = manifest.id ?? "";
|
|
179
191
|
const container = `${CPP_DEV_IMAGE}-run`;
|
|
180
|
-
const
|
|
192
|
+
const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
|
|
193
|
+
const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
|
|
194
|
+
const envFlags = [
|
|
195
|
+
...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
|
|
196
|
+
...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
|
|
197
|
+
...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
|
|
198
|
+
];
|
|
181
199
|
const docker = (args, opts = {}) =>
|
|
182
200
|
spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
|
|
183
201
|
|
|
@@ -240,10 +258,13 @@ function warnManifest(root) {
|
|
|
240
258
|
for (const e of errors) log.warn(`manifest.json: ${e}`);
|
|
241
259
|
}
|
|
242
260
|
|
|
243
|
-
export async function runDevBack() {
|
|
261
|
+
export async function runDevBack(args = []) {
|
|
244
262
|
ensureDocker();
|
|
245
263
|
const root = findPluginRoot();
|
|
246
264
|
warnManifest(root);
|
|
265
|
+
const { robotIp, robotToken } = await promptRobotConnection(args);
|
|
266
|
+
if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
|
|
267
|
+
if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
|
|
247
268
|
const type = manifestRuntime(root);
|
|
248
269
|
if (type === "ros2") return await runRos2(root);
|
|
249
270
|
if (type === "cpp") return await runCpp(root);
|
package/cli/src/devUi.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { dirname, resolve } from "node:path";
|
|
9
9
|
import { SANDBOX_HTML } from "./build.mjs";
|
|
10
|
-
import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log } from "./util.mjs";
|
|
10
|
+
import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log, parseArgs, prompt } from "./util.mjs";
|
|
11
11
|
|
|
12
12
|
const PREVIEW_PKG = "@rainbow-robotics/preview-host";
|
|
13
13
|
const CACHE_DIR = resolve(homedir(), ".muscat-pk");
|
|
@@ -79,7 +79,7 @@ function findViteBin(pluginRoot) {
|
|
|
79
79
|
|
|
80
80
|
// Spawn vite build --watch as a background process.
|
|
81
81
|
// Returns the child process, or null if vite/node not available.
|
|
82
|
-
function spawnViteWatch(pluginRoot, node) {
|
|
82
|
+
function spawnViteWatch(pluginRoot, node, { robotIp = "" } = {}) {
|
|
83
83
|
const viteBin = findViteBin(pluginRoot);
|
|
84
84
|
if (!node || !viteBin || !existsSync(viteBin)) return null;
|
|
85
85
|
|
|
@@ -87,6 +87,7 @@ function spawnViteWatch(pluginRoot, node) {
|
|
|
87
87
|
return spawn(node, [viteBin, "build", "--watch", "--mode", "development", "--outDir", DEV_OUT], {
|
|
88
88
|
cwd: pluginRoot,
|
|
89
89
|
stdio: "inherit",
|
|
90
|
+
env: { ...process.env, ...(robotIp ? { VITE_RRS_PY_HOST: robotIp } : {}) },
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -105,15 +106,18 @@ function waitForInitialBuild(distDir, timeoutMs = 30_000) {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
export async function runDevUi(args = []) {
|
|
108
|
-
const
|
|
109
|
-
const port =
|
|
109
|
+
const parsed = parseArgs(args);
|
|
110
|
+
const port = parsed.port ?? "4180";
|
|
111
|
+
const robotIp = parsed["robot-ip"] ?? process.env.MUSCAT_ROBOT_HOST ?? await prompt(" Robot IP (skip if no robot): ");
|
|
110
112
|
|
|
111
113
|
await checkKitUpdate();
|
|
112
114
|
const pluginRoot = findPluginRoot();
|
|
113
115
|
const outDir = stageDevAssets(pluginRoot);
|
|
114
116
|
|
|
117
|
+
if (robotIp) log.info(`MUSCAT_API_ORIGIN → http://${robotIp}:*`);
|
|
118
|
+
|
|
115
119
|
const node = findNodeBin();
|
|
116
|
-
const viteProc = spawnViteWatch(pluginRoot, node);
|
|
120
|
+
const viteProc = spawnViteWatch(pluginRoot, node, { robotIp });
|
|
117
121
|
|
|
118
122
|
if (viteProc) {
|
|
119
123
|
log.step("waiting for initial vite build…");
|
package/cli/src/python.mjs
CHANGED
|
@@ -27,22 +27,30 @@ export async function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null }
|
|
|
27
27
|
pruneMuskatImages();
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
amd64:
|
|
34
|
-
|
|
30
|
+
// pip --platform does NOT auto-expand manylinux tags (unlike sys_tags()) — exact match only.
|
|
31
|
+
// Specifying all common glibc milestones ensures packages built for any Ubuntu era are found.
|
|
32
|
+
const PIP_PLATFORM = {
|
|
33
|
+
amd64: [
|
|
34
|
+
"manylinux_2_5_x86_64",
|
|
35
|
+
"manylinux_2_17_x86_64",
|
|
36
|
+
"manylinux_2_27_x86_64",
|
|
37
|
+
"manylinux_2_28_x86_64",
|
|
38
|
+
"manylinux_2_34_x86_64",
|
|
39
|
+
"manylinux_2_39_x86_64",
|
|
40
|
+
"linux_x86_64",
|
|
41
|
+
],
|
|
42
|
+
arm64: [
|
|
43
|
+
"manylinux_2_17_aarch64",
|
|
44
|
+
"manylinux_2_27_aarch64",
|
|
45
|
+
"manylinux_2_28_aarch64",
|
|
46
|
+
"manylinux_2_34_aarch64",
|
|
47
|
+
"manylinux_2_39_aarch64",
|
|
48
|
+
"linux_aarch64",
|
|
49
|
+
],
|
|
35
50
|
};
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* 1. uv pip compile requirements.txt → dist/requirements.lock (arch 무관, 1회)
|
|
40
|
-
* 2. archs별 pip download --platform <manylinux_tag> → dist/wheels/ (네이티브 휠 벤더링)
|
|
41
|
-
*
|
|
42
|
-
* none-any 휠은 pip이 두 번째 arch에서 자동 skip → 중복 없음.
|
|
43
|
-
* requirements.txt 없으면 조용히 건너뜀.
|
|
44
|
-
*/
|
|
45
|
-
export async function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
|
|
52
|
+
export async function lockAndVendor(pluginRoot, archs = ["amd64", "arm64"]) {
|
|
53
|
+
// ponytail: pip --platform used intentionally (not QEMU) — downloads wheels without running target arch code.
|
|
46
54
|
const reqTxt = resolve(pluginRoot, "requirements.txt");
|
|
47
55
|
if (!existsSync(reqTxt)) {
|
|
48
56
|
log.warn("requirements.txt not found — lock/vendor skipped");
|
|
@@ -66,45 +74,28 @@ export async function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
|
|
|
66
74
|
if (lockResult.status !== 0) throw new Error("lock generation failed (uv pip compile)");
|
|
67
75
|
log.ok("dist/requirements.lock ready");
|
|
68
76
|
|
|
69
|
-
// arch별 휠 벤더링 — QEMU 불필요 (download only, --platform 고정)
|
|
70
|
-
// 한 arch가 실패해도 나머지 arch는 계속 시도한다.
|
|
71
77
|
const failed = [];
|
|
72
78
|
for (const arch of archs) {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
75
|
-
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
// arch별 이미지 빌드 (--platform 지정) → pip이 glibc+arch 자동 감지 → 올바른 휠 선택.
|
|
79
|
-
const archImage = `${PYTHON_DEV_IMAGE}-${arch}`;
|
|
80
|
-
await ensureImage(archImage, { platform: dockerPlatform });
|
|
81
|
-
|
|
82
|
-
log.step(`vendoring wheels for ${arch} (${dockerPlatform})…`);
|
|
83
|
-
// 패키지별 개별 download로 private/미공개 패키지 실패 시에도 나머지 계속 진행.
|
|
84
|
-
const script = [
|
|
85
|
-
`set -e`,
|
|
86
|
-
`cd /plugin`,
|
|
87
|
-
`mkdir -p dist/wheels`,
|
|
88
|
-
`pkgs=$(grep -v '^#' dist/requirements.lock | grep -v '^[[:space:]]' | grep -v '^$')`,
|
|
89
|
-
`SKIP=""`,
|
|
90
|
-
`for pkg in $pkgs; do`,
|
|
91
|
-
` pip3 download "$pkg" --no-deps --only-binary=:all: -d dist/wheels -q 2>&1 || SKIP="$SKIP $pkg"`,
|
|
92
|
-
`done`,
|
|
93
|
-
`[ -z "$SKIP" ] || echo "WARN: no wheel found (package not on PyPI or missing binary wheel):$SKIP"`,
|
|
94
|
-
].join("\n");
|
|
79
|
+
const pipPlatforms = PIP_PLATFORM[arch];
|
|
80
|
+
if (!pipPlatforms) { log.warn(`unknown arch '${arch}' — skipped`); continue; }
|
|
81
|
+
log.step(`vendoring wheels for ${arch}…`);
|
|
95
82
|
const r = spawnSync(
|
|
96
83
|
"docker",
|
|
97
84
|
[
|
|
98
85
|
"run", "--rm",
|
|
99
|
-
"
|
|
86
|
+
"-e", "PIP_ROOT_USER_ACTION=ignore",
|
|
100
87
|
"-v", `${pluginRoot}:/plugin`,
|
|
101
|
-
|
|
102
|
-
|
|
88
|
+
"-w", "/plugin",
|
|
89
|
+
PYTHON_DEV_IMAGE,
|
|
90
|
+
"bash", "-c",
|
|
91
|
+
"mkdir -p dist/wheels && pip3 download -r dist/requirements.lock --no-deps --only-binary=:all: -d dist/wheels -q " +
|
|
92
|
+
"--python-version 312 --implementation cp --abi cp312 " +
|
|
93
|
+
pipPlatforms.map((p) => `--platform ${p}`).join(" "),
|
|
103
94
|
],
|
|
104
95
|
{ stdio: "inherit" },
|
|
105
96
|
);
|
|
106
97
|
if (r.status !== 0) {
|
|
107
|
-
log.warn(`wheel vendoring failed for ${arch}
|
|
98
|
+
log.warn(`wheel vendoring failed for ${arch}`);
|
|
108
99
|
failed.push(arch);
|
|
109
100
|
} else {
|
|
110
101
|
log.ok(`dist/wheels/ ready (${arch})`);
|
|
@@ -114,9 +105,5 @@ export async function lockAndVendor(pluginRoot, archs = ["x86_64", "aarch64"]) {
|
|
|
114
105
|
if (failed.length === archs.length) {
|
|
115
106
|
throw new Error(`wheel vendoring failed for all archs: ${failed.join(", ")}`);
|
|
116
107
|
}
|
|
117
|
-
|
|
118
|
-
log.warn(`wheel vendoring partial — failed: ${failed.join(", ")}, ok: ${archs.filter((a) => !failed.includes(a)).join(", ")}`);
|
|
119
|
-
} else {
|
|
120
|
-
log.ok(`dist/wheels/ ready (${archs.join(", ")})`);
|
|
121
|
-
}
|
|
108
|
+
return failed;
|
|
122
109
|
}
|
package/cli/src/util.mjs
CHANGED
|
@@ -76,6 +76,31 @@ export async function prompt(question, fallback = "") {
|
|
|
76
76
|
return answer || fallback;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const LOCAL_HOSTS = new Set(["", "localhost", "127.0.0.1", "::1"]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ask for robot IP (and token if remote). Returns { robotIp, robotToken }.
|
|
83
|
+
* Skips prompts if already set in process.env (from a parent command like `dev`).
|
|
84
|
+
* Supports --robot-ip / --robot-token flags for non-interactive use.
|
|
85
|
+
*/
|
|
86
|
+
export async function promptRobotConnection(args = []) {
|
|
87
|
+
const parsed = parseArgs(args);
|
|
88
|
+
const robotIp =
|
|
89
|
+
parsed["robot-ip"] ??
|
|
90
|
+
process.env.MUSCAT_ROBOT_HOST ??
|
|
91
|
+
(await prompt(" Robot IP (skip if no robot): "));
|
|
92
|
+
|
|
93
|
+
if (LOCAL_HOSTS.has(robotIp)) return { robotIp, robotToken: "" };
|
|
94
|
+
|
|
95
|
+
const robotToken =
|
|
96
|
+
parsed["robot-token"] ??
|
|
97
|
+
process.env.MUSCAT_ROBOT_TOKEN ??
|
|
98
|
+
(await prompt(" SDK Token (required for remote): "));
|
|
99
|
+
|
|
100
|
+
if (!robotToken) throw new Error("SDK token is required for remote robot connection.");
|
|
101
|
+
return { robotIp, robotToken };
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/** Walk up from cwd to find the plugin project root (has manifest.json). */
|
|
80
105
|
export function findPluginRoot(start = process.cwd()) {
|
|
81
106
|
let dir = resolve(start);
|
|
@@ -99,6 +99,26 @@ connect(App);
|
|
|
99
99
|
|
|
100
100
|
---
|
|
101
101
|
|
|
102
|
+
## Developing directly on the robot PC
|
|
103
|
+
|
|
104
|
+
If you're running `muscat-pk dev` **on the robot PC itself**, Muscat is likely already
|
|
105
|
+
running its own Socket.IO bridge on port 10000. In that case the CLI detects the port
|
|
106
|
+
conflict and asks:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
⚠ port 10000 is already in use — can't start Socket.IO bridge.
|
|
110
|
+
Proceed without Socket.IO bridge? [y/N]:
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Answer **y**. Your plugin backend will connect to the Muscat bridge that's already
|
|
114
|
+
running instead of starting a new one — exactly what you want when developing on the
|
|
115
|
+
robot.
|
|
116
|
+
|
|
117
|
+
> If you answer **n** (or just press Enter), the CLI exits so you can free the port
|
|
118
|
+
> manually and retry.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
102
122
|
## Backend (`py/main.py`)
|
|
103
123
|
|
|
104
124
|
The backend communicates with the frontend using `rainbow.rb_sdk.RBPluginCall`.
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Muscat plugin backend — __PLUGIN_NAME__
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
On-robot (default):
|
|
5
5
|
muscat-pk dev:back
|
|
6
6
|
python main.py
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Remote dev (from your laptop):
|
|
9
|
+
Set env vars before running:
|
|
10
|
+
MUSCAT_ROBOT_HOST=192.168.1.100
|
|
11
|
+
MUSCAT_ROBOT_TOKEN=<service token>
|
|
12
|
+
Then: muscat-pk dev:back
|
|
10
13
|
"""
|
|
11
14
|
|
|
12
|
-
import
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
13
17
|
from typing import Any
|
|
14
18
|
|
|
15
19
|
from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
|
|
@@ -18,40 +22,30 @@ from rainbow.rb_sdk import RBManipulateSDK, RBPluginCall
|
|
|
18
22
|
PLUGIN_NAME = "__PLUGIN_NAME__"
|
|
19
23
|
|
|
20
24
|
plugin = RBPluginCall()
|
|
25
|
+
manipulate = RBManipulateSDK()
|
|
21
26
|
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
print(f"[{PLUGIN_NAME}] event:", payload)
|
|
28
|
+
def register_handlers():
|
|
29
|
+
# Register handlers here — called after connect() so subscriptions survive
|
|
30
|
+
# session reconnection in remote-dev mode.
|
|
27
31
|
|
|
32
|
+
@plugin.subscribe("event")
|
|
33
|
+
def on_ui_event(payload: dict[str, Any] | None = None):
|
|
34
|
+
"""Handle messages published from the plugin UI with rb.pub("event", ...)."""
|
|
35
|
+
print(f"[{PLUGIN_NAME}] event:", payload)
|
|
28
36
|
|
|
29
|
-
@plugin.serve("grip")
|
|
30
|
-
def grip(payload: dict[str, Any] | None = None):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
@plugin.serve("grip")
|
|
38
|
+
def grip(payload: dict[str, Any] | None = None):
|
|
39
|
+
"""Handle the default UI button: rb.action("grip", { width })."""
|
|
40
|
+
width = (payload or {}).get("width", 20)
|
|
41
|
+
print(f"[{PLUGIN_NAME}] grip requested: width={width}")
|
|
42
|
+
plugin.publish("state", {"ready": True, "last_grip_width": width})
|
|
43
|
+
return {"ok": True, "width": width}
|
|
34
44
|
|
|
35
|
-
plugin.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@plugin.serve("tool/echo")
|
|
40
|
-
def echo(payload: dict[str, Any] | None = None):
|
|
41
|
-
"""Small query example callable from the UI with rb.action("tool/echo", ...)."""
|
|
42
|
-
return {"echo": payload, "plugin": PLUGIN_NAME}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def ping_ui_example():
|
|
46
|
-
"""
|
|
47
|
-
Example: call a queryable served by the UI.
|
|
48
|
-
|
|
49
|
-
In the frontend, register:
|
|
50
|
-
rb.serve("tool/ping", async (payload) => ({ pong: payload }))
|
|
51
|
-
"""
|
|
52
|
-
res = plugin.call("tool/ping", payload={"hello": "plugin"})
|
|
53
|
-
print(f"[{PLUGIN_NAME}] ping response:", res)
|
|
54
|
-
return res
|
|
45
|
+
@plugin.serve("tool/echo")
|
|
46
|
+
def echo(payload: dict[str, Any] | None = None):
|
|
47
|
+
"""Small query example callable from the UI with rb.action("tool/echo", ...)."""
|
|
48
|
+
return {"echo": payload, "plugin": PLUGIN_NAME}
|
|
55
49
|
|
|
56
50
|
|
|
57
51
|
def move_j_example():
|
|
@@ -61,8 +55,6 @@ def move_j_example():
|
|
|
61
55
|
This function is not called automatically. Add safety checks and call it
|
|
62
56
|
from your own handler when the robot is ready.
|
|
63
57
|
"""
|
|
64
|
-
manipulate = RBManipulateSDK()
|
|
65
|
-
|
|
66
58
|
return manipulate.move.call_move_j(
|
|
67
59
|
robot_model="C500920",
|
|
68
60
|
target={
|
|
@@ -73,16 +65,34 @@ def move_j_example():
|
|
|
73
65
|
)
|
|
74
66
|
|
|
75
67
|
|
|
76
|
-
def main():
|
|
68
|
+
async def main():
|
|
69
|
+
robot_host = os.getenv("MUSCAT_ROBOT_HOST")
|
|
70
|
+
robot_token = os.getenv("MUSCAT_ROBOT_TOKEN")
|
|
71
|
+
|
|
72
|
+
if robot_host and robot_token:
|
|
73
|
+
# Remote dev: connect to the robot before registering any handlers.
|
|
74
|
+
# connect() closes and reopens the Zenoh session, so handlers registered
|
|
75
|
+
# before this call would be lost.
|
|
76
|
+
await manipulate.connect(token=robot_token, host=robot_host)
|
|
77
|
+
|
|
78
|
+
register_handlers()
|
|
79
|
+
|
|
77
80
|
plugin.publish("state", {"ready": True, "plugin": PLUGIN_NAME})
|
|
78
81
|
print(f"[{PLUGIN_NAME}] backend ready")
|
|
79
82
|
|
|
80
83
|
try:
|
|
81
84
|
while True:
|
|
82
|
-
|
|
83
|
-
except KeyboardInterrupt:
|
|
85
|
+
await asyncio.sleep(1)
|
|
86
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
87
|
+
pass
|
|
88
|
+
finally:
|
|
89
|
+
if robot_host and robot_token:
|
|
90
|
+
await manipulate.disconnect()
|
|
84
91
|
print(f"[{PLUGIN_NAME}] stopped")
|
|
85
92
|
|
|
86
93
|
|
|
87
94
|
if __name__ == "__main__":
|
|
88
|
-
|
|
95
|
+
try:
|
|
96
|
+
asyncio.run(main())
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
pass
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rainbow-robotics/muscat-plugin-kit",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.43",
|
|
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
|
+
"@rainbow-robotics/preview-host": "0.1.2-dev.43",
|
|
35
35
|
"socket.io": "^4.8.1"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
package/cli/src/preview.mjs
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
// muscat-pk preview — build the plugin and open preview-host connected to a
|
|
2
|
-
// real Muscat server (robot or local). No local Socket.IO server is started.
|
|
3
|
-
import { createInterface } from "node:readline";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
6
|
-
import { resolve } from "node:path";
|
|
7
|
-
import { buildPlugin } from "./build.mjs";
|
|
8
|
-
import { findPreviewHostRoot } from "./devUi.mjs";
|
|
9
|
-
import { checkKitUpdate, log, parseArgs } from "./util.mjs";
|
|
10
|
-
|
|
11
|
-
function prompt(question, defaultValue) {
|
|
12
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
-
return new Promise((res) => {
|
|
14
|
-
rl.question(question, (answer) => {
|
|
15
|
-
rl.close();
|
|
16
|
-
res(answer.trim() || defaultValue);
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function runPreview(args = []) {
|
|
22
|
-
const opts = parseArgs(args, { booleans: ["help"] });
|
|
23
|
-
|
|
24
|
-
if (opts.help) {
|
|
25
|
-
console.log(
|
|
26
|
-
[
|
|
27
|
-
"",
|
|
28
|
-
"Usage: muscat-pk preview [options]",
|
|
29
|
-
"",
|
|
30
|
-
" 빌드된 플러그인을 실제 Muscat 서버에 연결해 미리봅니다.",
|
|
31
|
-
" Socket.IO 서버를 직접 띄우지 않습니다.",
|
|
32
|
-
"",
|
|
33
|
-
"Options:",
|
|
34
|
-
" --host <ip> Muscat 서버 IP 또는 hostname (default: localhost)",
|
|
35
|
-
" --port <n> preview-host 로컬 포트 (default: 4180)",
|
|
36
|
-
" --ws-port <n> Muscat Socket.IO 포트 (default: 10000)",
|
|
37
|
-
"",
|
|
38
|
-
].join("\n"),
|
|
39
|
-
);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
await checkKitUpdate();
|
|
44
|
-
|
|
45
|
-
// ── 로봇 IP 결정 ────────────────────────────────────────────────────────────
|
|
46
|
-
let robotHost = opts.host ?? "";
|
|
47
|
-
if (!robotHost) {
|
|
48
|
-
robotHost = await prompt(
|
|
49
|
-
"Muscat 서버 IP 또는 hostname [localhost]: ",
|
|
50
|
-
"localhost",
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const wsPort = opts["ws-port"] ?? "10000";
|
|
54
|
-
const socketUrl = `ws://${robotHost}:${wsPort}`;
|
|
55
|
-
|
|
56
|
-
const previewPort = opts.port ?? "4180";
|
|
57
|
-
|
|
58
|
-
log.step(`Muscat 서버: ${socketUrl}`);
|
|
59
|
-
|
|
60
|
-
// ── 플러그인 빌드 ────────────────────────────────────────────────────────────
|
|
61
|
-
const { distDir } = await buildPlugin();
|
|
62
|
-
|
|
63
|
-
// ── preview-host 실행 ────────────────────────────────────────────────────────
|
|
64
|
-
const previewRoot = findPreviewHostRoot();
|
|
65
|
-
const serveEntry = resolve(previewRoot, "serve.mjs");
|
|
66
|
-
|
|
67
|
-
if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
|
|
68
|
-
log.step("preview-host 번들 빌드 중 (최초 1회)…");
|
|
69
|
-
await new Promise((res, rej) => {
|
|
70
|
-
const p = spawn(process.execPath, [resolve(previewRoot, "build.mjs")], {
|
|
71
|
-
stdio: "inherit",
|
|
72
|
-
});
|
|
73
|
-
p.on("exit", (c) => (c === 0 ? res() : rej(new Error("preview-host build failed"))));
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
log.step(`preview-host 시작 → http://localhost:${previewPort}`);
|
|
78
|
-
log.step(`Socket.IO URL: ${socketUrl}`);
|
|
79
|
-
|
|
80
|
-
const child = spawn(
|
|
81
|
-
process.execPath,
|
|
82
|
-
[
|
|
83
|
-
serveEntry,
|
|
84
|
-
"--plugin", distDir,
|
|
85
|
-
"--port", String(previewPort),
|
|
86
|
-
"--socket-url", socketUrl,
|
|
87
|
-
],
|
|
88
|
-
{ stdio: "inherit" },
|
|
89
|
-
);
|
|
90
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
91
|
-
}
|