@mesh-tech/mesh-cli 0.1.0 → 0.12.0

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 (504) hide show
  1. package/assets/intent/agents-fence.md +10 -0
  2. package/assets/intent/intent-claude-gate.mjs +255 -0
  3. package/assets/log-shipper.mjs +94 -0
  4. package/bin/mesh.mjs +80 -0
  5. package/dist/bin/mesh.d.ts +7 -9
  6. package/dist/bin/mesh.d.ts.map +1 -1
  7. package/dist/bin/mesh.js +20826 -37
  8. package/dist/bin/mesh.js.map +7 -1
  9. package/dist/build-info.json +4 -0
  10. package/dist/src/commands/agent-api-client.d.ts +79 -0
  11. package/dist/src/commands/agent-api-client.d.ts.map +1 -0
  12. package/dist/src/commands/agent-api-client.js +170 -0
  13. package/dist/src/commands/agent-api-client.js.map +1 -0
  14. package/dist/src/commands/artifacts.d.ts +13 -0
  15. package/dist/src/commands/artifacts.d.ts.map +1 -0
  16. package/dist/src/commands/artifacts.js +140 -0
  17. package/dist/src/commands/artifacts.js.map +1 -0
  18. package/dist/src/commands/cluster.d.ts +45 -0
  19. package/dist/src/commands/cluster.d.ts.map +1 -0
  20. package/dist/src/commands/cluster.js +259 -0
  21. package/dist/src/commands/cluster.js.map +1 -0
  22. package/dist/src/commands/conversations.d.ts +61 -0
  23. package/dist/src/commands/conversations.d.ts.map +1 -0
  24. package/dist/src/commands/conversations.js +239 -0
  25. package/dist/src/commands/conversations.js.map +1 -0
  26. package/dist/src/commands/create-app.d.ts +26 -3
  27. package/dist/src/commands/create-app.d.ts.map +1 -1
  28. package/dist/src/commands/create-app.js +465 -107
  29. package/dist/src/commands/create-app.js.map +1 -1
  30. package/dist/src/commands/deploy.d.ts +19 -0
  31. package/dist/src/commands/deploy.d.ts.map +1 -1
  32. package/dist/src/commands/deploy.js +83 -142
  33. package/dist/src/commands/deploy.js.map +1 -1
  34. package/dist/src/commands/dev-doctor.d.ts +137 -0
  35. package/dist/src/commands/dev-doctor.d.ts.map +1 -0
  36. package/dist/src/commands/dev-doctor.js +671 -0
  37. package/dist/src/commands/dev-doctor.js.map +1 -0
  38. package/dist/src/commands/dev-launch.d.ts +59 -0
  39. package/dist/src/commands/dev-launch.d.ts.map +1 -0
  40. package/dist/src/commands/dev-launch.js +118 -0
  41. package/dist/src/commands/dev-launch.js.map +1 -0
  42. package/dist/src/commands/dev-token-server.d.ts +32 -0
  43. package/dist/src/commands/dev-token-server.d.ts.map +1 -0
  44. package/dist/src/commands/dev-token-server.js +68 -0
  45. package/dist/src/commands/dev-token-server.js.map +1 -0
  46. package/dist/src/commands/dev.d.ts +289 -2
  47. package/dist/src/commands/dev.d.ts.map +1 -1
  48. package/dist/src/commands/dev.js +2501 -574
  49. package/dist/src/commands/dev.js.map +1 -1
  50. package/dist/src/commands/docs.d.ts +3 -0
  51. package/dist/src/commands/docs.d.ts.map +1 -0
  52. package/dist/src/commands/docs.js +262 -0
  53. package/dist/src/commands/docs.js.map +1 -0
  54. package/dist/src/commands/hub/index.d.ts +99 -0
  55. package/dist/src/commands/hub/index.d.ts.map +1 -0
  56. package/dist/src/commands/hub/index.js +452 -0
  57. package/dist/src/commands/hub/index.js.map +1 -0
  58. package/dist/src/commands/init.d.ts +65 -0
  59. package/dist/src/commands/init.d.ts.map +1 -0
  60. package/dist/src/commands/init.js +370 -0
  61. package/dist/src/commands/init.js.map +1 -0
  62. package/dist/src/commands/install-shim.d.ts +21 -0
  63. package/dist/src/commands/install-shim.d.ts.map +1 -0
  64. package/dist/src/commands/install-shim.js +132 -0
  65. package/dist/src/commands/install-shim.js.map +1 -0
  66. package/dist/src/commands/local/auth-provision.d.ts +71 -0
  67. package/dist/src/commands/local/auth-provision.d.ts.map +1 -0
  68. package/dist/src/commands/local/auth-provision.js +286 -0
  69. package/dist/src/commands/local/auth-provision.js.map +1 -0
  70. package/dist/src/commands/local/dev-local.d.ts +97 -0
  71. package/dist/src/commands/local/dev-local.d.ts.map +1 -0
  72. package/dist/src/commands/local/dev-local.js +306 -0
  73. package/dist/src/commands/local/dev-local.js.map +1 -0
  74. package/dist/src/commands/local/docker-runner.d.ts +37 -0
  75. package/dist/src/commands/local/docker-runner.d.ts.map +1 -0
  76. package/dist/src/commands/local/docker-runner.js +104 -0
  77. package/dist/src/commands/local/docker-runner.js.map +1 -0
  78. package/dist/src/commands/local/helpers.d.ts +22 -0
  79. package/dist/src/commands/local/helpers.d.ts.map +1 -0
  80. package/dist/src/commands/local/helpers.js +46 -0
  81. package/dist/src/commands/local/helpers.js.map +1 -0
  82. package/dist/src/commands/local/hub-local.d.ts +51 -0
  83. package/dist/src/commands/local/hub-local.d.ts.map +1 -0
  84. package/dist/src/commands/local/hub-local.js +197 -0
  85. package/dist/src/commands/local/hub-local.js.map +1 -0
  86. package/dist/src/commands/local/index.d.ts +23 -0
  87. package/dist/src/commands/local/index.d.ts.map +1 -0
  88. package/dist/src/commands/local/index.js +386 -0
  89. package/dist/src/commands/local/index.js.map +1 -0
  90. package/dist/src/commands/local/mocks.d.ts +361 -0
  91. package/dist/src/commands/local/mocks.d.ts.map +1 -0
  92. package/dist/src/commands/local/mocks.js +756 -0
  93. package/dist/src/commands/local/mocks.js.map +1 -0
  94. package/dist/src/commands/local/seed-zitadel.d.ts +151 -0
  95. package/dist/src/commands/local/seed-zitadel.d.ts.map +1 -0
  96. package/dist/src/commands/local/seed-zitadel.js +408 -0
  97. package/dist/src/commands/local/seed-zitadel.js.map +1 -0
  98. package/dist/src/commands/local/seed.d.ts +106 -0
  99. package/dist/src/commands/local/seed.d.ts.map +1 -0
  100. package/dist/src/commands/local/seed.js +252 -0
  101. package/dist/src/commands/local/seed.js.map +1 -0
  102. package/dist/src/commands/local/stack.d.ts +173 -0
  103. package/dist/src/commands/local/stack.d.ts.map +1 -0
  104. package/dist/src/commands/local/stack.js +499 -0
  105. package/dist/src/commands/local/stack.js.map +1 -0
  106. package/dist/src/commands/login.d.ts +256 -12
  107. package/dist/src/commands/login.d.ts.map +1 -1
  108. package/dist/src/commands/login.js +978 -50
  109. package/dist/src/commands/login.js.map +1 -1
  110. package/dist/src/commands/peer-addressing.d.ts +51 -0
  111. package/dist/src/commands/peer-addressing.d.ts.map +1 -0
  112. package/dist/src/commands/peer-addressing.js +64 -0
  113. package/dist/src/commands/peer-addressing.js.map +1 -0
  114. package/dist/src/commands/port-ownership.d.ts +69 -0
  115. package/dist/src/commands/port-ownership.d.ts.map +1 -0
  116. package/dist/src/commands/port-ownership.js +161 -0
  117. package/dist/src/commands/port-ownership.js.map +1 -0
  118. package/dist/src/commands/registry-publish.d.ts +3 -0
  119. package/dist/src/commands/registry-publish.d.ts.map +1 -0
  120. package/dist/src/commands/registry-publish.js +161 -0
  121. package/dist/src/commands/registry-publish.js.map +1 -0
  122. package/dist/src/commands/registry.d.ts +146 -2
  123. package/dist/src/commands/registry.d.ts.map +1 -1
  124. package/dist/src/commands/registry.js +541 -85
  125. package/dist/src/commands/registry.js.map +1 -1
  126. package/dist/src/commands/secrets/index.d.ts.map +1 -1
  127. package/dist/src/commands/secrets/index.js +15 -1
  128. package/dist/src/commands/secrets/index.js.map +1 -1
  129. package/dist/src/commands/secrets/migrate-config.d.ts +24 -0
  130. package/dist/src/commands/secrets/migrate-config.d.ts.map +1 -0
  131. package/dist/src/commands/secrets/migrate-config.js +277 -0
  132. package/dist/src/commands/secrets/migrate-config.js.map +1 -0
  133. package/dist/src/commands/secrets/set.d.ts +4 -0
  134. package/dist/src/commands/secrets/set.d.ts.map +1 -1
  135. package/dist/src/commands/secrets/set.js +243 -64
  136. package/dist/src/commands/secrets/set.js.map +1 -1
  137. package/dist/src/commands/skills.d.ts +34 -0
  138. package/dist/src/commands/skills.d.ts.map +1 -0
  139. package/dist/src/commands/skills.js +207 -0
  140. package/dist/src/commands/skills.js.map +1 -0
  141. package/dist/src/commands/stack.d.ts.map +1 -1
  142. package/dist/src/commands/stack.js +361 -23
  143. package/dist/src/commands/stack.js.map +1 -1
  144. package/dist/src/commands/temporal.d.ts +15 -0
  145. package/dist/src/commands/temporal.d.ts.map +1 -0
  146. package/dist/src/commands/temporal.js +781 -0
  147. package/dist/src/commands/temporal.js.map +1 -0
  148. package/dist/src/commands/tenant.d.ts +56 -0
  149. package/dist/src/commands/tenant.d.ts.map +1 -0
  150. package/dist/src/commands/tenant.js +233 -0
  151. package/dist/src/commands/tenant.js.map +1 -0
  152. package/dist/src/commands/tunnel/index.d.ts +49 -3
  153. package/dist/src/commands/tunnel/index.d.ts.map +1 -1
  154. package/dist/src/commands/tunnel/index.js +331 -8
  155. package/dist/src/commands/tunnel/index.js.map +1 -1
  156. package/dist/src/commands/vcs/clone.d.ts +3 -0
  157. package/dist/src/commands/vcs/clone.d.ts.map +1 -0
  158. package/dist/src/commands/vcs/clone.js +20 -0
  159. package/dist/src/commands/vcs/clone.js.map +1 -0
  160. package/dist/src/commands/vcs/common.d.ts +21 -0
  161. package/dist/src/commands/vcs/common.d.ts.map +1 -0
  162. package/dist/src/commands/vcs/common.js +62 -0
  163. package/dist/src/commands/vcs/common.js.map +1 -0
  164. package/dist/src/commands/vcs/drafts.d.ts +13 -0
  165. package/dist/src/commands/vcs/drafts.d.ts.map +1 -0
  166. package/dist/src/commands/vcs/drafts.js +45 -0
  167. package/dist/src/commands/vcs/drafts.js.map +1 -0
  168. package/dist/src/commands/vcs/get.d.ts +8 -0
  169. package/dist/src/commands/vcs/get.d.ts.map +1 -0
  170. package/dist/src/commands/vcs/get.js +61 -0
  171. package/dist/src/commands/vcs/get.js.map +1 -0
  172. package/dist/src/commands/vcs/index.d.ts +3 -0
  173. package/dist/src/commands/vcs/index.d.ts.map +1 -0
  174. package/dist/src/commands/vcs/index.js +58 -0
  175. package/dist/src/commands/vcs/index.js.map +1 -0
  176. package/dist/src/commands/vcs/propose.d.ts +23 -0
  177. package/dist/src/commands/vcs/propose.d.ts.map +1 -0
  178. package/dist/src/commands/vcs/propose.js +94 -0
  179. package/dist/src/commands/vcs/propose.js.map +1 -0
  180. package/dist/src/commands/vcs/review.d.ts +15 -0
  181. package/dist/src/commands/vcs/review.d.ts.map +1 -0
  182. package/dist/src/commands/vcs/review.js +35 -0
  183. package/dist/src/commands/vcs/review.js.map +1 -0
  184. package/dist/src/commands/vcs-enrich.d.ts +15 -0
  185. package/dist/src/commands/vcs-enrich.d.ts.map +1 -0
  186. package/dist/src/commands/vcs-enrich.js +28 -0
  187. package/dist/src/commands/vcs-enrich.js.map +1 -0
  188. package/dist/src/commands/vpn/index.d.ts +24 -0
  189. package/dist/src/commands/vpn/index.d.ts.map +1 -0
  190. package/dist/src/commands/vpn/index.js +543 -0
  191. package/dist/src/commands/vpn/index.js.map +1 -0
  192. package/dist/src/commands/vpn/tunnel.d.ts +11 -0
  193. package/dist/src/commands/vpn/tunnel.d.ts.map +1 -0
  194. package/dist/src/commands/vpn/tunnel.js +188 -0
  195. package/dist/src/commands/vpn/tunnel.js.map +1 -0
  196. package/dist/src/commands/workflow.d.ts +24 -0
  197. package/dist/src/commands/workflow.d.ts.map +1 -0
  198. package/dist/src/commands/workflow.js +577 -0
  199. package/dist/src/commands/workflow.js.map +1 -0
  200. package/dist/src/docs/assemble.d.ts +217 -0
  201. package/dist/src/docs/assemble.d.ts.map +1 -0
  202. package/dist/src/docs/assemble.js +739 -0
  203. package/dist/src/docs/assemble.js.map +1 -0
  204. package/dist/src/docs/cli-reference.d.ts +93 -0
  205. package/dist/src/docs/cli-reference.d.ts.map +1 -0
  206. package/dist/src/docs/cli-reference.js +256 -0
  207. package/dist/src/docs/cli-reference.js.map +1 -0
  208. package/dist/src/docs/discover.d.ts +60 -0
  209. package/dist/src/docs/discover.d.ts.map +1 -0
  210. package/dist/src/docs/discover.js +233 -0
  211. package/dist/src/docs/discover.js.map +1 -0
  212. package/dist/src/docs/portal.d.ts +79 -0
  213. package/dist/src/docs/portal.d.ts.map +1 -0
  214. package/dist/src/docs/portal.js +242 -0
  215. package/dist/src/docs/portal.js.map +1 -0
  216. package/dist/src/docs/registry-docs.d.ts +43 -0
  217. package/dist/src/docs/registry-docs.d.ts.map +1 -0
  218. package/dist/src/docs/registry-docs.js +138 -0
  219. package/dist/src/docs/registry-docs.js.map +1 -0
  220. package/dist/src/docs/schema.d.ts +48 -0
  221. package/dist/src/docs/schema.d.ts.map +1 -0
  222. package/dist/src/docs/schema.js +96 -0
  223. package/dist/src/docs/schema.js.map +1 -0
  224. package/dist/src/docs/serve.d.ts +27 -0
  225. package/dist/src/docs/serve.d.ts.map +1 -0
  226. package/dist/src/docs/serve.js +102 -0
  227. package/dist/src/docs/serve.js.map +1 -0
  228. package/dist/src/docs/start.d.ts +80 -0
  229. package/dist/src/docs/start.d.ts.map +1 -0
  230. package/dist/src/docs/start.js +313 -0
  231. package/dist/src/docs/start.js.map +1 -0
  232. package/dist/src/eval/agent-eval.d.mts +14 -0
  233. package/dist/src/eval/agent-eval.d.mts.map +1 -0
  234. package/dist/src/eval/agent-eval.mjs +273 -0
  235. package/dist/src/eval/agent-eval.mjs.map +1 -0
  236. package/dist/src/index.js +624 -7
  237. package/dist/src/index.js.map +7 -1
  238. package/dist/src/program.d.ts +26 -0
  239. package/dist/src/program.d.ts.map +1 -0
  240. package/dist/src/program.js +76 -0
  241. package/dist/src/program.js.map +1 -0
  242. package/dist/src/utils/auth-preflight.d.ts +159 -0
  243. package/dist/src/utils/auth-preflight.d.ts.map +1 -0
  244. package/dist/src/utils/auth-preflight.js +298 -0
  245. package/dist/src/utils/auth-preflight.js.map +1 -0
  246. package/dist/src/utils/aws-auth.d.ts +181 -0
  247. package/dist/src/utils/aws-auth.d.ts.map +1 -0
  248. package/dist/src/utils/aws-auth.js +527 -0
  249. package/dist/src/utils/aws-auth.js.map +1 -0
  250. package/dist/src/utils/bastion.d.ts +1 -1
  251. package/dist/src/utils/bastion.d.ts.map +1 -1
  252. package/dist/src/utils/bastion.js +29 -8
  253. package/dist/src/utils/bastion.js.map +1 -1
  254. package/dist/src/utils/build-info.d.ts +33 -0
  255. package/dist/src/utils/build-info.d.ts.map +1 -0
  256. package/dist/src/utils/build-info.js +102 -0
  257. package/dist/src/utils/build-info.js.map +1 -0
  258. package/dist/src/utils/capture-history.d.ts +62 -0
  259. package/dist/src/utils/capture-history.d.ts.map +1 -0
  260. package/dist/src/utils/capture-history.js +94 -0
  261. package/dist/src/utils/capture-history.js.map +1 -0
  262. package/dist/src/utils/deploy-preflight.d.ts +52 -0
  263. package/dist/src/utils/deploy-preflight.d.ts.map +1 -0
  264. package/dist/src/utils/deploy-preflight.js +183 -0
  265. package/dist/src/utils/deploy-preflight.js.map +1 -0
  266. package/dist/src/utils/errors.d.ts +75 -0
  267. package/dist/src/utils/errors.d.ts.map +1 -0
  268. package/dist/src/utils/errors.js +106 -0
  269. package/dist/src/utils/errors.js.map +1 -0
  270. package/dist/src/utils/first-party-contexts.d.ts +40 -0
  271. package/dist/src/utils/first-party-contexts.d.ts.map +1 -0
  272. package/dist/src/utils/first-party-contexts.js +53 -0
  273. package/dist/src/utils/first-party-contexts.js.map +1 -0
  274. package/dist/src/utils/index.d.ts +2 -0
  275. package/dist/src/utils/index.d.ts.map +1 -1
  276. package/dist/src/utils/index.js +2 -0
  277. package/dist/src/utils/index.js.map +1 -1
  278. package/dist/src/utils/kubeconfig.d.ts +62 -0
  279. package/dist/src/utils/kubeconfig.d.ts.map +1 -0
  280. package/dist/src/utils/kubeconfig.js +118 -0
  281. package/dist/src/utils/kubeconfig.js.map +1 -0
  282. package/dist/src/utils/log.d.ts +27 -1
  283. package/dist/src/utils/log.d.ts.map +1 -1
  284. package/dist/src/utils/log.js +59 -5
  285. package/dist/src/utils/log.js.map +1 -1
  286. package/dist/src/utils/pid.d.ts +47 -0
  287. package/dist/src/utils/pid.d.ts.map +1 -0
  288. package/dist/src/utils/pid.js +83 -0
  289. package/dist/src/utils/pid.js.map +1 -0
  290. package/dist/src/utils/pulumi-run.d.ts +23 -0
  291. package/dist/src/utils/pulumi-run.d.ts.map +1 -0
  292. package/dist/src/utils/pulumi-run.js +112 -0
  293. package/dist/src/utils/pulumi-run.js.map +1 -0
  294. package/dist/src/utils/pulumi.d.ts +15 -0
  295. package/dist/src/utils/pulumi.d.ts.map +1 -1
  296. package/dist/src/utils/pulumi.js +35 -0
  297. package/dist/src/utils/pulumi.js.map +1 -1
  298. package/dist/src/utils/reachability.d.ts +29 -0
  299. package/dist/src/utils/reachability.d.ts.map +1 -0
  300. package/dist/src/utils/reachability.js +81 -0
  301. package/dist/src/utils/reachability.js.map +1 -0
  302. package/dist/src/utils/recover-conversation.d.ts +154 -0
  303. package/dist/src/utils/recover-conversation.d.ts.map +1 -0
  304. package/dist/src/utils/recover-conversation.js +314 -0
  305. package/dist/src/utils/recover-conversation.js.map +1 -0
  306. package/dist/src/utils/registry-broker.d.ts +148 -0
  307. package/dist/src/utils/registry-broker.d.ts.map +1 -0
  308. package/dist/src/utils/registry-broker.js +180 -0
  309. package/dist/src/utils/registry-broker.js.map +1 -0
  310. package/dist/src/utils/socks-forward.d.ts +40 -0
  311. package/dist/src/utils/socks-forward.d.ts.map +1 -0
  312. package/dist/src/utils/socks-forward.js +97 -0
  313. package/dist/src/utils/socks-forward.js.map +1 -0
  314. package/dist/src/utils/stack-flag.d.ts +23 -0
  315. package/dist/src/utils/stack-flag.d.ts.map +1 -0
  316. package/dist/src/utils/stack-flag.js +41 -0
  317. package/dist/src/utils/stack-flag.js.map +1 -0
  318. package/dist/src/utils/tailscale-targets.d.ts +52 -0
  319. package/dist/src/utils/tailscale-targets.d.ts.map +1 -0
  320. package/dist/src/utils/tailscale-targets.js +93 -0
  321. package/dist/src/utils/tailscale-targets.js.map +1 -0
  322. package/dist/src/utils/tailscale.d.ts +277 -0
  323. package/dist/src/utils/tailscale.d.ts.map +1 -0
  324. package/dist/src/utils/tailscale.js +1007 -0
  325. package/dist/src/utils/tailscale.js.map +1 -0
  326. package/dist/src/utils/temporal-auth.d.ts +43 -0
  327. package/dist/src/utils/temporal-auth.d.ts.map +1 -0
  328. package/dist/src/utils/temporal-auth.js +176 -0
  329. package/dist/src/utils/temporal-auth.js.map +1 -0
  330. package/dist/src/utils/temporal-codec.d.ts +101 -0
  331. package/dist/src/utils/temporal-codec.d.ts.map +1 -0
  332. package/dist/src/utils/temporal-codec.js +319 -0
  333. package/dist/src/utils/temporal-codec.js.map +1 -0
  334. package/dist/src/utils/tunnel-ownership.d.ts +130 -0
  335. package/dist/src/utils/tunnel-ownership.d.ts.map +1 -0
  336. package/dist/src/utils/tunnel-ownership.js +274 -0
  337. package/dist/src/utils/tunnel-ownership.js.map +1 -0
  338. package/dist/src/utils/vpn-join.d.ts +32 -0
  339. package/dist/src/utils/vpn-join.d.ts.map +1 -0
  340. package/dist/src/utils/vpn-join.js +62 -0
  341. package/dist/src/utils/vpn-join.js.map +1 -0
  342. package/dist/src/utils/vpn.d.ts +62 -0
  343. package/dist/src/utils/vpn.d.ts.map +1 -0
  344. package/dist/src/utils/vpn.js +183 -0
  345. package/dist/src/utils/vpn.js.map +1 -0
  346. package/dist/src/utils/workflow-fingerprint.d.ts +49 -0
  347. package/dist/src/utils/workflow-fingerprint.d.ts.map +1 -0
  348. package/dist/src/utils/workflow-fingerprint.js +135 -0
  349. package/dist/src/utils/workflow-fingerprint.js.map +1 -0
  350. package/dist/src/utils/worktree-identity.d.ts +86 -0
  351. package/dist/src/utils/worktree-identity.d.ts.map +1 -0
  352. package/dist/src/utils/worktree-identity.js +164 -0
  353. package/dist/src/utils/worktree-identity.js.map +1 -0
  354. package/fragments/agents/AGENTS.md.hbs +92 -0
  355. package/fragments/agents/CLAUDE.md.hbs +8 -0
  356. package/fragments/base/Pulumi.dev.yaml.hbs +10 -0
  357. package/fragments/base/Pulumi.yaml.hbs +9 -0
  358. package/fragments/base/index.ts.hbs +107 -0
  359. package/fragments/base/package.json.hbs +22 -0
  360. package/{templates/api-web-db/api → fragments/base}/tsconfig.json +3 -4
  361. package/fragments/bucket/api/src/storage.ts.hbs +81 -0
  362. package/fragments/database/api/src/db.ts.hbs +23 -0
  363. package/fragments/database/prisma/schema.prisma.hbs +18 -0
  364. package/{templates/api-web-db → fragments/service}/api/.dockerignore +2 -0
  365. package/fragments/service/api/package.json.hbs +40 -0
  366. package/fragments/service/api/src/index.ts.hbs +58 -0
  367. package/{templates/api-web-db → fragments/service/api}/tsconfig.json +5 -3
  368. package/fragments/temporal/worker/.dockerignore +4 -0
  369. package/fragments/temporal/worker/Dockerfile +15 -0
  370. package/{templates/temporal-api-worker → fragments/temporal}/worker/package.json.hbs +3 -2
  371. package/fragments/temporal/worker/src/activities.ts.hbs +14 -0
  372. package/fragments/temporal/worker/src/worker.ts.hbs +27 -0
  373. package/fragments/temporal/worker/src/workflows.ts.hbs +24 -0
  374. package/fragments/temporal/worker/tsconfig.json +13 -0
  375. package/package.json +35 -21
  376. package/skills/core/SKILL.md +365 -0
  377. package/skills/pull-remote-agent-context/SKILL.md +194 -0
  378. package/stack/docker-compose.hub.yml +134 -0
  379. package/stack/docker-compose.yml +447 -0
  380. package/stack/hub/Dockerfile.api +18 -0
  381. package/stack/hub/Dockerfile.auth +10 -0
  382. package/stack/hub/Dockerfile.ui +10 -0
  383. package/stack/init/01-databases.sql +12 -0
  384. package/stack/observability/blackbox.yml +20 -0
  385. package/stack/observability/otel-collector.yaml +73 -0
  386. package/stack/observability/prometheus.yml +36 -0
  387. package/stack/observability/tempo.yaml +25 -0
  388. package/stack/temporal/local.yaml +44 -0
  389. package/templates/api-auth/AGENTS.md.hbs +16 -0
  390. package/templates/api-auth/Pulumi.dev.yaml.hbs +8 -0
  391. package/templates/api-auth/Pulumi.yaml.hbs +8 -0
  392. package/templates/api-auth/README.md.hbs +67 -0
  393. package/templates/api-auth/api/package.json.hbs +28 -0
  394. package/templates/api-auth/api/src/index.ts.hbs +96 -0
  395. package/templates/api-auth/api/src/schema.ts.hbs +35 -0
  396. package/templates/api-auth/api/src/seed-authz.ts.hbs +48 -0
  397. package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +89 -0
  398. package/templates/api-auth/index.ts.hbs +65 -0
  399. package/templates/api-auth/package.json.hbs +20 -0
  400. package/templates/api-auth/skills/develop/SKILL.md.hbs +32 -0
  401. package/templates/api-role-gating/AGENTS.md.hbs +20 -0
  402. package/templates/api-role-gating/Pulumi.dev.yaml.hbs +8 -0
  403. package/templates/api-role-gating/Pulumi.yaml.hbs +8 -0
  404. package/templates/api-role-gating/README.md.hbs +64 -0
  405. package/templates/api-role-gating/api/Dockerfile +17 -0
  406. package/templates/api-role-gating/api/package.json.hbs +28 -0
  407. package/templates/api-role-gating/api/src/authn.ts.hbs +29 -0
  408. package/templates/api-role-gating/api/src/authz.ts.hbs +12 -0
  409. package/templates/api-role-gating/api/src/main.ts.hbs +58 -0
  410. package/templates/api-role-gating/api/src/schema.ts.hbs +22 -0
  411. package/templates/api-role-gating/api/tsconfig.json +13 -0
  412. package/templates/api-role-gating/index.ts.hbs +107 -0
  413. package/templates/api-role-gating/package.json.hbs +21 -0
  414. package/templates/api-role-gating/skills/develop/SKILL.md.hbs +60 -0
  415. package/templates/api-role-gating/tsconfig.json +10 -0
  416. package/templates/apps-repo/README.md.hbs +24 -0
  417. package/templates/apps-repo/gitignore +41 -0
  418. package/templates/apps-repo/npmrc.hbs +7 -0
  419. package/templates/apps-repo/package.json.hbs +15 -0
  420. package/templates/apps-repo/pnpm-workspace.yaml +3 -0
  421. package/templates/apps-repo/tsconfig.json +18 -0
  422. package/templates/external-service/AGENTS.md.hbs +20 -0
  423. package/templates/external-service/Pulumi.dev.yaml.hbs +8 -0
  424. package/templates/external-service/Pulumi.yaml.hbs +8 -0
  425. package/templates/external-service/README.md.hbs +56 -0
  426. package/templates/external-service/api/Dockerfile +15 -0
  427. package/templates/external-service/api/package.json.hbs +25 -0
  428. package/templates/external-service/api/src/index.ts.hbs +81 -0
  429. package/templates/external-service/index.ts.hbs +82 -0
  430. package/templates/external-service/mocks/vendorpay/package.json.hbs +9 -0
  431. package/templates/external-service/mocks/vendorpay/server.mjs.hbs +61 -0
  432. package/templates/external-service/package.json.hbs +34 -0
  433. package/templates/external-service/skills/develop/SKILL.md.hbs +55 -0
  434. package/templates/external-service/tsconfig.json +10 -0
  435. package/templates/platform-repo/AGENTS.md.hbs +18 -0
  436. package/templates/platform-repo/README.md.hbs +38 -0
  437. package/templates/platform-repo/core/Pulumi.yaml.hbs +12 -0
  438. package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +57 -0
  439. package/templates/platform-repo/core/index.ts +62 -0
  440. package/templates/platform-repo/core/package.json.hbs +22 -0
  441. package/templates/platform-repo/core/tsconfig.json +8 -0
  442. package/templates/platform-repo/package.json.hbs +15 -0
  443. package/templates/platform-repo/platform/Pulumi.yaml.hbs +12 -0
  444. package/templates/platform-repo/platform/Pulumi.{{tenant}}-{{env}}.yaml.hbs +59 -0
  445. package/templates/platform-repo/platform/index.ts +95 -0
  446. package/templates/platform-repo/platform/package.json.hbs +26 -0
  447. package/templates/platform-repo/platform/tsconfig.json +8 -0
  448. package/templates/platform-repo/pnpm-workspace.yaml +3 -0
  449. package/templates/workflow/AGENTS.md.hbs +15 -0
  450. package/templates/workflow/Pulumi.dev.yaml.hbs +8 -0
  451. package/templates/workflow/Pulumi.yaml.hbs +8 -0
  452. package/templates/workflow/README.md.hbs +45 -0
  453. package/templates/workflow/api/.dockerignore +4 -0
  454. package/templates/workflow/api/Dockerfile +15 -0
  455. package/templates/{temporal-api-worker → workflow}/api/package.json.hbs +8 -6
  456. package/templates/workflow/api/src/index.ts.hbs +130 -0
  457. package/templates/workflow/api/tsconfig.json +14 -0
  458. package/templates/workflow/index.ts.hbs +67 -0
  459. package/templates/workflow/package.json.hbs +20 -0
  460. package/templates/workflow/skills/develop/SKILL.md.hbs +32 -0
  461. package/templates/workflow/tsconfig.json +10 -0
  462. package/templates/workflow/worker/.dockerignore +4 -0
  463. package/templates/workflow/worker/Dockerfile +13 -0
  464. package/templates/workflow/worker/package.json.hbs +25 -0
  465. package/templates/workflow/worker/src/worker.ts.hbs +24 -0
  466. package/templates/workflow/worker/tsconfig.json +14 -0
  467. package/dist/src/commands/devbox.d.ts +0 -22
  468. package/dist/src/commands/devbox.d.ts.map +0 -1
  469. package/dist/src/commands/devbox.js +0 -463
  470. package/dist/src/commands/devbox.js.map +0 -1
  471. package/templates/api-web-db/api/Dockerfile +0 -16
  472. package/templates/api-web-db/api/package.json.hbs +0 -21
  473. package/templates/api-web-db/api/src/index.ts.hbs +0 -123
  474. package/templates/api-web-db/package.json.hbs +0 -27
  475. package/templates/api-web-db/sst.config.ts.hbs +0 -151
  476. package/templates/api-web-db/web/.dockerignore +0 -3
  477. package/templates/api-web-db/web/Dockerfile +0 -17
  478. package/templates/api-web-db/web/app/lib/db.server.ts +0 -36
  479. package/templates/api-web-db/web/app/root.tsx +0 -29
  480. package/templates/api-web-db/web/app/routes/home.tsx.hbs +0 -114
  481. package/templates/api-web-db/web/app/routes.ts +0 -3
  482. package/templates/api-web-db/web/package.json.hbs +0 -30
  483. package/templates/api-web-db/web/prisma/schema.prisma +0 -25
  484. package/templates/api-web-db/web/prisma.config.ts +0 -18
  485. package/templates/api-web-db/web/react-router.config.ts +0 -5
  486. package/templates/api-web-db/web/tsconfig.json +0 -19
  487. package/templates/api-web-db/web/vite.config.ts +0 -9
  488. package/templates/temporal-api-worker/README.md.hbs +0 -133
  489. package/templates/temporal-api-worker/api/src/index.ts.hbs +0 -145
  490. package/templates/temporal-api-worker/package.json.hbs +0 -29
  491. package/templates/temporal-api-worker/sst.config.ts.hbs +0 -161
  492. package/templates/temporal-api-worker/worker/Dockerfile +0 -11
  493. package/templates/temporal-api-worker/worker/src/bundle-workflows.ts +0 -38
  494. package/templates/temporal-api-worker/worker/src/worker.ts.hbs +0 -93
  495. /package/templates/{temporal-api-worker → api-auth}/api/.dockerignore +0 -0
  496. /package/templates/{temporal-api-worker → api-auth}/api/Dockerfile +0 -0
  497. /package/templates/{temporal-api-worker → api-auth}/api/tsconfig.json +0 -0
  498. /package/templates/{temporal-api-worker → api-auth}/tsconfig.json +0 -0
  499. /package/templates/{temporal-api-worker/worker → api-role-gating/api}/.dockerignore +0 -0
  500. /package/templates/{temporal-api-worker/worker → external-service/api}/tsconfig.json +0 -0
  501. /package/templates/{temporal-api-worker → workflow}/api/src/encryption-codec.ts +0 -0
  502. /package/templates/{temporal-api-worker → workflow}/worker/src/activities.ts.hbs +0 -0
  503. /package/templates/{temporal-api-worker → workflow}/worker/src/encryption-codec.ts +0 -0
  504. /package/templates/{temporal-api-worker → workflow}/worker/src/workflows.ts.hbs +0 -0
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * mesh dev — Pulumi-output-driven local development orchestrator
3
3
  *
