@rainbow-robotics/muscat-plugin-kit 1.1.4 → 1.1.5

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 (94) hide show
  1. package/cli/bin/muscat-pk.mjs +20 -2
  2. package/cli/src/bridge.mjs +26 -2
  3. package/cli/src/build.mjs +11 -1
  4. package/cli/src/cpp.mjs +6 -2
  5. package/cli/src/dev.mjs +2 -0
  6. package/cli/src/devBack.mjs +9 -4
  7. package/cli/src/ports.mjs +21 -7
  8. package/cli/src/python.mjs +7 -1
  9. package/cli/src/ros2.mjs +5 -1
  10. package/cli/src/update.mjs +34 -26
  11. package/cli/src/util.mjs +46 -1
  12. package/cli/storybook-dist/assets/{Color-YHDXOIA2-YYdZOPJH.js → Color-YHDXOIA2-YYWRJKA2.js} +1 -1
  13. package/cli/storybook-dist/assets/{Docs-3w-iBQPm.js → Docs-CKJ2w--X.js} +1 -1
  14. package/cli/storybook-dist/assets/{Docs-qI6e7Wy0.js → Docs-DIJ7cbFD.js} +1 -1
  15. package/cli/storybook-dist/assets/{Docs-DVG-oWp3.js → Docs-DLFwGiks.js} +1 -1
  16. package/cli/storybook-dist/assets/{Docs-xa7vkY1N.js → Docs-DT9RD87x.js} +1 -1
  17. package/cli/storybook-dist/assets/{Docs-D1XY1-wF.js → Docs-UmN7NFo1.js} +1 -1
  18. package/cli/storybook-dist/assets/{Docs-CZPzbRJI.js → Docs-dOc9cH1E.js} +1 -1
  19. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BE26HVj4.js → DocsRenderer-CFRXHY34-CyAy1mnJ.js} +1 -1
  20. package/cli/storybook-dist/assets/{MapEditor-D4C2XwU6.js → MapEditor-Jj1J2sUz.js} +286 -286
  21. package/cli/storybook-dist/assets/{NxAvatar.stories-5xlylW8S.js → NxAvatar.stories-B-iQoylp.js} +1 -1
  22. package/cli/storybook-dist/assets/{NxButton.stories-YRxoorOO.js → NxButton.stories-CawQZN07.js} +1 -1
  23. package/cli/storybook-dist/assets/{NxNumberInput.stories-YuPp7Wu0.js → NxNumberInput.stories-zlQJuGFP.js} +1 -1
  24. package/cli/storybook-dist/assets/{NxPagination.stories-HpA9nBE3.js → NxPagination.stories-VYXLplNl.js} +1 -1
  25. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-RkuBsu7A.js → NxPointerHoldButton.stories-BpqIkt0X.js} +1 -1
  26. package/cli/storybook-dist/assets/{NxRangeInput.stories-Bpv0nJOc.js → NxRangeInput.stories-Cu5ZWU0w.js} +1 -1
  27. package/cli/storybook-dist/assets/{NxSelectBox.stories-CG1h2bp3.js → NxSelectBox.stories-JECi_1PM.js} +1 -1
  28. package/cli/storybook-dist/assets/{NxSlideRange.stories-UdK8ZHHK.js → NxSlideRange.stories-DJe8F2Mv.js} +1 -1
  29. package/cli/storybook-dist/assets/{NxSwitch.stories-BM7d32cZ.js → NxSwitch.stories-DX6LxxQL.js} +1 -1
  30. package/cli/storybook-dist/assets/{NxTextArea.stories-DL6uoMyN.js → NxTextArea.stories-Cy42nYOI.js} +1 -1
  31. package/cli/storybook-dist/assets/{NxTextInput.stories-Cy8UZ2j4.js → NxTextInput.stories-Bb_q6FuW.js} +1 -1
  32. package/cli/storybook-dist/assets/{RBAccordion.stories-tShXBGM7.js → RBAccordion.stories-ChgPXO0_.js} +1 -1
  33. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-BdWxR0_T.js → RBAccordionGroup.stories-nmbqYYSE.js} +1 -1
  34. package/cli/storybook-dist/assets/{RBAlert.stories-qXR-YP3W.js → RBAlert.stories-ChOPuFxI.js} +1 -1
  35. package/cli/storybook-dist/assets/{RBBadge.stories-cEzAMHib.js → RBBadge.stories-Bas7g4jb.js} +1 -1
  36. package/cli/storybook-dist/assets/{RBBattery.stories-eGnAHW-z.js → RBBattery.stories-C0MywWvB.js} +1 -1
  37. package/cli/storybook-dist/assets/{RBCard.stories-CycdrGRs.js → RBCard.stories-Ba7y0u6Q.js} +1 -1
  38. package/cli/storybook-dist/assets/{RBCheckBox.stories-BSFFOcOq.js → RBCheckBox.stories-CsHFsXCd.js} +1 -1
  39. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CFJnuMjZ.js → RBCheckIconButton.stories-GERClL9R.js} +1 -1
  40. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-CAS-EfIx.js → RBCheckLabelButton.stories-C-BrmKaW.js} +1 -1
  41. package/cli/storybook-dist/assets/{RBChipTab.stories-DWsx5nBl.js → RBChipTab.stories-C8JCa6F9.js} +1 -1
  42. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-7DmzDv1u.js → RBChipsBoxTab.stories-DmUaHBJ3.js} +1 -1
  43. package/cli/storybook-dist/assets/{RBColorIcons.stories-BYhvvnJM.js → RBColorIcons.stories-Bcah6eZe.js} +1 -1
  44. package/cli/storybook-dist/assets/{RBCommand.stories-DWSPAw4x.js → RBCommand.stories-C3bwPq2T.js} +1 -1
  45. package/cli/storybook-dist/assets/{RBConfirm.stories-CtXYYbJ1.js → RBConfirm.stories-CT0GuE-J.js} +1 -1
  46. package/cli/storybook-dist/assets/{RBDateInput.stories-C8vfSeoE.js → RBDateInput.stories-CMd2xRCF.js} +1 -1
  47. package/cli/storybook-dist/assets/{RBDialog.stories-BsljNu-X.js → RBDialog.stories-Ui5Ni-P_.js} +1 -1
  48. package/cli/storybook-dist/assets/{RBDivider.stories-BaS6rsk7.js → RBDivider.stories-r8TDiO36.js} +1 -1
  49. package/cli/storybook-dist/assets/{RBDrawer.stories-Dadf_QAx.js → RBDrawer.stories-CymmrHjI.js} +1 -1
  50. package/cli/storybook-dist/assets/{RBDropdownMenu.stories-CuzDr6dG.js → RBDropdownMenu.stories-9fBcV2Ct.js} +1 -1
  51. package/cli/storybook-dist/assets/{RBEmptyView.stories-CL8YKZ4z.js → RBEmptyView.stories-By9vncNC.js} +1 -1
  52. package/cli/storybook-dist/assets/{RBIconButton.stories-C9uCobtq.js → RBIconButton.stories-DC29KMxX.js} +1 -1
  53. package/cli/storybook-dist/assets/{RBIcons.stories-Do543jrJ.js → RBIcons.stories-Dpd_V9b9.js} +1 -1
  54. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-fo5Mkohq.js → RBInlineLabelButton.stories-CfUQdAdt.js} +1 -1
  55. package/cli/storybook-dist/assets/{RBJoyStick.stories-DluMn4KO.js → RBJoyStick.stories-Dl-uXk9x.js} +1 -1
  56. package/cli/storybook-dist/assets/{RBLabelButton.stories-BisTX_da.js → RBLabelButton.stories-Dx_Vn6EM.js} +1 -1
  57. package/cli/storybook-dist/assets/{RBLight.stories-xs5nahD4.js → RBLight.stories-Cm5UYJ4n.js} +1 -1
  58. package/cli/storybook-dist/assets/{RBMenubar.stories-Cn-6_-AJ.js → RBMenubar.stories-CN5z7F6D.js} +1 -1
  59. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-5mfoXGC6.js → RBMultiRangeInput.stories-DrAMI4mQ.js} +1 -1
  60. package/cli/storybook-dist/assets/{RBPagination.stories-bcc-iOO2.js → RBPagination.stories-BVLXuTLw.js} +1 -1
  61. package/cli/storybook-dist/assets/{RBPopover.stories-DCMSSLeN.js → RBPopover.stories-VCws5q80.js} +1 -1
  62. package/cli/storybook-dist/assets/{RBPrompt.stories-D2qyZEoz.js → RBPrompt.stories-Cw5-wssh.js} +1 -1
  63. package/cli/storybook-dist/assets/{RBRadioBox.stories-CDc_WkjI.js → RBRadioBox.stories-ClqGtGA_.js} +1 -1
  64. package/cli/storybook-dist/assets/{RBRadioButton.stories-BCu10IWf.js → RBRadioButton.stories-SxcJFioj.js} +1 -1
  65. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-DoRRHADM.js → RBRangeDateInput.stories-L_wCftDv.js} +1 -1
  66. package/cli/storybook-dist/assets/{RBResizable.stories-ClPOvGPZ.js → RBResizable.stories-2VytDv6m.js} +1 -1
  67. package/cli/storybook-dist/assets/{RBSelectBox.stories-Chc3359A.js → RBSelectBox.stories-BoOKTSL_.js} +1 -1
  68. package/cli/storybook-dist/assets/{RBSheet.stories-GG_2M19S.js → RBSheet.stories-CUClMqKK.js} +1 -1
  69. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-wZw5a4Cc.js → RBSideNumberInput.stories-BTYDDAmk.js} +1 -1
  70. package/cli/storybook-dist/assets/{RBSlider.stories-B5KtjUqu.js → RBSlider.stories-Bu6vD9ym.js} +1 -1
  71. package/cli/storybook-dist/assets/{RBSpinner.stories-D1dZ-X8O.js → RBSpinner.stories-XmVcaTVS.js} +1 -1
  72. package/cli/storybook-dist/assets/{RBSwitch.stories-DUJP7uXw.js → RBSwitch.stories-CBYXvhkd.js} +1 -1
  73. package/cli/storybook-dist/assets/{RBTab.stories-DQNP4u6i.js → RBTab.stories-DFFYlkCv.js} +1 -1
  74. package/cli/storybook-dist/assets/{RBTable.stories-Bncn0LRp.js → RBTable.stories-CR2j2EQQ.js} +1 -1
  75. package/cli/storybook-dist/assets/{RBTableRow.stories-DGfPZAVt.js → RBTableRow.stories-CpPIP1au.js} +1 -1
  76. package/cli/storybook-dist/assets/{RBTabs.stories-KM5-HtJk.js → RBTabs.stories-Bew5XVDZ.js} +1 -1
  77. package/cli/storybook-dist/assets/{RBTextArea.stories-SBLKQy3l.js → RBTextArea.stories-JiRn-tk_.js} +1 -1
  78. package/cli/storybook-dist/assets/{RBTextField.stories-BtD9AYDp.js → RBTextField.stories-8LWyUBm9.js} +1 -1
  79. package/cli/storybook-dist/assets/{RBTextInput.stories-BAV7UURd.js → RBTextInput.stories-PLJ70xm5.js} +1 -1
  80. package/cli/storybook-dist/assets/{RBToast.stories-D9Hx1Gqv.js → RBToast.stories-BQf_9x0k.js} +1 -1
  81. package/cli/storybook-dist/assets/{RBToggle.stories-BsxBo-P4.js → RBToggle.stories-C5dZnmX_.js} +1 -1
  82. package/cli/storybook-dist/assets/{RBToggleGroup.stories-DtpdGy8j.js → RBToggleGroup.stories-D7fwXCxA.js} +1 -1
  83. package/cli/storybook-dist/assets/{RBTooltip.stories-CxLxUTJD.js → RBTooltip.stories-D-sWRxbA.js} +1 -1
  84. package/cli/storybook-dist/assets/{entry-preview-G2ctDavw.js → entry-preview-N9dpIJQA.js} +1 -1
  85. package/cli/storybook-dist/assets/{iframe-BOhIypRE.js → iframe-BwCxSq5d.js} +2 -2
  86. package/cli/storybook-dist/assets/{index-BgcrmuE1.js → index-CgJh_5EZ.js} +3 -3
  87. package/cli/storybook-dist/assets/{preview-D_vClpb_.js → preview-CzDPTg-k.js} +1 -1
  88. package/cli/storybook-dist/assets/preview-DZWUyVBI.css +1 -0
  89. package/cli/storybook-dist/assets/{preview-ChKiP_bM.js → preview-DsYc6ovL.js} +2 -2
  90. package/cli/storybook-dist/assets/{react-resizable-panels.browser-CvAJUcrm.js → react-resizable-panels.browser-CHDWt8fa.js} +1 -1
  91. package/cli/storybook-dist/iframe.html +1 -1
  92. package/cli/storybook-dist/project.json +1 -1
  93. package/package.json +3 -3
  94. package/cli/storybook-dist/assets/preview-CvPmpqMH.css +0 -1
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // muscat-pk — Muscat plugin developer CLI.
3
3
  // Subcommands: new, build, dev, dev:ui, dev:back, storybook, mcp, update, uninstall, sdk, version.
