@rainbow-robotics/muscat-plugin-kit 1.10.2-dev.5 → 1.10.2-dev.7
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 +3 -0
- package/cli/src/build.mjs +4 -9
- package/cli/src/cppIde.mjs +2 -1
- package/cli/src/dev.mjs +4 -1
- package/cli/src/devBack.mjs +10 -7
- package/cli/src/devId.mjs +54 -0
- package/cli/src/devUi.mjs +24 -14
- package/cli/src/mcpProxy.mjs +110 -0
- package/cli/src/muscatLocal.mjs +15 -2
- package/cli/src/storybook.mjs +2 -20
- package/cli/src/templates-embedded.mjs +2 -2
- package/cli/src/util.mjs +29 -1
- package/cli/src/zip.mjs +121 -0
- package/cli/storybook-dist/assets/{Color-YHDXOIA2-d4UialIW.js → Color-YHDXOIA2-BoqBLKNL.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-DpuckkF2.js → Docs-BhJTZ0C_.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-tWjVyRAS.js → Docs-BlIu-JrJ.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-vU43epz3.js → Docs-C4ktkQce.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-DOzwT3cw.js → Docs-Cg3A7xDz.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-CppxziOu.js → Docs-VKVuhalv.js} +1 -1
- package/cli/storybook-dist/assets/{Docs-BySWRVQD.js → Docs-_i1XHVXU.js} +1 -1
- package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-Boj7BBcx.js → DocsRenderer-CFRXHY34-BGHjhFiI.js} +1 -1
- package/cli/storybook-dist/assets/{entry-preview-BfxbSNyQ.js → entry-preview-C2IBQfq7.js} +1 -1
- package/cli/storybook-dist/assets/{iframe-D3ykNOcq.js → iframe-D-mTyH7q.js} +2 -2
- package/cli/storybook-dist/assets/{index-NJyPL3Uy.js → index-DKrg3vQJ.js} +3 -3
- package/cli/storybook-dist/assets/preview-CpePTsLG.js +5 -0
- package/cli/storybook-dist/assets/{preview-BoJV1ynr.js → preview-i5rYb1IR.js} +2 -2
- package/cli/storybook-dist/iframe.html +1 -1
- package/cli/storybook-dist/project.json +1 -1
- package/cli/templates/common/README.md +14 -1
- package/cli/templates/common/py/main.py +2 -2
- package/cli/templates/cpp/cpp/main.cpp +1 -1
- package/cli/templates/readme-cpp/README.md +16 -3
- package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +12 -1
- package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +18 -0
- package/package.json +3 -3
- package/cli/storybook-dist/assets/preview-3_yzT7Wt.js +0 -3
package/cli/bin/muscat-pk.mjs
CHANGED
|
@@ -121,6 +121,9 @@ async function main() {
|
|
|
121
121
|
case "storybook":
|
|
122
122
|
return await runStorybook(args);
|
|
123
123
|
case "mcp": {
|
|
124
|
+
// Relay by default so `muscat-pk update` reaches a running session; --serve is the server.
|
|
125
|
+
if (args[0] !== "--serve" && args[0] !== "--install" && args[0] !== "install")
|
|
126
|
+
return (await import("../src/mcpProxy.mjs")).runMcpProxy();
|
|
124
127
|
const { runMcp, installMcp } = await import("../src/mcp.mjs");
|
|
125
128
|
if (args[0] === "--install" || args[0] === "install")
|
|
126
129
|
return installMcp(args[1]);
|
package/cli/src/build.mjs
CHANGED
|
@@ -13,9 +13,10 @@ import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, re
|
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { basename, dirname, resolve } from "node:path";
|
|
15
15
|
import { createInterface } from "node:readline/promises";
|
|
16
|
-
import {
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
17
|
import { ensureDocker, findPluginRoot, hasBin, humanSize, log, parseArgs, prompt } from "./util.mjs";
|
|
18
18
|
import { assertManifest, manifestRuntime } from "./manifest.mjs";
|
|
19
|
+
import { writeZip } from "./zip.mjs";
|
|
19
20
|
|
|
20
21
|
// Find a usable JS runtime for running vite as a subprocess.
|
|
21
22
|
// In binary (Bun) mode, try `bun` first (it can run vite directly), then node.
|
|
@@ -343,14 +344,8 @@ function packZip(pluginRoot) {
|
|
|
343
344
|
const entries = ["dist"];
|
|
344
345
|
|
|
345
346
|
log.step(`packing ${entries.join(", ")} → ${zipName}`);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
cwd: pluginRoot,
|
|
349
|
-
stdio: "inherit",
|
|
350
|
-
});
|
|
351
|
-
} catch {
|
|
352
|
-
throw new Error("`zip` not found. Install zip or pack manually.");
|
|
353
|
-
}
|
|
347
|
+
// In-process, not the `zip` CLI: Windows has none, and it keeps the robot's executable bits (zip.mjs).
|
|
348
|
+
writeZip(zipPath, entries, pluginRoot);
|
|
354
349
|
|
|
355
350
|
const zipBytes = statSync(zipPath).size;
|
|
356
351
|
log.ok(`${zipName} ${humanSize(zipBytes)}`);
|
package/cli/src/cppIde.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
-
import { cppSdkFiles, log } from "./util.mjs";
|
|
8
|
+
import { cppSdkFiles, ensureDevDir, log } from "./util.mjs";
|
|
9
9
|
|
|
10
10
|
const SYSROOT = ".muscat-dev/sysroot";
|
|
11
11
|
const CONFIG_NAME = "Muscat";
|
|
@@ -25,6 +25,7 @@ export function syncCppIde(root, image) {
|
|
|
25
25
|
const stamp = resolve(sysroot, ".image-id");
|
|
26
26
|
if (!existsSync(stamp) || readFileSync(stamp, "utf8") !== id) {
|
|
27
27
|
rmSync(sysroot, { recursive: true, force: true });
|
|
28
|
+
ensureDevDir(root);
|
|
28
29
|
const create = spawnSync("docker", ["create", image], { encoding: "utf8" });
|
|
29
30
|
if (create.status !== 0) return log.warn("could not copy SDK headers for the editor (docker create failed).");
|
|
30
31
|
const container = create.stdout.trim();
|
package/cli/src/dev.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { runDevUi } from "./devUi.mjs";
|
|
8
8
|
import { stopDevBackContainers } from "./devBack.mjs";
|
|
9
9
|
import { ensureLocalMuscat, GATEWAY_PORT, isPortInUse, robotHasMuscat } from "./muscatLocal.mjs";
|
|
10
|
-
import { assertManifestPorts, assertManifestVariables } from "./manifest.mjs";
|
|
10
|
+
import { assertManifestPorts, assertManifestVariables, readManifest } from "./manifest.mjs";
|
|
11
|
+
import { devPluginId } from "./devId.mjs";
|
|
11
12
|
import {
|
|
12
13
|
ensureDocker,
|
|
13
14
|
ensureIdeVenv,
|
|
@@ -119,6 +120,8 @@ export async function runDev(args = []) {
|
|
|
119
120
|
process.env.MUSCAT_ROBOT_TOKEN = robotToken;
|
|
120
121
|
|
|
121
122
|
const remoteRobot = isRemoteRobot(robotIp);
|
|
123
|
+
// Before dev:back is spawned: it inherits the id through the environment.
|
|
124
|
+
devPluginId(readManifest(root).id, { robotIp, args });
|
|
122
125
|
const hostAgent = await maybeStartHostAgent(root, { robotIp, robotToken });
|
|
123
126
|
const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT || "";
|
|
124
127
|
// No proxy (Linux, disabled, ...): fall back to the container reverse-dialing
|
package/cli/src/devBack.mjs
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
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,
|
|
11
|
+
import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
12
12
|
import { isIP } from "node:net";
|
|
13
|
-
import {
|
|
14
|
-
import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { dockerUserFlags, ensureDevDir, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
|
|
15
15
|
import { ensureRemoteZenohEnv, getFreePort, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
|
|
16
16
|
import { GATEWAY_PORT, localMuscatContainer, robotHasMuscat } from "./muscatLocal.mjs";
|
|
17
|
+
import { devPluginId } from "./devId.mjs";
|
|
17
18
|
import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
|
|
18
19
|
import {
|
|
19
20
|
portEnvFlags,
|
|
@@ -110,8 +111,8 @@ async function serveRosPeers(root, pluginId, restart) {
|
|
|
110
111
|
if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
|
|
111
112
|
return { ok: false, error: "peers must be a list of IP addresses" };
|
|
112
113
|
}
|
|
114
|
+
ensureDevDir(root);
|
|
113
115
|
const file = resolve(root, ROS_PEERS_FILE);
|
|
114
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
115
116
|
writeFileSync(file, JSON.stringify(peers) + "\n");
|
|
116
117
|
log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
|
|
117
118
|
restart();
|
|
@@ -147,7 +148,7 @@ async function runPython(root, portRanges, robotGateway) {
|
|
|
147
148
|
await pyEnsureImage();
|
|
148
149
|
|
|
149
150
|
const manifest = readManifest(root);
|
|
150
|
-
const pluginId = manifest.id ?? "";
|
|
151
|
+
const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
|
|
151
152
|
const container = `${PYTHON_DEV_IMAGE}-run`;
|
|
152
153
|
const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
|
|
153
154
|
const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
|
|
@@ -527,7 +528,7 @@ async function runRos2(root, portRanges, robotGateway) {
|
|
|
527
528
|
|
|
528
529
|
await ensureImage();
|
|
529
530
|
|
|
530
|
-
const pluginId = manifest.id ?? "";
|
|
531
|
+
const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
|
|
531
532
|
const container = `${ROS2_DEV_IMAGE}-run`;
|
|
532
533
|
const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
|
|
533
534
|
const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
|
|
@@ -622,7 +623,7 @@ async function runCpp(root, portRanges, robotGateway) {
|
|
|
622
623
|
|
|
623
624
|
await cppEnsureImage();
|
|
624
625
|
|
|
625
|
-
const pluginId = manifest.id ?? "";
|
|
626
|
+
const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
|
|
626
627
|
const container = `${CPP_DEV_IMAGE}-run`;
|
|
627
628
|
const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
|
|
628
629
|
const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
|
|
@@ -705,6 +706,8 @@ export async function runDevBack(args = []) {
|
|
|
705
706
|
const { robotIp, robotToken } = await promptRobotConnection(args);
|
|
706
707
|
if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
|
|
707
708
|
if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
|
|
709
|
+
// Under `dev` this returns the id `dev` chose (environment); standalone it decides it the same way.
|
|
710
|
+
devPluginId(readManifest(root).id, { robotIp, args });
|
|
708
711
|
// Standalone `dev:back` against a remote robot needs the Zenoh reverse-path
|
|
709
712
|
// advertise host/port too (idempotent — `muscat-pk dev` already set these).
|
|
710
713
|
// Byte-proxy mode (a host-side host_agent set MUSCAT_PROXY_ENDPOINT) makes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The plugin id a dev session runs under.
|
|
2
|
+
//
|
|
3
|
+
// Machines developing the same plugin against one robot register the same topics on its gateway, and
|
|
4
|
+
// requests (rb.action, external routes) then reach whichever backend registered last — with no error,
|
|
5
|
+
// so one developer's button runs the other's code. Against a remote robot each machine therefore runs
|
|
6
|
+
// as <manifest id>.dev-<user>-<random>. The random part is kept in ~/.muscat-pk/dev-id: the id stays the
|
|
7
|
+
// same across runs (stable external URLs) and differs between machines even when host or user names match.
|
|
8
|
+
// --keep-id runs under the manifest id (program execute, external clients that use the real id); then only
|
|
9
|
+
// one machine may be connected to that robot.
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir, userInfo } from "node:os";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
import { PLUGIN_ID_RE } from "./manifest.mjs";
|
|
15
|
+
import { log } from "./util.mjs";
|
|
16
|
+
import { isRemoteRobot } from "./zenohDev.mjs";
|
|
17
|
+
|
|
18
|
+
const DEV_ID_FILE = resolve(homedir(), ".muscat-pk", "dev-id");
|
|
19
|
+
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
20
|
+
// `dev` decides once; its dev:back child gets no CLI args and reads the id from here.
|
|
21
|
+
const ENV = "MUSCAT_PK_DEV_PLUGIN_ID";
|
|
22
|
+
|
|
23
|
+
function machineToken() {
|
|
24
|
+
const saved = existsSync(DEV_ID_FILE) ? readFileSync(DEV_ID_FILE, "utf8").trim() : "";
|
|
25
|
+
if (/^[a-z0-9]{6}$/.test(saved)) return saved;
|
|
26
|
+
const token = [...randomBytes(6)].map((b) => ALPHABET[b % ALPHABET.length]).join("");
|
|
27
|
+
mkdirSync(dirname(DEV_ID_FILE), { recursive: true });
|
|
28
|
+
writeFileSync(DEV_ID_FILE, `${token}\n`);
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function userName() {
|
|
33
|
+
try {
|
|
34
|
+
return userInfo().username.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12);
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The id for this dev session (UI, backend, ROS namespace and external routes all use it). */
|
|
41
|
+
export function devPluginId(manifestId, { robotIp, args = [] }) {
|
|
42
|
+
if (process.env[ENV]) return process.env[ENV];
|
|
43
|
+
let id = manifestId;
|
|
44
|
+
if (manifestId && isRemoteRobot(robotIp) && !args.includes("--keep-id")) {
|
|
45
|
+
const candidate = `${manifestId}.${["dev", userName(), machineToken()].filter(Boolean).join("-")}`;
|
|
46
|
+
if (PLUGIN_ID_RE.test(candidate)) id = candidate;
|
|
47
|
+
}
|
|
48
|
+
if (id !== manifestId) {
|
|
49
|
+
log.step(`dev plugin id ${id} — this machine's own on the shared robot (--keep-id runs as ${manifestId})`);
|
|
50
|
+
log.step(`external routes: http://${robotIp}:3000/common/plugin/${id}/<path>`);
|
|
51
|
+
}
|
|
52
|
+
if (id) process.env[ENV] = id;
|
|
53
|
+
return id;
|
|
54
|
+
}
|
package/cli/src/devUi.mjs
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { createServer as createNetServer } from "node:net";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import {
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { checkKitUpdate, findPluginRoot, hasBin, kitRoot, log, parseArgs, prompt } from "./util.mjs";
|
|
10
|
-
import {
|
|
9
|
+
import { checkKitUpdate, ensureDevDir, findPluginRoot, hasBin, kitRoot, log, openBrowser, parseArgs, prompt } from "./util.mjs";
|
|
10
|
+
import { devPluginId } from "./devId.mjs";
|
|
11
|
+
import { readManifest } from "./manifest.mjs";
|
|
12
|
+
import { ensureLocalMuscat, GATEWAY_PORT, localFullMuscat, robotHasMuscat } from "./muscatLocal.mjs";
|
|
11
13
|
import { isRemoteRobot } from "./zenohDev.mjs";
|
|
12
14
|
|
|
13
15
|
|
|
@@ -32,7 +34,6 @@ const CACHE_DIR = resolve(homedir(), ".muscat-pk");
|
|
|
32
34
|
const CACHE_ROOT = resolve(CACHE_DIR, "node_modules", "@rainbow-robotics", "preview-host");
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
const DEV_OUT = ".muscat-dev";
|
|
36
37
|
|
|
37
38
|
function findNodeBin() {
|
|
38
39
|
if (typeof Bun === "undefined") return process.execPath;
|
|
@@ -81,11 +82,15 @@ function syncAssets(pluginRoot, outDir) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// preview-host takes the plugin id from this copy, so the UI talks under the session's dev id.
|
|
86
|
+
function stageDevAssets(pluginRoot, pluginId) {
|
|
87
|
+
const outDir = ensureDevDir(pluginRoot);
|
|
87
88
|
const manifestSrc = resolve(pluginRoot, "manifest.json");
|
|
88
|
-
if (existsSync(manifestSrc))
|
|
89
|
+
if (existsSync(manifestSrc)) {
|
|
90
|
+
const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
|
|
91
|
+
if (pluginId) manifest.id = pluginId;
|
|
92
|
+
writeFileSync(resolve(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
93
|
+
}
|
|
89
94
|
syncAssets(pluginRoot, outDir);
|
|
90
95
|
return outDir;
|
|
91
96
|
}
|
|
@@ -162,7 +167,7 @@ export async function runDevUi(args = []) {
|
|
|
162
167
|
|
|
163
168
|
await checkKitUpdate();
|
|
164
169
|
const pluginRoot = findPluginRoot();
|
|
165
|
-
const outDir = stageDevAssets(pluginRoot);
|
|
170
|
+
const outDir = stageDevAssets(pluginRoot, devPluginId(readManifest(pluginRoot).id, { robotIp, args }));
|
|
166
171
|
const port = await getFreePort(requestedPort);
|
|
167
172
|
|
|
168
173
|
// No robot: the bundle talks to localhost:3000, so put a Muscat there (`dev` already did).
|
|
@@ -194,6 +199,9 @@ export async function runDevUi(args = []) {
|
|
|
194
199
|
});
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
const localDevMuscat = await localFullMuscat(robotIp);
|
|
203
|
+
const uiHost = isRemoteRobot(robotIp) ? robotIp : localDevMuscat ? "localhost" : "";
|
|
204
|
+
|
|
197
205
|
const previewRoot = findPreviewHostRoot();
|
|
198
206
|
const serveEntry = resolve(previewRoot, "serve.mjs");
|
|
199
207
|
if (!existsSync(resolve(previewRoot, "dist", "index.html"))) {
|
|
@@ -214,11 +222,11 @@ export async function runDevUi(args = []) {
|
|
|
214
222
|
"--plugin", outDir,
|
|
215
223
|
"--port", String(port),
|
|
216
224
|
"--dev-server-url", `http://localhost:${devPort}`,
|
|
217
|
-
// A remote robot turns the bundle into that
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
...((await robotHasMuscat(robotIp)) ? ["--socket-url", `ws://${
|
|
225
|
+
// A remote robot (or a local muscat-dev) turns the bundle into that Muscat's UI; otherwise
|
|
226
|
+
// it stays the mock preview.
|
|
227
|
+
...(uiHost ? ["--robot-host", uiHost] : []),
|
|
228
|
+
// The dev plugin's socket goes to that Muscat's gateway; otherwise to the local runtime.
|
|
229
|
+
...((await robotHasMuscat(robotIp)) || localDevMuscat ? ["--socket-url", `ws://${uiHost}:${GATEWAY_PORT}`] : []),
|
|
222
230
|
],
|
|
223
231
|
// preview-host only prints its address and paths; the "UI →" line below says what matters.
|
|
224
232
|
{ stdio: ["inherit", "ignore", "inherit"] },
|
|
@@ -227,6 +235,8 @@ export async function runDevUi(args = []) {
|
|
|
227
235
|
for (let i = 0; i < 50 && !(await fetch(`http://localhost:${port}/`).then(() => true, () => false)); i++)
|
|
228
236
|
await new Promise((r) => setTimeout(r, 200));
|
|
229
237
|
log.ok(`UI → http://localhost:${port}`);
|
|
238
|
+
// Only for a person at a terminal — not CI, scripts or `--no-open`.
|
|
239
|
+
if (process.stdout.isTTY && !process.env.CI && !parsed["no-open"]) openBrowser(`http://localhost:${port}`);
|
|
230
240
|
|
|
231
241
|
let cleaned = false;
|
|
232
242
|
const cleanup = () => {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `muscat-pk mcp` — AI clients keep one stdio MCP server per session, so a `muscat-pk update`
|
|
2
|
+
// never reached a running one. This relays stdio to the real server (`mcp --serve`) and, when the
|
|
3
|
+
// installed kit changes, swaps that child for the new one: it replays the client's initialize and
|
|
4
|
+
// sends tools/list_changed, so the connection stays up and the next calls use the new tools.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { kitRoot, resolveLocalCliBin } from "./util.mjs";
|
|
10
|
+
|
|
11
|
+
const isBinaryMode = typeof Bun !== "undefined";
|
|
12
|
+
const REINIT_ID = "__muscat_pk_reinit__";
|
|
13
|
+
const POLL_MS = 3000;
|
|
14
|
+
|
|
15
|
+
// Re-resolved on every (re)start: an update may install, bump or remove the project's local kit.
|
|
16
|
+
function serverCommand() {
|
|
17
|
+
const local = resolveLocalCliBin(process.cwd());
|
|
18
|
+
if (local) {
|
|
19
|
+
return { cmd: isBinaryMode ? "node" : process.execPath, args: [local, "mcp", "--serve"], watch: resolve(local, "../../../package.json") };
|
|
20
|
+
}
|
|
21
|
+
if (isBinaryMode) return { cmd: process.execPath, args: ["mcp", "--serve"], watch: process.execPath };
|
|
22
|
+
return { cmd: process.execPath, args: [process.argv[1], "mcp", "--serve"], watch: resolve(kitRoot, "package.json") };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stamp = (file) => {
|
|
26
|
+
try {
|
|
27
|
+
return `${file}:${statSync(file).mtimeMs}`;
|
|
28
|
+
} catch {
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function runMcpProxy() {
|
|
34
|
+
let child = null;
|
|
35
|
+
let initRequest = null;
|
|
36
|
+
const pending = new Set(); // client request ids the current child hasn't answered
|
|
37
|
+
const toClient = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
38
|
+
|
|
39
|
+
const failPending = () => {
|
|
40
|
+
for (const id of pending) {
|
|
41
|
+
toClient({ jsonrpc: "2.0", id, error: { code: -32603, message: "muscat-pk was updated during this call — retry it." } });
|
|
42
|
+
}
|
|
43
|
+
pending.clear();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const start = () => {
|
|
47
|
+
const { cmd, args } = serverCommand();
|
|
48
|
+
const c = spawn(cmd, args, { stdio: ["pipe", "pipe", "inherit"] });
|
|
49
|
+
createInterface({ input: c.stdout }).on("line", (line) => {
|
|
50
|
+
let msg = null;
|
|
51
|
+
try {
|
|
52
|
+
msg = JSON.parse(line);
|
|
53
|
+
} catch {
|
|
54
|
+
/* not JSON — pass through */
|
|
55
|
+
}
|
|
56
|
+
if (msg?.id === REINIT_ID) {
|
|
57
|
+
// Our replayed initialize: the client already has its answer from the first child.
|
|
58
|
+
c.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
|
59
|
+
toClient({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (msg && msg.id !== undefined && !msg.method) pending.delete(msg.id);
|
|
63
|
+
process.stdout.write(line + "\n");
|
|
64
|
+
});
|
|
65
|
+
c.on("exit", () => {
|
|
66
|
+
if (c !== child) return; // replaced on purpose
|
|
67
|
+
child = null;
|
|
68
|
+
failPending();
|
|
69
|
+
});
|
|
70
|
+
child = c;
|
|
71
|
+
if (initRequest) c.stdin.write(JSON.stringify({ ...initRequest, id: REINIT_ID }) + "\n");
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const restart = () => {
|
|
75
|
+
const old = child;
|
|
76
|
+
child = null;
|
|
77
|
+
old?.kill();
|
|
78
|
+
failPending();
|
|
79
|
+
start();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
start();
|
|
83
|
+
let last = stamp(serverCommand().watch);
|
|
84
|
+
setInterval(() => {
|
|
85
|
+
const now = stamp(serverCommand().watch);
|
|
86
|
+
if (now !== last) {
|
|
87
|
+
last = now;
|
|
88
|
+
process.stderr.write("[muscat-pk] kit updated — restarting the MCP server\n");
|
|
89
|
+
restart();
|
|
90
|
+
}
|
|
91
|
+
}, POLL_MS).unref();
|
|
92
|
+
|
|
93
|
+
createInterface({ input: process.stdin })
|
|
94
|
+
.on("line", (line) => {
|
|
95
|
+
let msg = null;
|
|
96
|
+
try {
|
|
97
|
+
msg = JSON.parse(line);
|
|
98
|
+
} catch {
|
|
99
|
+
/* forward as-is */
|
|
100
|
+
}
|
|
101
|
+
if (msg?.method === "initialize") initRequest = msg;
|
|
102
|
+
if (!child) start(); // the server crashed — bring it back (it replays initialize)
|
|
103
|
+
if (msg && msg.id !== undefined && msg.method) pending.add(msg.id);
|
|
104
|
+
child.stdin.write(line + "\n");
|
|
105
|
+
})
|
|
106
|
+
.on("close", () => {
|
|
107
|
+
child?.kill();
|
|
108
|
+
process.exit(0);
|
|
109
|
+
});
|
|
110
|
+
}
|
package/cli/src/muscatLocal.mjs
CHANGED
|
@@ -128,8 +128,7 @@ async function waitForApi(timeoutMs = 180_000) {
|
|
|
128
128
|
|
|
129
129
|
// Does the robot at `ip` run Muscat (common answers /is-muscat)? Then the dev UI and backend use its
|
|
130
130
|
// gateway and no local Muscat is started. Local addresses and unreachable hosts → false.
|
|
131
|
-
|
|
132
|
-
if (!ip || LOCAL_HOSTS.has(ip)) return false;
|
|
131
|
+
async function isMuscat(ip) {
|
|
133
132
|
try {
|
|
134
133
|
const res = await fetch(`http://${ip}:${API_PORT}/common/is-muscat`, { signal: AbortSignal.timeout(3000) });
|
|
135
134
|
return res.ok && (await res.json())?.result === true;
|
|
@@ -138,6 +137,20 @@ export async function robotHasMuscat(ip) {
|
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
|
|
140
|
+
export async function robotHasMuscat(ip) {
|
|
141
|
+
if (!ip || LOCAL_HOSTS.has(ip)) return false;
|
|
142
|
+
return isMuscat(ip);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// rby2's full Muscat dev stack on this machine — unlike muscat-pk's own minimal runtime it has the
|
|
146
|
+
// whole UI backend, so `dev` without a robot IP shows its real Muscat UI, like a robot's.
|
|
147
|
+
const LOCAL_FULL_MUSCAT = "muscat-dev";
|
|
148
|
+
export async function localFullMuscat(ip) {
|
|
149
|
+
if (!LOCAL_HOSTS.has(ip ?? "") || !hasBin("docker")) return false;
|
|
150
|
+
const r = spawnSync("docker", ["ps", "--filter", `name=^${LOCAL_FULL_MUSCAT}$`, "--format", "{{.Names}}"], { encoding: "utf8" });
|
|
151
|
+
return !!(r.stdout || "").trim() && isMuscat("127.0.0.1");
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
// Is a host TCP port already bound?
|
|
142
155
|
export function isPortInUse(port) {
|
|
143
156
|
return new Promise((res) => {
|
package/cli/src/storybook.mjs
CHANGED
|
@@ -14,10 +14,10 @@ import {
|
|
|
14
14
|
rmSync,
|
|
15
15
|
statSync,
|
|
16
16
|
} from "node:fs";
|
|
17
|
-
import { execSync
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
18
|
import { copyFileSync } from "node:fs";
|
|
19
19
|
import { extname, join, resolve } from "node:path";
|
|
20
|
-
import { kitRoot, log, parseArgs } from "./util.mjs";
|
|
20
|
+
import { kitRoot, log, openBrowser, parseArgs } from "./util.mjs";
|
|
21
21
|
|
|
22
22
|
// Shipped location (under cli/ → included in the npm "files" list).
|
|
23
23
|
const SHIPPED_DIST = resolve(kitRoot, "cli/storybook-dist");
|
|
@@ -93,24 +93,6 @@ export function buildStorybook() {
|
|
|
93
93
|
log.ok(`storybook → ${SHIPPED_DIST}`);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function openBrowser(url) {
|
|
97
|
-
const cmd =
|
|
98
|
-
process.platform === "darwin"
|
|
99
|
-
? "open"
|
|
100
|
-
: process.platform === "win32"
|
|
101
|
-
? "start"
|
|
102
|
-
: "xdg-open";
|
|
103
|
-
try {
|
|
104
|
-
spawn(cmd, [url], {
|
|
105
|
-
stdio: "ignore",
|
|
106
|
-
detached: true,
|
|
107
|
-
shell: process.platform === "win32",
|
|
108
|
-
}).unref();
|
|
109
|
-
} catch {
|
|
110
|
-
/* headless / no browser — the URL is printed anyway */
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
96
|
function serve(distDir, port) {
|
|
115
97
|
const server = createServer((req, res) => {
|
|
116
98
|
const url = decodeURIComponent((req.url || "/").split("?")[0]);
|