4
- * Reads `pulumi stack output dev --json` to discover services and tunnels,
4
+ * Reads `pulumi stack output dev --json` to discover services and VPC endpoints,
5
5
  * then launches each service's dev command in a tmux window.
6
+ * Requires VPN connection for direct access to VPC resources.
6
7
  *
7
8
  * Usage:
8
9
  * mesh dev Start all services, attach tmux
@@ -12,15 +13,82 @@
12
13
  * mesh dev logs <service> Tail a service's output
13
14
  * mesh dev restart <svc> Restart a single service
14
15
  */
15
- import { execFileSync, spawnSync } from "child_process";
16
- import * as fs from "fs";
17
- import * as net from "net";
18
- import * as os from "os";
19
- import * as path from "path";
20
- import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
21
- import { logInfo, logSuccess, logError, logWarn } from "../utils/index.js";
16
+ import { execFileSync, spawn, spawnSync } from 'child_process';
17
+ import * as fs from 'fs';
18
+ import * as net from 'net';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { Option } from 'commander';
22
+ import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
23
+ import { logInfo, logSuccess, logError, logWarn, isVpnConnected, getTailscaleInfo, headscaleDnsConfig, registerDnsRecords, unregisterDnsRecords, readDnsRecords, getPlatformBastionInfo, } from '../utils/index.js';
24
+ import { derivePlatformContext, renderCredentialProcessProfile, resolveAwsCredentials, resolveStableMeshBin, selectRoleForCaller, } from '../utils/aws-auth.js';
25
+ import { readCredentials, getContextConfig, probeCredentials, getValidToken } from './login.js';
26
+ import { resolveVpnJoinBroker, mintPreAuthKey } from '../utils/vpn-join.js';
27
+ import { credProbeToPreflightError } from '../utils/pulumi-run.js';
28
+ import { startTailscaleTunnels, tailscaleAvailable, daemonState as tailscaleDaemonState, readTunnelState as readTailscaleState, readDaemonMeta as readTailscaleDaemonMeta, } from '../utils/tailscale.js';
29
+ import { pulumiStackOutput, readStackConfig } from '../utils/pulumi.js';
30
+ import { ensureKubeconfig, resolveHubPlatformName, sessionKubeconfigPath, } from '../utils/kubeconfig.js';
31
+ import { resolveTemporalAuth } from '../utils/temporal-auth.js';
32
+ import { probeTcpReachable, probeConnectionHolds } from '../utils/reachability.js';
33
+ import { fingerprintWorkflowSource } from '../utils/workflow-fingerprint.js';
34
+ import { buildLaunchCommand, envFileName, waitForPort, writeEnvFile } from './dev-launch.js';
35
+ import { buildLocalDevOutput, detectLocalTenant, ensureLocalPlatformRunning, hasStackBacking, localAppNamespace } from './local/dev-local.js';
36
+ import { ensureAppTenantAuth, registerLocalApp } from './local/auth-provision.js';
37
+ import { localAwsEnv, ensureTemporalNamespace } from './local/seed.js';
38
+ import { composeExternalUp, composeExternalsDown, externalMode, localProbesRemove, parseExternalsSelection, readLocalMocks, seedLocalMock, } from './local/mocks.js';
39
+ import { logShipperPath } from './local/dev-local.js';
40
+ import { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs, dockerDevRestart } from './local/docker-runner.js';
41
+ import { MeshCliError } from '../utils/errors.js';
42
+ import { resolveStackOption } from '../utils/stack-flag.js';
43
+ import { rewriteClusterHostsToLocal } from './peer-addressing.js';
44
+ import { ALL_CHECKS, aggregateStatus, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
45
+ import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
46
+ /**
47
+ * tmux session name for a dev launch. The primary checkout keeps the historical
48
+ * `${projectName}-dev`; linked worktrees get a `${projectName}-${slug}` so two
49
+ * worktrees of the same app don't share (and kill) each other's session, state,
50
+ * and env-file dir (MESH-2013).
51
+ */
52
+ function deriveSessionName(projectName, wt) {
53
+ return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
54
+ }
55
+ /**
56
+ * Render the resolved per-worktree plan (`mesh dev --dry-run`): identity,
57
+ * session/state/env paths, and the port assignment — everything that must be
58
+ * collision-free across concurrent worktrees, without launching anything.
59
+ */
60
+ function printDevPlan(sessionName, appRoot, wt, devOutput) {
61
+ const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
62
+ console.log(`\nmesh dev plan — ${kind}\n`);
63
+ console.log(` worktree root : ${wt.worktreeRoot}`);
64
+ console.log(` app root : ${appRoot}`);
65
+ console.log(` token : ${wt.token || '(none — primary)'}`);
66
+ console.log(` tmux session : ${sessionName}`);
67
+ console.log(` session state : ${getSessionStatePath(sessionName)}`);
68
+ console.log(` env dir : ${getSessionEnvDir(sessionName)}`);
69
+ const tq = wt.taskQueueSuffix
70
+ ? `<app-task-queue>${wt.taskQueueSuffix}`
71
+ : '<app-task-queue> (primary — unchanged)';
72
+ console.log(` task queue : ${tq}`);
73
+ console.log(`\n services (port · resolved source dir):`);
74
+ const locals = Object.entries(devOutput.services).filter(([, s]) => (s.port ?? 0) >= 0 && s.command && s.command.length > 0);
75
+ if (locals.length === 0) {
76
+ console.log(` (none — all services are deployed/K8s)`);
77
+ }
78
+ else {
79
+ const monorepoRoot = findMonorepoRoot();
80
+ const wtWithSep = wt.worktreeRoot.endsWith(path.sep) ? wt.worktreeRoot : wt.worktreeRoot + path.sep;
81
+ for (const [name, s] of locals) {
82
+ const dir = path.resolve(appRoot, rebaseServiceSrc(s.src, monorepoRoot));
83
+ const outside = dir !== wt.worktreeRoot && !dir.startsWith(wtWithSep);
84
+ const port = s.port > 0 ? `:${s.port}` : '(no port)';
85
+ console.log(` ${name.padEnd(18)} ${port.padEnd(7)} ${dir}${outside ? ' ⚠ OUTSIDE worktree' : ''}`);
86
+ }
87
+ }
88
+ console.log('');
89
+ }
22
90
  function getSessionStatePath(sessionName) {
23
- const dir = path.join(os.tmpdir(), "mesh-dev-sessions");
91
+ const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');
24
92
  if (!fs.existsSync(dir))
25
93
  fs.mkdirSync(dir, { recursive: true });
26
94
  return path.join(dir, `${sessionName}.json`);
@@ -33,44 +101,101 @@ function loadSessionState(sessionName) {
33
101
  if (!fs.existsSync(filePath))
34
102
  return null;
35
103
  try {
36
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
104
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
37
105
  }
38
106
  catch {
39
107
  return null;
40
108
  }
41
109
  }
110
+ /**
111
+ * Relaunch safety (MESH-2017): if the workflow source changed since this session
112
+ * launched, restarting the worker replays any in-flight conversations against the
113
+ * NEW code — a replay-incompatible change strands them. Warn and require `--force`.
114
+ *
115
+ * Silent no-op when there's no prior session, no stored fingerprint (pre-feature
116
+ * session), or nothing changed — so a restart of unchanged code never nags.
117
+ * Returns `true` if the caller should ABORT (changed && !force).
118
+ */
119
+ /** Pure: did the workflow source change since launch (→ risk of stranding)? */
120
+ export function workflowChanged(prior, current) {
121
+ return Boolean(prior) && Boolean(current) && current !== prior;
122
+ }
123
+ function workflowChangeWouldStrand(priorState, appRoot, force) {
124
+ const prior = priorState?.workflowFingerprint;
125
+ if (!prior)
126
+ return false;
127
+ const current = fingerprintWorkflowSource(appRoot, priorState?.devOutput?.services);
128
+ if (!workflowChanged(prior, current))
129
+ return false;
130
+ logWarn('Workflow code changed since this session started.\n' +
131
+ ' Restarting the worker replays any in-flight conversations against the NEW code —\n' +
132
+ ' a replay-incompatible change strands them (they get stuck and become unviewable).\n' +
133
+ ' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\n' +
134
+ ' goldens (see the temporal-workflow-safety skill), or accept the risk.');
135
+ if (force) {
136
+ logWarn(' Proceeding anyway (--force).');
137
+ return false;
138
+ }
139
+ logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
140
+ return true;
141
+ }
142
+ /**
143
+ * Per-session directory holding one sourceable env file per service
144
+ * (`<service>.env.sh`, mode 0600). Written at launch; `mesh dev restart`
145
+ * sources the same file so a restarted service gets a byte-identical env.
146
+ */
147
+ function getSessionEnvDir(sessionName) {
148
+ return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
149
+ }
150
+ function getServiceEnvFilePath(sessionName, serviceName) {
151
+ return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));
152
+ }
42
153
  function removeSessionState(sessionName) {
43
154
  try {
44
155
  fs.unlinkSync(getSessionStatePath(sessionName));
45
156
  }
46
157
  catch { }
158
+ try {
159
+ fs.rmSync(getSessionEnvDir(sessionName), { recursive: true, force: true });
160
+ }
161
+ catch { }
47
162
  }
48
163
  /**
49
- * Check whether a TCP port is available on localhost.
164
+ * Check whether a TCP port is available probing the way services actually bind.
165
+ *
166
+ * This used to bind `127.0.0.1` only, and that made it blind to exactly the
167
+ * processes it exists to find. Node's `listen(port)` (and `@hono/node-server`,
168
+ * which every agent-api uses) binds the WILDCARD address `::` in dual-stack mode,
169
+ * holding the port on every interface — yet a probe bound to `127.0.0.1` still
170
+ * succeeds against such a holder. So the check returned `true` for a port already
171
+ * held by a prior `mesh dev` service, `allocatePorts` kept the preferred port, and
172
+ * the service then died on `EADDRINUSE` at its real bind.
173
+ *
174
+ * Observed 2026-07-31: the `studio-api` pane died on `EADDRINUSE :::40161` while a
175
+ * stale agent-api from an earlier boot kept serving on the same port, so the Studio
176
+ * UI drove a process from a previous session's configuration and the conversation
177
+ * list came back empty. Reproduced in isolation — holder on `::`, loopback probe
178
+ * reports free, real bind fails (MESH-2590).
179
+ *
180
+ * Both addresses are probed: the wildcard catches all-interface holders, and the
181
+ * loopback probe still catches a loopback-only squatter. A false "busy" is cheap —
182
+ * `allocatePorts` simply picks another port — while a false "free" crashes the
183
+ * service, so this deliberately errs toward busy.
50
184
  */
