@rainbow-robotics/muscat-plugin-kit 1.10.2-dev.6 → 1.10.2-dev.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/cli/bin/muscat-pk.mjs +3 -0
  2. package/cli/src/build.mjs +4 -9
  3. package/cli/src/dev.mjs +2 -1
  4. package/cli/src/devBack.mjs +6 -12
  5. package/cli/src/devSession.mjs +67 -0
  6. package/cli/src/devUi.mjs +15 -0
  7. package/cli/src/mcp.mjs +7 -1
  8. package/cli/src/mcpProxy.mjs +110 -0
  9. package/cli/src/muscatLocal.mjs +15 -1
  10. package/cli/src/templates-embedded.mjs +2 -2
  11. package/cli/src/zip.mjs +121 -0
  12. package/cli/storybook-dist/assets/{Color-YHDXOIA2-BoqBLKNL.js → Color-YHDXOIA2-B1KKcB6K.js} +1 -1
  13. package/cli/storybook-dist/assets/{Docs-BlIu-JrJ.js → Docs-2Fjmybwk.js} +1 -1
  14. package/cli/storybook-dist/assets/{Docs-BhJTZ0C_.js → Docs-CHv6SVVZ.js} +1 -1
  15. package/cli/storybook-dist/assets/{Docs-VKVuhalv.js → Docs-Ccehtd2V.js} +1 -1
  16. package/cli/storybook-dist/assets/{Docs-C4ktkQce.js → Docs-DLOo5tzV.js} +1 -1
  17. package/cli/storybook-dist/assets/{Docs-Cg3A7xDz.js → Docs-DtdcK7tN.js} +1 -1
  18. package/cli/storybook-dist/assets/{Docs-_i1XHVXU.js → Docs-exoI2PyN.js} +1 -1
  19. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BGHjhFiI.js → DocsRenderer-CFRXHY34-DHVj1Lpb.js} +1 -1
  20. package/cli/storybook-dist/assets/{entry-preview-C2IBQfq7.js → entry-preview-fUNTcv77.js} +1 -1
  21. package/cli/storybook-dist/assets/{iframe-D-mTyH7q.js → iframe-Ck1s_iK9.js} +2 -2
  22. package/cli/storybook-dist/assets/{index-DKrg3vQJ.js → index-BkRHCy11.js} +3 -3
  23. package/cli/storybook-dist/assets/{preview-CpePTsLG.js → preview-CDcrEKt5.js} +2 -4
  24. package/cli/storybook-dist/assets/{preview-i5rYb1IR.js → preview-Cf2hNwTD.js} +2 -2
  25. package/cli/storybook-dist/iframe.html +1 -1
  26. package/cli/storybook-dist/project.json +1 -1
  27. package/cli/templates/common/README.md +1 -1
  28. package/cli/templates/common/py/main.py +2 -2
  29. package/cli/templates/cpp/cpp/main.cpp +1 -1
  30. package/cli/templates/readme-cpp/README.md +3 -3
  31. package/package.json +3 -3
