@rainbow-robotics/muscat-plugin-kit 1.10.1-dev.1 → 1.10.1

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 (205) hide show
  1. package/README.md +282 -282
  2. package/cli/README.md +274 -274
  3. package/cli/bin/create-muscat-plugin.mjs +8 -8
  4. package/cli/bin/muscat-pk.mjs +155 -155
  5. package/cli/bundle-templates.mjs +31 -31
  6. package/cli/docker/Dockerfile.cpp +28 -28
  7. package/cli/docker/Dockerfile.dev +30 -30
  8. package/cli/docker/Dockerfile.python +21 -21
  9. package/cli/m5-e2e.mjs +69 -69
  10. package/cli/prepack.mjs +10 -10
  11. package/cli/src/ai-rules.mjs +70 -70
  12. package/cli/src/build.mjs +447 -447
  13. package/cli/src/check-package.mjs +13 -13
  14. package/cli/src/completion.mjs +166 -166
  15. package/cli/src/cpp.mjs +78 -78
  16. package/cli/src/cppIde.mjs +75 -75
  17. package/cli/src/dev.mjs +165 -165
  18. package/cli/src/devBack.mjs +734 -727
  19. package/cli/src/devUi.mjs +247 -243
  20. package/cli/src/esbuild.mjs +56 -56
  21. package/cli/src/host_agent.py +223 -223
  22. package/cli/src/manifest.mjs +287 -287
  23. package/cli/src/mcp.mjs +1071 -1071
  24. package/cli/src/muscatLocal.mjs +209 -209
  25. package/cli/src/new.mjs +343 -343
  26. package/cli/src/pluginDevServer.mjs +111 -111
  27. package/cli/src/ports.mjs +193 -193
  28. package/cli/src/python.mjs +112 -112
  29. package/cli/src/ros2.mjs +109 -109
  30. package/cli/src/sdk.mjs +117 -117
  31. package/cli/src/storybook.mjs +168 -168
  32. package/cli/src/type.mjs +141 -141
  33. package/cli/src/uninstall.mjs +81 -81
  34. package/cli/src/update.mjs +295 -295
  35. package/cli/src/util.mjs +441 -441
  36. package/cli/src/zenohDev.mjs +83 -81
  37. package/cli/storybook-dist/_design-tokens.css +4957 -4957
  38. package/cli/storybook-dist/_stories-src/NxAvatar.stories.tsx +27 -27
  39. package/cli/storybook-dist/_stories-src/NxPagination.stories.tsx +28 -28
  40. package/cli/storybook-dist/_stories-src/RBAccordion/RBAccordion.stories.tsx +46 -46
  41. package/cli/storybook-dist/_stories-src/RBAccordion/RBAccordionGroup.stories.tsx +73 -73
  42. package/cli/storybook-dist/_stories-src/RBBadge/RBBadge.stories.tsx +93 -93
  43. package/cli/storybook-dist/_stories-src/RBBattery/RBBattery.stories.tsx +74 -74
  44. package/cli/storybook-dist/_stories-src/RBButton/RBCheckIconButton.stories.tsx +28 -28
  45. package/cli/storybook-dist/_stories-src/RBButton/RBCheckLabelButton.stories.tsx +84 -84
  46. package/cli/storybook-dist/_stories-src/RBButton/RBIconButton.stories.tsx +57 -57
  47. package/cli/storybook-dist/_stories-src/RBButton/RBInlineLabelButton.stories.tsx +41 -41
  48. package/cli/storybook-dist/_stories-src/RBButton/RBLabelButton.stories.tsx +60 -60
  49. package/cli/storybook-dist/_stories-src/RBDatePicker/RBDateInput.stories.tsx +44 -44
  50. package/cli/storybook-dist/_stories-src/RBDatePicker/RBRangeDateInput.stories.tsx +56 -56
  51. package/cli/storybook-dist/_stories-src/RBDialog/RBAlert.stories.tsx +51 -51
  52. package/cli/storybook-dist/_stories-src/RBDialog/RBConfirm.stories.tsx +55 -55
  53. package/cli/storybook-dist/_stories-src/RBDialog/RBPrompt.stories.tsx +52 -52
  54. package/cli/storybook-dist/_stories-src/RBDivider/RBDivider.stories.tsx +93 -93
  55. package/cli/storybook-dist/_stories-src/RBEmptyView/RBEmptyView.stories.tsx +49 -49
  56. package/cli/storybook-dist/_stories-src/RBIcons/RBColorIcons.stories.tsx +108 -108
  57. package/cli/storybook-dist/_stories-src/RBIcons/RBIcons.stories.tsx +106 -106
  58. package/cli/storybook-dist/_stories-src/RBInput/RBCheckBox.stories.tsx +89 -89
  59. package/cli/storybook-dist/_stories-src/RBInput/RBMultiRangeInput.stories.tsx +64 -64
  60. package/cli/storybook-dist/_stories-src/RBInput/RBRadioBox.stories.tsx +65 -65
  61. package/cli/storybook-dist/_stories-src/RBInput/RBRadioButton.stories.tsx +68 -68
  62. package/cli/storybook-dist/_stories-src/RBInput/RBSelectBox.stories.tsx +216 -216
  63. package/cli/storybook-dist/_stories-src/RBInput/RBSideNumberInput.stories.tsx +90 -90
  64. package/cli/storybook-dist/_stories-src/RBInput/RBText/RBTextArea.stories.tsx +45 -45
  65. package/cli/storybook-dist/_stories-src/RBInput/RBText/RBTextInput.stories.tsx +50 -50
  66. package/cli/storybook-dist/_stories-src/RBInput/RBTextField.stories.tsx +102 -102
  67. package/cli/storybook-dist/_stories-src/RBJoyStick/RBJoyStick.stories.tsx +31 -31
  68. package/cli/storybook-dist/_stories-src/RBLight/RBLight.stories.tsx +36 -36
  69. package/cli/storybook-dist/_stories-src/RBPagination/RBPagination.stories.tsx +49 -49
  70. package/cli/storybook-dist/_stories-src/RBShadcn/RBCard/RBCard.stories.tsx +89 -89
  71. package/cli/storybook-dist/_stories-src/RBShadcn/RBCommand/RBCommand.stories.tsx +116 -116
  72. package/cli/storybook-dist/_stories-src/RBShadcn/RBDialog/RBDialog.stories.tsx +74 -74
  73. package/cli/storybook-dist/_stories-src/RBShadcn/RBDrawer/RBDrawer.stories.tsx +99 -99
  74. package/cli/storybook-dist/_stories-src/RBShadcn/RBDropdownMenu/RBDropdownMenu.stories.tsx +123 -123
  75. package/cli/storybook-dist/_stories-src/RBShadcn/RBMenubar/RBMenubar.stories.tsx +135 -135
  76. package/cli/storybook-dist/_stories-src/RBShadcn/RBPopover/RBPopover.stories.tsx +69 -69
  77. package/cli/storybook-dist/_stories-src/RBShadcn/RBResizable/RBResizable.stories.tsx +49 -49
  78. package/cli/storybook-dist/_stories-src/RBShadcn/RBSheet/RBSheet.stories.tsx +91 -91
  79. package/cli/storybook-dist/_stories-src/RBShadcn/RBSlider/RBSlider.stories.tsx +38 -38
  80. package/cli/storybook-dist/_stories-src/RBShadcn/RBSwitch/RBSwitch.stories.tsx +112 -112
  81. package/cli/storybook-dist/_stories-src/RBShadcn/RBTabs/RBTabs.stories.tsx +75 -75
  82. package/cli/storybook-dist/_stories-src/RBShadcn/RBToggle/RBToggle.stories.tsx +51 -51
  83. package/cli/storybook-dist/_stories-src/RBShadcn/RBToggleGroup/RBToggleGroup.stories.tsx +84 -84
  84. package/cli/storybook-dist/_stories-src/RBTab/RBChipTab.stories.tsx +102 -102
  85. package/cli/storybook-dist/_stories-src/RBTab/RBChipsBoxTab.stories.tsx +78 -78
  86. package/cli/storybook-dist/_stories-src/RBTab/RBTab.stories.tsx +57 -57
  87. package/cli/storybook-dist/_stories-src/RBTable/RBTable.stories.tsx +177 -177
  88. package/cli/storybook-dist/_stories-src/RBTable/RBTableRow.stories.tsx +64 -64
  89. package/cli/storybook-dist/_stories-src/RBToast/RBToast.stories.tsx +117 -117
  90. package/cli/storybook-dist/_stories-src/RBTooltip/RBTooltip.stories.tsx +50 -50
  91. package/cli/storybook-dist/_stories-src/loading/RBSpinner.stories.tsx +30 -30
  92. package/cli/storybook-dist/_stories-src/nx-button/NxButton.stories.tsx +54 -54
  93. package/cli/storybook-dist/_stories-src/nx-button/NxPointerHoldButton.stories.tsx +57 -57
  94. package/cli/storybook-dist/_stories-src/nx-icons/NxIcons.stories.tsx +48 -48
  95. package/cli/storybook-dist/_stories-src/nx-inputs/NxNumberInput.stories.tsx +103 -103
  96. package/cli/storybook-dist/_stories-src/nx-inputs/NxRangeInput.stories.tsx +27 -27
  97. package/cli/storybook-dist/_stories-src/nx-inputs/NxSelectBox.stories.tsx +67 -67
  98. package/cli/storybook-dist/_stories-src/nx-inputs/NxSlideRange.stories.tsx +45 -45
  99. package/cli/storybook-dist/_stories-src/nx-inputs/NxSwitch.stories.tsx +27 -27
  100. package/cli/storybook-dist/_stories-src/nx-inputs/NxTextArea.stories.tsx +48 -48
  101. package/cli/storybook-dist/_stories-src/nx-inputs/NxTextInput.stories.tsx +46 -46
  102. package/cli/storybook-dist/assets/{Color-YHDXOIA2-D6JqZzDJ.js → Color-YHDXOIA2-B4IER17l.js} +1 -1
  103. package/cli/storybook-dist/assets/{Docs-ESb7JAHg.js → Docs-C8GCHFtK.js} +3 -3
  104. package/cli/storybook-dist/assets/{Docs-6gD2kUo1.js → Docs-CgdEMPQP.js} +2 -2
  105. package/cli/storybook-dist/assets/{Docs-B9wd8Ok7.js → Docs-Ck3WHvk6.js} +3 -3
  106. package/cli/storybook-dist/assets/{Docs-CgFbWWXM.js → Docs-D9NHsoeZ.js} +2 -2
  107. package/cli/storybook-dist/assets/{Docs-DBQup8Bu.js → Docs-DNYQC7nl.js} +3 -3
  108. package/cli/storybook-dist/assets/{Docs-CMjovnSY.js → Docs-DUfeiM4I.js} +4 -4
  109. package/cli/storybook-dist/assets/{DocsRenderer-CFRXHY34-BOUI1AHi.js → DocsRenderer-CFRXHY34-C0WhyDoR.js} +1 -1
  110. package/cli/storybook-dist/assets/{NxAvatar.stories-DsYFMUh3.js → NxAvatar.stories-CwnK8AUF.js} +1 -1
  111. package/cli/storybook-dist/assets/{NxButton.stories-CHfNcqsc.js → NxButton.stories-ueYdosfQ.js} +8 -8
  112. package/cli/storybook-dist/assets/{NxNumberInput.stories-BFw_4vFE.js → NxNumberInput.stories-M5ZCLbrK.js} +1 -1
  113. package/cli/storybook-dist/assets/{NxPagination.stories-DVfxyw4M.js → NxPagination.stories-bxxUnHEL.js} +1 -1
  114. package/cli/storybook-dist/assets/{NxPointerHoldButton.stories-BzyxUWoZ.js → NxPointerHoldButton.stories-BHxB2kr5.js} +1 -1
  115. package/cli/storybook-dist/assets/{NxRangeInput.stories-DmRdzBc1.js → NxRangeInput.stories-BKfr-fp4.js} +6 -6
  116. package/cli/storybook-dist/assets/{NxSelectBox.stories-B1KkNUOf.js → NxSelectBox.stories-CG8FuTBq.js} +1 -1
  117. package/cli/storybook-dist/assets/{NxSlideRange.stories-CyOdvfQ6.js → NxSlideRange.stories-BRTFtMTL.js} +1 -1
  118. package/cli/storybook-dist/assets/{NxSwitch.stories-DrDBw0qp.js → NxSwitch.stories-Bwuyzn-R.js} +1 -1
  119. package/cli/storybook-dist/assets/{NxTextArea.stories-DTB2aqNS.js → NxTextArea.stories-COq2sGqu.js} +1 -1
  120. package/cli/storybook-dist/assets/{NxTextInput.stories-DU0rucmM.js → NxTextInput.stories-CjsBLz05.js} +1 -1
  121. package/cli/storybook-dist/assets/{RBAccordion.stories-DTfOtXtp.js → RBAccordion.stories-BARwuZ0Q.js} +3 -3
  122. package/cli/storybook-dist/assets/{RBAccordionGroup.stories-9y9BjrUe.js → RBAccordionGroup.stories-COrPxETW.js} +10 -10
  123. package/cli/storybook-dist/assets/{RBAlert.stories-DLTAv9fT.js → RBAlert.stories-BQrkjZPt.js} +3 -3
  124. package/cli/storybook-dist/assets/{RBBadge.stories-BNMACewA.js → RBBadge.stories-BTsLL-lK.js} +1 -1
  125. package/cli/storybook-dist/assets/{RBBattery.stories-B3kXMAt5.js → RBBattery.stories-DAeQdJbc.js} +1 -1
  126. package/cli/storybook-dist/assets/{RBCard.stories-Y-s8R2n7.js → RBCard.stories-HyhOE80R.js} +39 -39
  127. package/cli/storybook-dist/assets/{RBCheckBox.stories-DBuT9tL4.js → RBCheckBox.stories-KFk0WqX_.js} +1 -1
  128. package/cli/storybook-dist/assets/{RBCheckIconButton.stories-CWu1TL56.js → RBCheckIconButton.stories-CXbbktF5.js} +1 -1
  129. package/cli/storybook-dist/assets/{RBCheckLabelButton.stories-sCu2MGCX.js → RBCheckLabelButton.stories-DWfYz_9Z.js} +17 -17
  130. package/cli/storybook-dist/assets/{RBChipTab.stories-BX9qLJkR.js → RBChipTab.stories-JPEgVFAS.js} +5 -5
  131. package/cli/storybook-dist/assets/{RBChipsBoxTab.stories-35u1aJ-P.js → RBChipsBoxTab.stories-CTPuWXrQ.js} +1 -1
  132. package/cli/storybook-dist/assets/{RBColorIcons.stories-DAOZ5Ies.js → RBColorIcons.stories-DiFRZ5jk.js} +1 -1
  133. package/cli/storybook-dist/assets/RBCommand.stories-Pf1HAVIR.js +73 -0
  134. package/cli/storybook-dist/assets/{RBConfirm.stories-iMXcUh7Z.js → RBConfirm.stories-D1cEPUXk.js} +3 -3
  135. package/cli/storybook-dist/assets/{RBDateInput.stories-hK5rXECf.js → RBDateInput.stories-RPYpCjig.js} +1 -1
  136. package/cli/storybook-dist/assets/RBDialog.stories-DJH-0XSR.js +38 -0
  137. package/cli/storybook-dist/assets/{RBDivider.stories-BAK-iZ1Z.js → RBDivider.stories-uEj1vOgK.js} +1 -1
  138. package/cli/storybook-dist/assets/{RBDrawer.stories-B2nqf2Nh.js → RBDrawer.stories-BiMzElb2.js} +12 -12
  139. package/cli/storybook-dist/assets/RBDropdownMenu.stories-Da_TBkla.js +86 -0
  140. package/cli/storybook-dist/assets/{RBEmptyView.stories-nCenrgBh.js → RBEmptyView.stories-BHeKGfPS.js} +1 -1
  141. package/cli/storybook-dist/assets/{RBIconButton.stories-DRH7O3sp.js → RBIconButton.stories-NEsRFoYp.js} +1 -1
  142. package/cli/storybook-dist/assets/{RBIcons.stories-D3PyJXG8.js → RBIcons.stories-CcZ-TTLS.js} +1 -1
  143. package/cli/storybook-dist/assets/{RBInlineLabelButton.stories-modQbt10.js → RBInlineLabelButton.stories-B0wpdQyR.js} +1 -1
  144. package/cli/storybook-dist/assets/{RBJoyStick.stories-BY14qVgR.js → RBJoyStick.stories-DEgBou3K.js} +1 -1
  145. package/cli/storybook-dist/assets/{RBLabelButton.stories-DzF7vGwm.js → RBLabelButton.stories-DT8drIsF.js} +1 -1
  146. package/cli/storybook-dist/assets/{RBLight.stories-lF4FRmVh.js → RBLight.stories-Dh-aoeyG.js} +1 -1
  147. package/cli/storybook-dist/assets/{RBMenubar.stories-XhrI7bsV.js → RBMenubar.stories-D-H7UqVh.js} +1 -1
  148. package/cli/storybook-dist/assets/{RBMultiRangeInput.stories-DprllQDl.js → RBMultiRangeInput.stories-hlclv6jI.js} +1 -1
  149. package/cli/storybook-dist/assets/{RBPagination.stories-2v2gBJfC.js → RBPagination.stories-DWCeoZ_S.js} +1 -1
  150. package/cli/storybook-dist/assets/{RBPopover.stories-C8iLIZpv.js → RBPopover.stories-C514M09_.js} +1 -1
  151. package/cli/storybook-dist/assets/{RBPrompt.stories-Th_HrWHI.js → RBPrompt.stories-BSItvfiT.js} +1 -1
  152. package/cli/storybook-dist/assets/{RBRadioBox.stories-CoHuLaN3.js → RBRadioBox.stories-BBb4YbFU.js} +1 -1
  153. package/cli/storybook-dist/assets/{RBRadioButton.stories-DULxE_ff.js → RBRadioButton.stories-CXyOGJCv.js} +1 -1
  154. package/cli/storybook-dist/assets/{RBRangeDateInput.stories-D8A9KBSS.js → RBRangeDateInput.stories-qvhubu0e.js} +1 -1
  155. package/cli/storybook-dist/assets/{RBResizable.stories-C41QfrQ3.js → RBResizable.stories-CZKeyGjH.js} +14 -14
  156. package/cli/storybook-dist/assets/{RBSelectBox.stories-C1gNmwzw.js → RBSelectBox.stories-CRKP3ybf.js} +1 -1
  157. package/cli/storybook-dist/assets/RBSheet.stories-sCM3ZJUa.js +39 -0
  158. package/cli/storybook-dist/assets/{RBSideNumberInput.stories-9LlgQ8nh.js → RBSideNumberInput.stories-Bb7gw8RF.js} +3 -3
  159. package/cli/storybook-dist/assets/{RBSlider.stories-oUgo5K9d.js → RBSlider.stories-23OAQU5z.js} +3 -3
  160. package/cli/storybook-dist/assets/{RBSpinner.stories-DDpohbY2.js → RBSpinner.stories-2xcQdcmt.js} +1 -1
  161. package/cli/storybook-dist/assets/{RBSwitch.stories-gW-GN7QO.js → RBSwitch.stories-BACUaJm_.js} +7 -7
  162. package/cli/storybook-dist/assets/{RBTab.stories-CN-PV3HH.js → RBTab.stories-CDHE3sVs.js} +1 -1
  163. package/cli/storybook-dist/assets/{RBTable.stories-BAZLpxtM.js → RBTable.stories-C0Cfhfl7.js} +29 -29
  164. package/cli/storybook-dist/assets/{RBTableRow.stories-CwjVrszB.js → RBTableRow.stories-BF8kIkpp.js} +1 -1
  165. package/cli/storybook-dist/assets/{RBTabs.stories-JuEo2sUL.js → RBTabs.stories-BdJWRq1o.js} +7 -7
  166. package/cli/storybook-dist/assets/{RBTextArea.stories-D0_Pv4qZ.js → RBTextArea.stories-CGy2xeYH.js} +3 -3
  167. package/cli/storybook-dist/assets/{RBTextField.stories-CCsOvZqV.js → RBTextField.stories-IfUOzxCM.js} +15 -15
  168. package/cli/storybook-dist/assets/{RBTextInput.stories-Cccn3VNm.js → RBTextInput.stories-VjXNFo4R.js} +3 -3
  169. package/cli/storybook-dist/assets/{RBToast.stories-I5617t0K.js → RBToast.stories-BzcobblV.js} +1 -1
  170. package/cli/storybook-dist/assets/{RBToggle.stories-DsgKjGsJ.js → RBToggle.stories-DuLE5mKv.js} +4 -4
  171. package/cli/storybook-dist/assets/{RBToggleGroup.stories-BZEzSDuz.js → RBToggleGroup.stories-CuR0N0GJ.js} +5 -5
  172. package/cli/storybook-dist/assets/{RBTooltip.stories-4oSyAWsA.js → RBTooltip.stories--aAgNC8_.js} +1 -1
  173. package/cli/storybook-dist/assets/{SideNumberInput-DSKbd0bG.js → SideNumberInput-DyWQxYUj.js} +743 -743
  174. package/cli/storybook-dist/assets/{entry-preview-B35XREGs.js → entry-preview-D7IHsza_.js} +1 -1
  175. package/cli/storybook-dist/assets/{iframe-BB797lmL.js → iframe-CbaIp5I-.js} +2 -2
  176. package/cli/storybook-dist/assets/{index-BU2567EP.js → index-C6CBgMuG.js} +3 -3
  177. package/cli/storybook-dist/assets/{preview-C7DaSWyc.js → preview-B-iYLDuW.js} +2 -2
  178. package/cli/storybook-dist/assets/{preview-CbuRHit4.js → preview-CRWqc9DH.js} +1 -1
  179. package/cli/storybook-dist/assets/{react-resizable-panels.browser-ChIvlwo7.js → react-resizable-panels.browser-8hC8dr-6.js} +8 -8
  180. package/cli/storybook-dist/iframe.html +2 -2
  181. package/cli/storybook-dist/index.html +1 -1
  182. package/cli/storybook-dist/project.json +1 -1
  183. package/cli/templates/common/.mcp.json +8 -8
  184. package/cli/templates/common/README.md +1231 -1231
  185. package/cli/templates/common/assets/icon.svg +45 -45
  186. package/cli/templates/common/manifest.json +26 -26
  187. package/cli/templates/common/package.json +22 -22
  188. package/cli/templates/common/py/main.py +125 -125
  189. package/cli/templates/common/tsconfig.json +13 -13
  190. package/cli/templates/common/ui/content.tsx +216 -216
  191. package/cli/templates/common/ui/style.css +6 -6
  192. package/cli/templates/common/vite.config.ts +10 -10
  193. package/cli/templates/cpp/cpp/main.cpp +73 -73
  194. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/__PLUGIN_SLUG__/node.py +25 -25
  195. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/package.xml +10 -10
  196. package/cli/templates/ros2/ros2_ws/src/__PLUGIN_SLUG__/setup.py +18 -18
  197. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/package.xml +11 -11
  198. package/cli/templates/ros2-cpp/ros2_ws/src/__PLUGIN_SLUG__/src/node.cpp +23 -23
  199. package/cli/vite.d.mts +25 -25
  200. package/cli/vite.mjs +292 -292
  201. package/package.json +3 -3
  202. package/cli/storybook-dist/assets/RBCommand.stories-BSqXDkKw.js +0 -73
  203. package/cli/storybook-dist/assets/RBDialog.stories-BAktz-ku.js +0 -38
  204. package/cli/storybook-dist/assets/RBDropdownMenu.stories-DbW2fWAj.js +0 -86
  205. package/cli/storybook-dist/assets/RBSheet.stories-_6VH-RUm.js +0 -39