51
- async function isPortFree(port) {
52
- return new Promise((resolve) => {
185
+ export async function isPortFree(port) {
186
+ const bindSucceeds = (host) => new Promise((resolve) => {
53
187
  const server = net.createServer();
54
- server.once("error", () => resolve(false));
55
- server.listen(port, "127.0.0.1", () => {
56
- server.close(() => resolve(true));
57
- });
188
+ server.once('error', () => resolve(false));
189
+ const onListening = () => server.close(() => resolve(true));
190
+ if (host === undefined)
191
+ server.listen(port, onListening);
192
+ else
193
+ server.listen(port, host, onListening);
58
194
  });
59
- }
60
- /**
61
- * Check if a port is being listened on (synchronous, for status display).
62
- */
63
- function isPortListeningSync(port) {
64
- try {
65
- execFileSync("bash", ["-c", `echo >/dev/tcp/127.0.0.1/${port}`], {
66
- timeout: 500,
67
- stdio: "ignore",
68
- });
69
- return true;
70
- }
71
- catch {
195
+ // Wildcard first: it is how the services bind, so it is the probe that matters.
196
+ if (!(await bindSucceeds()))
72
197
  return false;
73
- }
198
+ return bindSucceeds('127.0.0.1');
74
199
  }
75
200
  /**
76
201
  * Ask the OS for an ephemeral free port.
@@ -78,79 +203,203 @@ function isPortListeningSync(port) {
78
203
  async function findFreePort() {
79
204
  return new Promise((resolve, reject) => {
80
205
  const server = net.createServer();
81
- server.once("error", reject);
82
- server.listen(0, "127.0.0.1", () => {
206
+ server.once('error', reject);
207
+ server.listen(0, '127.0.0.1', () => {
83
208
  const { port } = server.address();
84
209
  server.close(() => resolve(port));
85
210
  });
86
211
  });
87
212
  }
213
+ /** Resolve true if something is accepting connections on 127.0.0.1:port. */
214
+ function isPortListening(port) {
215
+ return new Promise((resolve) => {
216
+ const s = new net.Socket();
217
+ let done = false;
218
+ const fin = (ok) => {
219
+ if (done)
220
+ return;
221
+ done = true;
222
+ s.destroy();
223
+ resolve(ok);
224
+ };
225
+ s.setTimeout(300);
226
+ s.once('connect', () => fin(true));
227
+ s.once('timeout', () => fin(false));
228
+ s.once('error', () => fin(false));
229
+ s.connect(port, '127.0.0.1');
230
+ });
231
+ }
88
232
  /**
89
- * Clone devOutput and replace any colliding ports with dynamically
90
- * allocated free ports. Preferred ports are kept when available.
233
+ * Rewrite a service's OWN listen-port env var(s) from `oldPort` to `newPort`
234
+ * when `allocatePorts` reassigns its port (e.g. a worktree block). Matches any
235
+ * port-ish var (`PORT` or `*_PORT`, e.g. `VCS_PORT`/`AGENT_API_PORT`) whose value
236
+ * is exactly the old port — so a service that reads its own env var still binds
237
+ * the reassigned port instead of its deploy-time default (which would collide
238
+ * with another worktree/session on that default). The name + exact-value match
239
+ * keeps this from touching cross-service references or unrelated vars.
240
+ * Mutates `service.env`. Pure otherwise — exported for unit testing. (MESH-2088)
91
241
  */
92
- async function allocatePorts(devOutput) {
93
- const allocated = JSON.parse(JSON.stringify(devOutput));
94
- const usedPorts = new Set();
95
- // Tunnels first (services may depend on them)
96
- for (const [name, tunnel] of Object.entries(allocated.tunnels)) {
97
- const preferred = tunnel.localPort;
98
- if (preferred > 0 &&
99
- !usedPorts.has(preferred) &&
100
- (await isPortFree(preferred))) {
101
- usedPorts.add(preferred);
102
- }
103
- else {
104
- const newPort = await findFreePort();
105
- if (preferred > 0)
106
- logWarn(`Port ${preferred} busy for tunnel '${name}', using ${newPort}`);
107
- tunnel.localPort = newPort;
108
- usedPorts.add(newPort);
242
+ export function rewriteOwnServicePort(service, oldPort, newPort) {
243
+ for (const [key, spec] of Object.entries(service.env ?? {})) {
244
+ if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {
245
+ spec.value = String(newPort);
109
246
  }
110
247
  }
111
- // Service ports — track remappings for cross-service URL rewriting
248
+ }
249
+ /**
250
+ * Clone devOutput and assign each service a local port.
251
+ *
252
+ * The PRIMARY checkout keeps the app's preferred ports (falling back to an
253
+ * OS-ephemeral port only if one is busy). LINKED worktrees draw from a reserved
254
+ * port block keyed on (worktree, app) — MESH-2013 for the worktree axis, #2889
255
+ * for the app axis — so concurrent sessions don't collide, where the
256
+ * `EADDRINUSE on 8080/8081/9000` class only happened because every worktree
257
+ * reused the same preferred ports.
258
+ *
259
+ * Disjoint by DEFAULT, not by construction: the block is a hash bucket, so two
260
+ * (worktree, app) pairs can share one. The in-block scan below plus the
261
+ * ephemeral fallback are the backstop; MESH-2375 tracks making it deterministic.
262
+ */
263
+ async function allocatePorts(devOutput, worktree) {
264
+ const allocated = JSON.parse(JSON.stringify(devOutput));
265
+ const usedPorts = new Set();
112
266
  const portRemaps = new Map();
267
+ const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);
268
+ let blockCursor = 0; // next unused index within the service sub-range
113
269
  for (const [name, service] of Object.entries(allocated.services)) {
114
270
  if (service.port <= 0)
115
271
  continue;
116
- // Skip port allocation for deployed services (no dev command).
117
- // They run in K8s and are accessed via tunnels, not local ports.
272
+ // Skip deployed services (no dev command) — they run in K8s, no local port.
118
273
  if (!service.command || service.command.length === 0)
119
274
  continue;
120
275
  const preferred = service.port;
121
- if (!usedPorts.has(preferred) &&
122
- (await isPortFree(preferred))) {
123
- usedPorts.add(preferred);
276
+ let target = -1;
277
+ if (worktree.isPrimary) {
278
+ if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {
279
+ target = preferred;
280
+ }
281
+ else {
282
+ target = await findFreePort();
283
+ logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);
284
+ }
124
285
  }
125
286
  else {
126
- const newPort = await findFreePort();
127
- logWarn(`Port ${preferred} busy for service '${name}', using ${newPort}`);
128
- // Update PORT env var to match
129
- if (service.env?.PORT && service.env.PORT.value === String(preferred)) {
130
- service.env.PORT.value = String(newPort);
287
+ // Reserved block: next free port in [blockBase, blockBase+SERVICE_SUBRANGE).
288
+ for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
289
+ const candidate = blockBase + i;
290
+ if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
291
+ target = candidate;
292
+ blockCursor = i + 1;
293
+ break;
294
+ }
131
295
  }
132
- portRemaps.set(preferred, newPort);
133
- service.port = newPort;
134
- usedPorts.add(newPort);
296
+ if (target < 0) {
297
+ // Block exhausted or contended — degrade to an OS-ephemeral port.
298
+ target = await findFreePort();
299
+ logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
300
+ }
301
+ }
302
+ if (target !== preferred) {
303
+ // Rewrite the service's OWN port env var(s) — not just a var literally
304
+ // named `PORT`. Without this, a service reading `VCS_PORT`/`AGENT_API_PORT`
305
+ // keeps its deploy-time default and collides across worktrees (MESH-2088).
306
+ rewriteOwnServicePort(service, preferred, target);
307
+ portRemaps.set(preferred, target);
308
+ service.port = target;
135
309
  }
310
+ usedPorts.add(target);
136
311
  }
137
312
  // Cross-service URL rewriting: when a service port is reassigned,
138
313
  // update any env var in other services that references the old port
139
314
  // (e.g., API_URL from a link() pointing to the API's original port).
140
- if (portRemaps.size > 0) {
141
- for (const service of Object.values(allocated.services)) {
142
- if (!service.env)
143
- continue;
144
- for (const envVar of Object.values(service.env)) {
145
- for (const [oldPort, newPort] of portRemaps) {
146
- if (envVar.value.includes(`localhost:${oldPort}`)) {
147
- envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);
148
- }
315
+ rewriteCrossServicePorts(allocated, portRemaps);
316
+ rewriteClusterHostsToLocal(allocated);
317
+ return allocated;
318
+ }
319
+ /**
320
+ * Rewrite `localhost:${oldPort}` → `localhost:${newPort}` in EVERY service's env
321
+ * values, for each `oldPort → newPort` entry in `portRemaps`. Mutates the given
322
+ * `devOutput` in place — callers pass a clone they own, so this stays safe.
323
+ *
324
+ * Shared by `allocatePorts` (launch-time reallocation) and `mergePinnedPorts`
325
+ * (refresh-time re-pinning) so both apply identical cross-service rewrites.
326
+ */
327
+ function rewriteCrossServicePorts(devOutput, portRemaps) {
328
+ if (portRemaps.size === 0)
329
+ return;
330
+ for (const service of Object.values(devOutput.services)) {
331
+ if (!service.env)
332
+ continue;
333
+ for (const envVar of Object.values(service.env)) {
334
+ for (const [oldPort, newPort] of portRemaps) {
335
+ if (envVar.value.includes(`localhost:${oldPort}`)) {
336
+ envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);
149
337
  }
150
338
  }
151
339
  }
152
340
  }
153
- return allocated;
341
+ }
342
+ /**
343
+ * Merge a session's PINNED ports/tunnels into a freshly-read DevOutput.
344
+ *
345
+ * `mesh dev restart <svc> --refresh-env` re-reads `pulumi stack output` to pick
346
+ * up config/deploy changes, but the running session already allocated local
347
+ * ports (`allocatePorts`) and opened SSM tunnels. Re-reading the raw stack
348
+ * output would carry the DEPLOYED (pre-allocation) ports, so writing those into
349
+ * the env file would point the service — and its peers — at the wrong ports and
350
+ * break the live tunnels.
351
+ *
352
+ * This returns `fresh` (so new/changed non-port env values win) but overrides:
353
+ * - EVERY service present in both `fresh` and `pinned` keeps its pinned/
354
+ * allocated PORT (both `service.port` and `service.env.PORT.value`) — not
355
+ * just the refreshed one, so a regenerated service's peer references land on
356
+ * the session-allocated ports, and
357
+ * - every tunnel present in `pinned` with the pinned host:port (the live SSM
358
+ * endpoints); tunnels that exist only in `fresh` survive untouched.
359
+ *
360
+ * It then re-applies BOTH of the peer-addressing rewrites `allocatePorts` does —
361
+ * there are two now, and naming only one here is how they drift apart:
362
+ * - PORT: for each service whose port differs between `fresh` (preferred) and
363
+ * `pinned` (allocated), `localhost:${freshPort}` → `localhost:${pinnedPort}`
364
+ * across EVERY service's env (e.g. a `ui`'s `API_URL` pointing at `api`).
365
+ * - HOST: an in-cluster address for a service running LOCALLY becomes its local
366
+ * one. `pulumi stack output` carries cluster hosts verbatim, so a refresh
367
+ * without this restores exactly the value MESH-2597 exists to remove.
368
+ *
369
+ * Pure: deep-clones and never mutates its arguments.
370
+ */
371
+ export function mergePinnedPorts(fresh, pinned) {
372
+ const merged = JSON.parse(JSON.stringify(fresh));
373
+ // Pinned tunnels win (live SSM/VPC endpoints); fresh-only tunnels survive.
374
+ const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {}));
375
+ merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };
376
+ // Pin EVERY service's allocated port (never re-allocate). Track fresh→pinned
377
+ // port remaps so peer references (localhost:<freshPort>) can be rewritten.
378
+ const portRemaps = new Map();
379
+ for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {
380
+ const pinnedSvc = pinned.services?.[name];
381
+ if (!pinnedSvc || typeof pinnedSvc.port !== 'number')
382
+ continue;
383
+ const freshPort = mergedSvc.port;
384
+ mergedSvc.port = pinnedSvc.port;
385
+ if (mergedSvc.env?.PORT) {
386
+ mergedSvc.env.PORT.value = String(pinnedSvc.port);
387
+ }
388
+ if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {
389
+ portRemaps.set(freshPort, pinnedSvc.port);
390
+ }
391
+ }
392
+ // Re-apply cross-service URL rewrite (mirrors allocatePorts): peer references
393
+ // to a preferred port become references to the session-allocated port.
394
+ rewriteCrossServicePorts(merged, portRemaps);
395
+ // BOTH rewrites, or `--refresh-env` undoes the other one. This path re-reads
396
+ // `pulumi stack output`, which carries in-cluster hosts verbatim, so without
397
+ // this a refresh restores `AGENT_API_INTERNAL_URL` to
398
+ // `http://studio-api.<ns>.svc.cluster.local:8080` for a service running locally
399
+ // — reintroducing MESH-2597 on the command whose entire purpose is refreshing
400
+ // env.
401
+ rewriteClusterHostsToLocal(merged);
402
+ return merged;
154
403
  }
155
404
  // ============================================================================
156
405
  // Pulumi helpers
@@ -164,12 +413,12 @@ function findMonorepoRoot() {
164
413
  const root = path.parse(dir).root;
165
414
  while (dir !== root) {
166
415
  // Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)
167
- if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml")) ||
168
- fs.existsSync(path.join(dir, "turbo.json"))) {
416
+ if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||
417
+ fs.existsSync(path.join(dir, 'turbo.json'))) {
169
418
  return dir;
170
419
  }
171
420
  // .git as fallback — but only if it's a directory (not a worktree file)
172
- const gitPath = path.join(dir, ".git");
421
+ const gitPath = path.join(dir, '.git');
173
422
  if (fs.existsSync(gitPath)) {
174
423
  return dir;
175
424
  }
@@ -190,10 +439,8 @@ function discoverApps(monorepoRoot) {
190
439
  const projectName = getProjectName(appDir);
191
440
  const stacks = fs
192
441
  .readdirSync(appDir)
193
- .filter((f) => f.startsWith("Pulumi.") &&
194
- f.endsWith(".yaml") &&
195
- f !== "Pulumi.yaml")
196
- .map((f) => f.replace(/^Pulumi\./, "").replace(/\.yaml$/, ""));
442
+ .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml')
443
+ .map((f) => f.replace(/^Pulumi\./, '').replace(/\.yaml$/, ''));
197
444
  apps.push({
198
445
  name: projectName,
199
446
  tenant,
@@ -203,42 +450,40 @@ function discoverApps(monorepoRoot) {
203
450
  });
204
451
  }
205
452
  // 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/
206
- const searchDirs = ["tenants", "tests/tenants"];
453
+ const searchDirs = ['tenants', 'tests/tenants'];
207
454
  for (const searchDir of searchDirs) {
208
455
  const base = path.join(monorepoRoot, searchDir);
209
456
  if (!fs.existsSync(base))
210
457
  continue;
211
458
  for (const tenant of fs.readdirSync(base)) {
212
- const appsDir = path.join(base, tenant, "apps");
459
+ const appsDir = path.join(base, tenant, 'apps');
213
460
  if (!fs.existsSync(appsDir) || !fs.statSync(appsDir).isDirectory())
214
461
  continue;
215
462
  for (const app of fs.readdirSync(appsDir)) {
216
463
  const appDir = path.join(appsDir, app);
217
- if (!fs.existsSync(path.join(appDir, "Pulumi.yaml")))
464
+ if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
218
465
  continue;
219
466
  addApp(appDir, tenant);
220
467
  }
221
468
  }
222
469
  }
223
470
  // 2. Flat layout: apps/<app>/ (tenant read from first stack config)
224
- const flatAppsDir = path.join(monorepoRoot, "apps");
471
+ const flatAppsDir = path.join(monorepoRoot, 'apps');
225
472
  if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {
226
473
  for (const app of fs.readdirSync(flatAppsDir)) {
227
474
  const appDir = path.join(flatAppsDir, app);
228
475
  if (!fs.statSync(appDir).isDirectory())
229
476
  continue;
230
- if (!fs.existsSync(path.join(appDir, "Pulumi.yaml")))
477
+ if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
231
478
  continue;
232
479
  // Read tenant from first stack config's mesh:tenant
233
- let tenant = "unknown";
480
+ let tenant = 'unknown';
234
481
  const stackConfigs = fs
235
482
  .readdirSync(appDir)
236
- .filter((f) => f.startsWith("Pulumi.") &&
237
- f.endsWith(".yaml") &&
238
- f !== "Pulumi.yaml");
483
+ .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');
239
484
  if (stackConfigs.length > 0) {
240
485
  try {
241
- const content = fs.readFileSync(path.join(appDir, stackConfigs[0]), "utf-8");
486
+ const content = fs.readFileSync(path.join(appDir, stackConfigs[0]), 'utf-8');
242
487
  const tenantMatch = content.match(/mesh:tenant:\s*(\S+)/);
243
488
  if (tenantMatch)
244
489
  tenant = tenantMatch[1];
@@ -257,13 +502,13 @@ function findAppRoot(appPath) {
257
502
  if (appPath) {
258
503
  // Resolve relative to cwd or monorepo root
259
504
  const resolved = path.resolve(appPath);
260
- if (fs.existsSync(path.join(resolved, "Pulumi.yaml")))
505
+ if (fs.existsSync(path.join(resolved, 'Pulumi.yaml')))
261
506
  return resolved;
262
507
  // Try relative to monorepo root
263
508
  const mono = findMonorepoRoot();
264
509
  if (mono) {
265
510
  const fromMono = path.resolve(mono, appPath);
266
- if (fs.existsSync(path.join(fromMono, "Pulumi.yaml")))
511
+ if (fs.existsSync(path.join(fromMono, 'Pulumi.yaml')))
267
512
  return fromMono;
268
513
  }
269
514
  logError(`No Pulumi.yaml found at: ${appPath}`);
@@ -272,7 +517,7 @@ function findAppRoot(appPath) {
272
517
  let dir = process.cwd();
273
518
  const root = path.parse(dir).root;
274
519
  while (dir !== root) {
275
- if (fs.existsSync(path.join(dir, "Pulumi.yaml"))) {
520
+ if (fs.existsSync(path.join(dir, 'Pulumi.yaml'))) {
276
521
  return dir;
277
522
  }
278
523
  dir = path.dirname(dir);
@@ -283,10 +528,10 @@ function findAppRoot(appPath) {
283
528
  * Read the Pulumi project name from Pulumi.yaml.
284
529
  */
285
530
  function getProjectName(appRoot) {
286
- const yamlPath = path.join(appRoot, "Pulumi.yaml");
531
+ const yamlPath = path.join(appRoot, 'Pulumi.yaml');
287
532
  if (!fs.existsSync(yamlPath))
288
533
  return path.basename(appRoot);
289
- const content = fs.readFileSync(yamlPath, "utf-8");
534
+ const content = fs.readFileSync(yamlPath, 'utf-8');
290
535
  const match = content.match(/^name:\s*(.+)$/m);
291
536
  return match?.[1]?.trim() ?? path.basename(appRoot);
292
537
  }
@@ -303,10 +548,10 @@ function detectStack(appRoot, stageArg) {
303
548
  if (process.env.MESH_STAGE)
304
549
  return process.env.MESH_STAGE;
305
550
  try {
306
- const result = execFileSync("pulumi", ["stack", "--show-name"], {
551
+ const result = execFileSync('pulumi', ['stack', '--show-name'], {
307
552
  cwd: appRoot,
308
- encoding: "utf-8",
309
- stdio: ["ignore", "pipe", "ignore"],
553
+ encoding: 'utf-8',
554
+ stdio: ['ignore', 'pipe', 'ignore'],
310
555
  }).trim();
311
556
  if (result)
312
557
  return result;
@@ -316,7 +561,9 @@ function detectStack(appRoot, stageArg) {
316
561
  }
317
562
  // Infer from Pulumi.<stack>.yaml config files
318
563
  try {
319
- const files = fs.readdirSync(appRoot).filter((f) => f.startsWith("Pulumi.") && f.endsWith(".yaml") && f !== "Pulumi.yaml");
564
+ const files = fs
565
+ .readdirSync(appRoot)
566
+ .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');
320
567
  if (files.length === 1) {
321
568
  const match = files[0].match(/^Pulumi\.(.+)\.yaml$/);
322
569
  if (match?.[1]) {
@@ -328,20 +575,66 @@ function detectStack(appRoot, stageArg) {
328
575
  catch {
329
576
  // Fall through
330
577
  }
331
- return "dev";
578
+ return 'dev';
579
+ }
580
+ /**
581
+ * Build the `--stack <name>` args for Pulumi commands.
582
+ * Newer Pulumi versions require fully-qualified names (organization/project/stack)
583
+ * with `--stack`. If the stack is already selected, we can omit the flag entirely.
584
+ */
585
+ function stackArgs(appRoot, stack) {
586
+ // Check if the requested stack is already the selected one — if so, skip --stack
587
+ try {
588
+ const selected = execFileSync('pulumi', ['stack', '--show-name'], {
589
+ cwd: appRoot,
590
+ encoding: 'utf-8',
591
+ stdio: ['ignore', 'pipe', 'ignore'],
592
+ }).trim();
593
+ if (selected === stack)
594
+ return [];
595
+ }
596
+ catch {
597
+ // ignore
598
+ }
599
+ // Try selecting the stack first (works with short names), then omit --stack
600
+ try {
601
+ execFileSync('pulumi', ['stack', 'select', stack], {
602
+ cwd: appRoot,
603
+ encoding: 'utf-8',
604
+ stdio: ['ignore', 'pipe', 'ignore'],
605
+ });
606
+ return [];
607
+ }
608
+ catch {
609
+ // Fall through — pass --stack and hope the name is fully qualified
610
+ }
611
+ return ['--stack', stack];
332
612
  }
333
613
  /**
334
614
  * Read the dev output from Pulumi stack.
335
615
  * Tries `app` output first (new env.register() format), falls back to `dev`.
336
616
  */
337
- function getDevOutput(appRoot, stack) {
617
+ /**
618
+ * Thrown by `getDevOutput` when no readable Pulumi stack output exists (usually a
619
+ * fresh app that hasn't been `stack init` + `deploy`ed yet). Typed so each caller
620
+ * presents it in its OWN context: only the launch path can conclude "nothing to
621
+ * run" and print first-run guidance; the `--refresh-env`/restart handlers must
622
+ * keep the running session's env untouched (MESH-2097). Carries the underlying
623
+ * read error so an auth/network/JSON failure isn't misdiagnosed as "fresh app".
624
+ */
625
+ class MissingStackOutputError extends Error {
626
+ stackName;
627
+ constructor(stackName, cause) {
628
+ super(`No Pulumi stack output for '${stackName}'`, { cause });
629
+ this.stackName = stackName;
630
+ this.name = 'MissingStackOutputError';
631
+ }
632
+ }
633
+ function getDevOutput(appRoot, stack, awsEnv) {
634
+ const sa = stackArgs(appRoot, stack);
338
635
  // Try new format: export const app = env.register() → { dev: { ... } }
339
636
  try {
340
- const result = execFileSync("pulumi", ["stack", "output", "app", "--json", "--show-secrets", "--stack", stack], {
341
- cwd: appRoot,
342
- encoding: "utf-8",
343
- stdio: ["ignore", "pipe", "pipe"],
344
- });
637
+ const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);
345
638
  const appOutput = JSON.parse(result);
346
639
  if (appOutput.dev) {
347
640
  return appOutput.dev;
@@ -352,18 +645,16 @@ function getDevOutput(appRoot, stack) {
352
645
  }
353
646
  // Legacy format: export const dev = ...
354
647
  try {
355
- const result = execFileSync("pulumi", ["stack", "output", "dev", "--json", "--show-secrets", "--stack", stack], {
356
- cwd: appRoot,
357
- encoding: "utf-8",
358
- stdio: ["ignore", "pipe", "pipe"],
359
- });
648
+ const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);
360
649
  return JSON.parse(result);
361
650
  }
362
651
  catch (err) {
363
- logError(`Failed to read Pulumi stack output for stack '${stack}'.`);
364
- logInfo("Make sure you've deployed with: pulumi up");
365
- logInfo("And that the stack exports 'app' or 'dev' output.");
366
- throw err;
652
+ // No readable stack output. Throw a TYPED error so each caller decides how to
653
+ // present it (MESH-2097): the launch path prints first-run guidance + exits;
654
+ // the --refresh-env/restart handlers keep the running session untouched.
655
+ // (process.exit() here previously hijacked control flow past those fail-closed
656
+ // catches, and a bare `throw` escaped as an uncaught node stacktrace.)
657
+ throw new MissingStackOutputError(stack, err);
367
658
  }
368
659
  }
369
660
  // ============================================================================
@@ -371,7 +662,7 @@ function getDevOutput(appRoot, stack) {
371
662
  // ============================================================================
372
663
  function hasTmux() {
373
664
  try {
374
- execFileSync("which", ["tmux"], { stdio: "ignore" });
665
+ execFileSync('which', ['tmux'], { stdio: 'ignore' });
375
666
  return true;
376
667
  }
377
668
  catch {
@@ -380,7 +671,7 @@ function hasTmux() {
380
671
  }
381
672
  function sessionExists(name) {
382
673
  try {
383
- execFileSync("tmux", ["has-session", "-t", name], { stdio: "ignore" });
674
+ execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' });
384
675
  return true;
385
676
  }
386
677
  catch {
@@ -389,25 +680,38 @@ function sessionExists(name) {
389
680
  }
390
681
  function killSession(name) {
391
682
  try {
392
- execFileSync("tmux", ["kill-session", "-t", name], { stdio: "ignore" });
683
+ execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
393
684
  return true;
394
685
  }
395
686
  catch {
396
687
  return false;
397
688
  }
398
689
  }
690
+ const AWS_STATIC_CREDENTIAL_ENV_KEYS = [
691
+ 'AWS_ACCESS_KEY_ID',
692
+ 'AWS_SECRET_ACCESS_KEY',
693
+ 'AWS_SESSION_TOKEN',
694
+ 'AWS_WEB_IDENTITY_TOKEN_FILE',
695
+ 'AWS_ROLE_ARN',
696
+ ];
697
+ function applyAwsProfileOverride(profile) {
698
+ process.env.AWS_PROFILE = profile;
699
+ for (const key of AWS_STATIC_CREDENTIAL_ENV_KEYS) {
700
+ delete process.env[key];
701
+ }
702
+ }
399
703
  /**
400
704
  * Collect AWS credentials from the current environment.
401
705
  */
402
706
  function getAwsEnvVars() {
403
707
  const result = {};
404
708
  const vars = [
405
- "AWS_PROFILE",
406
- "AWS_ACCESS_KEY_ID",
407
- "AWS_SECRET_ACCESS_KEY",
408
- "AWS_SESSION_TOKEN",
409
- "AWS_REGION",
410
- "AWS_DEFAULT_REGION",
709
+ 'AWS_PROFILE',
710
+ 'AWS_ACCESS_KEY_ID',
711
+ 'AWS_SECRET_ACCESS_KEY',
712
+ 'AWS_SESSION_TOKEN',
713
+ 'AWS_REGION',
714
+ 'AWS_DEFAULT_REGION',
411
715
  ];
412
716
  for (const name of vars) {
413
717
  const value = process.env[name];
@@ -417,7 +721,114 @@ function getAwsEnvVars() {
417
721
  return result;
418
722
  }
419
723
  /**
420
- * Resolve env vars for a service, rewriting tunnel references.
724
+ * Build the AWS env vars to hand to long-lived child services (ui, api,
725
+ * ai-assistant) and SSM tunnels.
726
+ *
727
+ * For the AWS SSO credential path, returns AWS_PROFILE + AWS_REGION only so
728
+ * the AWS SDK's default provider chain can auto-refresh credentials from the
729
+ * SSO cache (~8h session) as short-lived STS tokens expire. Without this,
730
+ * child processes inherit a frozen STS triple and start throwing
731
+ * ExpiredTokenException ~1h into a dev session.
732
+ *
733
+ * For the Zitadel web-identity path, when `opts` is supplied we write an AWS
734
+ * config file whose `credential_process` re-invokes `mesh login … --credential-process`,
735
+ * so the AWS SDK's default provider chain auto-refreshes the underlying STS
736
+ * triple (the `mesh login` process re-issues the Zitadel token as needed).
737
+ * This removes the ~1h forced `mesh dev` restart. When `opts` is absent we
738
+ * fall back to the frozen STS triple (used by CI/`environment` and any caller
739
+ * that doesn't thread the context/role through).
740
+ *
741
+ * For the remaining non-SSO paths (CI/CD env keys, or no deployer role),
742
+ * returns the explicit STS triple — there is no profile to refresh from. The
743
+ * user must restart `mesh dev` when those tokens expire.
744
+ */
745
+ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
746
+ const result = {};
747
+ if (awsEnv.AWS_REGION)
748
+ result.AWS_REGION = awsEnv.AWS_REGION;
749
+ if (awsEnv.AWS_DEFAULT_REGION)
750
+ result.AWS_DEFAULT_REGION = awsEnv.AWS_DEFAULT_REGION;
751
+ if (method === 'sso' && profile) {
752
+ result.AWS_PROFILE = profile;
753
+ return result;
754
+ }
755
+ // Zitadel path with a credential_process profile: hand children a profile
756
+ // that self-refreshes via `mesh login`, never a frozen triple.
757
+ if (method === 'zitadel' && opts) {
758
+ const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');
759
+ const profileName = `mesh-dev-${sanitizedContext}`;
760
+ const configPath = path.join(opts.sessionScratchDir, 'aws-config');
761
+ const region = awsEnv.AWS_REGION ?? awsEnv.AWS_DEFAULT_REGION ?? '';
762
+ fs.mkdirSync(opts.sessionScratchDir, { recursive: true });
763
+ fs.writeFileSync(configPath, renderCredentialProcessProfile({
764
+ profileName,
765
+ context: opts.context,
766
+ roleArn: opts.roleArn,
767
+ region,
768
+ meshBin: opts.meshBin,
769
+ }), { mode: 0o600 });
770
+ const out = {
771
+ AWS_PROFILE: profileName,
772
+ AWS_CONFIG_FILE: configPath,
773
+ };
774
+ if (region)
775
+ out.AWS_REGION = region;
776
+ return out;
777
+ }
778
+ for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']) {
779
+ if (awsEnv[key])
780
+ result[key] = awsEnv[key];
781
+ }
782
+ if (profile && !result.AWS_ACCESS_KEY_ID) {
783
+ result.AWS_PROFILE = profile;
784
+ }
785
+ return result;
786
+ }
787
+ /**
788
+ * Resolve a service's source dir onto the CURRENT worktree (MESH-2013).
789
+ *
790
+ * `mesh dev` reads the service `src` from `pulumi stack output`. Two shapes exist:
791
+ * - New (post relative-src fix in infra-components): a monorepo-RELATIVE dir
792
+ * like `packages/agent-sdk` → just resolve it against this worktree's root.
793
+ * - Legacy: an ABSOLUTE path baked at the deploying `pulumi up`, e.g.
794
+ * `/…/mesh-platform/.worktrees/streaming-ui-builder/packages/agent-sdk`. The
795
+ * old logic stripped only up to `/mesh-platform/`, keeping the
796
+ * `.worktrees/<name>/` segment — so it re-rooted to a *sibling worktree* that
797
+ * also exists, silently running the wrong worktree's code. We now reduce to
798
+ * the monorepo-relative portion (dropping any `.worktrees/<name>/`) and
799
+ * re-root here, so a stack deployed from any worktree runs from THIS one.
800
+ */
801
+ function rebaseServiceSrc(src, monorepoRoot) {
802
+ if (!monorepoRoot)
803
+ return src;
804
+ const rel = monorepoRelativeSrc(src);
805
+ if (rel === null)
806
+ return src;
807
+ const rebased = path.join(monorepoRoot, rel);
808
+ return fs.existsSync(rebased) ? rebased : src;
809
+ }
810
+ /**
811
+ * Reduce a service `src` to its monorepo-relative form (pure; no filesystem).
812
+ * - relative (`./packages/x` or `packages/x`) → normalized `packages/x`
813
+ * - legacy absolute (`/…/mesh-platform[/.worktrees/<name>]/packages/x`) →
814
+ * `packages/x` (drops the repo root AND any deploying-worktree segment)
815
+ * Returns null when an absolute path has no recognizable monorepo marker.
816
+ */
817
+ export function monorepoRelativeSrc(src) {
818
+ if (!path.isAbsolute(src))
819
+ return src.replace(/^\.\//, '') || '.';
820
+ // Match the LAST `/mesh-platform` at a segment boundary — covers the repo root
821
+ // itself (nothing follows → '.'), a nested `.worktrees/<name>/`, and normal
822
+ // subpaths. A path ending at the repo root (e.g. a service whose src IS the
823
+ // monorepo root) previously slipped through and stayed absolute (MESH-2069).
824
+ const m = src.match(/^.*\/mesh-platform(?:\/(.*))?$/);
825
+ if (!m)
826
+ return null;
827
+ const rest = (m[1] ?? '').replace(/^\.worktrees\/[^/]+(?:\/|$)/, '');
828
+ return rest === '' ? '.' : rest;
829
+ }
830
+ /**
831
+ * Resolve env vars for a service, rewriting tunnel references to VPC endpoints.
421
832
  */
422
833
  function getServiceEnvVars(service, tunnels) {
423
834
  const result = {};
@@ -427,22 +838,20 @@ function getServiceEnvVars(service, tunnels) {
427
838
  if (spec.tunnel) {
428
839
  const tunnel = tunnels[spec.tunnel];
429
840
  if (tunnel) {
841
+ // Rewrite to VPC endpoint (VPN provides direct access)
430
842
  try {
431
- // Try as a full URL first (e.g. postgres://host:5432/db)
432
843
  const url = new URL(value);
433
844
  if (url.hostname) {
434
- url.hostname = "localhost";
435
- url.port = String(tunnel.localPort);
845
+ url.hostname = tunnel.host;
846
+ url.port = String(tunnel.port);
436
847
  value = url.toString();
437
848
  }
438
849
  else {
439
- // URL parsed but hostname empty (e.g. host:port parsed as protocol)
440
- value = `localhost:${tunnel.localPort}`;
850
+ value = `${tunnel.host}:${tunnel.port}`;
441
851
  }
442
852
  }
443
853
  catch {
444
- // Not a URL at all — bare host:port or just hostname
445
- value = `localhost:${tunnel.localPort}`;
854
+ value = `${tunnel.host}:${tunnel.port}`;
446
855
  }
447
856
  }
448
857
  }
@@ -459,9 +868,7 @@ function getServiceEnvVars(service, tunnels) {
459
868
  */
460
869
  function setTmuxEnv(sessionName, vars) {
461
870
  for (const [key, value] of Object.entries(vars)) {
462
- execFileSync("tmux", [
463
- "set-environment", "-t", sessionName, key, value,
464
- ], { stdio: "ignore" });
871
+ execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
465
872
  }
466
873
  }
467
874
  /**
@@ -474,9 +881,9 @@ function setTmuxEnv(sessionName, vars) {
474
881
  function envPrefix(vars) {
475
882
  const entries = Object.entries(vars);
476
883
  if (entries.length === 0)
477
- return "";
884
+ return '';
478
885
  const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);
479
- return `env ${parts.join(" ")} `;
886
+ return `env ${parts.join(' ')} `;
480
887
  }
481
888
  /** Escape a value for shell interpolation. */
482
889
  function shellEscape(s) {
@@ -491,9 +898,9 @@ function shellEscape(s) {
491
898
  // ============================================================================
492
899
  /**
493
900
  * Resolve secrets from Secrets Manager and return env var key→value pairs.
494
- * Applies tunnel rewrites to DATABASE_URL if a tunnel is active.
901
+ * VPN provides direct access no URL rewriting needed.
495
902
  */
496
- async function resolveSecrets(secrets, tunnels) {
903
+ async function resolveSecrets(secrets) {
497
904
  const client = new SecretsManagerClient({});
498
905
  const resolved = {};
499
906
  for (const [name, secret] of Object.entries(secrets)) {
@@ -503,25 +910,9 @@ async function resolveSecrets(secrets, tunnels) {
503
910
  continue;
504
911
  const values = JSON.parse(response.SecretString);
505
912
  for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {
506
- const raw = values[secretKey];
507
- if (raw === undefined)
913
+ const value = values[secretKey];
914
+ if (value === undefined)
508
915
  continue;
509
- let value = raw;
510
- // Rewrite DATABASE_URL host:port if there's a tunnel
511
- if (envVar === "DATABASE_URL" || secretKey === "DATABASE_URL") {
512
- for (const tunnel of Object.values(tunnels)) {
513
- try {
514
- const parsed = new URL(value);
515
- parsed.hostname = "localhost";
516
- parsed.port = String(tunnel.localPort);
517
- value = parsed.toString();
518
- break;
519
- }
520
- catch {
521
- // Not a URL
522
- }
523
- }
524
- }
525
916
  resolved[envVar] = value;
526
917
  }
527
918
  logSuccess(`Secret resolved: ${name} (${secret.secretName})`);
@@ -532,6 +923,491 @@ async function resolveSecrets(secrets, tunnels) {
532
923
  }
533
924
  return resolved;
534
925
  }
926
+ /**
927
+ * Resolve the Temporal encoding key from the K8s secret in the app namespace.
928
+ *
929
+ * The Pulumi-generated devEncodingKey is a placeholder that differs from the
930
+ * deployed key, causing decryption failures when the local worker encounters
931
+ * existing Temporal payloads. Reading the real key from K8s ensures local dev
932
+ * can decrypt data created by deployed workers.
933
+ *
934
+ * Returns the encoding key value, or undefined if not available.
935
+ */
936
+ function resolveTemporalEncodingKey(tenant, env, appName) {
937
+ const namespace = `${tenant}-${env}-${appName}`;
938
+ const secretName = `${namespace}-temporal-encoding-key`;
939
+ try {
940
+ const b64 = execFileSync('kubectl', [
941
+ 'get',
942
+ 'secret',
943
+ secretName,
944
+ '-n',
945
+ namespace,
946
+ '-o',
947
+ 'jsonpath={.data.TEMPORAL_ENCODING_KEY}',
948
+ ], { encoding: 'utf-8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
949
+ if (b64) {
950
+ const key = Buffer.from(b64, 'base64').toString('utf-8');
951
+ logSuccess(`Temporal encoding key resolved from K8s secret (${secretName})`);
952
+ return key;
953
+ }
954
+ }
955
+ catch {
956
+ // Non-fatal — secret may not exist yet or kubectl may not have access
957
+ }
958
+ return undefined;
959
+ }
960
+ /**
961
+ * Re-resolve the session-level Temporal auth vars the SAME way launch does
962
+ * (see startServices): kubeconfig from SSM, M2M auth, encoding key from the
963
+ * K8s secret, and TEMPORAL_ADDRESS from the (pinned) tunnel. Used by
964
+ * `mesh dev restart --refresh-env` so a regenerated env file carries the live
965
+ * resolved values, not the dev-output placeholders (which would desync payload
966
+ * encryption between services).
967
+ */
968
+ async function resolveTemporalAuthVars(sessionName, devOutput) {
969
+ const tenant = devOutput.platform?.tenant ?? 'mesh';
970
+ const platformEnv = devOutput.platform?.env ?? 'dev';
971
+ // Hub platform name — the EKS export lives under it, not under a hardcoded
972
+ // "mesh" (MESH-2515); same axis as the bastion resolution.
973
+ const platformName = resolveHubPlatformName(devOutput.platform);
974
+ const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
975
+ onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
976
+ });
977
+ if (kubeconfigPath) {
978
+ process.env.KUBECONFIG = kubeconfigPath;
979
+ setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
980
+ }
981
+ else if (!process.env.KUBECONFIG) {
982
+ // ensureKubeconfig couldn't re-resolve the cluster from SSM (transient /
983
+ // stale creds). Fall back to the session kubeconfig written at launch so the
984
+ // kubectl calls below don't default to ~/.kube/config — which macOS TCC
985
+ // denies for shells without Full Disk Access ("operation not permitted").
986
+ const existing = sessionKubeconfigPath(sessionName);
987
+ if (fs.existsSync(existing)) {
988
+ // Set only this process's env — deliberately NOT setTmuxEnv: this is a
989
+ // best-effort fallback to a possibly-stale last-good kubeconfig, so we use
990
+ // it for the kubectl calls below without persisting it into the session
991
+ // (a later successful re-resolve is the one that should win the session).
992
+ process.env.KUBECONFIG = existing;
993
+ }
994
+ }
995
+ let temporalAuthVars = {};
996
+ if (devOutput.tunnels['temporal']) {
997
+ // Deliberately NOT `platformName` from above: the Temporal-auth fallback is
998
+ // `?? tenant` (dedicated tenants may run their own Temporal), while the EKS
999
+ // fallback is `?? 'mesh'`. Whether that difference is correct is MESH-2551;
1000
+ // don't "fix" either default to match the other without reading it.
1001
+ temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
1002
+ const appName = devOutput.app ?? '';
1003
+ if (appName) {
1004
+ const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
1005
+ if (encodingKey) {
1006
+ temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
1007
+ }
1008
+ }
1009
+ const temporalTunnel = devOutput.tunnels['temporal'];
1010
+ temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
1011
+ }
1012
+ return temporalAuthVars;
1013
+ }
1014
+ /**
1015
+ * Resolve the dev-user identity vars (DEV_USER_*) from cached `mesh login`
1016
+ * credentials for the platform context (e.g. 'mesh.dev'). Credentials are
1017
+ * stored under the platform context, not the tenant name — all tenants share
1018
+ * the same Zitadel instance.
1019
+ *
1020
+ * Services (e.g., Hub UI) use these as a fallback when the real oauth2-proxy
1021
+ * `X-Forwarded-*` request headers are absent:
1022
+ * DEV_USER_ID - subject from idToken, matches conversation ownership
1023
+ * DEV_USER_EMAIL - user email from idToken
1024
+ * DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
1025
+ * DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
1026
+ *
1027
+ * Returned vars must be overlaid onto each service's env FILE (not just the
1028
+ * tmux session env): the dev output may carry a placeholder value (e.g. hub
1029
+ * ui's DEV_USER_ACCESS_TOKEN="dev-local"), and the sourced per-service env
1030
+ * file wins over the session env, so a session-env-only injection gets
1031
+ * clobbered and every tenant-scoped Hub view silently 401s.
1032
+ */
1033
+ function resolveDevUserVars(devOutput) {
1034
+ const credContext = devOutput.platform
1035
+ ? devOutput.platform.tenant === 'local'
1036
+ ? 'local'
1037
+ : `mesh.${devOutput.platform.env}`
1038
+ : null;
1039
+ if (!credContext)
1040
+ return {};
1041
+ const meshCreds = readCredentials(credContext);
1042
+ if (!meshCreds?.idToken)
1043
+ return {};
1044
+ const vars = { DEV_USER_ID_TOKEN: meshCreds.idToken };
1045
+ try {
1046
+ const payload = JSON.parse(Buffer.from(meshCreds.idToken.split('.')[1], 'base64url').toString());
1047
+ if (payload.sub)
1048
+ vars.DEV_USER_ID = payload.sub;
1049
+ if (payload.email)
1050
+ vars.DEV_USER_EMAIL = payload.email;
1051
+ }
1052
+ catch {
1053
+ /* ignore decode errors */
1054
+ }
1055
+ if (meshCreds.accessToken) {
1056
+ vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;
1057
+ }
1058
+ return vars;
1059
+ }
1060
+ // ============================================================================
1061
+ // SSM tunnel fallback (when VPN is not connected)
1062
+ // ============================================================================
1063
+ /**
1064
+ * Base port for SSM tunnels. Preferred ports use 20000 + original_port to
1065
+ * avoid collisions with dev services (1000-9999 range). If the preferred
1066
+ * port is already reserved or occupied, mesh dev walks upward until it finds
1067
+ * an available port.
1068
+ *
1069
+ * Examples:
1070
+ * PostgreSQL 5432 → 25432
1071
+ * Temporal 7233 → 27233
1072
+ * Temporal UI 8080 → 28080
1073
+ */
1074
+ const SSM_TUNNEL_BASE_PORT = 20000;
1075
+ /** How long to wait for an SSM tunnel's local port to accept TCP before failing. */
1076
+ const TUNNEL_READY_TIMEOUT_MS = 60_000;
1077
+ /**
1078
+ * Build the client-facing `host:port` for an SSM tunnel.
1079
+ *
1080
+ * The `aws ssm start-session` port-forwarder binds IPv4 `127.0.0.1` only.
1081
+ * Advertising `localhost` lets Node/grpc-js resolve to IPv6 `::1` first, which
1082
+ * the tunnel isn't listening on — so e.g. the Temporal gRPC client fails with
1083
+ * `ECONNREFUSED ::1:<port>` and the Hub's Automations/workflow lists never load.
1084
+ * Maps the literal `localhost` (how tunnels are advertised today) to `127.0.0.1`
1085
+ * so clients hit the bound listener. Any other host — VPN-direct VPC endpoints,
1086
+ * or a different loopback literal like `::1`/`127.0.0.1` — passes through unchanged.
1087
+ */
1088
+ export function tunnelClientAddress(tunnel) {
1089
+ const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;
1090
+ return `${host}:${tunnel.port}`;
1091
+ }
1092
+ /**
1093
+ * Render the Connections section (live tunnel health) as status-window lines.
1094
+ *
1095
+ * The status window refreshes every ~2s, so this is where a mid-session tunnel
1096
+ * death surfaces (MESH-2016): a dead tunnel shows `⚠ … DOWN` and a SINGLE
1097
+ * remediation line explains the `ECONNREFUSED` retry spam the dependent service
1098
+ * windows emit — instead of leaving the operator to decode that spam themselves.
1099
+ * Returns `[]` when there are no tunnels (nothing to show).
1100
+ */
1101
+ export function formatTunnelHealth(health) {
1102
+ if (health.length === 0)
1103
+ return [];
1104
+ const lines = ['Connections:'];
1105
+ for (const h of health) {
1106
+ const icon = h.reachable ? '✓' : '⚠';
1107
+ const suffix = h.reachable ? '' : ' DOWN';
1108
+ lines.push(` ${icon} ${h.name.padEnd(16)} ${h.address}${suffix}`);
1109
+ }
1110
+ const down = health.filter((h) => !h.reachable).map((h) => h.name);
1111
+ if (down.length > 0) {
1112
+ const noun = down.length === 1 ? 'tunnel' : 'tunnels';
1113
+ lines.push('');
1114
+ lines.push(` ⚠ ${down.length} ${noun} down (${down.join(', ')}) — dependent services are ` +
1115
+ `retrying (ECONNREFUSED spam is expected). Relaunch: mesh dev`);
1116
+ }
1117
+ return lines;
1118
+ }
1119
+ /** Probe every tunnel's client address in parallel and resolve its health. */
1120
+ async function probeTunnelHealth(tunnels) {
1121
+ return Promise.all(Object.entries(tunnels).map(async ([name, tun]) => ({
1122
+ name,
1123
+ address: tunnelClientAddress(tun),
1124
+ // Dial the SAME address the client uses (localhost→127.0.0.1) so the probe
1125
+ // sees exactly what a dependent service would. Use the connection-HOLDS
1126
+ // probe, not a bare accept: a forwarder binds its local port even when its
1127
+ // upstream leg is dead (a dropped SSM session, or a Tailscale SOCKS bridge
1128
+ // dialing a stale port — MESH-2169), so "port accepts" would report a dead
1129
+ // tunnel as healthy. Holding briefly catches the accept-then-close teardown.
1130
+ reachable: await probeConnectionHolds(tun.host === 'localhost' ? '127.0.0.1' : tun.host, tun.port, 800),
1131
+ })));
1132
+ }
1133
+ /**
1134
+ * Probe the routing-critical tunnel of a (Tailscale-rewritten) devOutput and
1135
+ * return its name if the forwarder binds but does not ROUTE, else null.
1136
+ *
1137
+ * Only `temporal` is gated — it's the load-bearing VPC dependency (services die
1138
+ * on a gRPC deadline when it's dead), and keying on it mirrors the vpn-direct
1139
+ * reachability gate ({@link gateVpnDirectReachability}). A Tailscale forwarder
1140
+ * accepts its local port even when its SOCKS upstream is dead or the joined
1141
+ * tailnet doesn't route the VPC; such a forwarder tears the connection down
1142
+ * within the grace window, so it fails the connection-HOLDS probe that a bare
1143
+ * accept (the readiness gate's `waitForPort`) would pass (MESH-2169/MESH-2268).
1144
+ */
1145
+ export async function firstUnroutableTunnel(tunnels) {
1146
+ const temporal = tunnels['temporal'];
1147
+ if (!temporal || temporal.host !== 'localhost')
1148
+ return null;
1149
+ const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);
1150
+ return holds ? null : 'temporal';
1151
+ }
1152
+ /**
1153
+ * Resolve which tunnel backing `mesh dev` should use.
1154
+ *
1155
+ * An explicit `--transport` flag wins. Both `auto` and the default (unset)
1156
+ * prefer Tailscale when it's available (brew `tailscaled` present), else fall
1157
+ * back to the VPN-direct/SSM choice: VPN-direct when the VPN is connected, else
1158
+ * SSM when the session-manager plugin is present. Where Tailscale isn't
1159
+ * available the behavior is unchanged from before this feature.
1160
+ */
1161
+ export function resolveTransport(flag, ctx) {
1162
+ if (flag === "tailscale" || flag === "ssm")
1163
+ return flag;
1164
+ if (flag !== undefined && flag !== "auto") {
1165
+ throw new Error(`Invalid --transport '${flag}'. Valid values: auto (default), tailscale, ssm.`);
1166
+ }
1167
+ const legacy = ctx.vpnConnected ? "vpn-direct" : ctx.hasSsmPlugin ? "ssm" : "vpn-direct";
1168
+ return ctx.tailscaleAvailable ? "tailscale" : legacy;
1169
+ }
1170
+ /**
1171
+ * Correct an auto-selected `vpn-direct` transport against ACTUAL reachability of
1172
+ * the Temporal VPC endpoint (MESH-2016). `resolveTransport` uses a presence check
1173
+ * (any Tailscale/VPN up) that's tailnet-blind — Tailscale-for-SSH looks connected
1174
+ * but doesn't route the mesh VPC. Only `vpn-direct` is reachability-gated (it's
1175
+ * never an explicit choice); every other transport passes through unchanged.
1176
+ *
1177
+ * Pure so the fallback logic is testable independently of the TCP probe.
1178
+ */
1179
+ export function reachabilityFallbackTransport(transport, temporalReachable, hasSsmPlugin) {
1180
+ if (transport !== "vpn-direct")
1181
+ return { transport, reason: "not-vpn-direct" };
1182
+ if (temporalReachable)
1183
+ return { transport, reason: "reachable" };
1184
+ if (hasSsmPlugin)
1185
+ return { transport: "ssm", reason: "fallback-ssm" };
1186
+ return { transport, reason: "unreachable-no-plugin" };
1187
+ }
1188
+ /**
1189
+ * Apply the reachability gate to a (possibly auto-selected) transport: if it's
1190
+ * `vpn-direct`, TCP-dial the Temporal VPC endpoint and downgrade to SSM when it's
1191
+ * actually unreachable, logging the decision. Returns the effective transport;
1192
+ * a no-op for any non-`vpn-direct` transport.
1193
+ *
1194
+ * Called on BOTH auto-select paths — the normal resolve result AND the
1195
+ * Tailscale→legacy fallback (when no mesh login config exists for the derived
1196
+ * context) — so a `vpn-direct` can never reach `startServices` unprobed
1197
+ * (MESH-2016).
1198
+ */
1199
+ async function gateVpnDirectReachability(transport, devOutput) {
1200
+ if (transport !== "vpn-direct")
1201
+ return transport;
1202
+ const temporalTunnel = devOutput.tunnels?.["temporal"];
1203
+ if (!temporalTunnel)
1204
+ return transport;
1205
+ const { host, port } = temporalTunnel;
1206
+ const reachable = await probeTcpReachable(host, port, 1500);
1207
+ const decision = reachabilityFallbackTransport(transport, reachable, hasSessionManagerPlugin());
1208
+ if (decision.reason === "reachable") {
1209
+ logInfo(`Transport: VPN-direct — Temporal VPC endpoint ${host}:${port} is reachable.`);
1210
+ }
1211
+ else if (decision.reason === "fallback-ssm") {
1212
+ logWarn(`Transport: VPN reports connected but the Temporal VPC endpoint ${host}:${port} is unreachable ` +
1213
+ `(tailnet-blind presence check) — falling back to SSM tunnels (2XXXX port range; VPN-only features ` +
1214
+ `like in-cluster kubectl are unavailable). Pin with --transport=ssm to skip this probe.`);
1215
+ }
1216
+ else if (decision.reason === "unreachable-no-plugin") {
1217
+ logWarn(`Transport: Temporal VPC endpoint ${host}:${port} is unreachable and the SSM session-manager ` +
1218
+ `plugin is missing — VPN-direct will likely fail. Fix VPN routing or install the plugin ` +
1219
+ `(see: mesh dev doctor).`);
1220
+ }
1221
+ return decision.transport;
1222
+ }
1223
+ /**
1224
+ * Derive the Headscale login-server URL from a login context's issuer
1225
+ * (`identity.<env>.<domain>` → `vpn.<env>.<domain>`). Used for the Tailscale
1226
+ * transport's OIDC join. Returns null if the context has no cached login config.
1227
+ */
1228
+ function deriveLoginServer(context) {
1229
+ const cfg = getContextConfig(context);
1230
+ if (!cfg?.issuer)
1231
+ return null;
1232
+ try {
1233
+ const u = new URL(cfg.issuer);
1234
+ const parts = u.hostname.split(".");
1235
+ parts[0] = "vpn";
1236
+ return `https://${parts.join(".")}`;
1237
+ }
1238
+ catch {
1239
+ return null;
1240
+ }
1241
+ }
1242
+ export function preferredSsmLocalPort(remotePort) {
1243
+ const preferred = SSM_TUNNEL_BASE_PORT + remotePort;
1244
+ if (preferred > 65535) {
1245
+ throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: preferred local port ${preferred} exceeds 65535`);
1246
+ }
1247
+ return preferred;
1248
+ }
1249
+ /**
1250
+ * Reserve a deterministic local-port candidate for an SSM tunnel.
1251
+ *
1252
+ * This prevents two tunnels with the same remote port (common for HTTPS
1253
+ * endpoints) from being assigned the same localhost port before the AWS CLI
1254
+ * has a chance to bind it.
1255
+ */
1256
+ export function reserveSsmLocalPortCandidate(remotePort, reservedPorts, startAt = preferredSsmLocalPort(remotePort)) {
1257
+ for (let port = startAt; port <= 65535; port += 1) {
1258
+ if (!reservedPorts.has(port)) {
1259
+ reservedPorts.add(port);
1260
+ return port;
1261
+ }
1262
+ }
1263
+ throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no free candidate ports remain`);
1264
+ }
1265
+ async function allocateSsmLocalPort(remotePort, reservedPorts) {
1266
+ let nextCandidate = preferredSsmLocalPort(remotePort);
1267
+ while (nextCandidate <= 65535) {
1268
+ const candidate = reserveSsmLocalPortCandidate(remotePort, reservedPorts, nextCandidate);
1269
+ if (await isPortFree(candidate)) {
1270
+ return candidate;
1271
+ }
1272
+ nextCandidate = candidate + 1;
1273
+ }
1274
+ throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`);
1275
+ }
1276
+ /**
1277
+ * Check if the session-manager-plugin is installed (required for SSM tunnels).
1278
+ */
1279
+ function hasSessionManagerPlugin() {
1280
+ try {
1281
+ execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });
1282
+ return true;
1283
+ }
1284
+ catch {
1285
+ return false;
1286
+ }
1287
+ }
1288
+ /**
1289
+ * Start SSM port-forwarding tunnels as tmux windows within the dev session.
1290
+ *
1291
+ * For each tunnel defined in devOutput (e.g., temporal, rds), this:
1292
+ * 1. Reads the bastion instance ID and service endpoints from SSM
1293
+ * 2. Spawns `aws ssm start-session` in a dedicated tmux window
1294
+ * 3. Rewrites devOutput.tunnels to point at localhost:2XXXX
1295
+ *
1296
+ * Returns the rewritten devOutput with tunnels pointing to localhost.
1297
+ */
1298
+ async function startSsmTunnels(sessionName, devOutput) {
1299
+ const platformEnv = devOutput.platform?.env ?? 'dev';
1300
+ // The bastion is a HUB platform resource (/mesh-platform/<hub>/<env>/platform-bastion),
1301
+ // not an app-tenant resource — devOutput.platform.tenant is the APP tenant (e.g. acme),
1302
+ // which for shared tenants has no bastion. Resolve under the platform/hub name; default
1303
+ // to "mesh" (matching how resolveTemporalAuth reads platform-level params).
1304
+ const bastionTenant = devOutput.platform?.name ?? 'mesh';
1305
+ // Read bastion info from SSM
1306
+ const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
1307
+ if (!bastion) {
1308
+ logError('Could not read platform bastion info from SSM.');
1309
+ logInfo('Make sure PlatformBastion is deployed in your platform stack.');
1310
+ logInfo("If you don't have a bastion, connect via VPN instead: mesh vpn connect");
1311
+ throw new Error('SSM tunnel fallback unavailable: no bastion found');
1312
+ }
1313
+ const rewritten = JSON.parse(JSON.stringify(devOutput));
1314
+ const tunnelCount = Object.keys(devOutput.tunnels).length;
1315
+ if (tunnelCount === 0) {
1316
+ logInfo('No tunnels defined — SSM fallback not needed.');
1317
+ return rewritten;
1318
+ }
1319
+ logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
1320
+ // Map devOutput tunnel names to bastion service keys
1321
+ // devOutput uses names like "temporal", "rds"; bastion uses "temporal-frontend", "rds"
1322
+ const tunnelToBastionKey = {
1323
+ temporal: 'temporal-frontend',
1324
+ 'temporal-ui': 'temporal-ui',
1325
+ rds: 'rds',
1326
+ db: 'rds',
1327
+ database: 'rds',
1328
+ };
1329
+ // Observability tunnels are non-fatal under SSM fallback — skip with a warning
1330
+ // rather than aborting the dev loop when the bastion doesn't expose them.
1331
+ const OPTIONAL_TUNNEL_NAMES = new Set([
1332
+ 'loki',
1333
+ 'prometheus',
1334
+ 'tempo',
1335
+ 'pushgateway',
1336
+ 'grafana',
1337
+ ]);
1338
+ const missingEndpoints = [];
1339
+ const skippedOptional = [];
1340
+ const reservedPorts = new Set();
1341
+ const plannedTunnels = [];
1342
+ for (const [tunnelName] of Object.entries(devOutput.tunnels)) {
1343
+ const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
1344
+ const endpoint = bastion.services[bastionKey];
1345
+ if (!endpoint) {
1346
+ // Observability tunnels degrade gracefully under SSM fallback: without
1347
+ // them the consuming app's monitoring views (logs/metrics/traces) won't
1348
+ // work, but they must not block the core dev loop. Only critical tunnels
1349
+ // (temporal, rds, ...) are fatal when the bastion can't reach them.
1350
+ if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
1351
+ skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
1352
+ }
1353
+ else {
1354
+ missingEndpoints.push(`${tunnelName} (bastion key: ${bastionKey})`);
1355
+ }
1356
+ continue;
1357
+ }
1358
+ plannedTunnels.push({
1359
+ tunnelName,
1360
+ endpoint,
1361
+ localPort: await allocateSsmLocalPort(endpoint.port, reservedPorts),
1362
+ });
1363
+ }
1364
+ if (skippedOptional.length > 0) {
1365
+ logWarn('Skipping optional observability tunnel(s) not exposed by the bastion (SSM fallback):');
1366
+ for (const skipped of skippedOptional) {
1367
+ logInfo(` Skipped: ${skipped}`);
1368
+ }
1369
+ logInfo(' Monitoring views (logs/metrics/traces) stay unavailable until VPN is connected.');
1370
+ }
1371
+ if (missingEndpoints.length > 0) {
1372
+ logError('SSM tunnel fallback cannot cover every required dev tunnel.');
1373
+ for (const missing of missingEndpoints) {
1374
+ logInfo(` Missing: ${missing}`);
1375
+ }
1376
+ logInfo(` Available bastion services: ${Object.keys(bastion.services).join(', ') || '(none)'}`);
1377
+ logInfo('Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.');
1378
+ throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
1379
+ }
1380
+ for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
1381
+ // Create a tmux window for this tunnel
1382
+ const windowName = `tunnel-${tunnelName}`;
1383
+ const ssmCmd = [
1384
+ 'aws',
1385
+ 'ssm',
1386
+ 'start-session',
1387
+ '--target',
1388
+ bastion.instanceId,
1389
+ '--document-name',
1390
+ 'AWS-StartPortForwardingSessionToRemoteHost',
1391
+ '--parameters',
1392
+ `'${JSON.stringify({
1393
+ host: [endpoint.host],
1394
+ portNumber: [String(endpoint.port)],
1395
+ localPortNumber: [String(localPort)],
1396
+ })}'`,
1397
+ ].join(' ');
1398
+ execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
1399
+ // Keep window alive so tunnel errors are visible
1400
+ execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1401
+ execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);
1402
+ // Rewrite the tunnel to point at localhost
1403
+ rewritten.tunnels[tunnelName] = {
1404
+ host: 'localhost',
1405
+ port: localPort,
1406
+ };
1407
+ logSuccess(` ${tunnelName}: localhost:${localPort} → ${endpoint.host}:${endpoint.port} (via SSM)`);
1408
+ }
1409
+ return rewritten;
1410
+ }
535
1411
  /**
536
1412
  * Update the DevBox host's Caddy reverse proxy config.
537
1413
  *
@@ -541,9 +1417,9 @@ async function resolveSecrets(secrets, tunnels) {
541
1417
  */
542
1418
  async function updateDevboxProxy(devOutput, appRoot) {
543
1419
  const appName = devOutput.app ?? path.basename(appRoot);
544
- const tenant = devOutput.platform?.tenant ?? "mesh";
545
- const env = devOutput.platform?.env ?? "dev";
546
- const stack = devOutput.stack ?? "dev";
1420
+ const tenant = devOutput.platform?.tenant ?? 'mesh';
1421
+ const env = devOutput.platform?.env ?? 'dev';
1422
+ const stack = devOutput.stack ?? 'dev';
547
1423
  const baseDomain = `${tenant}-${env}.mesh.local`;
548
1424
  // Build hostname → port map
549
1425
  const services = {};
@@ -553,148 +1429,462 @@ async function updateDevboxProxy(devOutput, appRoot) {
553
1429
  }
554
1430
  }
555
1431
  for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
556
- if (tunnel.localPort) {
557
- services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.localPort;
1432
+ if (tunnel.port) {
1433
+ services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.port;
558
1434
  }
559
1435
  }
560
1436
  if (Object.keys(services).length === 0)
561
1437
  return;
562
1438
  // Generate Caddyfile
563
1439
  const lines = [
564
- "# Auto-generated by mesh dev — do not edit manually.",
565
- "",
566
- ":8080 {",
1440
+ '# Auto-generated by mesh dev — do not edit manually.',
1441
+ '',
1442
+ ':8080 {',
567
1443
  ' respond /health "OK" 200',
568
- "}",
569
- "",
1444
+ '}',
1445
+ '',
570
1446
  ];
571
1447
  for (const [hostname, port] of Object.entries(services)) {
572
1448
  lines.push(`http://${hostname} {`);
573
1449
  lines.push(` reverse_proxy localhost:${port}`);
574
- lines.push("}");
575
- lines.push("");
1450
+ lines.push('}');
1451
+ lines.push('');
576
1452
  }
577
- const caddyfile = lines.join("\n");
1453
+ const caddyfile = lines.join('\n');
578
1454
  // Write to host's /etc/caddy via a throwaway container with a host volume mount.
579
1455
  // The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.
580
- execFileSync("docker", [
581
- "run", "--rm",
582
- "-v", "/etc/caddy:/etc/caddy",
583
- "busybox", "sh", "-c",
1456
+ execFileSync('docker', [
1457
+ 'run',
1458
+ '--rm',
1459
+ '-v',
1460
+ '/etc/caddy:/etc/caddy',
1461
+ 'busybox',
1462
+ 'sh',
1463
+ '-c',
584
1464
  `cat > /etc/caddy/Caddyfile << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
585
- ], { stdio: "pipe" });
1465
+ ], { stdio: 'pipe' });
586
1466
  // Reload Caddy on the host via nsenter into PID 1's mount namespace
587
- execFileSync("docker", [
588
- "run", "--rm", "--pid=host", "--privileged",
589
- "busybox", "nsenter", "-t", "1", "-m", "--",
590
- "caddy", "reload", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile",
591
- ], { stdio: "pipe" });
592
- logSuccess("DevBox proxy updated:");
1467
+ execFileSync('docker', [
1468
+ 'run',
1469
+ '--rm',
1470
+ '--pid=host',
1471
+ '--privileged',
1472
+ 'busybox',
1473
+ 'nsenter',
1474
+ '-t',
1475
+ '1',
1476
+ '-m',
1477
+ '--',
1478
+ 'caddy',
1479
+ 'reload',
1480
+ '--config',
1481
+ '/etc/caddy/Caddyfile',
1482
+ '--adapter',
1483
+ 'caddyfile',
1484
+ ], { stdio: 'pipe' });
1485
+ logSuccess('DevBox proxy updated:');
593
1486
  for (const [hostname, port] of Object.entries(services)) {
594
1487
  logInfo(` http://${hostname} → localhost:${port}`);
595
1488
  }
596
1489
  }
597
- // ============================================================================
598
- // Core actions
599
- // ============================================================================
600
- /**
601
- * Start all services in tmux windows.
602
- */
603
- async function startServices(sessionName, appRoot, devOutput, headless) {
604
- if (!hasTmux()) {
605
- logError("tmux is required. Install with: brew install tmux");
606
- process.exit(1);
1490
+ async function setupSubdomainRouting(devOutput, appRoot) {
1491
+ // 1. Get Tailscale info
1492
+ const tsInfo = await getTailscaleInfo();
1493
+ if (!tsInfo)
1494
+ return null;
1495
+ const appName = devOutput.app ?? path.basename(appRoot);
1496
+ const stack = devOutput.stack ?? 'dev';
1497
+ const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
1498
+ // 2. Derive Headscale DNS config from platform context (same convention as `mesh vpn`)
1499
+ const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
1500
+ const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);
1501
+ // 3. Build DNS records for all services + tunnels
1502
+ const records = [];
1503
+ for (const [name, service] of Object.entries(devOutput.services)) {
1504
+ if (service.port) {
1505
+ records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
1506
+ }
607
1507
  }
608
- // Kill existing session
609
- if (sessionExists(sessionName)) {
610
- logInfo(`Killing existing session: ${sessionName}`);
611
- killSession(sessionName);
1508
+ for (const [name] of Object.entries(devOutput.tunnels)) {
1509
+ records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
1510
+ }
1511
+ // 4. Remove stale DNS records (best-effort)
1512
+ try {
1513
+ unregisterDnsRecords(dnsConfig, baseDomain);
1514
+ }
1515
+ catch {
1516
+ /* ignore */
1517
+ }
1518
+ // 5. Generate Caddyfile
1519
+ const caddyDir = '/tmp/mesh-dev-caddy';
1520
+ const caddyfile = path.join(caddyDir, 'Caddyfile');
1521
+ if (!fs.existsSync(caddyDir))
1522
+ fs.mkdirSync(caddyDir, { recursive: true });
1523
+ // Build Caddy config — bind directly to the Tailscale IP on port 80.
1524
+ // In kernel tun mode, tailscale0 is a real interface so Caddy can listen
1525
+ // on it without any tailscale serve layer in between.
1526
+ const caddyLines = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];
1527
+ for (const [name, service] of Object.entries(devOutput.services)) {
1528
+ if (service.port) {
1529
+ caddyLines.push(`http://${name}.${baseDomain} {`);
1530
+ caddyLines.push(` bind ${tsInfo.ip}`);
1531
+ caddyLines.push(` reverse_proxy localhost:${service.port}`);
1532
+ caddyLines.push('}');
1533
+ caddyLines.push('');
1534
+ }
1535
+ }
1536
+ for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
1537
+ caddyLines.push(`http://${name}.${baseDomain} {`);
1538
+ caddyLines.push(` bind ${tsInfo.ip}`);
1539
+ caddyLines.push(` reverse_proxy ${tunnel.host}:${tunnel.port}`);
1540
+ caddyLines.push('}');
1541
+ caddyLines.push('');
1542
+ }
1543
+ fs.writeFileSync(caddyfile, caddyLines.join('\n'));
1544
+ // 6. Start or reload Caddy
1545
+ let caddyRunning = false;
1546
+ try {
1547
+ execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });
1548
+ caddyRunning = true;
1549
+ }
1550
+ catch {
1551
+ /* not running */
1552
+ }
1553
+ if (caddyRunning) {
1554
+ try {
1555
+ execFileSync('caddy', ['reload', '--config', caddyfile, '--adapter', 'caddyfile'], {
1556
+ stdio: 'pipe',
1557
+ });
1558
+ }
1559
+ catch {
1560
+ logWarn('Caddy reload failed — check Caddyfile syntax');
1561
+ return null;
1562
+ }
1563
+ }
1564
+ else {
1565
+ try {
1566
+ execFileSync('which', ['caddy'], { stdio: 'pipe' });
1567
+ }
1568
+ catch {
1569
+ logWarn('Caddy not found. Install for subdomain routing: curl -fsSL https://caddyserver.com/api/download?os=linux&arch=arm64 -o /usr/local/bin/caddy && chmod +x /usr/local/bin/caddy');
1570
+ return null;
1571
+ }
1572
+ const caddy = spawn('caddy', ['run', '--config', caddyfile, '--adapter', 'caddyfile'], {
1573
+ stdio: 'ignore',
1574
+ detached: true,
1575
+ cwd: caddyDir,
1576
+ });
1577
+ caddy.unref();
1578
+ }
1579
+ // 7. Register DNS records (after Caddy is confirmed running)
1580
+ try {
1581
+ const existing = readDnsRecords(dnsConfig);
1582
+ const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));
1583
+ allRecords.push(...records);
1584
+ registerDnsRecords(dnsConfig, allRecords);
1585
+ }
1586
+ catch {
1587
+ logWarn('DNS registration failed — Headscale pod may be unreachable. Dev routing may not work.');
1588
+ return null;
1589
+ }
1590
+ return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };
1591
+ }
1592
+ // ============================================================================
1593
+ // Core actions
1594
+ // ============================================================================
1595
+ /**
1596
+ * Start all services in tmux windows.
1597
+ */
1598
+ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv, tunnelPlan = { transport: 'vpn-direct' }, worktreeRoot, taskQueueSuffix = '') {
1599
+ if (!hasTmux()) {
1600
+ logError('tmux is required. Install with: brew install tmux');
1601
+ process.exit(1);
1602
+ }
1603
+ // Always kill any existing session and recreate for a clean start.
1604
+ // Tunnels (if any) are set up as part of this flow below.
1605
+ if (sessionExists(sessionName)) {
1606
+ logInfo(`Killing existing session: ${sessionName}`);
1607
+ killSession(sessionName);
612
1608
  }
613
1609
  const serviceNames = Object.keys(devOutput.services);
614
1610
  if (serviceNames.length === 0) {
615
- logWarn("No services defined in dev output.");
616
- return;
1611
+ logWarn('No services defined in dev output.');
1612
+ return devOutput;
617
1613
  }
618
- const tunnelNames = Object.keys(devOutput.tunnels);
619
- // Create session with a status window
1614
+ // Create tmux session with a status window (always fresh).
620
1615
  logInfo(`Creating tmux session: ${sessionName}`);
621
- execFileSync("tmux", [
622
- "new-session", "-d", "-s", sessionName, "-n", "status", "-c", appRoot,
623
- ]);
624
- // Start tunnels via tunnel manager in the status window (before services)
625
- if (tunnelNames.length > 0) {
626
- const bastionTunnels = Object.entries(devOutput.tunnels).filter(([, t]) => (t.type ?? "bastion") === "bastion");
627
- // Validate bastion tunnels have a bastion instance
628
- if (bastionTunnels.length > 0 && !devOutput.bastion?.instanceId) {
629
- logError("Bastion tunnels defined but no bastion.instanceId in dev output.");
630
- logInfo("Add bastion: { instanceId: '...' } to your dev stack output.");
631
- process.exit(1);
1616
+ execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
1617
+ // Set AWS credentials on the session BEFORE starting anything that needs them
1618
+ // (SSM tunnels, services). tmux windows inherit the session environment.
1619
+ setTmuxEnv(sessionName, awsEnv);
1620
+ // Bring up VPC tunnels (rewriting devOutput.tunnels to localhost:PORT) via the
1621
+ // chosen backing. Tailscale (shared per-tenant userspace daemon): an explicit
1622
+ // `--transport tailscale` now hard-fails on any bring-up error (see below); only
1623
+ // an auto-resolved tailscale choice falls back to SSM. vpn-direct needs no tunnels.
1624
+ const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
1625
+ let effectiveTransport = tunnelPlan.transport;
1626
+ if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
1627
+ try {
1628
+ const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);
1629
+ // Verify the forwarders actually ROUTE, not just bind (MESH-2268). Transport
1630
+ // selection picks tailscale on PRESENCE (a tailscaled binary exists), so an
1631
+ // auto-selected daemon can come up joined to a tailnet that doesn't route the
1632
+ // mesh VPC, or with a dead SOCKS upstream (MESH-2169) — the forwarder binds
1633
+ // its port either way and the bare-accept readiness gate below (waitForPort)
1634
+ // would pass it, letting services start and then die on a gRPC deadline.
1635
+ // Throw on a dead critical tunnel so the catch below applies the same
1636
+ // explicit-hard-fail-vs-auto-SSM-fallback policy as a bring-up failure — and
1637
+ // only commit the rewrite (reassign devOutput) once it holds, so an SSM
1638
+ // fallback runs startSsmTunnels on the ORIGINAL, un-rewritten tunnels.
1639
+ const dead = await firstUnroutableTunnel(rewritten.tunnels);
1640
+ if (dead) {
1641
+ throw new Error(`forwarder for '${dead}' bound but does not route the VPC ` +
1642
+ `(dead SOCKS upstream or non-routing tailnet)`);
1643
+ }
1644
+ devOutput = rewritten;
1645
+ // Announce ONLY after the join + forwarders actually came up — the previous
1646
+ // pre-flight "Using userspace-Tailscale tunnels" printed before this even ran,
1647
+ // so a fallback still read as success (MESH-2098).
1648
+ logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');
632
1649
  }
633
- // Set AWS creds on session so tunnel windows inherit them
634
- setTmuxEnv(sessionName, getAwsEnvVars());
635
- // Write tunnel config for the tunnel manager
636
- const stateDir = path.join(os.tmpdir(), "mesh-dev-sessions");
637
- if (!fs.existsSync(stateDir))
638
- fs.mkdirSync(stateDir, { recursive: true });
639
- const tunnelConfigPath = path.join(stateDir, `${sessionName}-tunnels.json`);
640
- fs.writeFileSync(tunnelConfigPath, JSON.stringify({
641
- tunnels: devOutput.tunnels,
642
- bastionId: devOutput.bastion?.instanceId,
643
- }));
644
- // Run tunnel manager in the status window (combines tunnel management + status display)
645
- // Use process.argv to self-reference (npx mesh may not work in all setups)
646
- const meshBin = process.argv.slice(0, 2).join(" ");
647
- const managerCmd = `${meshBin} dev __tunnel-manager --config '${tunnelConfigPath}' --tmux-session '${sessionName}'`;
648
- execFileSync("tmux", [
649
- "send-keys", "-t", `${sessionName}:status`, managerCmd, "Enter",
650
- ]);
1650
+ catch (err) {
1651
+ const reason = err instanceof Error ? err.message : String(err);
1652
+ if (tunnelPlan.explicit) {
1653
+ // The user explicitly asked for --transport=tailscale. Don't silently
1654
+ // override that with SSM — surface the real reason (often a pending VPN
1655
+ // node registration whose URL was printed above) and let them act.
1656
+ logError(`Tailscale transport failed: ${reason}`);
1657
+ logInfo('If a VPN registration URL was shown above, open it to authorize this machine, then re-run.');
1658
+ logInfo('Or switch backing explicitly: mesh dev --transport ssm');
1659
+ // We already killed the prior session and created a fresh (empty) one
1660
+ // above; tear it down so an explicit-tailscale failure leaves no orphaned
1661
+ // half-session behind.
1662
+ killSession(sessionName);
1663
+ process.exit(1);
1664
+ }
1665
+ logWarn(`Tailscale transport unavailable (${reason}) — falling back to SSM tunnels.`);
1666
+ effectiveTransport = 'ssm';
1667
+ }
1668
+ }
1669
+ if (hasTunnels && effectiveTransport === 'ssm') {
1670
+ devOutput = await startSsmTunnels(sessionName, devOutput);
1671
+ }
1672
+ if (hasTunnels && effectiveTransport !== 'vpn-direct') {
1673
+ // Gate service start on tunnel readiness: poll each tunnel's local port
1674
+ // until it accepts TCP. Without this, Temporal-connected services (api,
1675
+ // worker, vcs) raced the SSM tunnel and died connection-refused at launch.
651
1676
  for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
652
- const target = tunnel.type === "kubectl"
653
- ? `${tunnel.namespace ?? "default"}/${tunnel.resource ?? tName}:${tunnel.port}`
654
- : `${tunnel.host}:${tunnel.port}`;
655
- logSuccess(`Tunnel: ${tName} localhost:${tunnel.localPort} → ${target}`);
1677
+ if (tunnel.host !== 'localhost')
1678
+ continue; // not SSM-rewritten
1679
+ logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);
1680
+ const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);
1681
+ if (!ready) {
1682
+ logError(`Tunnel '${tName}' did not become ready on localhost:${tunnel.port} within ${TUNNEL_READY_TIMEOUT_MS / 1000}s.`);
1683
+ logInfo(` Check the tunnel window for errors: tmux attach -t ${sessionName} (window tunnel-${tName})`);
1684
+ logInfo(' Common causes: expired AWS credentials, bastion stopped, session-manager-plugin errors.');
1685
+ throw new Error(`SSM tunnel '${tName}' not ready on localhost:${tunnel.port} after ${TUNNEL_READY_TIMEOUT_MS / 1000}s`);
1686
+ }
1687
+ logSuccess(` Tunnel ready: ${tName} (localhost:${tunnel.port})`);
1688
+ }
1689
+ }
1690
+ const tunnelNames = Object.keys(devOutput.tunnels);
1691
+ // Set up subdomain routing if Tailscale is available
1692
+ const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);
1693
+ // Log VPC connections
1694
+ if (tunnelNames.length > 0) {
1695
+ const isSSM = Object.values(devOutput.tunnels).some((t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT);
1696
+ logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');
1697
+ for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
1698
+ if (subdomainCtx) {
1699
+ logInfo(` ${tName} \u2192 http://${tName}.${subdomainCtx.baseDomain}`);
1700
+ }
1701
+ else {
1702
+ logInfo(` ${tName} \u2192 ${tunnel.host}:${tunnel.port}`);
1703
+ }
656
1704
  }
657
- // Give tunnels a moment to establish before starting services
658
- logInfo("Waiting for tunnels to establish...");
659
- spawnSync("sleep", ["3"]);
660
1705
  }
661
1706
  // Resolve secrets from Secrets Manager
662
1707
  let secretEnvVars = {};
663
1708
  if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
664
- secretEnvVars = await resolveSecrets(devOutput.secrets, devOutput.tunnels);
1709
+ secretEnvVars = await resolveSecrets(devOutput.secrets);
1710
+ }
1711
+ // Resolve Temporal auth credentials from SSM when a temporal endpoint is configured.
1712
+ const tenant = devOutput.platform?.tenant ?? 'mesh';
1713
+ const platformEnv = devOutput.platform?.env ?? 'dev';
1714
+ // Write a session-scoped kubeconfig (aws eks get-token) so the kubectl calls
1715
+ // below (encoding-key read, temporal-pod env fallback) don't depend on an
1716
+ // ambient ~/.kube/config. Best-effort: continue without it if unresolved.
1717
+ // The local platform (`mesh start`) has no cluster — skip entirely.
1718
+ const isLocalPlatform = tenant === 'local';
1719
+ if (!isLocalPlatform) {
1720
+ // Hub platform name — the EKS export lives under it, not under a hardcoded
1721
+ // "mesh" (MESH-2515); same axis as the bastion resolution.
1722
+ const platformName = resolveHubPlatformName(devOutput.platform);
1723
+ const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
1724
+ onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
1725
+ });
1726
+ if (kubeconfigPath) {
1727
+ process.env.KUBECONFIG = kubeconfigPath;
1728
+ setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
1729
+ logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) → ${kubeconfigPath}`);
1730
+ }
1731
+ else {
1732
+ logWarn('Could not resolve EKS kubeconfig from SSM; kubectl calls will use ambient config (if any).');
1733
+ }
665
1734
  }
666
- // Set shared env vars (AWS creds + secrets) on the tmux session.
667
- // Per-service vars are set before each window is created.
668
- setTmuxEnv(sessionName, getAwsEnvVars());
1735
+ let temporalAuthVars = {};
1736
+ if (devOutput.tunnels['temporal']) {
1737
+ // Deliberately NOT the `?? 'mesh'` fallback the EKS path above uses: the
1738
+ // Temporal-auth fallback is `?? tenant` (dedicated tenants may run their
1739
+ // own Temporal). Whether that difference is correct is MESH-2551.
1740
+ temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
1741
+ // Resolve the encoding key from K8s so local dev can decrypt
1742
+ // payloads created by deployed workers.
1743
+ const appName = devOutput.app ?? '';
1744
+ if (appName) {
1745
+ const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
1746
+ if (encodingKey) {
1747
+ temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
1748
+ }
1749
+ }
1750
+ // Set TEMPORAL_ADDRESS to the VPC endpoint for direct VPN access (or the
1751
+ // pinned loopback address under SSM fallback — see tunnelClientAddress).
1752
+ const temporalTunnel = devOutput.tunnels['temporal'];
1753
+ temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
1754
+ }
1755
+ // Set shared env vars (secrets + temporal auth) on the tmux session.
1756
+ // AWS creds were already set earlier (before SSM tunnels started).
669
1757
  setTmuxEnv(sessionName, secretEnvVars);
1758
+ setTmuxEnv(sessionName, temporalAuthVars);
1759
+ // Inject dev user identity from mesh login credentials so services can
1760
+ // emulate the oauth2-proxy auth headers locally.
1761
+ //
1762
+ // The credentials are stored under the platform context (e.g. 'mesh.dev'),
1763
+ // not the tenant name. Use 'mesh' as the platform name since all tenants
1764
+ // share the same Zitadel instance.
1765
+ //
1766
+ // Services (e.g., Hub UI) use these env vars as a fallback when the real
1767
+ // oauth2-proxy `X-Forwarded-*` request headers are absent:
1768
+ // DEV_USER_ID - subject from idToken, matches conversation ownership
1769
+ // DEV_USER_EMAIL - user email from idToken
1770
+ // DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
1771
+ // DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
1772
+ // Local platform creds live under the `local` context (mesh login local).
1773
+ const credContext = devOutput.platform
1774
+ ? isLocalPlatform
1775
+ ? 'local'
1776
+ : `mesh.${devOutput.platform.env}`
1777
+ : null;
1778
+ const devUserVars = resolveDevUserVars(devOutput);
1779
+ if (Object.keys(devUserVars).length > 0) {
1780
+ setTmuxEnv(sessionName, devUserVars);
1781
+ logSuccess(`Dev user injected from mesh login: ${credContext}`);
1782
+ // Serve a continuously-fresh dev-user token so services survive past the
1783
+ // ~1h Zitadel horizon without a restart (MESH-2039). The static vars above
1784
+ // remain the fallback when this URL is absent. App-agnostic: gated on the
1785
+ // platform context, not the app — any `mesh dev` app inherits it.
1786
+ const tokenPort = await findFreePort();
1787
+ const tokenUrl = `http://127.0.0.1:${tokenPort}`;
1788
+ setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
1789
+ execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);
1790
+ execFileSync('tmux', ['set-option', '-t', `${sessionName}:token-server`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1791
+ execFileSync('tmux', [
1792
+ 'send-keys',
1793
+ '-t',
1794
+ `${sessionName}:token-server`,
1795
+ `npx mesh dev __token-server ${tokenPort} ${credContext}`,
1796
+ 'Enter',
1797
+ ]);
1798
+ // Best-effort readiness so the first requests land (non-fatal).
1799
+ for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {
1800
+ await new Promise((r) => setTimeout(r, 200));
1801
+ }
1802
+ logSuccess(`Dev-user token-server: ${tokenUrl} (context ${credContext})`);
1803
+ }
1804
+ else if (credContext) {
1805
+ logWarn(`No mesh login credentials found for '${credContext}'.`);
1806
+ logInfo(` Run: mesh login ${credContext}`);
1807
+ logInfo(' Hub UI will show unauthenticated state without valid credentials.');
1808
+ }
1809
+ const monorepoRoot = findMonorepoRoot();
670
1810
  for (const [name, service] of Object.entries(devOutput.services)) {
671
- const cmd = service.command.join(" ");
1811
+ const cmd = service.command.join(' ');
672
1812
  // Skip services with no dev command (e.g., deployed-only like ApiDocs)
673
1813
  if (!cmd) {
674
1814
  logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
675
1815
  continue;
676
1816
  }
677
- const serviceDir = path.resolve(appRoot, service.src);
678
- // Per-service env vars are inlined into the command via `env K=V`
679
- // to avoid cross-service leaks through the tmux session environment.
680
- const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
681
- const prefixedCmd = `${envPrefix(serviceVars)}${cmd}`;
682
- execFileSync("tmux", [
683
- "new-window", "-t", sessionName, "-n", name, "-c", serviceDir,
684
- ]);
1817
+ const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
1818
+ // Wrong-worktree guard (MESH-2013): if `rebaseServiceSrc` couldn't re-root the
1819
+ // deploy-time absolute path onto THIS worktree, `serviceDir` can point into a
1820
+ // DIFFERENT worktree silently running stale code. Refuse rather than launch it.
1821
+ if (worktreeRoot && fs.existsSync(serviceDir)) {
1822
+ const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
1823
+ if (serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep)) {
1824
+ logError(`Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +
1825
+ `outside this worktree (${worktreeRoot}). The stack was likely deployed ` +
1826
+ `from another worktree. Re-run \`mesh deploy up\` here, or pass --app.`);
1827
+ process.exit(1);
1828
+ }
1829
+ }
1830
+ // Skip services whose source directory doesn't exist in this worktree.
1831
+ // This happens when the Pulumi stack was deployed from a different branch
1832
+ // that added a service not yet present here (e.g., hub-worker added on
1833
+ // feature/hub-credential-management but absent from feature/app-detail-redesign).
1834
+ if (!fs.existsSync(serviceDir)) {
1835
+ logWarn(`Skipped: ${name} (source directory not found: ${serviceDir}` +
1836
+ `${serviceDir !== service.src ? ` — rebased from ${service.src}` : ''})`);
1837
+ continue;
1838
+ }
1839
+ // Per-service env vars are written to a 0600 env file and `source`d by
1840
+ // the launch command, scoping them to the process (no cross-service
1841
+ // leaks through the tmux session environment) while keeping the
1842
+ // send-keys string short — inlining the full env used to intermittently
1843
+ // exceed what tmux send-keys delivers intact, silently truncating the
1844
+ // command so the service never started.
1845
+ //
1846
+ // Overlay the RESOLVED Temporal vars (real encoding key + tunnel address +
1847
+ // M2M auth, fetched from K8s/SSM) on top of the dev-output env. The dev
1848
+ // output carries placeholder values from the Agent's Temporal link (e.g.
1849
+ // a `dev-encoding-key-*` placeholder), and the per-service env would
1850
+ // otherwise shadow the session-level resolved values — desyncing services
1851
+ // that DO carry the placeholder (worker) from those that don't (api),
1852
+ // which breaks payload encryption (mismatched encoding keys → decode null).
1853
+ const serviceVars = {
1854
+ ...getServiceEnvVars(service, devOutput.tunnels),
1855
+ ...temporalAuthVars,
1856
+ // Overlay the resolved dev-user identity for the same reason as the
1857
+ // Temporal vars: the dev output may carry a placeholder (e.g. hub ui's
1858
+ // DEV_USER_ACCESS_TOKEN="dev-local"), and since the sourced env file
1859
+ // wins over the tmux session env, the placeholder would clobber the
1860
+ // real token injected above — silently 401ing tenant-scoped Hub views.
1861
+ ...devUserVars,
1862
+ // Per-worktree Temporal task-queue isolation (MESH-2013 Slice 2): agent
1863
+ // workers/apps append this to their task-queue names so concurrent
1864
+ // worktrees don't steal each other's workflow tasks. Empty on the primary
1865
+ // checkout → omitted → unchanged historical behavior.
1866
+ ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
1867
+ };
1868
+ const envFilePath = getServiceEnvFilePath(sessionName, name);
1869
+ writeEnvFile(envFilePath, serviceVars);
1870
+ // Local mode ships the service's stdout to the platform's log pipeline
1871
+ // via OTLP (assets/log-shipper.mjs): the OTEL_* vars in the env file
1872
+ // carry the hosted k8s_* attribution, so the Hub's logs view covers
1873
+ // local runs with zero file plumbing.
1874
+ const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
1875
+ execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
685
1876
  // Keep window alive on exit so errors are visible
686
- execFileSync("tmux", [
687
- "set-option", "-t", `${sessionName}:${name}`, "remain-on-exit", "on",
688
- ], { stdio: "ignore" });
689
- execFileSync("tmux", [
690
- "send-keys", "-t", `${sessionName}:${name}`, prefixedCmd, "Enter",
691
- ]);
692
- logSuccess(`Started: ${name} (${service.src}, port ${service.port})`);
1877
+ execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
1878
+ stdio: 'ignore',
1879
+ });
1880
+ execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);
1881
+ // Log the ACTUAL launch dir (`serviceDir`, after the worktree rebase), not the
1882
+ // raw deploy-time `service.src` — printing the pre-rebase path made a correctly
1883
+ // re-rooted service look like it was running from a different (stale) worktree.
1884
+ logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);
693
1885
  }
694
1886
  // Generate reverse proxy config for DevBox hostname-based routing.
695
- // When running inside a devbox (DEVCONTAINER=1 on EC2), update the host's
696
- // Caddy config so services are accessible via {service}.mesh.local.
697
- if (process.env.DEVCONTAINER === "1") {
1887
+ if (process.env.DEVCONTAINER === '1') {
698
1888
  try {
699
1889
  await updateDevboxProxy(devOutput, appRoot);
700
1890
  }
@@ -702,56 +1892,65 @@ async function startServices(sessionName, appRoot, devOutput, headless) {
702
1892
  logWarn(`Could not update devbox proxy: ${e.message}`);
703
1893
  }
704
1894
  }
705
- // Status window: if no tunnels, run watch for live status updates.
706
- // (When tunnels exist, the tunnel manager already runs in the status window.)
707
- if (tunnelNames.length === 0) {
708
- const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
709
- execFileSync("tmux", [
710
- "send-keys", "-t", `${sessionName}:status`, statusCmd, "Enter",
711
- ]);
712
- }
713
- console.log("");
1895
+ // Run live status updates in the status window
1896
+ const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
1897
+ execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
1898
+ console.log('');
714
1899
  logSuccess(`Dev session started: ${sessionName}`);
715
- console.log("");
1900
+ console.log('');
716
1901
  if (tunnelNames.length > 0) {
717
- console.log("Tunnels:");
1902
+ console.log('Connections (VPN direct):');
718
1903
  for (const tName of tunnelNames) {
719
1904
  const t = devOutput.tunnels[tName];
720
- const target = t.type === "kubectl"
721
- ? `${t.namespace ?? "default"}/${t.resource ?? tName}:${t.port}`
722
- : `${t.host}:${t.port}`;
723
- const label = t.type === "kubectl" ? " (kubectl)" : "";
724
- console.log(` ${tName.padEnd(20)} localhost:${t.localPort} → ${target}${label}`);
1905
+ if (subdomainCtx) {
1906
+ console.log(` ${tName.padEnd(20)} http://${tName}.${subdomainCtx.baseDomain}`);
1907
+ }
1908
+ else {
1909
+ console.log(` ${tName.padEnd(20)} ${t.host}:${t.port}`);
1910
+ }
725
1911
  }
726
- console.log("");
1912
+ console.log('');
727
1913
  }
728
- console.log("Services:");
1914
+ console.log('Services:');
729
1915
  for (const name of serviceNames) {
730
1916
  const s = devOutput.services[name];
731
1917
  const hasCmd = s.command && s.command.length > 0;
732
1918
  if (hasCmd) {
733
- const addr = s.port ? `http://localhost:${s.port}` : "(no port)";
734
- console.log(` ${name.padEnd(20)} ${addr}`);
1919
+ if (subdomainCtx && s.port) {
1920
+ console.log(` ${name.padEnd(20)} http://${name}.${subdomainCtx.baseDomain}`);
1921
+ }
1922
+ else {
1923
+ const addr = s.port ? `http://localhost:${s.port}` : '(no port)';
1924
+ console.log(` ${name.padEnd(20)} ${addr}`);
1925
+ }
735
1926
  }
736
1927
  else {
737
1928
  console.log(` ${name.padEnd(20)} (deployed)`);
738
1929
  }
739
1930
  }
740
- console.log("");
1931
+ console.log('');
741
1932
  if (!headless) {
742
- logInfo("Attaching to tmux session...");
743
- spawnSync("tmux", ["attach", "-t", sessionName], { stdio: "inherit" });
1933
+ logInfo('Attaching to tmux session...');
1934
+ if (process.env.TMUX) {
1935
+ spawnSync('tmux', ['switch-client', '-t', sessionName], { stdio: 'inherit' });
1936
+ }
1937
+ else {
1938
+ spawnSync('tmux', ['attach', '-t', sessionName], { stdio: 'inherit' });
1939
+ }
744
1940
  }
745
1941
  else {
746
1942
  console.log(`Attach with: tmux attach -t ${sessionName}`);
747
1943
  console.log(`Stop with: mesh dev --kill`);
748
- console.log("");
1944
+ console.log('');
749
1945
  }
1946
+ // Return the (possibly SSM-tunnel-rewritten) devOutput so the caller can
1947
+ // persist it in session state for --status / restart.
1948
+ return devOutput;
750
1949
  }
751
1950
  /**
752
1951
  * Show status of running services.
753
1952
  */
754
- function showStatus(sessionName, devOutput, asJson) {
1953
+ async function showStatus(sessionName, devOutput, asJson) {
755
1954
  if (!sessionExists(sessionName)) {
756
1955
  if (asJson) {
757
1956
  console.log(JSON.stringify({ running: false, session: sessionName }));
@@ -764,106 +1963,135 @@ function showStatus(sessionName, devOutput, asJson) {
764
1963
  // Get tmux window list
765
1964
  let windows = [];
766
1965
  try {
767
- const raw = execFileSync("tmux", [
768
- "list-windows", "-t", sessionName, "-F", "#{window_name} #{pane_dead}",
769
- ], { encoding: "utf-8" });
770
- windows = raw.trim().split("\n");
1966
+ const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
1967
+ windows = raw.trim().split('\n');
771
1968
  }
772
1969
  catch {
773
1970
  // Session might have disappeared
774
1971
  }
775
1972
  const windowStatus = {};
776
1973
  for (const line of windows) {
777
- const [name, dead] = line.split(" ");
1974
+ const [name, dead] = line.split(' ');
778
1975
  if (name)
779
- windowStatus[name] = dead === "1" ? "exited" : "running";
1976
+ windowStatus[name] = dead === '1' ? 'exited' : 'running';
780
1977
  }
781
1978
  const tunnelNames = Object.keys(devOutput.tunnels);
1979
+ // Live-probe each tunnel so a mid-session tunnel death shows as DOWN here
1980
+ // (this render runs every ~2s in the status window) — MESH-2016.
1981
+ const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
1982
+ const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
782
1983
  if (asJson) {
783
1984
  const services = {};
784
1985
  for (const [name, svc] of Object.entries(devOutput.services)) {
785
1986
  services[name] = {
786
- status: windowStatus[name] ?? "unknown",
1987
+ status: windowStatus[name] ?? 'unknown',
787
1988
  port: svc.port,
788
1989
  src: svc.src,
789
1990
  };
790
1991
  }
791
1992
  const tunnels = {};
792
1993
  for (const [name, tun] of Object.entries(devOutput.tunnels)) {
793
- const target = tun.type === "kubectl"
794
- ? `${tun.namespace ?? "default"}/${tun.resource ?? name}:${tun.port}`
795
- : `${tun.host}:${tun.port}`;
796
- const listening = isPortListeningSync(tun.localPort);
797
1994
  tunnels[name] = {
798
- status: listening ? "running" : "down",
799
- localPort: tun.localPort,
800
- remote: target,
801
- type: tun.type ?? "bastion",
1995
+ host: tun.host,
1996
+ port: tun.port,
1997
+ reachable: healthByName.get(name)?.reachable ?? false,
802
1998
  };
803
1999
  }
804
2000
  console.log(JSON.stringify({ running: true, session: sessionName, services, tunnels }, null, 2));
805
2001
  }
806
2002
  else {
807
- console.log("── mesh dev ──");
808
- console.log("");
2003
+ console.log('\u2500\u2500 mesh dev \u2500\u2500');
2004
+ console.log('');
809
2005
  console.log(`Session: ${sessionName}`);
810
2006
  if (tunnelNames.length > 0) {
811
- // Tunnel manager runs in a single window; check health by probing local ports
812
- const managerStatus = windowStatus["tunnels"] ?? "unknown";
813
- const managerIcon = managerStatus === "running" ? "●" : "✗";
814
- console.log("");
815
- console.log(`Tunnels: ${managerIcon} manager ${managerStatus}`);
816
- for (const [name, tun] of Object.entries(devOutput.tunnels)) {
817
- const listening = isPortListeningSync(tun.localPort);
818
- const icon = listening ? "●" : "✗";
819
- const status = listening ? "up" : "down";
820
- const target = tun.type === "kubectl"
821
- ? `${tun.namespace ?? "default"}/${tun.resource ?? name}:${tun.port}`
822
- : `${tun.host}:${tun.port}`;
823
- const label = tun.type === "kubectl" ? " kubectl" : "";
824
- console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} :${tun.localPort} → ${target}${label}`);
825
- }
826
- }
827
- console.log("");
828
- console.log("Services:");
2007
+ console.log('');
2008
+ for (const line of formatTunnelHealth(tunnelHealth))
2009
+ console.log(line);
2010
+ }
2011
+ console.log('');
2012
+ console.log('Services:');
829
2013
  for (const [name, svc] of Object.entries(devOutput.services)) {
830
2014
  const hasCmd = svc.command && svc.command.length > 0;
831
2015
  if (!hasCmd) {
832
2016
  console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
833
2017
  continue;
834
2018
  }
835
- const status = windowStatus[name] ?? "unknown";
836
- const icon = status === "running" ? "" : status === "exited" ? "" : "?";
837
- const addr = svc.port ? `:${svc.port}` : "";
2019
+ const status = windowStatus[name] ?? 'unknown';
2020
+ const icon = status === 'running' ? '' : status === 'exited' ? '' : '?';
2021
+ const addr = svc.port ? `:${svc.port}` : '';
838
2022
  console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);
839
2023
  }
840
- console.log("");
841
- console.log("Commands:");
842
- console.log(" mesh dev restart <s> Restart a service");
843
- console.log(" mesh dev logs <s> Tail service logs");
844
- console.log(" mesh dev --kill Stop everything");
845
- console.log("");
846
- console.log("tmux: Ctrl+b n/p switch windows, d detach");
2024
+ console.log('');
2025
+ console.log('Commands:');
2026
+ console.log(' mesh dev restart <s> Restart a service');
2027
+ console.log(' mesh dev logs <s> Tail service logs');
2028
+ console.log(' mesh dev --kill Stop everything');
2029
+ console.log('');
2030
+ console.log('tmux: Ctrl+b n/p switch windows, d detach');
847
2031
  }
848
2032
  }
849
2033
  /**
850
2034
  * Restart a single service window.
851
2035
  */
852
- function restartService(sessionName, serviceName, _appRoot, devOutput) {
853
- const service = devOutput.services[serviceName];
2036
+ async function restartService(sessionName, serviceName, appRoot, devOutput, awsEnv, opts) {
2037
+ let service = devOutput.services[serviceName];
854
2038
  if (!service) {
855
2039
  logError(`Unknown service: ${serviceName}`);
856
- logInfo(`Available: ${Object.keys(devOutput.services).join(", ")}`);
2040
+ logInfo(`Available: ${Object.keys(devOutput.services).join(', ')}`);
857
2041
  process.exit(1);
858
2042
  }
859
2043
  if (!sessionExists(sessionName)) {
860
2044
  logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
861
2045
  process.exit(1);
862
2046
  }
2047
+ // --refresh-env: regenerate this service's env file from CURRENT stack/SSM
2048
+ // outputs BEFORE killing the running process, so a failed read leaves the
2049
+ // good env file (and the running service) untouched (fail-closed).
2050
+ if (opts?.refreshEnv) {
2051
+ if (!opts.stack) {
2052
+ logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
2053
+ return;
2054
+ }
2055
+ // The session's allocated devOutput pins the ports/tunnels to reuse.
2056
+ const pinned = opts.sessionState?.devOutput ?? devOutput;
2057
+ let fresh;
2058
+ try {
2059
+ fresh = getDevOutput(appRoot, opts.stack, awsEnv);
2060
+ }
2061
+ catch (err) {
2062
+ logError(`--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`);
2063
+ logInfo(String(err?.message ?? err));
2064
+ return;
2065
+ }
2066
+ const merged = mergePinnedPorts(fresh, pinned);
2067
+ const mergedService = merged.services[serviceName];
2068
+ if (!mergedService) {
2069
+ logError(`--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`);
2070
+ return;
2071
+ }
2072
+ const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);
2073
+ // Recompute the per-worktree suffix (deterministic from the worktree path,
2074
+ // so it matches what launch wrote) — a restarted service must keep polling
2075
+ // this worktree's isolated task queue (MESH-2013 Slice 2).
2076
+ const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
2077
+ const serviceVars = {
2078
+ ...getServiceEnvVars(mergedService, merged.tunnels),
2079
+ ...temporalAuthVars,
2080
+ // Same overlay as launch: without it, --refresh-env rewrites the env
2081
+ // file from dev output and its DEV_USER_* placeholders clobber the real
2082
+ // token from `mesh login` (the "restart silently 401s the Hub" trap).
2083
+ ...resolveDevUserVars(merged),
2084
+ ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
2085
+ };
2086
+ writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);
2087
+ // Relaunch from the refreshed service definition (command/src may have changed).
2088
+ service = mergedService;
2089
+ logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
2090
+ }
863
2091
  const target = `${sessionName}:${serviceName}`;
864
2092
  // Kill the current pane process and respawn
865
2093
  try {
866
- execFileSync("tmux", ["respawn-pane", "-k", "-t", target], { stdio: "ignore" });
2094
+ execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
867
2095
  }
868
2096
  catch {
869
2097
  // Window might not exist yet
@@ -871,12 +2099,27 @@ function restartService(sessionName, serviceName, _appRoot, devOutput) {
871
2099
  process.exit(1);
872
2100
  }
873
2101
  // Refresh shared env (AWS creds may have rotated) on the session.
874
- // Per-service vars are inlined into the command to avoid cross-service leaks.
875
- setTmuxEnv(sessionName, getAwsEnvVars());
876
- const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
877
- const cmd = service.command.join(" ");
878
- const prefixedCmd = `${envPrefix(serviceVars)}${cmd}`;
879
- execFileSync("tmux", ["send-keys", "-t", target, prefixedCmd, "Enter"]);
2102
+ setTmuxEnv(sessionName, awsEnv);
2103
+ const cmd = service.command.join(' ');
2104
+ // Source the SAME env file written at launch — it is the session's env
2105
+ // contract for this service, guaranteeing a byte-identical environment.
2106
+ // Reconstructing env here has historically diverged from launch (e.g.
2107
+ // TEMPORAL_ENCODING_KEY drift → "cannot decrypt payload" / missing key →
2108
+ // "Unknown encoding: binary/encrypted").
2109
+ const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
2110
+ let restartCmd;
2111
+ if (fs.existsSync(envFilePath)) {
2112
+ const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
2113
+ // Keep the local log pipeline attached across restarts.
2114
+ restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
2115
+ }
2116
+ else {
2117
+ logWarn(`No launch env file for '${serviceName}' (${envFilePath}) — session predates env-file launches.`);
2118
+ logWarn('Falling back to reconstructed env; restart `mesh dev` for a faithful environment.');
2119
+ const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
2120
+ restartCmd = `${envPrefix(serviceVars)}${cmd}`;
2121
+ }
2122
+ execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
880
2123
  logSuccess(`Restarted: ${serviceName}`);
881
2124
  }
882
2125
  /**
@@ -889,216 +2132,121 @@ function showLogs(sessionName, serviceName, tail) {
889
2132
  }
890
2133
  const target = `${sessionName}:${serviceName}`;
891
2134
  try {
892
- const result = execFileSync("tmux", [
893
- "capture-pane", "-t", target, "-p", "-S", `-${tail}`,
894
- ], { encoding: "utf-8" });
2135
+ const result = execFileSync('tmux', ['capture-pane', '-t', target, '-p', '-S', `-${tail}`], {
2136
+ encoding: 'utf-8',
2137
+ });
895
2138
  process.stdout.write(result);
896
2139
  }
897
2140
  catch {
898
2141
  logError(`Could not capture logs for '${serviceName}'.`);
899
- logInfo("Is the service name correct? Check with: mesh dev --status");
2142
+ logInfo('Is the service name correct? Check with: mesh dev --status');
900
2143
  process.exit(1);
901
2144
  }
902
2145
  }
903
2146
  // ============================================================================
904
- // Tunnel Manager
2147
+ // Command registration
905
2148
  // ============================================================================
906
- import { spawn } from "child_process";
907
2149
  /**
908
- * Tunnel manager spawns all tunnels as child processes, monitors health,
909
- * auto-restarts on failure, and prints a live status table.
910
- *
911
- * Runs in a single tmux window instead of one window per tunnel.
2150
+ * Assemble a {@link DoctorContext} for the current app/stack/session, resolving
2151
+ * the deployer role the same way the `restart`/launch paths do (default role +
2152
+ * Zitadel-claim admin selection). Used by `mesh dev doctor`.
912
2153
  */
913
- function runTunnelManager(configPath, sessionName) {
914
- const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
915
- const tunnelEntries = Object.entries(config.tunnels);
916
- if (tunnelEntries.length === 0) {
917
- console.log("No tunnels to manage.");
918
- return;
919
- }
920
- const states = tunnelEntries.map(([name, tunnel]) => {
921
- let argv;
922
- if ((tunnel.type ?? "bastion") === "bastion") {
923
- argv = [
924
- "aws", "ssm", "start-session",
925
- "--target", config.bastionId,
926
- "--document-name", "AWS-StartPortForwardingSessionToRemoteHost",
927
- "--parameters", JSON.stringify({
928
- host: [tunnel.host],
929
- portNumber: [String(tunnel.port)],
930
- localPortNumber: [String(tunnel.localPort)],
931
- }),
932
- ];
933
- }
934
- else {
935
- const ns = tunnel.namespace ?? "default";
936
- const resource = tunnel.resource ?? `svc/${name}`;
937
- argv = ["kubectl", "port-forward", "-n", ns, resource, `${tunnel.localPort}:${tunnel.port}`];
938
- }
939
- return {
940
- name,
941
- tunnel,
942
- process: null,
943
- status: "starting",
944
- restarts: 0,
945
- lastStart: 0,
946
- argv,
947
- };
948
- });
949
- function startTunnel(state) {
950
- state.status = "starting";
951
- state.lastStart = Date.now();
952
- const [cmd, ...args] = state.argv;
953
- const child = spawn(cmd, args, {
954
- stdio: ["ignore", "pipe", "pipe"],
955
- });
956
- child.stdout?.on("data", () => {
957
- // SSM/kubectl output — tunnel is establishing
958
- if (state.status === "starting") {
959
- state.status = "up";
960
- }
961
- });
962
- child.stderr?.on("data", (data) => {
963
- const msg = data.toString().trim();
964
- if (msg && !msg.includes("Starting session") && !msg.includes("Forwarding from")) {
965
- console.log(` [${state.name}] ${msg}`);
966
- }
2154
+ function buildDoctorContext(appRoot, stack, sessionName) {
2155
+ const state = loadSessionState(sessionName);
2156
+ const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
2157
+ const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
2158
+ const platformContext = derivePlatformContext(appRoot, stack);
2159
+ let deployerRole = defaultDeployerRole;
2160
+ if (defaultDeployerRole) {
2161
+ const meshCreds = platformContext ? readCredentials(platformContext) : null;
2162
+ const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
2163
+ deployerRole = selectRoleForCaller(idToken, {
2164
+ defaultRole: defaultDeployerRole,
2165
+ adminRole: adminDeployerRole ?? undefined,
967
2166
  });
968
- child.on("exit", (code) => {
969
- state.process = null;
970
- if (shuttingDown)
971
- return;
972
- state.status = "down";
973
- state.restarts++;
974
- // Back off: wait longer between restarts (1s, 2s, 4s, 8s, max 30s)
975
- const delay = Math.min(1000 * Math.pow(2, Math.min(state.restarts - 1, 4)), 30000);
976
- console.log(` [${state.name}] exited (code ${code}), restarting in ${delay / 1000}s...`);
977
- state.status = "restarting";
978
- setTimeout(() => {
979
- if (!shuttingDown)
980
- startTunnel(state);
981
- }, delay);
982
- });
983
- state.process = child;
984
- // Mark as "up" after a short delay if still running
985
- setTimeout(() => {
986
- if (state.process && state.status === "starting") {
987
- state.status = "up";
988
- }
989
- }, 2000);
990
- }
991
- let shuttingDown = false;
992
- function shutdown() {
993
- if (shuttingDown)
994
- return;
995
- shuttingDown = true;
996
- console.log("\nShutting down tunnels...");
997
- for (const state of states) {
998
- if (state.process) {
999
- state.process.kill("SIGTERM");
1000
- }
1001
- }
1002
- setTimeout(() => process.exit(0), 1000);
1003
- }
1004
- process.on("SIGTERM", shutdown);
1005
- process.on("SIGINT", shutdown);
1006
- // Print header
1007
- console.log("mesh dev — tunnel manager");
1008
- console.log("─".repeat(60));
1009
- for (const state of states) {
1010
- const target = state.tunnel.type === "kubectl"
1011
- ? `${state.tunnel.namespace ?? "default"}/${state.tunnel.resource ?? state.name}:${state.tunnel.port}`
1012
- : `${state.tunnel.host}:${state.tunnel.port}`;
1013
- const label = state.tunnel.type === "kubectl" ? "kubectl" : "bastion";
1014
- console.log(` ${state.name.padEnd(16)} :${state.tunnel.localPort} → ${target} (${label})`);
1015
- }
1016
- console.log("─".repeat(60));
1017
- console.log("");
1018
- // Start all tunnels
1019
- for (const state of states) {
1020
- startTunnel(state);
1021
- }
1022
- // Periodically print full status (tunnels + services)
1023
- function printStatus() {
1024
- if (shuttingDown)
1025
- return;
1026
- // Clear screen and move cursor to top
1027
- process.stdout.write("\x1b[2J\x1b[H");
1028
- console.log("── mesh dev ──");
1029
- console.log("");
1030
- // Tunnels
1031
- console.log("Tunnels:");
1032
- for (const s of states) {
1033
- const icon = s.status === "up" ? "●" : s.status === "restarting" ? "↻" : s.status === "starting" ? "…" : "✗";
1034
- const statusLabel = s.status === "up" ? "up" : s.status === "restarting" ? `restart(${s.restarts})` : s.status;
1035
- const target = s.tunnel.type === "kubectl"
1036
- ? `${s.tunnel.namespace ?? "default"}/${s.tunnel.resource ?? s.name}:${s.tunnel.port}`
1037
- : `${s.tunnel.host}:${s.tunnel.port}`;
1038
- const label = s.tunnel.type === "kubectl" ? " kubectl" : "";
1039
- console.log(` ${icon} ${s.name.padEnd(16)} ${statusLabel.padEnd(14)} :${s.tunnel.localPort} → ${target}${label}`);
1040
- }
1041
- // Services (check tmux window status if session name provided)
1042
- if (sessionName) {
1043
- let windows = [];
1044
- try {
1045
- const raw = execFileSync("tmux", [
1046
- "list-windows", "-t", sessionName, "-F", "#{window_name} #{pane_dead}",
1047
- ], { encoding: "utf-8" });
1048
- windows = raw.trim().split("\n");
1049
- }
1050
- catch { }
1051
- const windowStatus = {};
1052
- for (const line of windows) {
1053
- const [name, dead] = line.split(" ");
1054
- if (name)
1055
- windowStatus[name] = dead === "1" ? "exited" : "running";
1056
- }
1057
- // Get service names from session state
1058
- const state = loadSessionState(sessionName);
1059
- if (state?.devOutput?.services) {
1060
- console.log("");
1061
- console.log("Services:");
1062
- for (const [name, svc] of Object.entries(state.devOutput.services)) {
1063
- const hasCmd = svc.command && svc.command.length > 0;
1064
- if (!hasCmd) {
1065
- console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
1066
- continue;
1067
- }
1068
- const status = windowStatus[name] ?? "unknown";
1069
- const icon = status === "running" ? "●" : status === "exited" ? "✗" : "?";
1070
- const addr = svc.port ? `:${svc.port}` : "";
1071
- console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(14)} ${addr.padEnd(8)} ${svc.src}`);
1072
- }
1073
- }
1074
- }
1075
- console.log("");
1076
- console.log("mesh dev --status / --kill | Ctrl+b n/p switch windows");
1077
2167
  }
1078
- // Print initial status after tunnels start, then refresh every 2s
1079
- setTimeout(printStatus, 3000);
1080
- setInterval(printStatus, 2000);
2168
+ return {
2169
+ appRoot,
2170
+ stack,
2171
+ sessionName,
2172
+ deployerRole,
2173
+ platformContext,
2174
+ credMethod: null,
2175
+ sessionState: state,
2176
+ };
1081
2177
  }
1082
- // ============================================================================
1083
- // Command registration
1084
- // ============================================================================
1085
2178
  export function registerDevCommand(program) {
1086
2179
  const dev = program
1087
- .command("dev")
1088
- .description("Start local dev environment (reads Pulumi stack outputs)")
1089
- .option("--app <path>", "Path to a Pulumi app (relative to monorepo root or cwd)")
1090
- .option("--stage <stage>", "Pulumi stack name (default: auto-detect)")
1091
- .option("--headless", "Start without attaching to tmux")
1092
- .option("--kill", "Kill existing dev session")
1093
- .option("--status", "Show service status")
1094
- .option("--json", "Output status as JSON (with --status)")
1095
- .option("--session <name>", "Custom tmux session name");
2180
+ .command('dev')
2181
+ .description('Start local dev environment (reads Pulumi stack outputs)')
2182
+ .option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')
2183
+ .option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')
2184
+ .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
2185
+ .option('--headless', 'Start without attaching to tmux')
2186
+ .option('--kill', 'Kill existing dev session')
2187
+ .option('--status', 'Show service status')
2188
+ .option('--json', 'Output status as JSON (with --status)')
2189
+ .option('--session <name>', 'Override the tmux session name (auto-derived per git worktree by default — omit to keep concurrent worktrees isolated)')
2190
+ .option('--dry-run', 'Print the resolved worktree/session/port plan and exit without launching')
2191
+ .option('--force', 'Relaunch even if workflow code changed since launch (may strand in-flight conversations)')
2192
+ .option('--profile <name>', 'AWS SSO profile to use (e.g., mesh-dev)')
2193
+ .addOption(new Option('--transport <mode>', 'Tunnel backing (default: auto — Tailscale when available, else SSM/VPN)').choices(['auto', 'ssm', 'tailscale']))
2194
+ .option('--local', 'Run against the local Mesh platform from `mesh start` (no AWS, no VPN, no Pulumi state). Auto-selected when the app has no Pulumi.yaml.')
2195
+ .option('--externals [names]', "Also realize the app's declared external services (package.json → mesh.externals): all of them, or a comma-separated subset of name[=mode] entries. Modes: mock (emulate — OpenAPI spec via Prism, or a mock process), local (a local version via docker compose, e.g. a vendor DB replica), remote (connect to the actual service — vendor sandbox credentials, or the external configured in the app's tenant environment). name=mode overrides the declaration's default for this run (e.g. plaid-db=remote). Each realization seeds the ExternalService credential secret so resolveCredentials() runs unchanged. Local mode only.")
2196
+ .option('--mock [names]', 'Alias for --externals.')
2197
+ .option('--runner <runner>', "Process runner for local mode: 'tmux' (default — dev machines) or 'docker' (CI/headless: services run as a docker compose project with host networking; Linux semantics).", 'tmux');
2198
+ // Hidden: the dev-user token-server (MESH-2039). Launched into its own tmux
2199
+ // window by the dev launcher; serves a continuously-fresh dev-user token.
2200
+ dev
2201
+ .command('__token-server <port> <context>', { hidden: true })
2202
+ .action(async (port, context) => {
2203
+ const { startTokenServer } = await import('./dev-token-server.js');
2204
+ await startTokenServer(Number(port), context);
2205
+ });
1096
2206
  // Default action: start or show status
1097
2207
  dev.action(async (options) => {
2208
+ // An explicit --profile must win over stale shell-exported STS credentials.
2209
+ // The AWS SDK/CLI prefer AWS_ACCESS_KEY_ID over AWS_PROFILE, so clear any
2210
+ // static credentials before resolving the deployer role.
2211
+ if (options.profile) {
2212
+ applyAwsProfileOverride(options.profile);
2213
+ }
1098
2214
  // Fast-path: --status/--kill with explicit --session can skip app root resolution
1099
2215
  if (options.session && (options.status || options.kill)) {
1100
2216
  const sessionName = options.session;
2217
+ if (loadSessionState(sessionName)?.runner === 'docker') {
2218
+ if (options.kill) {
2219
+ dockerDevDown(sessionName);
2220
+ composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
2221
+ localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
2222
+ removeSessionState(sessionName);
2223
+ logSuccess(`Killed docker dev session: ${sessionName}`);
2224
+ }
2225
+ else {
2226
+ console.log(dockerDevPs(sessionName));
2227
+ }
2228
+ return;
2229
+ }
1101
2230
  if (options.kill) {
2231
+ composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
2232
+ localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
2233
+ // Clean up DNS records for this app
2234
+ try {
2235
+ const devOutput = loadSessionState(sessionName)?.devOutput;
2236
+ if (devOutput) {
2237
+ const tsInfo = await getTailscaleInfo();
2238
+ if (tsInfo) {
2239
+ const appName = devOutput.app ?? sessionName.replace(/-dev$/, '');
2240
+ const stack = devOutput.stack ?? 'dev';
2241
+ const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
2242
+ const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
2243
+ unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);
2244
+ }
2245
+ }
2246
+ }
2247
+ catch {
2248
+ /* best effort */
2249
+ }
1102
2250
  if (sessionExists(sessionName)) {
1103
2251
  killSession(sessionName);
1104
2252
  logSuccess(`Killed session: ${sessionName}`);
@@ -1112,16 +2260,50 @@ export function registerDevCommand(program) {
1112
2260
  // --status with --session: use state file, no Pulumi needed
1113
2261
  const state = loadSessionState(sessionName);
1114
2262
  if (state) {
1115
- showStatus(sessionName, state.devOutput, !!options.json);
2263
+ await showStatus(sessionName, state.devOutput, !!options.json);
1116
2264
  return;
1117
2265
  }
1118
2266
  // No state — fall through to normal flow
1119
2267
  }
1120
2268
  const appRoot = findAppRoot(options.app);
1121
2269
  const projectName = getProjectName(appRoot);
1122
- const sessionName = options.session ?? `${projectName}-dev`;
2270
+ // Port block scoped to (worktree, app): concurrent sibling-app sessions
2271
+ // must draw disjoint ports (probing races slow boots). Scoped once here so
2272
+ // local and tethered modes resolve the SAME plan — every other field stays
2273
+ // worktree-scoped, so session naming and task queues are unaffected.
2274
+ const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);
2275
+ const sessionName = options.session ?? deriveSessionName(projectName, worktree);
1123
2276
  // --kill
1124
2277
  if (options.kill) {
2278
+ composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
2279
+ localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
2280
+ if (loadSessionState(sessionName)?.runner === 'docker') {
2281
+ dockerDevDown(sessionName);
2282
+ removeSessionState(sessionName);
2283
+ logSuccess(`Killed docker dev session: ${sessionName}`);
2284
+ return;
2285
+ }
2286
+ // Tailscale VPN tunnels are machine-global + shared across sessions, so
2287
+ // they persist on purpose after a kill. Capture the tenant before state
2288
+ // is removed so we can point at the explicit stop below.
2289
+ const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';
2290
+ // Clean up DNS records for this app
2291
+ try {
2292
+ const devOutput = loadSessionState(sessionName)?.devOutput;
2293
+ if (devOutput) {
2294
+ const tsInfo = await getTailscaleInfo();
2295
+ if (tsInfo) {
2296
+ const appName = devOutput.app ?? projectName;
2297
+ const stack = devOutput.stack ?? 'dev';
2298
+ const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
2299
+ const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
2300
+ unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);
2301
+ }
2302
+ }
2303
+ }
2304
+ catch {
2305
+ /* best effort */
2306
+ }
1125
2307
  if (sessionExists(sessionName)) {
1126
2308
  killSession(sessionName);
1127
2309
  logSuccess(`Killed session: ${sessionName}`);
@@ -1130,37 +2312,589 @@ export function registerDevCommand(program) {
1130
2312
  logInfo(`No active session: ${sessionName}`);
1131
2313
  }
1132
2314
  removeSessionState(sessionName);
2315
+ if (readTailscaleState(killTsTenant)) {
2316
+ logInfo(`VPN tunnels persist across sessions — stop them with: mesh vpn tunnel down --tenant ${killTsTenant}`);
2317
+ }
2318
+ return;
2319
+ }
2320
+ // ── Local mode (design §5.2) ─────────────────────────────────────
2321
+ // Explicit --local, or auto when the app has no Pulumi program at all
2322
+ // (hasStackBacking also checks the monorepo ROOT — apps like apps/hub
2323
+ // deploy via a root program and must keep the tethered default).
2324
+ // Synthesizes DevOutput from repo conventions and wires services to the
2325
+ // `mesh start` platform: no AWS credentials, no VPN, no Pulumi state.
2326
+ const localMode = !!options.local || !hasStackBacking(appRoot);
2327
+ if (localMode) {
2328
+ // Read-only status query: report and exit BEFORE any side effect
2329
+ // (platform boot, Zitadel provisioning, registry writes) — parity
2330
+ // with the tethered path's early --status short-circuit.
2331
+ if (options.status) {
2332
+ const state = loadSessionState(sessionName);
2333
+ if (state?.runner === 'docker') {
2334
+ console.log(dockerDevPs(sessionName));
2335
+ return;
2336
+ }
2337
+ const statusOutput = state?.devOutput ??
2338
+ buildLocalDevOutput(appRoot, detectLocalTenant(appRoot), { mocks: {} });
2339
+ showStatus(sessionName, statusOutput, !!options.json);
2340
+ return;
2341
+ }
2342
+ const dockerRunner = options.runner === 'docker';
2343
+ if (!dockerRunner && !hasTmux()) {
2344
+ logError('tmux is not installed. Fix: brew install tmux (or use --runner docker)');
2345
+ process.exit(1);
2346
+ }
2347
+ await ensureLocalPlatformRunning();
2348
+ const localTenant = detectLocalTenant(appRoot);
2349
+ logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);
2350
+ // --externals (alias --mock): resolve the requested subset of the
2351
+ // app's declared externals (package.json → mesh.externals). Entries
2352
+ // are name[=mode] — an explicit mode overrides the declaration's
2353
+ // default for this run (materialized onto the decl so every
2354
+ // downstream externalMode() call agrees). Mock-mode externals join
2355
+ // the same orchestration as services named mock-{name}; all
2356
+ // realizations get their credential secret seeded after ports are
2357
+ // final.
2358
+ const externalsRequest = options.externals ?? options.mock;
2359
+ let selectedMocks = {};
2360
+ // The externals the developer NAMED (empty for the bare flag, which
2361
+ // selects everything declared). Failing to realize one of these is
2362
+ // fatal; failing to realize one the developer never asked for by name
2363
+ // only costs that external.
2364
+ const requestedExplicitly = new Set();
2365
+ if (externalsRequest) {
2366
+ const declared = readLocalMocks(appRoot);
2367
+ let requested;
2368
+ let overrides = new Map();
2369
+ if (externalsRequest === true) {
2370
+ requested = Object.keys(declared);
2371
+ }
2372
+ else {
2373
+ ({ names: requested, overrides } = parseExternalsSelection(String(externalsRequest)));
2374
+ for (const name of requested)
2375
+ requestedExplicitly.add(name);
2376
+ }
2377
+ const unknown = requested.filter((name) => !declared[name]);
2378
+ if (unknown.length > 0) {
2379
+ throw new MeshCliError(`Unknown external(s): ${unknown.join(', ')} — declared in package.json mesh.externals: ${Object.keys(declared).join(', ') || '(none)'}`, { remediation: { docs: 'package.json → "mesh": { "externals": { … } }' } });
2380
+ }
2381
+ if (requested.length === 0) {
2382
+ logWarn('No externals declared (package.json → mesh.externals) — continuing without.');
2383
+ }
2384
+ selectedMocks = Object.fromEntries(requested.map((name) => [
2385
+ name,
2386
+ overrides.has(name) ? { ...declared[name], mode: overrides.get(name) } : declared[name],
2387
+ ]));
2388
+ // Validate every declaration's resolved mode up front — a bad decl
2389
+ // (or an override the decl can't satisfy) should fail here, not
2390
+ // after half the orchestration is up.
2391
+ for (const [name, decl] of Object.entries(selectedMocks))
2392
+ externalMode(name, decl);
2393
+ }
2394
+ const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {
2395
+ mocks: selectedMocks,
2396
+ });
2397
+ // --dry-run (worktree-plan parity with tethered mode): print the
2398
+ // resolved session/port plan and exit before any side effect —
2399
+ // no Zitadel provisioning, no registry writes, no launch.
2400
+ if (options.dryRun) {
2401
+ printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));
2402
+ // Non-service realizations (local/remote) don't appear in the port
2403
+ // plan — list them so the dry run shows the whole picture.
2404
+ for (const [name, decl] of Object.entries(selectedMocks)) {
2405
+ const mode = externalMode(name, decl);
2406
+ if (mode === 'local') {
2407
+ console.log(` external ${name.padEnd(18)} local — docker compose (${decl.compose}) → localhost:${decl.port}`);
2408
+ }
2409
+ else if (mode === 'remote') {
2410
+ console.log(` external ${name.padEnd(18)} remote — actual service credentials (no local process)`);
2411
+ }
2412
+ }
2413
+ return;
2414
+ }
2415
+ // Auto-provision the app tenant's auth config (approved Zitadel
2416
+ // tenant-auth pattern: org per tenant → project per app → application
2417
+ // per service + credential secret). Best-effort: the dev loop still
2418
+ // works if Zitadel isn't seeded yet.
2419
+ try {
2420
+ const tenant = localTenant;
2421
+ const app = rawDevOutput.app ?? projectName;
2422
+ logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'…`);
2423
+ const services = Object.keys(rawDevOutput.services);
2424
+ // Mocks are stand-ins, not services of the app — no Zitadel
2425
+ // application for them (the registry still lists them for the Hub).
2426
+ const authServices = services.filter((name) => !name.startsWith('mock-'));
2427
+ // App-declared project roles (package.json → mesh.auth.roles): created
2428
+ // on the project and granted to the local `{app}-m2m` caller, so
2429
+ // role-scoped services (roles claim → scopes) accept its tokens.
2430
+ let authRoles = [];
2431
+ try {
2432
+ const appPkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'));
2433
+ if (Array.isArray(appPkg?.mesh?.auth?.roles)) {
2434
+ authRoles = appPkg.mesh.auth.roles.filter((r) => typeof r === 'string');
2435
+ }
2436
+ }
2437
+ catch {
2438
+ // no package.json / unparsable — roles stay empty
2439
+ }
2440
+ await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });
2441
+ // The app's own Temporal namespace ({tenant}-{env}-{app}) — the same
2442
+ // name the cloud provisioner creates and the Hub's per-app workflow
2443
+ // view resolves.
2444
+ await ensureTemporalNamespace(localAppNamespace(tenant, app));
2445
+ }
2446
+ catch (err) {
2447
+ logWarn(`Auth auto-provisioning skipped: ${err instanceof Error ? err.message : err}` +
2448
+ ` — if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`);
2449
+ }
2450
+ const devOutput = await allocatePorts(rawDevOutput, worktree);
2451
+ // Register AFTER allocatePorts: the registry must carry the ports the
2452
+ // services actually listen on, or StackPort/Hub (and anything reading
2453
+ // /mesh-platform/…/stacks/local) points at dead URLs.
2454
+ try {
2455
+ await registerLocalApp({
2456
+ tenant: localTenant,
2457
+ app: devOutput.app ?? projectName,
2458
+ services: Object.keys(devOutput.services),
2459
+ ports: Object.fromEntries(Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port])),
2460
+ // ExternalService.link() parity: consuming services carry the
2461
+ // external names, so the Hub shows consumers + per-app uptime.
2462
+ links: Object.values(selectedMocks)
2463
+ .map((decl) => decl.external)
2464
+ .filter((n) => !!n),
2465
+ });
2466
+ }
2467
+ catch (err) {
2468
+ logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);
2469
+ }
2470
+ // Wire realizations AFTER ports are final: realize each declaration
2471
+ // by mode, then seed the external's credential secret and register it
2472
+ // in the Hub — the app's resolveCredentials() path runs unchanged.
2473
+ // mock → mock-{name} service URL (Prism / mock process)
2474
+ // local → docker compose up -d --wait, endpoint from decl.port
2475
+ // remote → actual-service credentials seeded (inline, or pulled
2476
+ // from the app's tenant environment)
2477
+ const composeExternals = [];
2478
+ const externalProbeFiles = [];
2479
+ for (const [name, decl] of Object.entries(selectedMocks)) {
2480
+ const mode = externalMode(name, decl);
2481
+ try {
2482
+ let probeFile;
2483
+ if (mode === 'local') {
2484
+ logInfo(`Starting docker external '${name}' (${decl.compose})…`);
2485
+ // undefined = adopted a realization another checkout owns —
2486
+ // not recorded, so --kill leaves it running.
2487
+ const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);
2488
+ if (composeRef)
2489
+ composeExternals.push(composeRef);
2490
+ probeFile = await seedLocalMock({
2491
+ tenant: localTenant,
2492
+ app: devOutput.app ?? projectName,
2493
+ name,
2494
+ decl,
2495
+ endpoint: { url: `http://localhost:${decl.port}`, host: 'localhost', port: decl.port },
2496
+ });
2497
+ }
2498
+ else if (mode === 'remote') {
2499
+ probeFile = await seedLocalMock({
2500
+ tenant: localTenant,
2501
+ app: devOutput.app ?? projectName,
2502
+ name,
2503
+ decl,
2504
+ });
2505
+ }
2506
+ else {
2507
+ const mockService = devOutput.services[`mock-${name}`];
2508
+ if (!mockService)
2509
+ continue;
2510
+ probeFile = await seedLocalMock({
2511
+ tenant: localTenant,
2512
+ app: devOutput.app ?? projectName,
2513
+ name,
2514
+ decl,
2515
+ endpoint: {
2516
+ url: `http://localhost:${mockService.port}`,
2517
+ host: 'localhost',
2518
+ port: mockService.port,
2519
+ },
2520
+ });
2521
+ }
2522
+ if (probeFile)
2523
+ externalProbeFiles.push(probeFile);
2524
+ }
2525
+ catch (err) {
2526
+ // A remote external the developer didn't name costs only itself:
2527
+ // the bare `--externals` selects everything declared, and a
2528
+ // machine with no AWS identity (or no vendor key in the shell)
2529
+ // must still get its mock and local externals — that's the
2530
+ // zero-access local loop. Naming it keeps the failure fatal.
2531
+ if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {
2532
+ logWarn(`External '${name}': skipped — ${err.message}` +
2533
+ ` (re-run with \`mesh dev --externals ${name}=remote\` to make this fatal).`);
2534
+ continue;
2535
+ }
2536
+ if (err instanceof MeshCliError)
2537
+ throw err; // bad decl / missing env var / failed remote pull — hard stop
2538
+ if (mode === 'local') {
2539
+ // The app's services depend on this external — starting them
2540
+ // against a dead dependency helps nobody.
2541
+ throw new MeshCliError(`Docker external '${name}' failed to start: ${err instanceof Error ? err.message : err}`, { remediation: { command: `docker compose -f ${decl.compose} up # debug it directly` } });
2542
+ }
2543
+ logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);
2544
+ }
2545
+ }
2546
+ if (dockerRunner) {
2547
+ // C2: render the same DevOutput as a docker compose project.
2548
+ const composePath = writeDevCompose(sessionName, appRoot, devOutput.services);
2549
+ logInfo(`Docker runner: ${composePath}`);
2550
+ dockerDevUp(sessionName);
2551
+ saveSessionState(sessionName, {
2552
+ appRoot,
2553
+ stack: 'local',
2554
+ devOutput,
2555
+ startedAt: new Date().toISOString(),
2556
+ runner: 'docker',
2557
+ composeExternals,
2558
+ externalProbeFiles,
2559
+ });
2560
+ console.log('');
2561
+ logSuccess(`Dev session started (docker): ${sessionName}`);
2562
+ console.log('');
2563
+ console.log('Services:');
2564
+ for (const [name, service] of Object.entries(devOutput.services)) {
2565
+ console.log(` ${name.padEnd(20)} http://localhost:${service.port}`);
2566
+ }
2567
+ console.log('');
2568
+ console.log(`Status with: mesh dev --status --session '${sessionName}'`);
2569
+ console.log('Stop with: mesh dev --kill');
2570
+ return;
2571
+ }
2572
+ const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(), // ministack wiring — the only "AWS" children need
2573
+ { transport: 'vpn-direct' });
2574
+ saveSessionState(sessionName, {
2575
+ appRoot,
2576
+ stack: 'local',
2577
+ devOutput: finalDevOutput,
2578
+ startedAt: new Date().toISOString(),
2579
+ runner: 'tmux',
2580
+ composeExternals,
2581
+ externalProbeFiles,
2582
+ });
1133
2583
  return;
1134
2584
  }
1135
- const stack = detectStack(appRoot, options.stage);
2585
+ if (options.mock || options.externals) {
2586
+ logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet — MESH-1885). Continuing without them.');
2587
+ }
2588
+ const stack = detectStack(appRoot, resolveStackOption(options));
1136
2589
  logInfo(`Project: ${projectName}, Stack: ${stack}`);
1137
- const rawDevOutput = getDevOutput(appRoot, stack);
2590
+ // Detect if the user accidentally ran `mesh dev` from the monorepo root
2591
+ // instead of an app directory. The monorepo root has a Pulumi.yaml but
2592
+ // no services/apps to run. Standalone apps (create-app scaffolds) ARE
2593
+ // their own workspace root — an api/ or worker/ package right here means
2594
+ // this is an app, not a monorepo root.
2595
+ const monorepoRoot = findMonorepoRoot();
2596
+ const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) => fs.existsSync(path.join(appRoot, dir, 'package.json')));
2597
+ if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {
2598
+ logError(`mesh dev should be run from an app directory, not the monorepo root.\n`);
2599
+ logInfo('Try:');
2600
+ logInfo(' cd apps/hub && mesh dev');
2601
+ logInfo('');
2602
+ logInfo('Or specify the app path:');
2603
+ logInfo(' mesh dev --app apps/hub');
2604
+ logInfo('');
2605
+ logInfo('List available apps:');
2606
+ logInfo(' mesh dev list');
2607
+ process.exit(1);
2608
+ }
2609
+ // ── Prerequisite checks ──────────────────────────────────────────
2610
+ // Check all prerequisites upfront so the user gets one clear list of
2611
+ // what needs fixing, rather than failing one step at a time.
2612
+ const issues = [];
2613
+ const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
2614
+ // AWS role + credential resolution.
2615
+ // The stack can declare two role ARNs:
2616
+ // - `mesh:deployerRole` — the default per-tenant deployer
2617
+ // - `mesh:adminDeployerRole` — optional admin variant assumed when the
2618
+ // caller's Zitadel JWT carries an admin-tier role (e.g.
2619
+ // `mesh.platform:admin`). selectRoleForCaller picks between them.
2620
+ // tmux + credential validation/diagnosis is handled below by the shared
2621
+ // doctor preflight so `mesh dev` and `mesh dev doctor` agree.
2622
+ const platformContext = derivePlatformContext(appRoot, stack);
2623
+ const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
2624
+ const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
2625
+ let deployerRole = defaultDeployerRole;
2626
+ if (defaultDeployerRole) {
2627
+ const meshCreds = platformContext ? readCredentials(platformContext) : null;
2628
+ const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
2629
+ deployerRole = selectRoleForCaller(idToken, {
2630
+ defaultRole: defaultDeployerRole,
2631
+ adminRole: adminDeployerRole ?? undefined,
2632
+ });
2633
+ if (adminDeployerRole && deployerRole === adminDeployerRole) {
2634
+ logInfo(`Caller has admin Zitadel role — assuming ${deployerRole.split('/').pop()} (admin variant)`);
2635
+ }
2636
+ }
2637
+ let awsEnv;
2638
+ let credMethod = 'ambient';
2639
+ // Method fed to the doctor context: the resolved method on success, or
2640
+ // `null` on a failed resolution so the `creds` check probes for the exact
2641
+ // reason (no-session / expired / assume-denied / stale-env-override).
2642
+ let credMethodForDoctor = 'ambient';
2643
+ if (deployerRole) {
2644
+ const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);
2645
+ if (!resolved) {
2646
+ awsEnv = {};
2647
+ credMethodForDoctor = null;
2648
+ }
2649
+ else {
2650
+ awsEnv = resolved.env;
2651
+ credMethod = resolved.method;
2652
+ credMethodForDoctor = resolved.method;
2653
+ }
2654
+ }
2655
+ else {
2656
+ awsEnv = getAwsEnvVars();
2657
+ credMethodForDoctor = 'ambient';
2658
+ }
2659
+ // 3. VPN or SSM fallback
2660
+ const vpnConnected = await isVpnConnected();
2661
+ // `let`: the auto-selected `vpn-direct` may be downgraded to `ssm` below by a
2662
+ // reachability probe once devOutput's Temporal endpoint is known (MESH-2016).
2663
+ let transport = resolveTransport(options.transport, {
2664
+ vpnConnected,
2665
+ hasSsmPlugin: hasSessionManagerPlugin(),
2666
+ tailscaleAvailable: tailscaleAvailable(),
2667
+ });
2668
+ if (options.transport === 'tailscale' && !tailscaleAvailable()) {
2669
+ issues.push(' \u2718 --transport=tailscale but the tailscaled binary is missing.\n' +
2670
+ ' Fix: brew install tailscale');
2671
+ }
2672
+ if (transport === 'ssm') {
2673
+ if (hasSessionManagerPlugin()) {
2674
+ if (!vpnConnected) {
2675
+ logWarn('VPN not connected \u2014 will use SSM port-forwarding tunnels for VPC resources.');
2676
+ }
2677
+ logInfo(' Tunnels use the 2XXXX port range (e.g., PostgreSQL on 25432, Temporal on 27233).');
2678
+ logInfo(' Limitations: no kubectl exec into pods, no internal ingress/ALB access.');
2679
+ logInfo(' For full VPC access, connect VPN: mesh vpn connect');
2680
+ }
2681
+ else {
2682
+ issues.push(' \u2718 VPN is not connected and session-manager-plugin is not installed.\n' +
2683
+ ' Option 1 (VPN): mesh vpn connect\n' +
2684
+ ' Option 2 (SSM): brew install --cask session-manager-plugin\n' +
2685
+ ' Option 3 (Tailscale): mesh dev --transport=tailscale');
2686
+ }
2687
+ }
2688
+ else if (transport === 'tailscale') {
2689
+ // The daemon bring-up + join happen in startServices; announce success there,
2690
+ // not here — otherwise a fallback to SSM still reads as "using Tailscale"
2691
+ // (MESH-2098). A pending node registration prints its own URL during the join.
2692
+ logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)…');
2693
+ }
2694
+ // Shared doctor preflight: the same checks as `mesh dev doctor`, so launch
2695
+ // and on-demand diagnosis name failures identically. Errors block launch;
2696
+ // warnings (config/worktree) print and proceed.
2697
+ const preflightCtx = {
2698
+ appRoot,
2699
+ stack,
2700
+ sessionName,
2701
+ deployerRole,
2702
+ platformContext,
2703
+ credMethod: credMethodForDoctor,
2704
+ sessionState: null,
2705
+ };
2706
+ const preflight = await runChecks(preflightCtx, 'preflight', ALL_CHECKS);
2707
+ const preflightReport = renderHuman(preflight);
2708
+ if (preflightReport)
2709
+ console.log(preflightReport);
2710
+ const preflightStatus = aggregateStatus(preflight.map((r) => r.result));
2711
+ if (preflightStatus === 'error' || issues.length > 0) {
2712
+ logError('\nPrerequisites not met. Fix the above before running mesh dev, then retry: mesh dev');
2713
+ for (const issue of issues)
2714
+ console.log(issue);
2715
+ process.exit(1);
2716
+ }
2717
+ // ── Credentials resolved, inject into environment ──────────────
2718
+ // Ensure AWS_REGION is set from the Pulumi stack config. Without this, AWS
2719
+ // SDK clients (SSM, Secrets Manager) would default to us-east-1.
2720
+ const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
2721
+ if (awsRegion && !awsEnv.AWS_REGION) {
2722
+ awsEnv.AWS_REGION = awsRegion;
2723
+ }
2724
+ Object.assign(process.env, awsEnv);
2725
+ // Build the env that long-lived child services (ui, api, ai-assistant) and
2726
+ // SSM tunnels will see. When credentials came from AWS SSO, prefer passing
2727
+ // AWS_PROFILE + AWS_REGION so the AWS SDK v3 default provider chain can
2728
+ // auto-refresh from the SSO cache (~8h session) as short-lived STS tokens
2729
+ // expire. Without this, the hub API starts throwing ExpiredTokenException
2730
+ // about an hour in and tenants disappear from the UI. For non-SSO paths
2731
+ // (Zitadel JWT, CI/CD env keys) there is no profile to fall back to, so
2732
+ // pass the STS keys directly and accept that the user must restart
2733
+ // `mesh dev` when they expire.
2734
+ // For the Zitadel deployer path, hand children a self-refreshing
2735
+ // credential_process profile (context + role from the resolution above,
2736
+ // written into this session's scratch dir) instead of a frozen STS triple.
2737
+ const zitadelOpts = credMethod === 'zitadel' && deployerRole
2738
+ ? (() => {
2739
+ const context = derivePlatformContext(appRoot, stack);
2740
+ return context
2741
+ ? {
2742
+ context,
2743
+ roleArn: deployerRole,
2744
+ sessionScratchDir: getSessionEnvDir(sessionName),
2745
+ meshBin: resolveStableMeshBin(process.argv[1]),
2746
+ }
2747
+ : undefined;
2748
+ })()
2749
+ : undefined;
2750
+ const childAwsEnv = buildChildAwsEnv(awsEnv, credMethod, profileHint, zitadelOpts);
2751
+ let rawDevOutput;
2752
+ try {
2753
+ rawDevOutput = getDevOutput(appRoot, stack, awsEnv);
2754
+ }
2755
+ catch (err) {
2756
+ if (err instanceof MissingStackOutputError) {
2757
+ // Only the launch path can conclude "nothing to run" — print the
2758
+ // forward-chaining first-run fix and exit cleanly (MESH-2097). The
2759
+ // suggested commands carry --stack because the bare forms error right
2760
+ // after `stack init` (matching stack.ts's next-step output).
2761
+ logError(`No Pulumi stack output for '${stack}' — mesh dev has nothing to run.`);
2762
+ logInfo('If this is a new app, initialize and materialize it first:');
2763
+ logInfo(' mesh stack init # create your personal dev stack');
2764
+ logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);
2765
+ logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \`app\` or \`dev\` output.)`);
2766
+ logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');
2767
+ // Surface the underlying read error so an auth/network/JSON failure isn't
2768
+ // silently misdiagnosed as "fresh app" with zero forensics.
2769
+ const cause = err.cause?.message;
2770
+ if (cause)
2771
+ logInfo(`(underlying stack-read error: ${cause})`);
2772
+ process.exit(1);
2773
+ }
2774
+ throw err;
2775
+ }
1138
2776
  // --status — use persisted state (has allocated ports), fall back to raw
1139
2777
  if (options.status) {
1140
2778
  const state = loadSessionState(sessionName);
1141
- showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
2779
+ await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
2780
+ return;
2781
+ }
2782
+ // Allocate dynamic ports (avoid collisions with other running apps).
2783
+ // devOutput is passed by value and may be rewritten inside startServices
2784
+ // if SSM tunnels are used (to point tunnels at localhost:2XXXX).
2785
+ const devOutput = await allocatePorts(rawDevOutput, worktree);
2786
+ // Reachability gate (MESH-2016): the transport chosen above used a PRESENCE
2787
+ // check (isVpnConnected → "is any Tailscale/VPN up"), which is tailnet-blind —
2788
+ // Tailscale-for-SSH looks connected but doesn't route the mesh VPC. Now that
2789
+ // devOutput is known, probe the real Temporal VPC endpoint and downgrade an
2790
+ // auto-selected vpn-direct to SSM when it's unreachable.
2791
+ transport = await gateVpnDirectReachability(transport, devOutput);
2792
+ // --dry-run: show the resolved worktree/session/port plan, launch nothing.
2793
+ if (options.dryRun) {
2794
+ printDevPlan(sessionName, appRoot, worktree, devOutput);
1142
2795
  return;
1143
2796
  }
1144
- // Allocate dynamic ports (avoid collisions with other running apps)
1145
- const devOutput = await allocatePorts(rawDevOutput);
2797
+ // Build the tunnel plan for the chosen transport. For Tailscale, resolve the
2798
+ // shared per-tenant daemon context (tenant = hub platform name, since the
2799
+ // bastion is a hub resource); reuse the daemon's SOCKS port if it's already up.
2800
+ let tunnelPlan = { transport };
2801
+ if (transport === 'tailscale') {
2802
+ const tsTenant = devOutput.platform?.name ?? 'mesh';
2803
+ const tsEnv = devOutput.platform?.env ?? 'dev';
2804
+ const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;
2805
+ const loginServer = deriveLoginServer(tsContext);
2806
+ if (!loginServer) {
2807
+ // Implicit selection (auto/unset) must degrade to the legacy transport,
2808
+ // not dead-end. Only an explicit --transport=tailscale may hard-error.
2809
+ const msg = `No login config for context '${tsContext}' — run: mesh login ${tsContext}`;
2810
+ if (options.transport === 'tailscale') {
2811
+ logError(msg);
2812
+ process.exit(1);
2813
+ }
2814
+ const legacy = vpnConnected
2815
+ ? 'vpn-direct'
2816
+ : hasSessionManagerPlugin()
2817
+ ? 'ssm'
2818
+ : 'vpn-direct';
2819
+ // Reachability-gate this legacy fallback too (MESH-2016): otherwise a
2820
+ // machine with tailscaled (→ auto picks tailscale) but no mesh login
2821
+ // config, with any tailnet up (vpnConnected), would land on an UNPROBED
2822
+ // vpn-direct and reproduce the exact ECONNREFUSED this feature fixes.
2823
+ const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);
2824
+ logWarn(`${msg} — falling back to ${gatedLegacy} transport.`);
2825
+ tunnelPlan = { transport: gatedLegacy };
2826
+ }
2827
+ else {
2828
+ const prior = readTailscaleState(tsTenant);
2829
+ const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';
2830
+ // Reuse the running daemon's real SOCKS port (daemon.json survives
2831
+ // `down`); never mint a fresh one the daemon isn't listening on.
2832
+ const socksPort = daemonUp
2833
+ ? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))
2834
+ : await findFreePort();
2835
+ // Zero-touch VPN join (MESH-2099): try to mint a single-use pre-auth key
2836
+ // from the platform's VpnJoinBroker so `tailscale up --authkey` joins
2837
+ // silently. Any failure leaves preAuthKey undefined → the interactive
2838
+ // browser-relay join (MESH-2098) is used instead.
2839
+ let preAuthKey;
2840
+ const brokerUrl = resolveVpnJoinBroker(tsContext);
2841
+ if (brokerUrl) {
2842
+ const minted = await mintPreAuthKey(tsContext, brokerUrl, { getValidToken });
2843
+ if (minted?.authKey) {
2844
+ preAuthKey = minted.authKey;
2845
+ }
2846
+ else {
2847
+ logInfo('zero-touch VPN join unavailable, falling back to browser registration');
2848
+ }
2849
+ }
2850
+ tunnelPlan = {
2851
+ transport,
2852
+ explicit: options.transport === 'tailscale',
2853
+ tailscale: {
2854
+ tenant: tsTenant,
2855
+ env: tsEnv,
2856
+ region: awsRegion ?? 'us-east-2',
2857
+ loginServer,
2858
+ socksPort,
2859
+ preAuthKey,
2860
+ },
2861
+ };
2862
+ }
2863
+ }
2864
+ // Relaunch safety (MESH-2017): a relaunch recreates the worker onto current
2865
+ // code. If the workflow source changed since this session started, warn before
2866
+ // it replays in-flight conversations against it (require --force). Checked
2867
+ // BEFORE startServices, which kills+recreates the session.
2868
+ if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {
2869
+ process.exit(1);
2870
+ }
2871
+ // Start services. startServices handles tmux session creation, tunnel setup
2872
+ // (SSM or Tailscale, per tunnelPlan), and service windows. It returns the
2873
+ // final devOutput with tunnels rewritten to localhost ports.
2874
+ const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, childAwsEnv, tunnelPlan, worktree.worktreeRoot, worktree.taskQueueSuffix);
1146
2875
  // Persist session state so --status and restart see allocated ports