@@ -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 { execSync, spawnSync } from "node:child_process";
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
- try {
347
- execSync(`zip -r -q "${zipPath}" ${entries.map((e) => `"${e}"`).join(" ")}`, {
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/dev.mjs CHANGED
@@ -5,7 +5,7 @@ import { connect as netConnect } from "node:net";
5
5
  import { resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runDevUi } from "./devUi.mjs";
8
- import { stopDevBackContainers } from "./devBack.mjs";
8
+ import { claimDevSession, stopDevBackContainers } from "./devSession.mjs";
9
9
  import { ensureLocalMuscat, GATEWAY_PORT, isPortInUse, robotHasMuscat } from "./muscatLocal.mjs";
10
10
  import { assertManifestPorts, assertManifestVariables, readManifest } from "./manifest.mjs";
11
11
  import { devPluginId } from "./devId.mjs";
@@ -111,6 +111,7 @@ async function maybeStartHostAgent(root, { robotIp, robotToken }) {
111
111
 
112
112
  export async function runDev(args = []) {
113
113
  const root = findPluginRoot();
114
+ await claimDevSession(root, "dev");
114
115
  assertManifestVariables(root);
115
116
  const portRanges = assertManifestPorts(root);
116
117
  ensureDocker();
@@ -13,6 +13,7 @@ import { isIP } from "node:net";
13
13
  import { resolve } from "node:path";
14
14
  import { dockerUserFlags, ensureDevDir, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
15
  import { ensureRemoteZenohEnv, getFreePort, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
+ import { claimDevSession, DEV_CONTAINER, stopDevBackContainers } from "./devSession.mjs";
16
17
  import { GATEWAY_PORT, localMuscatContainer, robotHasMuscat } from "./muscatLocal.mjs";
17
18
  import { devPluginId } from "./devId.mjs";
18
19
  import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
@@ -44,15 +45,7 @@ function exitAfterCleanup(code = 0) {
44
45
  if (process.env[MANAGED_DEV_ENV] !== "1") process.exit(code ?? 0);
45
46
  }
46
47
 
47
- export function stopDevBackContainers() {
48
- for (const container of [
49
- `${PYTHON_DEV_IMAGE}-run`,
50
- `${ROS2_DEV_IMAGE}-run`,
51
- `${CPP_DEV_IMAGE}-run`,
52
- ]) {
53
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
54
- }
55
- }
48
+ export { stopDevBackContainers };
56
49
 
57
50
  // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
58
51
  // join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
@@ -149,7 +142,7 @@ async function runPython(root, portRanges, robotGateway) {
149
142
 
150
143
  const manifest = readManifest(root);
151
144
  const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
152
- const container = `${PYTHON_DEV_IMAGE}-run`;
145
+ const container = DEV_CONTAINER;
153
146
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
154
147
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
155
148
  const zenohAdvertiseHost = process.env.MUSCAT_ZENOH_ADVERTISE_HOST ?? "";
@@ -529,7 +522,7 @@ async function runRos2(root, portRanges, robotGateway) {
529
522
  await ensureImage();
530
523
 
531
524
  const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
532
- const container = `${ROS2_DEV_IMAGE}-run`;
525
+ const container = DEV_CONTAINER;
533
526
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
534
527
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
535
528
  const net = backendNetwork(portRanges, robotGateway);
@@ -624,7 +617,7 @@ async function runCpp(root, portRanges, robotGateway) {
624
617
  await cppEnsureImage();
625
618
 
626
619
  const pluginId = process.env.MUSCAT_PK_DEV_PLUGIN_ID || (manifest.id ?? "");
627
- const container = `${CPP_DEV_IMAGE}-run`;
620
+ const container = DEV_CONTAINER;
628
621
  const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
629
622
  const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
630
623
  const net = backendNetwork(portRanges, robotGateway);
@@ -699,6 +692,7 @@ function warnManifest(root) {
699
692
 
700
693
  export async function runDevBack(args = []) {
701
694
  const root = findPluginRoot();
695
+ await claimDevSession(root, "dev:back");
702
696
  assertManifestVariables(root);
703
697
  warnManifest(root);
704
698
  const portRanges = assertManifestPorts(root);
@@ -0,0 +1,67 @@
1
+ // One Muscat plugin dev environment per PC: the backend container, the local Muscat runtime (and
2
+ // the plugin ports it publishes) and the host_agent relay port are shared. A new dev / dev:ui /
3
+ // dev:back offers to stop the running one and take over.
4
+ import { spawnSync } from "node:child_process";
5
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, resolve } from "node:path";
8
+ import { log, prompt } from "./util.mjs";
9
+
10
+ export const DEV_CONTAINER = "muscat-pk-dev-run";
11
+ // Per-runtime names from older kits.
12
+ const OLD_CONTAINERS = ["muscat-pk-python-dev-run", "muscat-pk-ros2-dev-run", "muscat-pk-cpp-dev-run"];
13
+ const LOCK = resolve(homedir(), ".muscat-pk", "dev-session.json");
14
+
15
+ export function stopDevBackContainers() {
16
+ for (const c of [DEV_CONTAINER, ...OLD_CONTAINERS]) spawnSync("docker", ["rm", "-f", c], { stdio: "ignore" });
17
+ }
18
+
19
+ const alive = (pid) => {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ } catch (e) {
24
+ return e.code === "EPERM";
25
+ }
26
+ };
27
+
28
+ function runningSession() {
29
+ try {
30
+ const s = JSON.parse(readFileSync(LOCK, "utf8"));
31
+ return s.pid !== process.pid && alive(s.pid) ? s : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function stopSession(pid) {
38
+ // SIGINT runs its own cleanup (UI, host_agent, containers). Windows has no signals — kill the tree.
39
+ if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
40
+ else process.kill(pid, "SIGINT");
41
+ for (let i = 0; i < 60 && alive(pid); i++) await new Promise((r) => setTimeout(r, 500));
42
+ if (alive(pid)) process.kill(pid, "SIGKILL");
43
+ }
44
+
45
+ export async function claimDevSession(root, command) {
46
+ if (process.env.MUSCAT_PK_MANAGED_DEV === "1") return; // `dev` claimed it for its dev:ui / dev:back
47
+ const other = runningSession();
48
+ if (other?.pid === process.ppid) return; // `dev`'s own dev:back child
49
+ if (other) {
50
+ log.warn(`A Muscat plugin dev environment is already running: muscat-pk ${other.command} in ${other.root} (pid ${other.pid}).`);
51
+ log.warn("Only one can run per PC — they share the backend container, the local Muscat and its ports.");
52
+ const answer = (await prompt(" Stop it and start this one? [y/N]: ")).toLowerCase();
53
+ if (answer !== "y" && answer !== "yes") throw new Error("another plugin dev environment is running — stop it first.");
54
+ await stopSession(other.pid);
55
+ stopDevBackContainers(); // whatever it could not clean up
56
+ log.ok("stopped the other plugin dev environment");
57
+ }
58
+ mkdirSync(dirname(LOCK), { recursive: true });
59
+ writeFileSync(LOCK, JSON.stringify({ pid: process.pid, root, command }));
60
+ process.on("exit", () => {
61
+ try {
62
+ if (JSON.parse(readFileSync(LOCK, "utf8")).pid === process.pid) rmSync(LOCK, { force: true });
63
+ } catch {
64
+ /* already gone */
65
+ }
66
+ });
67
+ }
package/cli/src/devUi.mjs CHANGED
@@ -11,6 +11,7 @@ import { devPluginId } from "./devId.mjs";
11
11
  import { readManifest } from "./manifest.mjs";
12
12
  import { ensureLocalMuscat, GATEWAY_PORT, localFullMuscat, robotHasMuscat } from "./muscatLocal.mjs";
13
13
  import { isRemoteRobot } from "./zenohDev.mjs";
14
+ import { claimDevSession } from "./devSession.mjs";
14
15
 
15
16
 
16
17
  const DEV_PLUGIN_PORT = 4181;
@@ -82,6 +83,14 @@ function syncAssets(pluginRoot, outDir) {
82
83
  }
83
84
 
84
85
 
86
+ // "(dev)" after every name the plugin shows, so a dev session is never mistaken for an installed copy.
87
+ const devLabel = (v) =>
88
+ typeof v === "string"
89
+ ? `${v} (dev)`
90
+ : v && typeof v === "object"
91
+ ? Object.fromEntries(Object.entries(v).map(([lang, s]) => [lang, `${s} (dev)`]))
92
+ : v;
93
+
85
94
  // preview-host takes the plugin id from this copy, so the UI talks under the session's dev id.
86
95
  function stageDevAssets(pluginRoot, pluginId) {
87
96
  const outDir = ensureDevDir(pluginRoot);
@@ -89,6 +98,11 @@ function stageDevAssets(pluginRoot, pluginId) {
89
98
  if (existsSync(manifestSrc)) {
90
99
  const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
91
100
  if (pluginId) manifest.id = pluginId;
101
+ manifest.name = devLabel(manifest.name);
102
+ for (const c of manifest.contributes ?? []) {
103
+ c.label = devLabel(c.label);
104
+ if (c.commandInfo?.name) c.commandInfo.name = devLabel(c.commandInfo.name);
105
+ }
92
106
  writeFileSync(resolve(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
93
107
  }
94
108
  syncAssets(pluginRoot, outDir);
@@ -163,6 +177,7 @@ function waitForPluginDevPort(readyFile, proc, timeoutMs = 120_000) {
163
177
  export async function runDevUi(args = []) {
164
178
  const parsed = parseArgs(args);
165
179
  const requestedPort = Number(parsed.port ?? "4180");
180
+ await claimDevSession(findPluginRoot(), "dev:ui");
166
181
  const robotIp = parsed["robot-ip"] ?? process.env.MUSCAT_ROBOT_HOST ?? await prompt(" Robot IP (skip if no robot): ");
167
182
 
168
183
  await checkKitUpdate();
package/cli/src/mcp.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  readdirSync,
32
32
  readFileSync,
33
33
  renameSync,
34
+ rmSync,
34
35
  statSync,
35
36
  writeFileSync,
36
37
  } from "node:fs";
@@ -470,7 +471,12 @@ function introspectInDocker(probe) {
470
471
  /* not a python plugin — latest */
471
472
  }
472
473
  // ponytail: "latest" cache is never refreshed; delete ~/.cache/... rb-sdk-py/latest to update.
473
- const cache = join(UI_INDEX_CACHE_DIR, "rb-sdk-py", ver || "latest");
474
+ const cacheRoot = join(UI_INDEX_CACHE_DIR, "rb-sdk-py");
475
+ const cache = join(cacheRoot, ver || "latest");
476
+ // Only the SDK version in use is kept (~165MB each) — other versions don't pile up.
477
+ for (const old of existsSync(cacheRoot) ? readdirSync(cacheRoot) : []) {
478
+ if (join(cacheRoot, old) !== cache) rmSync(join(cacheRoot, old), { recursive: true, force: true });
479
+ }
474
480
  mkdirSync(cache, { recursive: true });
475
481
  const pkg = ver ? `rainbow-rb-sdk==${ver}` : "rainbow-rb-sdk";
476
482
  const script =
@@ -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
+ }
@@ -6,7 +6,7 @@
6
6
  // start our own: the S3 runtime image with just the `common` service.
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { createServer } from "node:net";
9
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { resolve } from "node:path";
12
12
  import { mediaPortFlags, MEDIA_PORTS, publishPortFlags } from "./ports.mjs";
@@ -78,8 +78,22 @@ async function resolveVersions() {
78
78
  }
79
79
  }
80
80
 
81
+ // A new Muscat version replaces the previous runtime image (~2.8GB) and its scripts. One still in
82
+ // use by the running local Muscat stays until a later run (rmi refuses it).
83
+ export function removeOldRuntimes(total) {
84
+ const tags = spawnSync("docker", ["images", IMAGE, "--format", "{{.Tag}}"], { encoding: "utf8" }).stdout ?? "";
85
+ for (const tag of tags.split("\n").map((t) => t.trim()).filter((t) => t && t !== total)) {
86
+ spawnSync("docker", ["rmi", `${IMAGE}:${tag}`], { stdio: "ignore" });
87
+ }
88
+ const dir = resolve(CACHE, "runtime");
89
+ for (const old of existsSync(dir) ? readdirSync(dir) : []) {
90
+ if (old !== total) rmSync(resolve(dir, old), { recursive: true, force: true });
91
+ }
92
+ }
93
+
81
94
  async function ensureRuntimeImage(total) {
82
95
  const image = `${IMAGE}:${total}`;
96
+ removeOldRuntimes(total);
83
97
  if (canSkipImageBuild(image)) return image;
84
98
  const dir = resolve(CACHE, "runtime", total);
85
99
  if (!existsSync(resolve(dir, "Dockerfile.runtime"))) {