4
- import { color, kitVersion, log } from "../src/util.mjs";
4
+ import { realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+ import { color, kitVersion, log, resolveLocalCliBin } from "../src/util.mjs";
5
8
  import { runNew } from "../src/new.mjs";
6
9
  import { runBuild } from "../src/build.mjs";
7
10
  import { runDevUi } from "../src/devUi.mjs";
@@ -18,6 +21,21 @@ import { runCompletion } from "../src/completion.mjs";
18
21
  // on every invocation otherwise (see ros2.mjs/python.mjs/cpp.mjs for the same
19
22
  // pattern in build.mjs).
20
23
 
24
+ // 실행 중인 게 글로벌/바이너리인데 cwd 상위에 로컬 설치가 있으면 그쪽으로 넘긴다.
25
+ // vite preset / preview-host / manifest 검증이 항상 프로젝트가 고정한 버전과
26
+ // 맞아야 해서다 (버전이 안 맞으면 "module not found" 류 에러로 터진다).
27
+ (function delegateToLocalInstall() {
28
+ try {
29
+ const localBin = resolveLocalCliBin();
30
+ if (!localBin) return;
31
+ if (realpathSync(localBin) === realpathSync(fileURLToPath(import.meta.url))) return;
32
+ const r = spawnSync(process.execPath, [localBin, ...process.argv.slice(2)], { stdio: "inherit" });
33
+ process.exit(r.status ?? 0);
34
+ } catch {
35
+ // Bun 단일 바이너리 등 realpath가 안 통하는 환경 — 그냥 지금 프로세스로 계속.
36
+ }
37
+ })();
38
+
21
39
  const [, , cmd, ...args] = process.argv;
22
40
 
23
41
  const commandRow = (command, description) => ` ${color.cyan(command.padEnd(30))}${description}`;
@@ -38,7 +56,7 @@ function formatHelp() {
38
56
  commandRow("dev:back", `Run the plugin back end ${color.dim("(python: watchfiles reload / ros2: Docker)")}`),
39
57
  commandRow("storybook", `Browse the bundled rb-components Storybook ${color.dim(`(${option("--port")} <n>, ${option("--rebuild")})`)}`),
40
58
  commandRow("mcp", `Start an MCP server ${color.dim("(stdio)")} exposing rb-components/rb SDK docs to AI agents`),
41
- commandRow("update", `Update ${color.magenta("@rainbow-robotics/muscat-plugin-kit")} and ${color.magenta("@rainbow-robotics/plugin-ui")}`),
59
+ commandRow("update", `Update ${color.magenta("@rainbow-robotics/muscat-plugin-kit")} and ${color.magenta("@rainbow-robotics/plugin-ui")} ${color.dim(`(local-only if run from a project's local install; ${option("--global")} also updates the global CLI)`)}`),
42
60
  commandRow("uninstall", `Remove the installed ${color.cyan("muscat-pk")} CLI ${color.dim("(npm global or S3 binary)")}`),
43
61
  commandRow("sdk <list|update [version]>", "Manage the python rainbow-rb-sdk version"),
44
62
  commandRow("version", `Print the installed ${color.cyan("muscat-pk")} version`),
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { createServer, connect } from "node:net";
8
8
  import { existsSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
- import { hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
10
+ import { canSkipImageBuild, hasBin, kitRoot, log, pruneMuskatImages } from "./util.mjs";
11
11
 
12
12
  export const BRIDGE_IMAGE = "muscat-pk-bridge";
13
13
  export const BRIDGE_CONTAINER = "muscat-pk-bridge-run";
@@ -53,6 +53,10 @@ function waitForPort(port, timeoutMs = 15_000) {
53
53
  }
54
54
 
55
55
  function ensureImage() {
56
+ if (canSkipImageBuild(BRIDGE_IMAGE)) {
57
+ log.step(`${BRIDGE_IMAGE} already built — skipping docker build`);
58
+ return;
59
+ }
56
60
  log.step(`docker build -t ${BRIDGE_IMAGE} (first run is slow, then cached)…`);
57
61
  const r = spawnSync("docker", ["build", "-t", BRIDGE_IMAGE, "-f", DOCKERFILE, BUILD_CONTEXT], { stdio: "inherit" });
58
62
  if (r.status !== 0) throw new Error("docker build failed (bridge image).");
@@ -63,19 +67,37 @@ function ensureImage() {
63
67
  * Build the image, preflight the port, and start the bridge container.
64
68
  * Returns true if bridge started, false if port was already in use (caller decides).
65
69
  */
66
- export async function startBridge({ zenohPort = null } = {}) {
70
+ export async function startBridge({ zenohPort = null, pluginPorts = [] } = {}) {
67
71
  assertDockerReady();
68
72
 
69
73
  // remove our own stale container first, then check the host port is free
70
74
  spawnSync("docker", ["rm", "-f", BRIDGE_CONTAINER], { stdio: "ignore" });
71
75
  if (await isPortInUse(BRIDGE_PORT)) return false;
72
76
 
77
+ // Publishing a busy port makes `docker run` fail with a raw daemon error —
78
+ // say which port and keep the bridge up instead.
79
+ const busy = [];
80
+ for (const port of pluginPorts) {
81
+ if (await isPortInUse(port)) busy.push(port);
82
+ }
83
+ if (busy.length > 0) {
84
+ log.warn(
85
+ `declared plugin port(s) already in use on the host: ${busy.join(", ")} — ` +
86
+ `not published (the backend still listens inside the container).`,
87
+ );
88
+ }
89
+ const publishable = pluginPorts.filter((port) => !busy.includes(port));
90
+
73
91
  ensureImage();
74
92
 
75
93
  log.step("starting Socket.IO bridge container…");
94
+ // The backend joins THIS container's netns (--network container:), and a
95
+ // netns can only publish ports when its owner starts — so the plugin's
96
+ // declared ports have to be published here, not on the backend container.
76
97
  const portFlags = [
77
98
  "-p", `127.0.0.1:${BRIDGE_PORT}:${BRIDGE_PORT}`,
78
99
  ...(zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : []),
100
+ ...publishable.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]),
79
101
  ];
80
102
  const up = spawnSync(
81
103
  "docker",
@@ -87,6 +109,8 @@ export async function startBridge({ zenohPort = null } = {}) {
87
109
  await waitForPort(BRIDGE_PORT);
88
110
  log.step(`Socket.IO bridge listening on ws://127.0.0.1:${BRIDGE_PORT}`);
89
111
  if (zenohPort) log.step(`Zenoh dev peer port published on :${zenohPort}`);
112
+ if (publishable.length > 0)
113
+ log.step(`plugin backend ports published on 127.0.0.1: ${publishable.join(", ")}`);
90
114
  return true;
91
115
  }
92
116
 
package/cli/src/build.mjs CHANGED
@@ -227,7 +227,17 @@ export async function buildPlugin({ root, minify = false } = {}) {
227
227
  const pluginRoot = root ?? findPluginRoot();
228
228
  assertManifest(pluginRoot);
229
229
  const distDir = resolve(pluginRoot, "dist");
230
- rmSync(distDir, { recursive: true, force: true });
230
+ try {
231
+ rmSync(distDir, { recursive: true, force: true });
232
+ } catch (err) {
233
+ if (err?.code !== "EACCES" && err?.code !== "EPERM") throw err;
234
+ // 이전 버전이 컨테이너를 root로 돌려 남긴 dist/ — 한 번만 정리하면 된다
235
+ throw new Error(
236
+ `Permission denied when deleting dist/ (${err.path ?? distDir}).\n` +
237
+ `The previous build left files owned by root. Please clean it up once:\n` +
238
+ ` sudo rm -rf ${distDir}`,
239
+ );
240
+ }
231
241
  mkdirSync(distDir, { recursive: true });
232
242
 
233
243
  const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "manifest.json"), "utf8"));
package/cli/src/cpp.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Used by dev:back (interactive dev loop) and build (export the binary into the ZIP).
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { dirname } from "node:path";
6
- import { hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
7
 
8
8
  export const CPP_DEV_IMAGE = "muscat-pk-cpp-dev";
9
9
 
@@ -15,6 +15,10 @@ export function assertDockerReady() {
15
15
 
16
16
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
17
17
  export async function ensureImage(image = CPP_DEV_IMAGE, { platform = null } = {}) {
18
+ if (canSkipImageBuild(image)) {
19
+ log.step(`${image} already built — skipping docker build`);
20
+ return;
21
+ }
18
22
  const dockerfile = await resolveDockerfile("Dockerfile.cpp");
19
23
  log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
20
24
  const platformFlags = platform ? ["--platform", platform] : [];
@@ -54,7 +58,7 @@ export function cmakeBuild(root, { image = CPP_DEV_IMAGE, buildType = "Debug", a
54
58
  log.step(label);
55
59
  const r = spawnSync(
56
60
  "docker",
57
- ["run", "--rm", ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
61
+ ["run", "--rm", ...dockerUserFlags(), ...platformFlags, "-v", `${root}:/plugin`, "-w", "/plugin", actualImage, "bash", "-lc", script],
58
62
  { cwd: root, stdio: "inherit" },
59
63
  );
60
64
  if (r.status !== 0) throw new Error(`cmake build failed${arch ? ` (${arch})` : ""}.`);
package/cli/src/dev.mjs CHANGED
@@ -133,6 +133,8 @@ export async function runDev(args = []) {
133
133
  zenohPort: remoteRobot && !proxyEndpoint && !usesHostNetwork()
134
134
  ? Number(process.env.MUSCAT_ZENOH_PORT)
135
135
  : null,
136
+ // Linux uses --network host, so the ports are already on the host.
137
+ pluginPorts: usesHostNetwork() ? [] : portRanges,
136
138
  });
137
139
 
138
140
  if (!bridgeStarted) {
@@ -16,6 +16,7 @@ import { assertManifestPorts, manifestRuntime, readManifest, validateManifest }
16
16
  import {
17
17
  formatPortRanges,
18
18
  portEnvFlags,
19
+ publishPortFlags,
19
20
  startPortContractMonitor,
20
21
  } from "./ports.mjs";
21
22
  import {
@@ -108,6 +109,10 @@ async function runPython(root, portRanges) {
108
109
  !useHostNet && !bridgeContainer && !proxyEndpoint && zenohPort
109
110
  ? ["-p", `${zenohPort}:${zenohPort}`]
110
111
  : [];
112
+ // Standalone dev:back owns its netns, so it publishes its own declared ports.
113
+ // Under `dev` the bridge owns the netns and published them already.
114
+ const pluginPortFlags =
115
+ !useHostNet && !bridgeContainer ? publishPortFlags(portRanges) : [];
111
116
  const hostGatewayFlags = useHostNet || bridgeContainer
112
117
  ? []
113
118
  : ["--add-host", "host.docker.internal:host-gateway"];
@@ -142,7 +147,7 @@ async function runPython(root, portRanges) {
142
147
  docker(["rm", "-f", container], { stdio: "ignore" });
143
148
  log.step("starting Python dev container…");
144
149
  const up = docker(
145
- ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags,
150
+ ["run", "-d", "--name", container, ...netFlags, ...standaloneZenohPortFlags, ...pluginPortFlags,
146
151
  // host.docker.internal is automatic on Docker Desktop (macOS/Windows);
147
152
  // this makes it resolve on Linux too, for the no-bridge gateway fallback above.
148
153
  ...hostGatewayFlags,
@@ -151,7 +156,7 @@ async function runPython(root, portRanges) {
151
156
  { stdio: "inherit" },
152
157
  );
153
158
  if (up.status !== 0) throw new Error("failed to start dev container.");
154
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log });
159
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort] });
155
160
 
156
161
  log.step("pip install requirements + watchfiles (in container)…");
157
162
  docker(["exec", container, "bash", "-lc",
@@ -440,7 +445,7 @@ async function runRos2(root, portRanges) {
440
445
  docker(["rm", "-f", container], { stdio: "ignore" });
441
446
  log.step("starting ROS2 dev container…");
442
447
  const up = docker(
443
- ["run", "-d", "--name", container, "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
448
+ ["run", "-d", "--name", container, ...publishPortFlags(portRanges), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
444
449
  { stdio: "inherit" },
445
450
  );
446
451
  if (up.status !== 0) throw new Error("failed to start dev container.");
@@ -521,7 +526,7 @@ async function runCpp(root, portRanges) {
521
526
  docker(["rm", "-f", container], { stdio: "ignore" });
522
527
  log.step("starting C++ dev container…");
523
528
  const up = docker(
524
- ["run", "-d", "--name", container, "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
529
+ ["run", "-d", "--name", container, ...publishPortFlags(portRanges), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
525
530
  { stdio: "inherit" },
526
531
  );
527
532
  if (up.status !== 0) throw new Error("failed to start dev container.");
package/cli/src/ports.mjs CHANGED
@@ -109,6 +109,15 @@ export function portEnvFlags(ranges) {
109
109
  return ["-e", `RB_PLUGIN_PORTS=${ranges.map(formatPortRange).join(",")}`];
110
110
  }
111
111
 
112
+ /**
113
+ * `docker run -p` flags for the plugin's declared ports, so macOS/Windows can
114
+ * reach the backend's listeners (native Linux uses --network host and already
115
+ * has them). Only valid on the container that owns the netns.
116
+ */
117
+ export function publishPortFlags(ranges = []) {
118
+ return ranges.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]);
119
+ }
120
+
112
121
  function listeningTcpPorts(container) {
113
122
  const script = `
114
123
  for f in /proc/net/tcp /proc/net/tcp6; do
@@ -131,26 +140,31 @@ done | sort -n -u
131
140
  .filter((v) => Number.isInteger(v));
132
141
  }
133
142
 
134
- export function startPortContractMonitor({ container, ranges, log, intervalMs = 1000 }) {
143
+ export function startPortContractMonitor({ container, ranges, log, intervalMs = 1000, allow = [] }) {
135
144
  // Under --network host the container shares the host's netns, so
136
145
  // /proc/net/tcp shows every host/robot service port, not just the plugin's —
137
146
  // the contract can't be enforced. Skip it (no isolated ports to police).
138
147
  if (usesHostNetwork()) return () => false;
139
148
  const declared = ranges ?? [];
149
+ // muscat-pk's own dev plumbing shares this netns — not the plugin's doing.
150
+ const ignored = new Set([...INTERNAL_DEV_PORTS, ...allow.map(Number).filter(Number.isInteger)]);
140
151
  let reported = false;
141
152
  const timer = setInterval(() => {
142
- const ports = listeningTcpPorts(container)
143
- .filter((port) => !INTERNAL_DEV_PORTS.has(port))
144
- .filter((port) => isPluginManagedPort(port));
145
- const unexpected = ports.filter((port) => reservedPortReason(port) || !portInRanges(port, declared));
153
+ // Every listening port that isn't declared is a violation — a port outside
154
+ // 28600-28999 can't be declared at all, so it must not be opened either.
155
+ // Otherwise it silently escapes both this check and rby2's install-time
156
+ // conflict check, and only collides once it's on a robot.
157
+ const unexpected = listeningTcpPorts(container)
158
+ .filter((port) => !ignored.has(port))
159
+ .filter((port) => !portInRanges(port, declared));
146
160
  if (unexpected.length === 0) return;
147
161
 
148
162
  clearInterval(timer);
149
163
  reported = true;
150
164
  const declaredText = declared.length > 0 ? formatPortRanges(declared) : "(none)";
151
165
  log.error(
152
- `backend opened reserved or undeclared plugin TCP port(s): ${unexpected.join(", ")}. ` +
153
- `Add them to manifest.json "ports" within ${PLUGIN_PORT_MIN}-${PLUGIN_PORT_MAX}. ` +
166
+ `backend opened undeclared TCP port(s): ${unexpected.join(", ")}. ` +
167
+ `Every listening port must be in manifest.json "ports" within ${PLUGIN_PORT_MIN}-${PLUGIN_PORT_MAX}. ` +
154
168
  `Declared: ${declaredText}`,
155
169
  );
156
170
  spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, dockerUserFlags, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
7
 
8
8
  export const PYTHON_DEV_IMAGE = "muscat-pk-python-dev";
9
9
 
@@ -15,6 +15,10 @@ export function assertDockerReady() {
15
15
 
16
16
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
17
17
  export async function ensureImage(image = PYTHON_DEV_IMAGE, { platform = null } = {}) {
18
+ if (canSkipImageBuild(image)) {
19
+ log.step(`${image} already built — skipping docker build`);
20
+ return;
21
+ }
18
22
  const dockerfile = await resolveDockerfile("Dockerfile.python");
19
23
  log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
20
24
  const platformFlags = platform ? ["--platform", platform] : [];
@@ -66,6 +70,7 @@ export async function lockAndVendor(pluginRoot, archs = ["amd64", "arm64"]) {
66
70
  "docker",
67
71
  [
68
72
  "run", "--rm",
73
+ ...dockerUserFlags(),
69
74
  "-v", `${pluginRoot}:/plugin`,
70
75
  PYTHON_DEV_IMAGE,
71
76
  "bash", "-c",
@@ -85,6 +90,7 @@ export async function lockAndVendor(pluginRoot, archs = ["amd64", "arm64"]) {
85
90
  "docker",
86
91
  [
87
92
  "run", "--rm",
93
+ ...dockerUserFlags(),
88
94
  "-e", "PIP_ROOT_USER_ACTION=ignore",
89
95
  "-v", `${pluginRoot}:/plugin`,
90
96
  "-w", "/plugin",
package/cli/src/ros2.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, readdirSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
6
+ import { canSkipImageBuild, hasBin, log, pruneMuskatImages, resolveDockerfile } from "./util.mjs";
7
7
 
8
8
  export const ROS2_DEV_IMAGE = "muscat-pk-ros2-dev";
9
9
 
@@ -30,6 +30,10 @@ export const ARCH_DOCKER_PLATFORM = {
30
30
 
31
31
  /** Build the dev image from the kit's bundled Dockerfile (Docker layer-caches). */
32
32
  export async function ensureImage(image = ROS2_DEV_IMAGE, { platform = null } = {}) {
33
+ if (canSkipImageBuild(image)) {
34
+ log.step(`${image} already built — skipping docker build`);
35
+ return;
36
+ }
33
37
  const dockerfile = await resolveDockerfile("Dockerfile.dev");
34
38
  log.step(`docker build -t ${image}${platform ? ` (${platform})` : ""} (first run is slow, then cached)…`);
35
39
  const platformFlags = platform ? ["--platform", platform] : [];
@@ -14,7 +14,7 @@ import { execSync, spawn } from "node:child_process";
14
14
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join, resolve } from "node:path";
17
- import { findPluginRoot, KIT_PUBLISHED_NAME, kitVersion, log, parseArgs, prompt } from "./util.mjs";
17
+ import { findPluginRoot, isLocalKitInstall, KIT_PUBLISHED_NAME, kitVersion, log, parseArgs, prompt } from "./util.mjs";
18
18
 
19
19
  const PLUGIN_UI_PACKAGE = "@rainbow-robotics/plugin-ui";
20
20
  const S3_BASE = "https://rainbow-deploy.s3.ap-northeast-2.amazonaws.com/muscat-plugin-kit";
@@ -224,39 +224,47 @@ async function resolveTag(explicitTag) {
224
224
  }
225
225
 
226
226
  export async function runUpdate(args = []) {
227
- const opts = parseArgs(args);
227
+ const opts = parseArgs(args, { booleans: ["global"] });
228
228
  const explicitTag = opts._[0] || opts.version || opts.tag || "";
229
229
  const tag = await resolveTag(explicitTag);
230
230
 
231
+ // 지금 도는 게 프로젝트의 로컬 설치면 그 프로젝트만 건드린다. 이 머신의 다른
232
+ // 프로젝트들이 쓰는 글로벌 CLI까지 같이 올리려면 --global을 명시해야 한다.
233
+ const runningLocal = isLocalKitInstall();
234
+
231
235
  // 1. Update the CLI itself
232
- if (isBinaryMode) {
233
- log.step("Updating binary…");
234
- try {
235
- await updateBinary(tag);
236
- } catch (err) {
237
- log.warn(`Binary update failed: ${err.message}`);
238
- }
239
- } else {
240
- const isGlobal = (() => {
236
+ if (!runningLocal || opts.global) {
237
+ if (isBinaryMode) {
238
+ log.step("Updating binary…");
241
239
  try {
242
- const prefix = execSync("npm prefix -g", { encoding: "utf8" }).trim();
243
- return (
244
- // Unix: {prefix}/bin/muscat-pk
245
- existsSync(join(prefix, "bin", "muscat-pk")) ||
246
- // Windows npm: {prefix}/muscat-pk.cmd (no bin/ subdir)
247
- existsSync(join(prefix, "muscat-pk.cmd")) ||
248
- existsSync(join(prefix, "bin", "muscat-pk.cmd"))
249
- );
250
- } catch { return false; }
251
- })();
252
-
253
- if (isGlobal) {
254
- try {
255
- updateNpmGlobal(tag);
240
+ await updateBinary(tag);
256
241
  } catch (err) {
257
- log.warn(`Global CLI update failed: ${err.message}`);
242
+ log.warn(`Binary update failed: ${err.message}`);
243
+ }
244
+ } else {
245
+ const isGlobal = (() => {
246
+ try {
247
+ const prefix = execSync("npm prefix -g", { encoding: "utf8" }).trim();
248
+ return (
249
+ // Unix: {prefix}/bin/muscat-pk
250
+ existsSync(join(prefix, "bin", "muscat-pk")) ||
251
+ // Windows npm: {prefix}/muscat-pk.cmd (no bin/ subdir)
252
+ existsSync(join(prefix, "muscat-pk.cmd")) ||
253
+ existsSync(join(prefix, "bin", "muscat-pk.cmd"))
254
+ );
255
+ } catch { return false; }
256
+ })();
257
+
258
+ if (isGlobal) {
259
+ try {
260
+ updateNpmGlobal(tag);
261
+ } catch (err) {
262
+ log.warn(`Global CLI update failed: ${err.message}`);
263
+ }
258
264
  }
259
265
  }
266
+ } else {
267
+ log.info("Local install detected — updating this project only. Pass --global to also update the global CLI.");
260
268
  }
261
269
 
262
270
  // 2. Plugin project: structure migration + local deps
package/cli/src/util.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // Shared helpers for muscat-pk.
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, resolve, sep } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createInterface } from "node:readline/promises";
@@ -141,6 +141,23 @@ export function findPluginRoot(start = process.cwd()) {
141
141
  );
142
142
  }
143
143
 
144
+ /** Walk up from cwd looking for a project-local muscat-pk install. */
145
+ export function resolveLocalCliBin(start = process.cwd()) {
146
+ let dir = resolve(start);
147
+ for (;;) {
148
+ const candidate = resolve(dir, "node_modules/@rainbow-robotics/muscat-plugin-kit/cli/bin/muscat-pk.mjs");
149
+ if (existsSync(candidate)) return candidate;
150
+ const parent = dirname(dir);
151
+ if (parent === dir) return null;
152
+ dir = parent;
153
+ }
154
+ }
155
+
156
+ /** True if the code currently running is a project's local install (not global/binary). */
157
+ export function isLocalKitInstall() {
158
+ return kitRoot.includes(`${sep}node_modules${sep}`);
159
+ }
160
+
144
161
  const useColor = process.env.NO_COLOR !== "1" && (process.stdout.isTTY || process.env.FORCE_COLOR);
145
162
  const ansi = (code) => (s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s);
146
163
 
@@ -185,6 +202,34 @@ export function pruneMuskatImages() {
185
202
  );
186
203
  }
187
204
 
205
+ /** True if the image already exists in the local Docker image store. */
206
+ export function dockerImageExists(image) {
207
+ return spawnSync("docker", ["image", "inspect", image], { stdio: "ignore" }).status === 0;
208
+ }
209
+
210
+ /**
211
+ * True if `docker build` should be skipped because the image is already built.
212
+ * BuildKit resolves the FROM tag over the network on every build (the
213
+ * "load metadata for docker.io/..." step) even when all layers are cached, so
214
+ * rebuilding an image we already have breaks `dev` offline for no benefit.
215
+ * Force a rebuild with MUSCAT_PK_REBUILD_IMAGE=1.
216
+ */
217
+ export function canSkipImageBuild(image) {
218
+ if (process.env.MUSCAT_PK_REBUILD_IMAGE === "1") return false;
219
+ return dockerImageExists(image);
220
+ }
221
+
222
+ // Linux에서 컨테이너가 마운트에 root 소유 파일을 남기면 다음 빌드의 dist/ 삭제가 EACCES로 막힌다.
223
+ // (macOS/Windows는 Docker Desktop이 소유권을 호스트 유저로 매핑하므로 불필요)
224
+ export function dockerUserFlags() {
225
+ if (process.platform !== "linux" || typeof process.getuid !== "function") return [];
226
+ return [
227
+ "--user", `${process.getuid()}:${process.getgid()}`,
228
+ "-e", "HOME=/tmp",
229
+ "-e", "XDG_CACHE_HOME=/tmp/.cache",
230
+ ];
231
+ }
232
+
188
233
  export function humanSize(bytes) {
189
234
  if (bytes < 1024) return `${bytes} B`;
190
235
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;