2876
+ // and the final tunnel endpoints (localhost for SSM, VPC for VPN).
1147
2877
  saveSessionState(sessionName, {
1148
2878
  appRoot,
1149
2879
  stack,
1150
- devOutput,
2880
+ devOutput: finalDevOutput,
1151
2881
  startedAt: new Date().toISOString(),
2882
+ workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
1152
2883
  });
1153
- // Start services
1154
- await startServices(sessionName, appRoot, devOutput, !!options.headless);
1155
2884
  });
1156
2885
  // Subcommand: logs
1157
2886
  dev
1158
- .command("logs <service>")
1159
- .description("Show logs for a service")
1160
- .option("--tail <lines>", "Number of lines", "100")
2887
+ .command('logs <service>')
2888
+ .description('Show logs for a service')
2889
+ .option('--tail <lines>', 'Number of lines', '100')
1161
2890
  .action((service, opts) => {
1162
2891
  // --session is on the parent dev command
1163
2892
  const sessionName = dev.opts().session;
2893
+ const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
2894
+ if (loadSessionState(resolvedSession)?.runner === 'docker') {
2895
+ console.log(dockerDevLogs(resolvedSession, service, parseInt(opts.tail, 10)));
2896
+ return;
2897
+ }
1164
2898
  if (sessionName) {
1165
2899
  showLogs(sessionName, service, parseInt(opts.tail, 10));
1166
2900
  }
@@ -1172,31 +2906,154 @@ export function registerDevCommand(program) {
1172
2906
  });
1173
2907
  // Subcommand: restart
1174
2908
  dev
1175
- .command("restart <service>")
1176
- .description("Restart a service")
1177
- .option("--stage <stage>", "Pulumi stack name")
1178
- .action((service, opts) => {
1179
- // --session is on the parent dev command
1180
- const parentSession = dev.opts().session;
2909
+ .command('restart <service>')
2910
+ .description('Restart a service')
2911
+ .option('--stack <stack>', 'Pulumi stack name')
2912
+ .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
2913
+ .option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
2914
+ .option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')
2915
+ .action(async (service, opts) => {
2916
+ // Hoisted so the catch can name the right login context (not a hardcoded
2917
+ // one) even when a throw happens before cred resolution. MESH-2089.
2918
+ let ctx = null;
2919
+ try {
2920
+ // --session is on the parent dev command
2921
+ const parentSession = dev.opts().session;
2922
+ const parentProfile = dev.opts().profile;
2923
+ if (parentProfile) {
2924
+ applyAwsProfileOverride(parentProfile);
2925
+ }
2926
+ const appRoot = findAppRoot();
2927
+ const projectName = getProjectName(appRoot);
2928
+ const sessionName = parentSession ?? `${projectName}-dev`;
2929
+ const state = loadSessionState(sessionName);
2930
+ if (state?.runner === 'docker') {
2931
+ dockerDevRestart(sessionName, service);
2932
+ logSuccess(`Restarted: ${service}`);
2933
+ return;
2934
+ }
2935
+ // Relaunch safety (MESH-2017): restarting the WORKER onto changed workflow
2936
+ // code strands in-flight conversations; restarting other services (ui/api)
2937
+ // doesn't. Gate the worker restart on the workflow-source fingerprint.
2938
+ if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {
2939
+ process.exit(1);
2940
+ }
2941
+ const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
2942
+ ctx = derivePlatformContext(appRoot, stack);
2943
+ // Resolve credentials BEFORE reading any Pulumi/SSM/kubeconfig output
2944
+ // (MESH-2090). With ambient AWS_* cleared (the documented restart
2945
+ // hygiene), the parent-side AWS calls the restart path makes — the
2946
+ // getDevOutput fallback below, and under --refresh-env the
2947
+ // resolveTemporalAuthVars/ensureKubeconfig calls — read the default SDK
2948
+ // chain (process.env). Resolving first lets us thread the resolved creds
2949
+ // into this process's env, and lets the precise login preflight fire
2950
+ // before the devOutput fallback (which was the MESH-2089 crash site).
2951
+ const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
2952
+ const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
2953
+ let deployerRole = defaultDeployerRole;
2954
+ if (defaultDeployerRole) {
2955
+ const meshCreds = ctx ? readCredentials(ctx) : null;
2956
+ const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
2957
+ deployerRole = selectRoleForCaller(idToken, {
2958
+ defaultRole: defaultDeployerRole,
2959
+ adminRole: adminDeployerRole ?? undefined,
2960
+ });
2961
+ }
2962
+ const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;
2963
+ // Login preflight (MESH-2089): if a deployer role is configured but creds
2964
+ // couldn't be resolved AND there are no ambient AWS creds to fall back on,
2965
+ // the restart would proceed with no credentials and fail downstream. Name
2966
+ // the real problem (e.g. "run mesh login <ctx> --device") instead.
2967
+ if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {
2968
+ const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;
2969
+ logError(pf?.message ??
2970
+ `Couldn't resolve AWS credentials to restart '${service}'. Run: mesh login ${ctx ?? 'mesh.dev'} --device`);
2971
+ process.exit(1);
2972
+ }
2973
+ const baseAwsEnv = resolved?.env ?? getAwsEnvVars();
2974
+ // Ensure AWS_REGION is set from stack config so parent-side SDK clients
2975
+ // (SSM, EKS) don't default to us-east-1. Mirrors the launch path.
2976
+ const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
2977
+ if (awsRegion && !baseAwsEnv.AWS_REGION) {
2978
+ baseAwsEnv.AWS_REGION = awsRegion;
2979
+ }
2980
+ // Inject the resolved creds into THIS process's env so the parent-side
2981
+ // AWS calls below run credentialed even when ambient AWS_* was cleared
2982
+ // (MESH-2090). Mirrors the launch path's inject (see startServices).
2983
+ Object.assign(process.env, baseAwsEnv);
2984
+ const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
2985
+ const zitadelOpts = resolved?.method === 'zitadel' && deployerRole && ctx
2986
+ ? {
2987
+ context: ctx,
2988
+ roleArn: deployerRole,
2989
+ sessionScratchDir: getSessionEnvDir(sessionName),
2990
+ meshBin: resolveStableMeshBin(process.argv[1]),
2991
+ }
2992
+ : undefined;
2993
+ const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
2994
+ // Use persisted state (has allocated ports), fall back to raw Pulumi
2995
+ // output — now credentialed via the resolved env (MESH-2090).
2996
+ const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
2997
+ await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
2998
+ refreshEnv: !!opts.refreshEnv,
2999
+ stack,
3000
+ sessionState: state,
3001
+ });
3002
+ // Re-baseline the workflow fingerprint after a worker restart (MESH-2017):
3003
+ // the worker now runs the current code, so a subsequent unchanged restart
3004
+ // must NOT nag — otherwise a good-citizen `--force` earns a session of
3005
+ // false warnings, training the reflex this guard exists to prevent.
3006
+ if (/worker/i.test(service) && state) {
3007
+ saveSessionState(sessionName, {
3008
+ ...state,
3009
+ workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),
3010
+ });
3011
+ }
3012
+ }
3013
+ catch (err) {
3014
+ // Never crash uncaught (MESH-2089): a cred/kubeconfig/tmux failure should
3015
+ // name itself, not dump a bare Node stack. The underlying error is logged
3016
+ // so the exact cause is pinned if this recurs.
3017
+ logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
3018
+ logInfo(`If this is a credential/kubeconfig issue, run \`mesh login ${ctx ?? '<your platform context>'} --device\`, ` +
3019
+ 'or `mesh dev` to relaunch the session cleanly.');
3020
+ process.exit(1);
3021
+ }
3022
+ });
3023
+ // Subcommand: doctor — diagnose the dev session
3024
+ dev
3025
+ .command('doctor')
3026
+ .description('Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) — names the fix')
3027
+ .option('--stack <stack>', 'Pulumi stack name')
3028
+ .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
3029
+ .option('--json', 'Machine-readable output')
3030
+ .action(async (opts) => {
3031
+ const parentProfile = dev.opts().profile;
3032
+ if (parentProfile) {
3033
+ applyAwsProfileOverride(parentProfile);
3034
+ }
1181
3035
  const appRoot = findAppRoot();
1182
3036
  const projectName = getProjectName(appRoot);
1183
- const sessionName = parentSession ?? `${projectName}-dev`;
1184
- // Use persisted state (has allocated ports), fall back to raw Pulumi output
3037
+ const sessionName = dev.opts().session ?? `${projectName}-dev`;
1185
3038
  const state = loadSessionState(sessionName);
1186
- const devOutput = state?.devOutput ??
1187
- getDevOutput(appRoot, detectStack(appRoot, opts.stage));
1188
- restartService(sessionName, service, appRoot, devOutput);
3039
+ const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
3040
+ // `--json` may land on either the subcommand or the parent `dev`
3041
+ // command (both declare it), so honor both.
3042
+ const asJson = !!opts.json || !!dev.opts().json;
3043
+ const ctx = buildDoctorContext(appRoot, stack, sessionName);
3044
+ const status = await runDoctor(ctx, { json: asJson });
3045
+ process.exit(status === 'error' ? 1 : 0);
1189
3046
  });