@@ -1,727 +1,734 @@
1
- // muscat-pk dev:back — run the plugin back end during development.
2
- // Every backend runs in a warm Docker container that matches the robot
3
- // (Ubuntu 24.04), so dev behaves like production.
4
- // python : Python 3.12 container, pip install, run main.py with watchfiles
5
- // hot-reload. Shares the local Muscat container's netns (localhost:10000).
6
- // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
- // restart on source changes.
8
- // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
- // on source changes (rclcpp rebuilds on change).
10
- import { spawn, spawnSync } from "node:child_process";
11
- import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
12
- import { isIP } from "node:net";
13
- import { dirname, resolve } from "node:path";
14
- import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
- import { ensureRemoteZenohEnv, getFreePort, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
- import { GATEWAY_PORT, localMuscatContainer, robotHasMuscat } from "./muscatLocal.mjs";
17
- import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
18
- import {
19
- portEnvFlags,
20
- publishPortFlags,
21
- startPortContractMonitor,
22
- } from "./ports.mjs";
23
- import {
24
- PYTHON_DEV_IMAGE,
25
- assertDockerReady as pyAssertDockerReady,
26
- ensureImage as pyEnsureImage,
27
- } from "./python.mjs";
28
- import {
29
- ROS2_DEV_IMAGE,
30
- assertDockerReady,
31
- ensureImage,
32
- ros2PackageName,
33
- } from "./ros2.mjs";
34
- import {
35
- CPP_DEV_IMAGE,
36
- assertDockerReady as cppAssertDockerReady,
37
- ensureImage as cppEnsureImage,
38
- } from "./cpp.mjs";
39
-
40
- const MANAGED_DEV_ENV = "MUSCAT_PK_MANAGED_DEV";
41
-
42
- function exitAfterCleanup(code = 0) {
43
- if (process.env[MANAGED_DEV_ENV] !== "1") process.exit(code ?? 0);
44
- }
45
-
46
- export function stopDevBackContainers() {
47
- for (const container of [
48
- `${PYTHON_DEV_IMAGE}-run`,
49
- `${ROS2_DEV_IMAGE}-run`,
50
- `${CPP_DEV_IMAGE}-run`,
51
- ]) {
52
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
53
- }
54
- }
55
-
56
- // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
57
- // join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
58
- // netns, gateway via the host.
59
- // IS_DEV Muscat (muscat-dev): common is a zenoh-router client and opens no 7447 listener,
60
- // so the plugin must join the router too or `execute` queries never reach it.
61
- function muscatZenohEnv(container) {
62
- const r = spawnSync("docker", ["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", container], { encoding: "utf8" });
63
- return /^IS_DEV=(true|1)$/im.test(r.stdout || "")
64
- ? ["-e", "ZENOH_PEER_CONNECT_ENDPOINTS=tcp/zenoh-router:7447"]
65
- : [];
66
- }
67
-
68
- function backendNetwork(portRanges, robotGateway) {
69
- const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
70
- if (muscat) return { run: ["--network", `container:${muscat}`], env: muscatZenohEnv(muscat), shared: true };
71
- // The robot runs Muscat: its gateway, own netns (host network on Linux), declared ports published.
72
- if (robotGateway) {
73
- const run = usesHostNetwork() ? ["--network", "host"] : publishPortFlags(portRanges);
74
- return { run, env: ["-e", `RB_PLUGIN_GATEWAY_URL=${robotGateway}`], shared: usesHostNetwork() };
75
- }
76
- if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
77
- return {
78
- // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
79
- run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
80
- env: ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"],
81
- shared: false,
82
- };
83
- }
84
-
85
- // rb.rosPeers in dev: answer muscat/plugin/<id>/ros_peers/{get,set} on the gateway the way
86
- // Muscat (common) does on the robot — keep the list in .muscat-dev/ and restart the node
87
- // with ROS_STATIC_PEERS.
88
- const ROS_PEERS_FILE = ".muscat-dev/ros-peers.json";
89
-
90
- function readRosPeers(root) {
91
- try {
92
- return JSON.parse(readFileSync(resolve(root, ROS_PEERS_FILE), "utf8"));
93
- } catch {
94
- return [];
95
- }
96
- }
97
-
98
- async function serveRosPeers(root, pluginId, restart) {
99
- if (!pluginId) return;
100
- const { RbWs } = await import("@rainbow-robotics/plugin-ui/ws");
101
- const ws = await Promise.race([
102
- RbWs.connect("http://127.0.0.1:10000", pluginId).catch(() => null),
103
- new Promise((r) => setTimeout(() => r(null), 5000)),
104
- ]);
105
- if (!ws) return log.warn("rb.rosPeers unavailable in dev: plugin gateway not reachable on :10000");
106
- const base = `/muscat/plugin/${pluginId}/ros_peers`;
107
- await ws.serve(`${base}/get`, () => ({ peers: readRosPeers(root) }));
108
- await ws.serve(`${base}/set`, (payload) => {
109
- const peers = payload?.peers;
110
- if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
111
- return { ok: false, error: "peers must be a list of IP addresses" };
112
- }
113
- const file = resolve(root, ROS_PEERS_FILE);
114
- mkdirSync(dirname(file), { recursive: true });
115
- writeFileSync(file, JSON.stringify(peers) + "\n");
116
- log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
117
- restart();
118
- return { ok: true, peers };
119
- });
120
- }
121
-
122
- // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
123
- // `muscat-pk new` already creates this up front (see new.mjs); this just
124
- // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
125
- // Docker remains the actual execution environment — this never affects it.
126
- // Runs in the background so it never delays `dev:back` starting, and any
127
- // failure here (no local Python toolchain, offline, ...) is silent.
128
- function syncIdeVenv(root) {
129
- setImmediate(() => {
130
- try {
131
- ensureIdeVenv(root);
132
- } catch {
133
- /* best-effort only */
134
- }
135
- });
136
- }
137
-
138
- // ── python ────────────────────────────────────────────────────────────────────
139
- // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
140
- // Under `muscat-pk dev`, it shares the local Muscat container's netns, so the SDK
141
- // reaches the plugin gateway at the default localhost:10000.
142
- async function runPython(root, portRanges, robotGateway) {
143
- // Independent of Docker readiness — must still happen if Docker isn't
144
- // installed/running, since that's exactly when local IDE tooling matters most.
145
- syncIdeVenv(root);
146
- pyAssertDockerReady();
147
- await pyEnsureImage();
148
-
149
- const manifest = readManifest(root);
150
- const pluginId = manifest.id ?? "";
151
- const container = `${PYTHON_DEV_IMAGE}-run`;
152
- const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
153
- const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
154
- const zenohAdvertiseHost = process.env.MUSCAT_ZENOH_ADVERTISE_HOST ?? "";
155
- const zenohPort = process.env.MUSCAT_ZENOH_PORT ?? "";
156
- // See host_agent.py: a Docker Desktop container can't join the robot's
157
- // Zenoh mesh, so it speaks a byte protocol to the host-side proxy instead.
158
- const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT ?? "";
159
- // Native Linux (not WSL, not Docker Desktop) has real host networking: the
160
- // container shares the host's network stack, so it reaches the gateway at
161
- // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
162
- // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
163
- const net = backendNetwork(portRanges, robotGateway);
164
- // Only the container's OWN reverse-dial path needs to publish a listen port.
165
- // With a shared netns the port is already reachable; with the byte-proxy
166
- // the container just connects out — neither needs -p.
167
- const standaloneZenohPortFlags =
168
- !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
169
- // C++ SDK (rainbow-rb-sdk >= 0.10) can't use the byte shim: its Zenoh session is native. It listens
170
- // on this published port instead and the entry script registers it with the robot (see there).
171
- const sdkPeerPort = proxyEndpoint && !net.shared && zenohPort ? await getFreePort(Number(zenohPort) + 1) : "";
172
- const envFlags = [
173
- // docker exec below has no -t (no pseudo-TTY), so Python defaults to
174
- // block-buffered stdout — print() output wouldn't show up until the
175
- // buffer fills or the process exits. Force unbuffered.
176
- "-e", "PYTHONUNBUFFERED=1",
177
- ...net.env,
178
- ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
179
- ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
180
- ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
181
- // Byte-proxy mode: the container has no Zenoh session of its own — the
182
- // entry script's shim speaks the raw query/put/subscribe protocol to the
183
- // host_agent process at this address instead.
184
- ...(proxyEndpoint ? ["-e", `MUSCAT_PROXY_ENDPOINT=${proxyEndpoint}`] : []),
185
- ...(sdkPeerPort ? ["-e", `MUSCAT_SDK_PEER_PORT=${sdkPeerPort}`] : []),
186
- // Fallback (no proxy): the container reverse-dials the robot itself.
187
- ...(!proxyEndpoint && zenohAdvertiseHost ? ["-e", `MUSCAT_ZENOH_ADVERTISE_HOST=${zenohAdvertiseHost}`] : []),
188
- ...(!proxyEndpoint && zenohPort ? ["-e", `MUSCAT_ZENOH_PORT=${zenohPort}`] : []),
189
- ...portEnvFlags(portRanges),
190
- ];
191
- const docker = (args, opts = {}) =>
192
- spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
193
-
194
- // start a warm, long-lived container with the plugin mounted.
195
- // 호스트 uid 로 돌린다(dockerUserFlags) — root 로 쓴 build/·install/ 은 사용자가 지우지 못한다.
196
- docker(["rm", "-f", container], { stdio: "ignore" });
197
- const up = docker(
198
- ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...(sdkPeerPort ? ["-p", `${sdkPeerPort}:${sdkPeerPort}`] : []), ...realtimeFlags(), ...dockerUserFlags(),
199
- "-e", "PIP_ROOT_USER_ACTION=ignore",
200
- "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
201
- { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
202
- );
203
- if (up.status !== 0) throw new Error("failed to start dev container.");
204
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort, sdkPeerPort] });
205
-
206
- docker(["exec", container, "bash", "-lc",
207
- "pip install --break-system-packages --ignore-installed -q watchfiles" +
208
- " && { [ -f requirements.txt ] && pip install --break-system-packages --ignore-installed -q -r requirements.txt || true; }"]);
209
-
210
- docker(["exec", container, "bash", "-lc", `cat > /tmp/muscat-pk-python-dev-entry.py <<'PY'
211
- import os
212
-
213
- try:
214
- import glob
215
- import importlib.util
216
-
217
- proxy_endpoint = os.getenv("MUSCAT_PROXY_ENDPOINT")
218
- # Located without importing: importing rainbow.rb_sdk opens the SDK's Zenoh session.
219
- _rainbow = importlib.util.find_spec("rainbow")
220
- cpp_sdk = bool(_rainbow and any(glob.glob(os.path.join(p, "rb_sdk", "rb_sdk_cpp*.so")) for p in _rainbow.submodule_search_locations))
221
- if proxy_endpoint and cpp_sdk and os.getenv("MUSCAT_SDK_PEER_PORT"):
222
- # C++ SDK (rainbow-rb-sdk >= 0.10): its Zenoh session is native, so the byte shim below
223
- # can't reach it, and connect() would register the container's own unreachable IP.
224
- # Listen on the published port and register the host's address for it instead, the
225
- # same way host_agent registers itself; the robot then dials in through Docker.
226
- import json
227
- import threading
228
- import time
229
- import urllib.request
230
-
231
- from rainbow.rb_sdk import rb_sdk_cpp
232
-
233
- _port = int(os.environ["MUSCAT_SDK_PEER_PORT"])
234
- os.environ["ZENOH_PEER_LISTEN_ENDPOINTS"] = f"tcp/0.0.0.0:{_port}"
235
- for _cls in list(vars(rb_sdk_cpp).values()):
236
- if isinstance(_cls, type) and issubclass(_cls, rb_sdk_cpp.RBBaseSDK) and "connect" in _cls.__dict__:
237
- _cls.connect = lambda self, *args, **kwargs: None
238
-
239
- def _peer_http(method, path, body=None):
240
- headers = {"Content-Type": "application/json"}
241
- if os.getenv("MUSCAT_ROBOT_TOKEN"):
242
- headers["Authorization"] = "Bearer " + os.environ["MUSCAT_ROBOT_TOKEN"]
243
- req = urllib.request.Request(f"http://{os.environ['MUSCAT_ROBOT_HOST']}:8000{path}",
244
- data=json.dumps(body).encode() if body is not None else None,
245
- headers=headers, method=method)
246
- with urllib.request.urlopen(req, timeout=10) as resp:
247
- raw = resp.read()
248
- return json.loads(raw) if raw else {}
249
-
250
- def _peer_keepalive():
251
- advertise = proxy_endpoint.rsplit(":", 1)[0]
252
- peer_id = None
253
- while True:
254
- try:
255
- if peer_id is None:
256
- peer_id = _peer_http("POST", "/zenoh/peer", {"ip": advertise, "port": _port, "ttl": 30})["peer_id"]
257
- print(f"[muscat-pk] C++ SDK zenoh peer {advertise}:{_port} registered with the robot", flush=True)
258
- elif not _peer_http("POST", f"/zenoh/peer/{peer_id}/heartbeat", {"ttl": 30}).get("ok"):
259
- peer_id = None
260
- continue
261
- except Exception as exc:
262
- print(f"[muscat-pk] zenoh peer registration failed ({exc}); retrying", flush=True)
263
- peer_id = None
264
- time.sleep(15 if peer_id else 3)
265
-
266
- threading.Thread(target=_peer_keepalive, daemon=True).start()
267
- elif proxy_endpoint:
268
- # Byte-proxy mode: this container has no Zenoh session of its own.
269
- # ZenohClient's C++ core is replaced with a shim that speaks a raw
270
- # query/put/subscribe byte protocol to host_agent.py instead.
271
- import json
272
- import socket
273
- import struct
274
- import threading
275
-
276
- _proxy_host, _proxy_port_s = proxy_endpoint.rsplit(":", 1)
277
- _proxy_port = int(_proxy_port_s)
278
-
279
- def _send_frame(conn, lock, obj):
280
- body = json.dumps(obj).encode()
281
- with lock:
282
- conn.sendall(struct.pack(">I", len(body)) + body)
283
-
284
- def _recv_frame(conn):
285
- hdr = b""
286
- while len(hdr) < 4:
287
- chunk = conn.recv(4 - len(hdr))
288
- if not chunk:
289
- return None
290
- hdr += chunk
291
- n = struct.unpack(">I", hdr)[0]
292
- buf = b""
293
- while len(buf) < n:
294
- chunk = conn.recv(n - len(buf))
295
- if not chunk:
296
- return None
297
- buf += chunk
298
- return json.loads(buf.decode())
299
-
300
- class _ProxyRawClient:
301
- """Drop-in replacement for ZenohClient's C++ core, backed by the
302
- host_agent byte-proxy instead of a local Zenoh session."""
303
-
304
- sender = "RRS"
305
- sender_id = ""
306
-
307
- def __init__(self, host, port):
308
- self._conn = socket.create_connection((host, port), timeout=10)
309
- # create_connection() leaves the connect timeout applied to
310
- # the socket permanently; the background read loop below does
311
- # a long-lived blocking recv() for async "sample" pushes, so
312
- # it must not time out on an idle connection.
313
- self._conn.settimeout(None)
314
- self._send_lock = threading.Lock()
315
- self._req_id = 0
316
- self._req_lock = threading.Lock()
317
- self._pending = {}
318
- self._subs = {} # sub_id -> callback
319
- self._sub_by_keyexpr = {}
320
- threading.Thread(target=self._read_loop, daemon=True).start()
321
-
322
- def _next_id(self):
323
- with self._req_lock:
324
- self._req_id += 1
325
- return self._req_id
326
-
327
- def _read_loop(self):
328
- while True:
329
- frame = _recv_frame(self._conn)
330
- if frame is None:
331
- return
332
- ftype = frame.get("type")
333
- if ftype == "sample":
334
- cb = self._sub_by_keyexpr.get(frame["keyexpr"])
335
- if cb is not None:
336
- try:
337
- cb(frame["key"], bytes.fromhex(frame["payload_hex"]), frame.get("att", ""))
338
- except Exception as exc:
339
- print(f"[muscat-pk] proxy subscriber callback error: {exc}", flush=True)
340
- elif "id" in frame:
341
- ev = self._pending.pop(frame["id"], None)
342
- if ev is not None:
343
- ev[1] = frame
344
- ev[0].set()
345
-
346
- def _request(self, obj, timeout=30):
347
- req_id = self._next_id()
348
- obj["id"] = req_id
349
- ev = threading.Event()
350
- slot = [ev, None]
351
- self._pending[req_id] = slot
352
- _send_frame(self._conn, self._send_lock, obj)
353
- if not ev.wait(timeout):
354
- self._pending.pop(req_id, None)
355
- raise TimeoutError(f"proxy request timed out: {obj.get('type')} {obj.get('keyexpr')}")
356
- return slot[1]
357
-
358
- def query(self, keyexpr, payload, timeout_ms, target):
359
- resp = self._request({
360
- "type": "query", "keyexpr": keyexpr,
361
- "payload_hex": payload.hex() if payload else None,
362
- "timeout_ms": timeout_ms, "target": target,
363
- }, timeout=(timeout_ms / 1000) + 5)
364
- if not resp.get("ok"):
365
- raise RuntimeError(resp.get("error", "proxy query failed"))
366
- return [
367
- (r["key"], bytes.fromhex(r["payload_hex"]), r["att"], r["encoding"], r["is_error"])
368
- for r in resp["replies"]
369
- ]
370
-
371
- def query_all(self, keyexpr, payload, timeout_ms, target):
372
- return self.query(keyexpr, payload, timeout_ms, target)
373
-
374
- def publish(self, topic, payload, att, congestion_control, priority, express):
375
- resp = self._request({
376
- "type": "put", "keyexpr": topic,
377
- "payload_hex": bytes(payload).hex() if payload else None,
378
- })
379
- if not resp.get("ok"):
380
- raise RuntimeError(resp.get("error", "proxy put failed"))
381
-
382
- def has_subscriber(self, topic):
383
- return topic in self._sub_by_keyexpr
384
-
385
- def declare_subscriber(self, topic, callback, opts=None):
386
- resp = self._request({"type": "subscribe", "keyexpr": topic})
387
- if not resp.get("ok"):
388
- raise RuntimeError(resp.get("error", "proxy subscribe failed"))
389
- sub_id = self._next_id()
390
- self._subs[sub_id] = topic
391
- self._sub_by_keyexpr[topic] = callback
392
- return sub_id
393
-
394
- def undeclare_subscriber(self, sub_id):
395
- topic = self._subs.pop(sub_id, None)
396
- if topic is None:
397
- return
398
- self._sub_by_keyexpr.pop(topic, None)
399
- self._request({"type": "unsubscribe", "keyexpr": topic})
400
-
401
- def unsubscribe_topic(self, topic):
402
- for sub_id, t in list(self._subs.items()):
403
- if t == topic:
404
- self.undeclare_subscriber(sub_id)
405
-
406
- def unsubscribe_all(self):
407
- for sub_id in list(self._subs):
408
- self.undeclare_subscriber(sub_id)
409
-
410
- def get_sub_stats(self, sub_id):
411
- return {}
412
-
413
- def declare_queryable(self, keyexpr, handler):
414
- raise NotImplementedError("byte-proxy mode does not support declare_queryable")
415
-
416
- def undeclare_queryable_by_topic(self, keyexpr):
417
- pass
418
-
419
- # ZenohClient is a process-wide singleton whose __init__ eagerly
420
- # calls connect() on first construction. rainbow.rb_sdk.base
421
- # transitively imports rainbow.rb_log.log, which constructs one at
422
- # module level — so this patch must land before RBBaseSDK is
423
- # imported, or that eager construction wins with a real session.
424
- from rainbow.rb_zenoh.client import ZenohClient
425
-
426
- def _proxy_connect(self, *, config_pairs=None, sender=None, max_attempts=8, initial_delay_s=0.5, max_delay_s=3.0):
427
- if self._client is not None:
428
- return
429
- if sender is not None:
430
- self._sender = sender
431
- print(f"[muscat-pk] zenoh via host byte-proxy → {proxy_endpoint} (no local Zenoh session)", flush=True)
432
- self._client = _ProxyRawClient(_proxy_host, _proxy_port)
433
-
434
- ZenohClient.connect = _proxy_connect
435
-
436
- from rainbow.rb_sdk.base import RBBaseSDK
437
-
438
- async def _sdk_connect_via_proxy(self, *args, **kwargs):
439
- # No HTTP registration with common — host_agent already handles
440
- # that. Just make sure the (proxy-backed) shared ZenohClient exists.
441
- self._ensure_zenoh_client_alive()
442
- return None
443
-
444
- async def _sdk_disconnect_via_proxy(self, *args, **kwargs):
445
- return None
446
-
447
- RBBaseSDK.connect = _sdk_connect_via_proxy
448
- RBBaseSDK.disconnect = _sdk_disconnect_via_proxy
449
- else:
450
- # Fallback: no host byte-proxy — the container reverse-dials the robot itself.
451
- advertise_host = os.getenv("MUSCAT_ZENOH_ADVERTISE_HOST")
452
- zenoh_port = os.getenv("MUSCAT_ZENOH_PORT")
453
- from rainbow.rb_sdk.base import RBBaseSDK
454
- if advertise_host:
455
- RBBaseSDK._detect_local_ip = staticmethod(lambda host: advertise_host)
456
- if zenoh_port:
457
- _orig_connect = RBBaseSDK.connect
458
-
459
- async def _connect_with_dev_port(self, *args, **kwargs):
460
- kwargs.setdefault("zenoh_port", int(zenoh_port))
461
- return await _orig_connect(self, *args, **kwargs)
462
-
463
- RBBaseSDK.connect = _connect_with_dev_port
464
- except Exception as exc:
465
- print(f"[muscat-pk] SDK dev network patch skipped: {exc}", flush=True)
466
-
467
- import runpy
468
- runpy.run_path("/plugin/py/main.py", run_name="__main__")
469
- PY`]);
470
-
471
- if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/gateway access)");
472
- if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
473
- else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
474
- const child = spawn(
475
- "docker",
476
- ["exec", ...envFlags, container, "bash", "-lc",
477
- "python3 -m watchfiles --filter python --target-type command 'python3 /tmp/muscat-pk-python-dev-entry.py' py"],
478
- { cwd: root, stdio: ["inherit", "inherit", "pipe"] },
479
- );
480
- // watchfiles prints a KeyboardInterrupt traceback on SIGTERM — suppress it
481
- child.stderr.on("data", (buf) => {
482
- const text = buf.toString();
483
- if (!text.includes("KeyboardInterrupt") && !text.includes("raise_keyboard_interrupt")) {
484
- process.stderr.write(buf);
485
- }
486
- });
487
-
488
- let cleaned = false;
489
- const cleanupResources = () => {
490
- if (cleaned) return;
491
- cleaned = true;
492
- stopPortMonitor();
493
- if (!child.killed && child.exitCode === null) child.kill();
494
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
495
- };
496
- const cleanup = () => {
497
- cleanupResources();
498
- exitAfterCleanup(0);
499
- };
500
- child.on("exit", (code) => {
501
- cleanupResources();
502
- exitAfterCleanup(code ?? 0);
503
- });
504
- process.once("SIGINT", cleanup);
505
- process.once("SIGTERM", cleanup);
506
- process.once("SIGHUP", cleanup);
507
- }
508
-
509
- // ── ros2 ───────────────────────────────────────────────────────────────────────
510
- async function runRos2(root, portRanges, robotGateway) {
511
- const manifest = readManifest(root);
512
- const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
513
- // Independent of Docker readiness — must still happen if Docker isn't
514
- // installed/running, since that's exactly when local IDE tooling matters most.
515
- if (!isRclcpp) syncIdeVenv(root); // rclpy is plain Python — rclcpp is C++, no venv needed
516
-
517
- assertDockerReady();
518
- const pkg = ros2PackageName(root);
519
- if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
520
-
521
- await ensureImage();
522
-
523
- const pluginId = manifest.id ?? "";
524
- const container = `${ROS2_DEV_IMAGE}-run`;
525
- const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
526
- const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
527
- const net = backendNetwork(portRanges, robotGateway);
528
- const envFlags = [
529
- // Same rationale as runPython(): no -t on docker exec below means
530
- // rclpy's Python process stdout is block-buffered, not line-buffered.
531
- "-e", "PYTHONUNBUFFERED=1",
532
- // Same as the robot: ROS discovery stays on this host (allow a PC with ROS_STATIC_PEERS).
533
- "-e", "ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST",
534
- ...net.env,
535
- ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
536
- ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
537
- ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
538
- ...portEnvFlags(portRanges),
539
- ];
540
- const docker = (args, opts = {}) =>
541
- spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
542
-
543
- // start a warm, long-lived container with the workspace mounted
544
- docker(["rm", "-f", container], { stdio: "ignore" });
545
- const up = docker(
546
- ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
547
- { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
548
- );
549
- if (up.status !== 0) throw new Error("failed to start dev container.");
550
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
551
-
552
- // one-time deps + symlink build (edits to .py reflect without rebuilding)
553
- docker(["exec", container, "bash", "-lc",
554
- "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true; cd ros2_ws && colcon build --symlink-install"]);
555
-
556
- let child = null;
557
- const startNode = () => {
558
- if (child) child.kill();
559
- // killing the `docker exec` client doesn't stop the node inside the container
560
- spawnSync("docker", ["exec", container, "pkill", "-f", `${pkg}_node|${manifest.runtime?.launch || pkg}`], { stdio: "ignore" });
561
- const peers = readRosPeers(root);
562
- // Same command the robot runs: rclpy execs the console script, rclcpp uses `ros2 run`;
563
- // relative topics land under the plugin's namespace (rosNamespace).
564
- const launch = manifest.runtime?.launch || `${pkg}_node`;
565
- const nsArgs = PLUGIN_ID_RE.test(pluginId) ? ` --ros-args -r __ns:=${rosNamespace(pluginId)}` : "";
566
- const run = (isRclcpp ? `ros2 run ${pkg} ${launch}` : `exec ${launch}`) + nsArgs;
567
- child = spawn(
568
- "docker",
569
- ["exec", ...envFlags, ...(peers.length ? ["-e", `ROS_STATIC_PEERS=${peers.join(";")}`] : []), container, "bash", "-lc",
570
- `cd ros2_ws && source install/setup.bash && ${run}`],
571
- { cwd: root, stdio: "inherit" },
572
- );
573
- };
574
- startNode();
575
- serveRosPeers(root, pluginId, startNode);
576
-
577
- // restart node on source change. rclpy reflects .py edits live (symlink-install);
578
- // rclcpp (C++) and package-metadata changes require a colcon rebuild.
579
- let timer = null;
580
- const onChange = (_event, filename) => {
581
- clearTimeout(timer);
582
- timer = setTimeout(() => {
583
- const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
584
- if (structural || isRclcpp) {
585
- log.info(structural ? "package metadata changed — colcon rebuild…" : "source changed — colcon rebuild…");
586
- docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
587
- } else {
588
- log.info("source changed — restarting node…");
589
- }
590
- startNode();
591
- }, 300);
592
- };
593
- for (const target of ["ros2_ws/src", "main.py"]) {
594
- const full = resolve(root, target);
595
- if (existsSync(full)) watch(full, { recursive: true }, onChange);
596
- }
597
-
598
- const cleanup = () => {
599
- stopPortMonitor();
600
- if (child) child.kill();
601
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
602
- exitAfterCleanup(0);
603
- };
604
- process.on("SIGINT", cleanup);
605
- process.on("SIGTERM", cleanup);
606
- process.on("SIGHUP", cleanup);
607
- }
608
-
609
- // ── cpp ──────────────────────────────────────────────────────────────────────
610
- async function runCpp(root, portRanges, robotGateway) {
611
- cppAssertDockerReady();
612
- const manifest = readManifest(root);
613
- const entry = manifest.runtime?.entry;
614
- if (!entry) throw new Error("runtime.entry (the built executable name) is required for cpp.");
615
-
616
- await cppEnsureImage();
617
-
618
- const pluginId = manifest.id ?? "";
619
- const container = `${CPP_DEV_IMAGE}-run`;
620
- const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
621
- const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
622
- const net = backendNetwork(portRanges, robotGateway);
623
- const envFlags = [
624
- ...net.env,
625
- ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
626
- ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
627
- ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
628
- ...portEnvFlags(portRanges),
629
- ];
630
- const docker = (args, opts = {}) =>
631
- spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
632
-
633
- // start a warm, long-lived container with the plugin mounted
634
- docker(["rm", "-f", container], { stdio: "ignore" });
635
- const up = docker(
636
- ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
637
- { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
638
- );
639
- if (up.status !== 0) throw new Error("failed to start dev container.");
640
- const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
641
-
642
- const build = () => {
643
- return docker(["exec", container, "bash", "-lc",
644
- "cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Debug && cmake --build cpp/build -j"]);
645
- };
646
- build();
647
-
648
- let child = null;
649
- const startBin = () => {
650
- if (child) child.kill();
651
- child = spawn(
652
- "docker",
653
- ["exec", ...envFlags, container, "bash", "-lc", `./cpp/build/${entry}`],
654
- { cwd: root, stdio: "inherit" },
655
- );
656
- };
657
- startBin();
658
-
659
- // rebuild + restart on C++ source changes; ignore the build/ output dir.
660
- let timer = null;
661
- const onChange = (_event, filename) => {
662
- if (filename && String(filename).startsWith("build")) return;
663
- clearTimeout(timer);
664
- timer = setTimeout(() => {
665
- log.info("source changed — rebuilding…");
666
- const r = build();
667
- if (r.status === 0) startBin();
668
- else log.warn("build failed — keeping previous binary");
669
- }, 300);
670
- };
671
- const cppDir = resolve(root, "cpp");
672
- if (existsSync(cppDir)) watch(cppDir, { recursive: true }, onChange);
673
-
674
- const cleanup = () => {
675
- stopPortMonitor();
676
- if (child) child.kill();
677
- spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
678
- exitAfterCleanup(0);
679
- };
680
- process.on("SIGINT", cleanup);
681
- process.on("SIGTERM", cleanup);
682
- process.on("SIGHUP", cleanup);
683
- }
684
-
685
- function warnManifest(root) {
686
- const manifest = readManifest(root);
687
- if (!manifest || Object.keys(manifest).length === 0) return;
688
- const errors = validateManifest(manifest);
689
- for (const e of errors) log.warn(`manifest.json: ${e}`);
690
- }
691
-
692
- export async function runDevBack(args = []) {
693
- const root = findPluginRoot();
694
- assertManifestVariables(root);
695
- warnManifest(root);
696
- const portRanges = assertManifestPorts(root);
697
- ensureDocker();
698
- const { robotIp, robotToken } = await promptRobotConnection(args);
699
- if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
700
- if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
701
- // Standalone `dev:back` against a remote robot needs the Zenoh reverse-path
702
- // advertise host/port too (idempotent — `muscat-pk dev` already set these).
703
- // Byte-proxy mode (a host-side host_agent set MUSCAT_PROXY_ENDPOINT) makes
704
- // the container speak the proxy protocol instead, so it needs no reverse-dial.
705
- if (!process.env.MUSCAT_PROXY_ENDPOINT) await ensureRemoteZenohEnv(robotIp, { log });
706
- // Standalone against a local Muscat (muscat-dev, …): join its netns like `dev` does,
707
- // otherwise zenoh reaches nothing and `execute` never arrives.
708
- if (!process.env.MUSCAT_PK_MUSCAT_CONTAINER && !isRemoteRobot(robotIp)) {
709
- const muscat = localMuscatContainer();
710
- if (muscat) {
711
- process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
712
- }
713
- }
714
- // Stopped while still setting up (image build, pip install, first cmake build): the runtime's own
715
- // cleanup is not registered yet, so remove the container here. Once it is, it handles the signal.
716
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
717
- process.once(signal, () => {
718
- if (process.listenerCount(signal)) return;
719
- stopDevBackContainers();
720
- process.exit(130);
721
- });
722
- const robotGateway = (await robotHasMuscat(robotIp)) ? `http://${robotIp}:${GATEWAY_PORT}` : "";
723
- const type = manifestRuntime(root);
724
- if (type === "ros2") return await runRos2(root, portRanges, robotGateway);
725
- if (type === "cpp") return await runCpp(root, portRanges, robotGateway);
726
- return await runPython(root, portRanges, robotGateway);
727
- }
1
+ // muscat-pk dev:back — run the plugin back end during development.
2
+ // Every backend runs in a warm Docker container that matches the robot
3
+ // (Ubuntu 24.04), so dev behaves like production.
4
+ // python : Python 3.12 container, pip install, run main.py with watchfiles
5
+ // hot-reload. Shares the local Muscat container's netns (localhost:10000).
6
+ // cpp : Ubuntu + cmake container, build cpp/, run the binary, rebuild +
7
+ // restart on source changes.
8
+ // ros2 : Jazzy + Python 3.12 container, colcon build, run the node, restart
9
+ // on source changes (rclcpp rebuilds on change).
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
12
+ import { isIP } from "node:net";
13
+ import { dirname, resolve } from "node:path";
14
+ import { dockerUserFlags, ensureDocker, ensureIdeVenv, findPluginRoot, log, promptRobotConnection, realtimeFlags } from "./util.mjs";
15
+ import { ensureRemoteZenohEnv, getFreePort, isRemoteRobot, usesHostNetwork } from "./zenohDev.mjs";
16
+ import { GATEWAY_PORT, localMuscatContainer, robotHasMuscat } from "./muscatLocal.mjs";
17
+ import { PLUGIN_ID_RE, assertManifestPorts, assertManifestVariables, manifestRuntime, readManifest, rosNamespace, validateManifest } from "./manifest.mjs";
18
+ import {
19
+ portEnvFlags,
20
+ publishPortFlags,
21
+ startPortContractMonitor,
22
+ } from "./ports.mjs";
23
+ import {
24
+ PYTHON_DEV_IMAGE,
25
+ assertDockerReady as pyAssertDockerReady,
26
+ ensureImage as pyEnsureImage,
27
+ } from "./python.mjs";
28
+ import {
29
+ ROS2_DEV_IMAGE,
30
+ assertDockerReady,
31
+ ensureImage,
32
+ ros2PackageName,
33
+ } from "./ros2.mjs";
34
+ import {
35
+ CPP_DEV_IMAGE,
36
+ assertDockerReady as cppAssertDockerReady,
37
+ ensureImage as cppEnsureImage,
38
+ } from "./cpp.mjs";
39
+
40
+ const MANAGED_DEV_ENV = "MUSCAT_PK_MANAGED_DEV";
41
+
42
+ function exitAfterCleanup(code = 0) {
43
+ if (process.env[MANAGED_DEV_ENV] !== "1") process.exit(code ?? 0);
44
+ }
45
+
46
+ export function stopDevBackContainers() {
47
+ for (const container of [
48
+ `${PYTHON_DEV_IMAGE}-run`,
49
+ `${ROS2_DEV_IMAGE}-run`,
50
+ `${CPP_DEV_IMAGE}-run`,
51
+ ]) {
52
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
53
+ }
54
+ }
55
+
56
+ // Where the backend's 127.0.0.1 points. A local Muscat container (muscat-pk-muscat, muscat-dev):
57
+ // join its netns so the gateway and MediaMTX resolve like on the robot. Otherwise its own
58
+ // netns, gateway via the host.
59
+ // IS_DEV Muscat (muscat-dev): common is a zenoh-router client and opens no 7447 listener,
60
+ // so the plugin must join the router too or `execute` queries never reach it.
61
+ function muscatZenohEnv(container) {
62
+ const r = spawnSync("docker", ["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", container], { encoding: "utf8" });
63
+ return /^IS_DEV=(true|1)$/im.test(r.stdout || "")
64
+ ? ["-e", "ZENOH_PEER_CONNECT_ENDPOINTS=tcp/zenoh-router:7447"]
65
+ : [];
66
+ }
67
+
68
+ function backendNetwork(portRanges, robotGateway) {
69
+ const muscat = process.env.MUSCAT_PK_MUSCAT_CONTAINER;
70
+ if (muscat) return { run: ["--network", `container:${muscat}`], env: muscatZenohEnv(muscat), shared: true };
71
+ // The robot runs Muscat: its gateway, own netns (host network on Linux), declared ports published.
72
+ if (robotGateway) {
73
+ const run = usesHostNetwork() ? ["--network", "host"] : publishPortFlags(portRanges);
74
+ return { run, env: ["-e", `RB_PLUGIN_GATEWAY_URL=${robotGateway}`], shared: usesHostNetwork() };
75
+ }
76
+ if (usesHostNetwork()) return { run: ["--network", "host"], env: [], shared: true };
77
+ return {
78
+ // host.docker.internal is automatic on Docker Desktop; this makes it resolve on Linux too.
79
+ run: [...publishPortFlags(portRanges), "--add-host", "host.docker.internal:host-gateway"],
80
+ env: ["-e", "RB_PLUGIN_GATEWAY_URL=http://host.docker.internal:10000"],
81
+ shared: false,
82
+ };
83
+ }
84
+
85
+ // rb.rosPeers in dev: answer muscat/plugin/<id>/ros_peers/{get,set} on the gateway the way
86
+ // Muscat (common) does on the robot — keep the list in .muscat-dev/ and restart the node
87
+ // with ROS_STATIC_PEERS.
88
+ const ROS_PEERS_FILE = ".muscat-dev/ros-peers.json";
89
+
90
+ function readRosPeers(root) {
91
+ try {
92
+ return JSON.parse(readFileSync(resolve(root, ROS_PEERS_FILE), "utf8"));
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ async function serveRosPeers(root, pluginId, restart) {
99
+ if (!pluginId) return;
100
+ const { RbWs } = await import("@rainbow-robotics/plugin-ui/ws");
101
+ const ws = await Promise.race([
102
+ RbWs.connect("http://127.0.0.1:10000", pluginId).catch(() => null),
103
+ new Promise((r) => setTimeout(() => r(null), 5000)),
104
+ ]);
105
+ if (!ws) return log.warn("rb.rosPeers unavailable in dev: plugin gateway not reachable on :10000");
106
+ const base = `/muscat/plugin/${pluginId}/ros_peers`;
107
+ await ws.serve(`${base}/get`, () => ({ peers: readRosPeers(root) }));
108
+ await ws.serve(`${base}/set`, (payload) => {
109
+ const peers = payload?.peers;
110
+ if (!Array.isArray(peers) || !peers.every((p) => typeof p === "string" && isIP(p))) {
111
+ return { ok: false, error: "peers must be a list of IP addresses" };
112
+ }
113
+ const file = resolve(root, ROS_PEERS_FILE);
114
+ mkdirSync(dirname(file), { recursive: true });
115
+ writeFileSync(file, JSON.stringify(peers) + "\n");
116
+ log.step(`ROS_STATIC_PEERS → ${peers.join(";") || "(none)"} — restarting node`);
117
+ restart();
118
+ return { ok: true, peers };
119
+ });
120
+ }
121
+
122
+ // ── IDE tooling venv (best-effort, non-blocking) ────────────────────────────
123
+ // `muscat-pk new` already creates this up front (see new.mjs); this just
124
+ // refreshes it on every `dev`/`dev:back` in case requirements.txt changed.
125
+ // Docker remains the actual execution environment — this never affects it.
126
+ // Runs in the background so it never delays `dev:back` starting, and any
127
+ // failure here (no local Python toolchain, offline, ...) is silent.
128
+ function syncIdeVenv(root) {
129
+ setImmediate(() => {
130
+ try {
131
+ ensureIdeVenv(root);
132
+ } catch {
133
+ /* best-effort only */
134
+ }
135
+ });
136
+ }
137
+
138
+ // ── python ────────────────────────────────────────────────────────────────────
139
+ // Runs in a warm Ubuntu 24.04 + Python 3.12 container (matches the robot).
140
+ // Under `muscat-pk dev`, it shares the local Muscat container's netns, so the SDK
141
+ // reaches the plugin gateway at the default localhost:10000.
142
+ async function runPython(root, portRanges, robotGateway) {
143
+ // Independent of Docker readiness — must still happen if Docker isn't
144
+ // installed/running, since that's exactly when local IDE tooling matters most.
145
+ syncIdeVenv(root);
146
+ pyAssertDockerReady();
147
+ await pyEnsureImage();
148
+
149
+ const manifest = readManifest(root);
150
+ const pluginId = manifest.id ?? "";
151
+ const container = `${PYTHON_DEV_IMAGE}-run`;
152
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
153
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
154
+ const zenohAdvertiseHost = process.env.MUSCAT_ZENOH_ADVERTISE_HOST ?? "";
155
+ const zenohPort = process.env.MUSCAT_ZENOH_PORT ?? "";
156
+ // See host_agent.py: a Docker Desktop container can't join the robot's
157
+ // Zenoh mesh, so it speaks a byte protocol to the host-side proxy instead.
158
+ const proxyEndpoint = process.env.MUSCAT_PROXY_ENDPOINT ?? "";
159
+ // Native Linux (not WSL, not Docker Desktop) has real host networking: the
160
+ // container shares the host's network stack, so it reaches the gateway at
161
+ // localhost:10000 and the robot's Zenoh router / reverse-dial path directly
162
+ // — no netns sharing, port publishing, host.docker.internal, or byte-proxy.
163
+ const net = backendNetwork(portRanges, robotGateway);
164
+ // Only the container's OWN reverse-dial path needs to publish a listen port.
165
+ // With a shared netns the port is already reachable; with the byte-proxy
166
+ // the container just connects out — neither needs -p.
167
+ const standaloneZenohPortFlags =
168
+ !net.shared && !proxyEndpoint && zenohPort ? ["-p", `${zenohPort}:${zenohPort}`] : [];
169
+ // C++ SDK (rainbow-rb-sdk >= 0.10) can't use the byte shim or the fallback patch: its Zenoh session
170
+ // is native. It joins the robot's mesh itself on this host port (see the entry script); inside,
171
+ // the port + 1 accepts. With or without host_agent (dev vs standalone dev:back).
172
+ const sdkAdvertiseHost = proxyEndpoint ? proxyEndpoint.split(":")[0] : zenohAdvertiseHost;
173
+ const sdkPeerPort = sdkAdvertiseHost && !net.shared && zenohPort ? await getFreePort(Number(zenohPort) + 1) : "";
174
+ const sdkPeerFlags = sdkPeerPort
175
+ ? ["--sysctl", "net.ipv4.ip_nonlocal_bind=1", "-p", `${sdkPeerPort}:${Number(sdkPeerPort) + 1}`]
176
+ : [];
177
+ const envFlags = [
178
+ // docker exec below has no -t (no pseudo-TTY), so Python defaults to
179
+ // block-buffered stdout — print() output wouldn't show up until the
180
+ // buffer fills or the process exits. Force unbuffered.
181
+ "-e", "PYTHONUNBUFFERED=1",
182
+ ...net.env,
183
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
184
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
185
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
186
+ // Byte-proxy mode: the container has no Zenoh session of its own — the
187
+ // entry script's shim speaks the raw query/put/subscribe protocol to the
188
+ // host_agent process at this address instead.
189
+ ...(proxyEndpoint ? ["-e", `MUSCAT_PROXY_ENDPOINT=${proxyEndpoint}`] : []),
190
+ ...(sdkPeerPort ? ["-e", `MUSCAT_SDK_PEER_PORT=${sdkPeerPort}`, "-e", `MUSCAT_SDK_ADVERTISE_HOST=${sdkAdvertiseHost}`] : []),
191
+ // Fallback (no proxy): the container reverse-dials the robot itself.
192
+ ...(!proxyEndpoint && zenohAdvertiseHost ? ["-e", `MUSCAT_ZENOH_ADVERTISE_HOST=${zenohAdvertiseHost}`] : []),
193
+ ...(!proxyEndpoint && zenohPort ? ["-e", `MUSCAT_ZENOH_PORT=${zenohPort}`] : []),
194
+ ...portEnvFlags(portRanges),
195
+ ];
196
+ const docker = (args, opts = {}) =>
197
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
198
+
199
+ // start a warm, long-lived container with the plugin mounted.
200
+ // 호스트 uid 로 돌린다(dockerUserFlags) — root 로 쓴 build/·install/ 은 사용자가 지우지 못한다.
201
+ docker(["rm", "-f", container], { stdio: "ignore" });
202
+ const up = docker(
203
+ ["run", "-d", "--name", container, ...net.run, ...standaloneZenohPortFlags, ...sdkPeerFlags, ...realtimeFlags(), ...dockerUserFlags(),
204
+ "-e", "PIP_ROOT_USER_ACTION=ignore",
205
+ "-v", `${root}:/plugin`, "-w", "/plugin", PYTHON_DEV_IMAGE, "sleep", "infinity"],
206
+ { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
207
+ );
208
+ if (up.status !== 0) throw new Error("failed to start dev container.");
209
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [zenohPort, sdkPeerPort, sdkPeerPort && String(Number(sdkPeerPort) + 1)] });
210
+
211
+ docker(["exec", container, "bash", "-lc",
212
+ "pip install --break-system-packages --ignore-installed -q watchfiles" +
213
+ " && { [ -f requirements.txt ] && pip install --break-system-packages --ignore-installed -q -r requirements.txt || true; }"]);
214
+
215
+ docker(["exec", container, "bash", "-lc", `cat > /tmp/muscat-pk-python-dev-entry.py <<'PY'
216
+ import os
217
+
218
+ try:
219
+ import glob
220
+ import importlib.util
221
+
222
+ proxy_endpoint = os.getenv("MUSCAT_PROXY_ENDPOINT")
223
+ # Located without importing: importing rainbow.rb_sdk opens the SDK's Zenoh session.
224
+ _rainbow = importlib.util.find_spec("rainbow")
225
+ cpp_sdk = bool(_rainbow and any(glob.glob(os.path.join(p, "rb_sdk", "rb_sdk_cpp*.so")) for p in _rainbow.submodule_search_locations))
226
+ if cpp_sdk and os.getenv("MUSCAT_SDK_PEER_PORT"):
227
+ # C++ SDK (rainbow-rb-sdk >= 0.10): its Zenoh session is native, so the byte shim below
228
+ # can't reach it, and connect() would register the container's own unreachable IP.
229
+ # Robot peers dial each remote peer at the locators it gossips, so the session must
230
+ # advertise the host's ip:port: listen there (ip_nonlocal_bind; never accepts) plus on
231
+ # port + 1, where Docker forwards the host port. Then register like host_agent does.
232
+ import json
233
+ import threading
234
+ import time
235
+ import urllib.request
236
+
237
+ from rainbow.rb_sdk import rb_sdk_cpp
238
+
239
+ _port = int(os.environ["MUSCAT_SDK_PEER_PORT"])
240
+ _advertise = os.environ["MUSCAT_SDK_ADVERTISE_HOST"]
241
+ os.environ["ZENOH_PEER_LISTEN_ENDPOINTS"] = f"tcp/{_advertise}:{_port},tcp/0.0.0.0:{_port + 1}"
242
+ for _cls in list(vars(rb_sdk_cpp).values()):
243
+ if isinstance(_cls, type) and issubclass(_cls, rb_sdk_cpp.RBBaseSDK) and "connect" in _cls.__dict__:
244
+ _cls.connect = lambda self, *args, **kwargs: None
245
+
246
+ def _peer_http(method, path, body=None):
247
+ headers = {"Content-Type": "application/json"}
248
+ if os.getenv("MUSCAT_ROBOT_TOKEN"):
249
+ headers["Authorization"] = "Bearer " + os.environ["MUSCAT_ROBOT_TOKEN"]
250
+ req = urllib.request.Request(f"http://{os.environ['MUSCAT_ROBOT_HOST']}:8000{path}",
251
+ data=json.dumps(body).encode() if body is not None else None,
252
+ headers=headers, method=method)
253
+ with urllib.request.urlopen(req, timeout=10) as resp:
254
+ raw = resp.read()
255
+ return json.loads(raw) if raw else {}
256
+
257
+ def _peer_keepalive():
258
+ advertise = _advertise
259
+ peer_id = None
260
+ while True:
261
+ try:
262
+ if peer_id is None:
263
+ peer_id = _peer_http("POST", "/zenoh/peer", {"ip": advertise, "port": _port, "ttl": 30})["peer_id"]
264
+ print(f"[muscat-pk] C++ SDK zenoh peer {advertise}:{_port} registered with the robot", flush=True)
265
+ elif not _peer_http("POST", f"/zenoh/peer/{peer_id}/heartbeat", {"ttl": 30}).get("ok"):
266
+ peer_id = None
267
+ continue
268
+ except Exception as exc:
269
+ print(f"[muscat-pk] zenoh peer registration failed ({exc}); retrying", flush=True)
270
+ peer_id = None
271
+ time.sleep(15 if peer_id else 3)
272
+
273
+ threading.Thread(target=_peer_keepalive, daemon=True).start()
274
+ elif proxy_endpoint:
275
+ # Byte-proxy mode: this container has no Zenoh session of its own.
276
+ # ZenohClient's C++ core is replaced with a shim that speaks a raw
277
+ # query/put/subscribe byte protocol to host_agent.py instead.
278
+ import json
279
+ import socket
280
+ import struct
281
+ import threading
282
+
283
+ _proxy_host, _proxy_port_s = proxy_endpoint.rsplit(":", 1)
284
+ _proxy_port = int(_proxy_port_s)
285
+
286
+ def _send_frame(conn, lock, obj):
287
+ body = json.dumps(obj).encode()
288
+ with lock:
289
+ conn.sendall(struct.pack(">I", len(body)) + body)
290
+
291
+ def _recv_frame(conn):
292
+ hdr = b""
293
+ while len(hdr) < 4:
294
+ chunk = conn.recv(4 - len(hdr))
295
+ if not chunk:
296
+ return None
297
+ hdr += chunk
298
+ n = struct.unpack(">I", hdr)[0]
299
+ buf = b""
300
+ while len(buf) < n:
301
+ chunk = conn.recv(n - len(buf))
302
+ if not chunk:
303
+ return None
304
+ buf += chunk
305
+ return json.loads(buf.decode())
306
+
307
+ class _ProxyRawClient:
308
+ """Drop-in replacement for ZenohClient's C++ core, backed by the
309
+ host_agent byte-proxy instead of a local Zenoh session."""
310
+
311
+ sender = "RRS"
312
+ sender_id = ""
313
+
314
+ def __init__(self, host, port):
315
+ self._conn = socket.create_connection((host, port), timeout=10)
316
+ # create_connection() leaves the connect timeout applied to
317
+ # the socket permanently; the background read loop below does
318
+ # a long-lived blocking recv() for async "sample" pushes, so
319
+ # it must not time out on an idle connection.
320
+ self._conn.settimeout(None)
321
+ self._send_lock = threading.Lock()
322
+ self._req_id = 0
323
+ self._req_lock = threading.Lock()
324
+ self._pending = {}
325
+ self._subs = {} # sub_id -> callback
326
+ self._sub_by_keyexpr = {}
327
+ threading.Thread(target=self._read_loop, daemon=True).start()
328
+
329
+ def _next_id(self):
330
+ with self._req_lock:
331
+ self._req_id += 1
332
+ return self._req_id
333
+
334
+ def _read_loop(self):
335
+ while True:
336
+ frame = _recv_frame(self._conn)
337
+ if frame is None:
338
+ return
339
+ ftype = frame.get("type")
340
+ if ftype == "sample":
341
+ cb = self._sub_by_keyexpr.get(frame["keyexpr"])
342
+ if cb is not None:
343
+ try:
344
+ cb(frame["key"], bytes.fromhex(frame["payload_hex"]), frame.get("att", ""))
345
+ except Exception as exc:
346
+ print(f"[muscat-pk] proxy subscriber callback error: {exc}", flush=True)
347
+ elif "id" in frame:
348
+ ev = self._pending.pop(frame["id"], None)
349
+ if ev is not None:
350
+ ev[1] = frame
351
+ ev[0].set()
352
+
353
+ def _request(self, obj, timeout=30):
354
+ req_id = self._next_id()
355
+ obj["id"] = req_id
356
+ ev = threading.Event()
357
+ slot = [ev, None]
358
+ self._pending[req_id] = slot
359
+ _send_frame(self._conn, self._send_lock, obj)
360
+ if not ev.wait(timeout):
361
+ self._pending.pop(req_id, None)
362
+ raise TimeoutError(f"proxy request timed out: {obj.get('type')} {obj.get('keyexpr')}")
363
+ return slot[1]
364
+
365
+ def query(self, keyexpr, payload, timeout_ms, target):
366
+ resp = self._request({
367
+ "type": "query", "keyexpr": keyexpr,
368
+ "payload_hex": payload.hex() if payload else None,
369
+ "timeout_ms": timeout_ms, "target": target,
370
+ }, timeout=(timeout_ms / 1000) + 5)
371
+ if not resp.get("ok"):
372
+ raise RuntimeError(resp.get("error", "proxy query failed"))
373
+ return [
374
+ (r["key"], bytes.fromhex(r["payload_hex"]), r["att"], r["encoding"], r["is_error"])
375
+ for r in resp["replies"]
376
+ ]
377
+
378
+ def query_all(self, keyexpr, payload, timeout_ms, target):
379
+ return self.query(keyexpr, payload, timeout_ms, target)
380
+
381
+ def publish(self, topic, payload, att, congestion_control, priority, express):
382
+ resp = self._request({
383
+ "type": "put", "keyexpr": topic,
384
+ "payload_hex": bytes(payload).hex() if payload else None,
385
+ })
386
+ if not resp.get("ok"):
387
+ raise RuntimeError(resp.get("error", "proxy put failed"))
388
+
389
+ def has_subscriber(self, topic):
390
+ return topic in self._sub_by_keyexpr
391
+
392
+ def declare_subscriber(self, topic, callback, opts=None):
393
+ resp = self._request({"type": "subscribe", "keyexpr": topic})
394
+ if not resp.get("ok"):
395
+ raise RuntimeError(resp.get("error", "proxy subscribe failed"))
396
+ sub_id = self._next_id()
397
+ self._subs[sub_id] = topic
398
+ self._sub_by_keyexpr[topic] = callback
399
+ return sub_id
400
+
401
+ def undeclare_subscriber(self, sub_id):
402
+ topic = self._subs.pop(sub_id, None)
403
+ if topic is None:
404
+ return
405
+ self._sub_by_keyexpr.pop(topic, None)
406
+ self._request({"type": "unsubscribe", "keyexpr": topic})
407
+
408
+ def unsubscribe_topic(self, topic):
409
+ for sub_id, t in list(self._subs.items()):
410
+ if t == topic:
411
+ self.undeclare_subscriber(sub_id)
412
+
413
+ def unsubscribe_all(self):
414
+ for sub_id in list(self._subs):
415
+ self.undeclare_subscriber(sub_id)
416
+
417
+ def get_sub_stats(self, sub_id):
418
+ return {}
419
+
420
+ def declare_queryable(self, keyexpr, handler):
421
+ raise NotImplementedError("byte-proxy mode does not support declare_queryable")
422
+
423
+ def undeclare_queryable_by_topic(self, keyexpr):
424
+ pass
425
+
426
+ # ZenohClient is a process-wide singleton whose __init__ eagerly
427
+ # calls connect() on first construction. rainbow.rb_sdk.base
428
+ # transitively imports rainbow.rb_log.log, which constructs one at
429
+ # module level — so this patch must land before RBBaseSDK is
430
+ # imported, or that eager construction wins with a real session.
431
+ from rainbow.rb_zenoh.client import ZenohClient
432
+
433
+ def _proxy_connect(self, *, config_pairs=None, sender=None, max_attempts=8, initial_delay_s=0.5, max_delay_s=3.0):
434
+ if self._client is not None:
435
+ return
436
+ if sender is not None:
437
+ self._sender = sender
438
+ print(f"[muscat-pk] zenoh via host byte-proxy → {proxy_endpoint} (no local Zenoh session)", flush=True)
439
+ self._client = _ProxyRawClient(_proxy_host, _proxy_port)
440
+
441
+ ZenohClient.connect = _proxy_connect
442
+
443
+ from rainbow.rb_sdk.base import RBBaseSDK
444
+
445
+ async def _sdk_connect_via_proxy(self, *args, **kwargs):
446
+ # No HTTP registration with common — host_agent already handles
447
+ # that. Just make sure the (proxy-backed) shared ZenohClient exists.
448
+ self._ensure_zenoh_client_alive()
449
+ return None
450
+
451
+ async def _sdk_disconnect_via_proxy(self, *args, **kwargs):
452
+ return None
453
+
454
+ RBBaseSDK.connect = _sdk_connect_via_proxy
455
+ RBBaseSDK.disconnect = _sdk_disconnect_via_proxy
456
+ else:
457
+ # Fallback: no host byte-proxy — the container reverse-dials the robot itself.
458
+ advertise_host = os.getenv("MUSCAT_ZENOH_ADVERTISE_HOST")
459
+ zenoh_port = os.getenv("MUSCAT_ZENOH_PORT")
460
+ from rainbow.rb_sdk.base import RBBaseSDK
461
+ if advertise_host:
462
+ RBBaseSDK._detect_local_ip = staticmethod(lambda host: advertise_host)
463
+ if zenoh_port:
464
+ _orig_connect = RBBaseSDK.connect
465
+
466
+ async def _connect_with_dev_port(self, *args, **kwargs):
467
+ kwargs.setdefault("zenoh_port", int(zenoh_port))
468
+ return await _orig_connect(self, *args, **kwargs)
469
+
470
+ RBBaseSDK.connect = _connect_with_dev_port
471
+ except Exception as exc:
472
+ print(f"[muscat-pk] SDK dev network patch skipped: {exc}", flush=True)
473
+
474
+ import runpy
475
+ runpy.run_path("/plugin/py/main.py", run_name="__main__")
476
+ PY`]);
477
+
478
+ if (usesHostNetwork()) log.step("network: host (native Linux — direct robot/gateway access)");
479
+ if (proxyEndpoint) log.step(`Zenoh via host byte-proxy → ${proxyEndpoint}`);
480
+ else if (zenohAdvertiseHost && zenohPort) log.step(`Zenoh advertise → ${zenohAdvertiseHost}:${zenohPort}`);
481
+ const child = spawn(
482
+ "docker",
483
+ ["exec", ...envFlags, container, "bash", "-lc",
484
+ "python3 -m watchfiles --filter python --target-type command 'python3 /tmp/muscat-pk-python-dev-entry.py' py"],
485
+ { cwd: root, stdio: ["inherit", "inherit", "pipe"] },
486
+ );
487
+ // watchfiles prints a KeyboardInterrupt traceback on SIGTERM — suppress it
488
+ child.stderr.on("data", (buf) => {
489
+ const text = buf.toString();
490
+ if (!text.includes("KeyboardInterrupt") && !text.includes("raise_keyboard_interrupt")) {
491
+ process.stderr.write(buf);
492
+ }
493
+ });
494
+
495
+ let cleaned = false;
496
+ const cleanupResources = () => {
497
+ if (cleaned) return;
498
+ cleaned = true;
499
+ stopPortMonitor();
500
+ if (!child.killed && child.exitCode === null) child.kill();
501
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
502
+ };
503
+ const cleanup = () => {
504
+ cleanupResources();
505
+ exitAfterCleanup(0);
506
+ };
507
+ child.on("exit", (code) => {
508
+ cleanupResources();
509
+ exitAfterCleanup(code ?? 0);
510
+ });
511
+ process.once("SIGINT", cleanup);
512
+ process.once("SIGTERM", cleanup);
513
+ process.once("SIGHUP", cleanup);
514
+ }
515
+
516
+ // ── ros2 ───────────────────────────────────────────────────────────────────────
517
+ async function runRos2(root, portRanges, robotGateway) {
518
+ const manifest = readManifest(root);
519
+ const isRclcpp = manifest.runtime?.rosClient === "rclcpp";
520
+ // Independent of Docker readiness — must still happen if Docker isn't
521
+ // installed/running, since that's exactly when local IDE tooling matters most.
522
+ if (!isRclcpp) syncIdeVenv(root); // rclpy is plain Python — rclcpp is C++, no venv needed
523
+
524
+ assertDockerReady();
525
+ const pkg = ros2PackageName(root);
526
+ if (!pkg) throw new Error("no ROS2 package found under ros2_ws/src.");
527
+
528
+ await ensureImage();
529
+
530
+ const pluginId = manifest.id ?? "";
531
+ const container = `${ROS2_DEV_IMAGE}-run`;
532
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
533
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
534
+ const net = backendNetwork(portRanges, robotGateway);
535
+ const envFlags = [
536
+ // Same rationale as runPython(): no -t on docker exec below means
537
+ // rclpy's Python process stdout is block-buffered, not line-buffered.
538
+ "-e", "PYTHONUNBUFFERED=1",
539
+ // Same as the robot: ROS discovery stays on this host (allow a PC with ROS_STATIC_PEERS).
540
+ "-e", "ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST",
541
+ ...net.env,
542
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
543
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
544
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
545
+ ...portEnvFlags(portRanges),
546
+ ];
547
+ const docker = (args, opts = {}) =>
548
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
549
+
550
+ // start a warm, long-lived container with the workspace mounted
551
+ docker(["rm", "-f", container], { stdio: "ignore" });
552
+ const up = docker(
553
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-e", "PIP_ROOT_USER_ACTION=ignore", "-v", `${root}:/plugin`, "-w", "/plugin", ROS2_DEV_IMAGE, "sleep", "infinity"],
554
+ { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
555
+ );
556
+ if (up.status !== 0) throw new Error("failed to start dev container.");
557
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
558
+
559
+ // one-time deps + symlink build (edits to .py reflect without rebuilding)
560
+ docker(["exec", container, "bash", "-lc",
561
+ "pip install -r requirements.txt --break-system-packages --ignore-installed -q || true; cd ros2_ws && colcon build --symlink-install"]);
562
+
563
+ let child = null;
564
+ const startNode = () => {
565
+ if (child) child.kill();
566
+ // killing the `docker exec` client doesn't stop the node inside the container
567
+ spawnSync("docker", ["exec", container, "pkill", "-f", `${pkg}_node|${manifest.runtime?.launch || pkg}`], { stdio: "ignore" });
568
+ const peers = readRosPeers(root);
569
+ // Same command the robot runs: rclpy execs the console script, rclcpp uses `ros2 run`;
570
+ // relative topics land under the plugin's namespace (rosNamespace).
571
+ const launch = manifest.runtime?.launch || `${pkg}_node`;
572
+ const nsArgs = PLUGIN_ID_RE.test(pluginId) ? ` --ros-args -r __ns:=${rosNamespace(pluginId)}` : "";
573
+ const run = (isRclcpp ? `ros2 run ${pkg} ${launch}` : `exec ${launch}`) + nsArgs;
574
+ child = spawn(
575
+ "docker",
576
+ ["exec", ...envFlags, ...(peers.length ? ["-e", `ROS_STATIC_PEERS=${peers.join(";")}`] : []), container, "bash", "-lc",
577
+ `cd ros2_ws && source install/setup.bash && ${run}`],
578
+ { cwd: root, stdio: "inherit" },
579
+ );
580
+ };
581
+ startNode();
582
+ serveRosPeers(root, pluginId, startNode);
583
+
584
+ // restart node on source change. rclpy reflects .py edits live (symlink-install);
585
+ // rclcpp (C++) and package-metadata changes require a colcon rebuild.
586
+ let timer = null;
587
+ const onChange = (_event, filename) => {
588
+ clearTimeout(timer);
589
+ timer = setTimeout(() => {
590
+ const structural = filename && /(setup\.py|package\.xml|CMakeLists\.txt)$/.test(String(filename));
591
+ if (structural || isRclcpp) {
592
+ log.info(structural ? "package metadata changed — colcon rebuild…" : "source changed — colcon rebuild…");
593
+ docker(["exec", container, "bash", "-lc", "cd ros2_ws && colcon build --symlink-install"]);
594
+ } else {
595
+ log.info("source changed — restarting node…");
596
+ }
597
+ startNode();
598
+ }, 300);
599
+ };
600
+ for (const target of ["ros2_ws/src", "main.py"]) {
601
+ const full = resolve(root, target);
602
+ if (existsSync(full)) watch(full, { recursive: true }, onChange);
603
+ }
604
+
605
+ const cleanup = () => {
606
+ stopPortMonitor();
607
+ if (child) child.kill();
608
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
609
+ exitAfterCleanup(0);
610
+ };
611
+ process.on("SIGINT", cleanup);
612
+ process.on("SIGTERM", cleanup);
613
+ process.on("SIGHUP", cleanup);
614
+ }
615
+
616
+ // ── cpp ──────────────────────────────────────────────────────────────────────
617
+ async function runCpp(root, portRanges, robotGateway) {
618
+ cppAssertDockerReady();
619
+ const manifest = readManifest(root);
620
+ const entry = manifest.runtime?.entry;
621
+ if (!entry) throw new Error("runtime.entry (the built executable name) is required for cpp.");
622
+
623
+ await cppEnsureImage();
624
+
625
+ const pluginId = manifest.id ?? "";
626
+ const container = `${CPP_DEV_IMAGE}-run`;
627
+ const robotIp = process.env.MUSCAT_ROBOT_HOST ?? "";
628
+ const robotToken = process.env.MUSCAT_ROBOT_TOKEN ?? "";
629
+ const net = backendNetwork(portRanges, robotGateway);
630
+ const envFlags = [
631
+ ...net.env,
632
+ ...(pluginId ? ["-e", `RB_PLUGIN_ID=${pluginId}`] : []),
633
+ ...(robotIp ? ["-e", `MUSCAT_ROBOT_HOST=${robotIp}`] : []),
634
+ ...(robotToken ? ["-e", `MUSCAT_ROBOT_TOKEN=${robotToken}`] : []),
635
+ ...portEnvFlags(portRanges),
636
+ ];
637
+ const docker = (args, opts = {}) =>
638
+ spawnSync("docker", args, { cwd: root, stdio: "inherit", ...opts });
639
+
640
+ // start a warm, long-lived container with the plugin mounted
641
+ docker(["rm", "-f", container], { stdio: "ignore" });
642
+ const up = docker(
643
+ ["run", "-d", "--name", container, ...net.run, ...realtimeFlags(), ...dockerUserFlags(), "-v", `${root}:/plugin`, "-w", "/plugin", CPP_DEV_IMAGE, "sleep", "infinity"],
644
+ { stdio: ["ignore", "ignore", "inherit"] }, // stdout is just the container id
645
+ );
646
+ if (up.status !== 0) throw new Error("failed to start dev container.");
647
+ const stopPortMonitor = startPortContractMonitor({ container, ranges: portRanges, log, allow: [process.env.MUSCAT_ZENOH_PORT] });
648
+
649
+ const build = () => {
650
+ return docker(["exec", container, "bash", "-lc",
651
+ "cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Debug && cmake --build cpp/build -j"]);
652
+ };
653
+ build();
654
+
655
+ let child = null;
656
+ const startBin = () => {
657
+ if (child) child.kill();
658
+ child = spawn(
659
+ "docker",
660
+ ["exec", ...envFlags, container, "bash", "-lc", `./cpp/build/${entry}`],
661
+ { cwd: root, stdio: "inherit" },
662
+ );
663
+ };
664
+ startBin();
665
+
666
+ // rebuild + restart on C++ source changes; ignore the build/ output dir.
667
+ let timer = null;
668
+ const onChange = (_event, filename) => {
669
+ if (filename && String(filename).startsWith("build")) return;
670
+ clearTimeout(timer);
671
+ timer = setTimeout(() => {
672
+ log.info("source changed — rebuilding…");
673
+ const r = build();
674
+ if (r.status === 0) startBin();
675
+ else log.warn("build failed — keeping previous binary");
676
+ }, 300);
677
+ };
678
+ const cppDir = resolve(root, "cpp");
679
+ if (existsSync(cppDir)) watch(cppDir, { recursive: true }, onChange);
680
+
681
+ const cleanup = () => {
682
+ stopPortMonitor();
683
+ if (child) child.kill();
684
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
685
+ exitAfterCleanup(0);
686
+ };
687
+ process.on("SIGINT", cleanup);
688
+ process.on("SIGTERM", cleanup);
689
+ process.on("SIGHUP", cleanup);
690
+ }
691
+
692
+ function warnManifest(root) {
693
+ const manifest = readManifest(root);
694
+ if (!manifest || Object.keys(manifest).length === 0) return;
695
+ const errors = validateManifest(manifest);
696
+ for (const e of errors) log.warn(`manifest.json: ${e}`);
697
+ }
698
+
699
+ export async function runDevBack(args = []) {
700
+ const root = findPluginRoot();
701
+ assertManifestVariables(root);
702
+ warnManifest(root);
703
+ const portRanges = assertManifestPorts(root);
704
+ ensureDocker();
705
+ const { robotIp, robotToken } = await promptRobotConnection(args);
706
+ if (robotIp) process.env.MUSCAT_ROBOT_HOST = robotIp;
707
+ if (robotToken) process.env.MUSCAT_ROBOT_TOKEN = robotToken;
708
+ // Standalone `dev:back` against a remote robot needs the Zenoh reverse-path
709
+ // advertise host/port too (idempotent — `muscat-pk dev` already set these).
710
+ // Byte-proxy mode (a host-side host_agent set MUSCAT_PROXY_ENDPOINT) makes
711
+ // the container speak the proxy protocol instead, so it needs no reverse-dial.
712
+ if (!process.env.MUSCAT_PROXY_ENDPOINT) await ensureRemoteZenohEnv(robotIp, { log });
713
+ // Standalone against a local Muscat (muscat-dev, …): join its netns like `dev` does,
714
+ // otherwise zenoh reaches nothing and `execute` never arrives.
715
+ if (!process.env.MUSCAT_PK_MUSCAT_CONTAINER && !isRemoteRobot(robotIp)) {
716
+ const muscat = localMuscatContainer();
717
+ if (muscat) {
718
+ process.env.MUSCAT_PK_MUSCAT_CONTAINER = muscat;
719
+ }
720
+ }
721
+ // Stopped while still setting up (image build, pip install, first cmake build): the runtime's own
722
+ // cleanup is not registered yet, so remove the container here. Once it is, it handles the signal.
723
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
724
+ process.once(signal, () => {
725
+ if (process.listenerCount(signal)) return;
726
+ stopDevBackContainers();
727
+ process.exit(130);
728
+ });
729
+ const robotGateway = (await robotHasMuscat(robotIp)) ? `http://${robotIp}:${GATEWAY_PORT}` : "";
730
+ const type = manifestRuntime(root);
731
+ if (type === "ros2") return await runRos2(root, portRanges, robotGateway);
732
+ if (type === "cpp") return await runCpp(root, portRanges, robotGateway);
733
+ return await runPython(root, portRanges, robotGateway);
734
+ }