1190
3047
  // Subcommand: list — discover apps in the monorepo
1191
3048
  dev
1192
- .command("list")
1193
- .description("List Pulumi apps in the monorepo")
3049
+ .command('list')
3050
+ .description('List Pulumi apps in the monorepo')
1194
3051
  .action(() => {
1195
3052
  // --json is on the parent dev command
1196
3053
  const asJson = dev.opts().json;
1197
3054
  const mono = findMonorepoRoot();
1198
3055
  if (!mono) {
1199
- logError("Could not find monorepo root (pnpm-workspace.yaml or .git)");
3056
+ logError('Could not find monorepo root (pnpm-workspace.yaml or .git)');
1200
3057
  process.exit(1);
1201
3058
  }
1202
3059
  const apps = discoverApps(mono);
@@ -1214,35 +3071,105 @@ export function registerDevCommand(program) {
1214
3071
  }
1215
3072
  else {
1216
3073
  if (apps.length === 0) {
1217
- logInfo("No Pulumi apps found.");
3074
+ logInfo('No Pulumi apps found.');
1218
3075
  return;
1219
3076
  }
1220
- console.log("Apps in monorepo:");
1221
- console.log("");
3077
+ console.log('Apps in monorepo:');
3078
+ console.log('');
1222
3079
  for (const app of apps) {
1223
3080
  const sessionName = `${app.name}-dev`;
1224
3081
  const running = sessionExists(sessionName);
1225
- const icon = running ? "" : "";
1226
- const stacks = app.stacks.length > 0
1227
- ? ` (${app.stacks.join(", ")})`
1228
- : "";
3082
+ const icon = running ? '' : '';
3083
+ const stacks = app.stacks.length > 0 ? ` (${app.stacks.join(', ')})` : '';
1229
3084
  console.log(` ${icon} ${app.name.padEnd(24)} ${app.tenant.padEnd(12)} ${app.relativePath}${stacks}`);
1230
3085
  }
1231
- console.log("");
3086
+ console.log('');
1232
3087
  }
1233
3088
  });
1234
- // Hidden subcommand: tunnel manager (started internally by mesh dev)
3089
+ // Subcommand: test-user retrieve Temporal test user credentials
1235
3090
  dev
1236
- .command("__tunnel-manager")
1237
- .description("Internal: manage all tunnels in a single process")
1238
- .option("--config <path>", "Path to tunnel config JSON")
1239
- .option("--tmux-session <name>", "tmux session name for status display")
1240
- .action((opts) => {
1241
- if (!opts.config) {
1242
- logError("--config is required");
1243
- process.exit(1);
3091
+ .command('test-user [name]')
3092
+ .description('Get Temporal test user credentials (from Pulumi-managed test users)')
3093
+ .option('--tenant <tenant>', 'Tenant name', 'mesh')
3094
+ .option('--env <env>', 'Environment', 'dev')
3095
+ .option('--region <region>', 'AWS region', 'us-east-2')
3096
+ .action(async (name, opts) => {
3097
+ const { SSMClient, GetParameterCommand, GetParametersByPathCommand } = await import('@aws-sdk/client-ssm');
3098
+ const ssm = new SSMClient({ region: opts.region });
3099
+ const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;
3100
+ if (!name) {
3101
+ // List all test users
3102
+ try {
3103
+ const resp = await ssm.send(new GetParametersByPathCommand({
3104
+ Path: basePath,
3105
+ Recursive: true,
3106
+ WithDecryption: true,
3107
+ }));
3108
+ if (!resp.Parameters || resp.Parameters.length === 0) {
3109
+ logWarn(`No test users found at ${basePath}`);
3110
+ logInfo('Test users are defined in Pulumi config under mesh:temporal.authorization.testUsers');
3111
+ logInfo("They're only available on dev stacks.");
3112
+ return;
3113
+ }
3114
+ console.log('Temporal test users:');
3115
+ console.log('');
3116
+ for (const param of resp.Parameters) {
3117
+ const userName = param.Name.split('/').pop();
3118
+ const creds = JSON.parse(param.Value);
3119
+ console.log(` ${userName}`);
3120
+ console.log(` Email: ${creds.email}`);
3121
+ console.log(` Password: ${creds.password}`);
3122
+ console.log(` Roles: ${creds.roles.join(', ')}`);
3123
+ if (creds.description)
3124
+ console.log(` Note: ${creds.description}`);
3125
+ console.log('');
3126
+ }
3127
+ logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');
3128
+ }
3129
+ catch (err) {
3130
+ logError(`Failed to list test users: ${err instanceof Error ? err.message : String(err)}`);
3131
+ process.exit(1);
3132
+ }
3133
+ }
3134
+ else {
3135
+ // Get a specific test user
3136
+ try {
3137
+ const resp = await ssm.send(new GetParameterCommand({
3138
+ Name: `${basePath}/${name}`,
3139
+ WithDecryption: true,
3140
+ }));
3141
+ if (!resp.Parameter?.Value) {
3142
+ logError(`Test user '${name}' not found at ${basePath}/${name}`);
3143
+ return;
3144
+ }
3145
+ const creds = JSON.parse(resp.Parameter.Value);
3146
+ // Check if --json is set on parent
3147
+ const asJson = dev.opts().json;
3148
+ if (asJson) {
3149
+ console.log(JSON.stringify(creds, null, 2));
3150
+ }
3151
+ else {
3152
+ console.log(`Email: ${creds.email}`);
3153
+ console.log(`Password: ${creds.password}`);
3154
+ console.log(`Roles: ${creds.roles.join(', ')}`);
3155
+ if (creds.description)
3156
+ console.log(`Note: ${creds.description}`);
3157
+ console.log('');
3158
+ logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');
3159
+ }
3160
+ }
3161
+ catch (err) {
3162
+ const msg = err.message ?? String(err);
3163
+ if (msg.includes('ParameterNotFound') || err.name === 'ParameterNotFound') {
3164
+ logError(`Test user '${name}' not found.`);
3165
+ logInfo(`List available users with: mesh dev test-user`);
3166
+ }
3167
+ else {
3168
+ logError(`Failed to get test user: ${msg}`);
3169
+ }
3170
+ process.exit(1);
3171
+ }
1244
3172
  }
1245
- runTunnelManager(opts.config, opts.tmuxSession);
1246
3173
  });
1247
3174
  }
1248
3175
  //# sourceMappingURL=dev.js.map