@mesh-tech/mesh-cli 0.20.1 → 0.20.3

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 (436) hide show
  1. package/README.md +6 -5
  2. package/dist/bin/mesh.d.ts +0 -12
  3. package/dist/bin/mesh.js +99 -50
  4. package/dist/bin/mesh.js.map +2 -3
  5. package/dist/build-info.json +2 -2
  6. package/dist/src/commands/agent-api-client.d.ts +0 -70
  7. package/dist/src/commands/agent-api-client.js +0 -80
  8. package/dist/src/commands/app-check.d.ts +0 -56
  9. package/dist/src/commands/app-check.js +0 -72
  10. package/dist/src/commands/artifacts.d.ts +0 -11
  11. package/dist/src/commands/artifacts.js +0 -33
  12. package/dist/src/commands/cluster.d.ts +0 -39
  13. package/dist/src/commands/cluster.js +0 -73
  14. package/dist/src/commands/conversations.d.ts +0 -36
  15. package/dist/src/commands/conversations.js +0 -59
  16. package/dist/src/commands/create-app.d.ts +4 -88
  17. package/dist/src/commands/create-app.js +16 -155
  18. package/dist/src/commands/db/connect.d.ts +0 -7
  19. package/dist/src/commands/db/connect.js +0 -9
  20. package/dist/src/commands/db/credentials.d.ts +0 -7
  21. package/dist/src/commands/db/credentials.js +0 -10
  22. package/dist/src/commands/db/env.d.ts +0 -6
  23. package/dist/src/commands/db/env.js +0 -14
  24. package/dist/src/commands/db/exec.d.ts +0 -43
  25. package/dist/src/commands/db/exec.js +0 -69
  26. package/dist/src/commands/db/index.d.ts +0 -10
  27. package/dist/src/commands/db/index.js +0 -10
  28. package/dist/src/commands/db/psql.d.ts +0 -7
  29. package/dist/src/commands/db/psql.js +0 -18
  30. package/dist/src/commands/deploy.d.ts +0 -38
  31. package/dist/src/commands/deploy.js +1 -76
  32. package/dist/src/commands/dev-doctor.d.ts +0 -82
  33. package/dist/src/commands/dev-doctor.js +3 -132
  34. package/dist/src/commands/dev-launch.d.ts +0 -52
  35. package/dist/src/commands/dev-launch.js +0 -56
  36. package/dist/src/commands/dev-token-server.d.ts +0 -18
  37. package/dist/src/commands/dev-token-server.js +1 -24
  38. package/dist/src/commands/dev.d.ts +0 -246
  39. package/dist/src/commands/dev.js +5 -963
  40. package/dist/src/commands/docs.d.ts +0 -1
  41. package/dist/src/commands/docs.js +0 -12
  42. package/dist/src/commands/hub/index.d.ts +0 -60
  43. package/dist/src/commands/hub/index.js +2 -139
  44. package/dist/src/commands/init/wizard.d.ts +5 -116
  45. package/dist/src/commands/init/wizard.js +42 -110
  46. package/dist/src/commands/init.d.ts +0 -55
  47. package/dist/src/commands/init.js +0 -70
  48. package/dist/src/commands/install-shim.d.ts +0 -23
  49. package/dist/src/commands/install-shim.js +2 -29
  50. package/dist/src/commands/local/auth-provision.d.ts +0 -80
  51. package/dist/src/commands/local/auth-provision.js +0 -109
  52. package/dist/src/commands/local/dev-local.d.ts +0 -91
  53. package/dist/src/commands/local/dev-local.js +0 -147
  54. package/dist/src/commands/local/docker-runner.d.ts +0 -22
  55. package/dist/src/commands/local/docker-runner.js +2 -35
  56. package/dist/src/commands/local/helpers.d.ts +0 -14
  57. package/dist/src/commands/local/helpers.js +0 -13
  58. package/dist/src/commands/local/hub-local.d.ts +0 -96
  59. package/dist/src/commands/local/hub-local.js +0 -111
  60. package/dist/src/commands/local/index.d.ts +0 -14
  61. package/dist/src/commands/local/index.js +0 -86
  62. package/dist/src/commands/local/mocks.d.ts +0 -269
  63. package/dist/src/commands/local/mocks.js +1 -274
  64. package/dist/src/commands/local/seed-hub-catalog.d.ts +0 -25
  65. package/dist/src/commands/local/seed-hub-catalog.js +0 -41
  66. package/dist/src/commands/local/seed-zitadel.d.ts +0 -82
  67. package/dist/src/commands/local/seed-zitadel.js +0 -158
  68. package/dist/src/commands/local/seed.d.ts +0 -64
  69. package/dist/src/commands/local/seed.js +0 -78
  70. package/dist/src/commands/local/stack.d.ts +0 -141
  71. package/dist/src/commands/local/stack.js +0 -153
  72. package/dist/src/commands/login.d.ts +0 -265
  73. package/dist/src/commands/login.js +4 -418
  74. package/dist/src/commands/peer-addressing.d.ts +0 -39
  75. package/dist/src/commands/peer-addressing.js +0 -37
  76. package/dist/src/commands/port-ownership.d.ts +0 -44
  77. package/dist/src/commands/port-ownership.js +0 -47
  78. package/dist/src/commands/registry-publish.d.ts +0 -1
  79. package/dist/src/commands/registry-publish.js +0 -46
  80. package/dist/src/commands/registry.d.ts +4 -237
  81. package/dist/src/commands/registry.js +2 -316
  82. package/dist/src/commands/secrets/exec.d.ts +0 -16
  83. package/dist/src/commands/secrets/exec.js +0 -24
  84. package/dist/src/commands/secrets/index.d.ts +0 -9
  85. package/dist/src/commands/secrets/index.js +0 -10
  86. package/dist/src/commands/secrets/migrate-config.d.ts +0 -17
  87. package/dist/src/commands/secrets/migrate-config.js +0 -40
  88. package/dist/src/commands/secrets/reindex.d.ts +0 -38
  89. package/dist/src/commands/secrets/reindex.js +0 -39
  90. package/dist/src/commands/secrets/set.d.ts +0 -47
  91. package/dist/src/commands/secrets/set.js +0 -107
  92. package/dist/src/commands/site.d.ts +0 -1
  93. package/dist/src/commands/site.js +0 -61
  94. package/dist/src/commands/skills.d.ts +0 -61
  95. package/dist/src/commands/skills.js +0 -83
  96. package/dist/src/commands/stack.d.ts +0 -15
  97. package/dist/src/commands/stack.js +1 -128
  98. package/dist/src/commands/temporal.d.ts +0 -30
  99. package/dist/src/commands/temporal.js.map +0 -1
  100. package/dist/src/commands/tenant.d.ts +0 -31
  101. package/dist/src/commands/tenant.js +0 -55
  102. package/dist/src/commands/tunnel/index.d.ts +0 -40
  103. package/dist/src/commands/tunnel/index.js +0 -141
  104. package/dist/src/commands/vcs/clone.d.ts +0 -1
  105. package/dist/src/commands/vcs/clone.js +0 -4
  106. package/dist/src/commands/vcs/common.d.ts +0 -32
  107. package/dist/src/commands/vcs/common.js +0 -41
  108. package/dist/src/commands/vcs/drafts.d.ts +0 -1
  109. package/dist/src/commands/vcs/drafts.js +0 -1
  110. package/dist/src/commands/vcs/get.d.ts +0 -1
  111. package/dist/src/commands/vcs/get.js +0 -11
  112. package/dist/src/commands/vcs/index.d.ts +0 -1
  113. package/dist/src/commands/vcs/index.js +0 -1
  114. package/dist/src/commands/vcs/propose.d.ts +0 -26
  115. package/dist/src/commands/vcs/propose.js +3 -34
  116. package/dist/src/commands/vcs/review.d.ts +0 -1
  117. package/dist/src/commands/vcs/review.js +0 -1
  118. package/dist/src/commands/vcs/rm.d.ts +0 -11
  119. package/dist/src/commands/vcs/rm.js +0 -14
  120. package/dist/src/commands/vcs-enrich.d.ts +0 -12
  121. package/dist/src/commands/vcs-enrich.js +0 -12
  122. package/dist/src/commands/vpn/index.d.ts +0 -17
  123. package/dist/src/commands/vpn/index.js +0 -102
  124. package/dist/src/commands/vpn/tunnel.d.ts +0 -9
  125. package/dist/src/commands/vpn/tunnel.js +0 -37
  126. package/dist/src/commands/workflow.d.ts +0 -22
  127. package/dist/src/commands/workflow.js +0 -80
  128. package/dist/src/docs/assemble.d.ts +0 -117
  129. package/dist/src/docs/assemble.js +0 -142
  130. package/dist/src/docs/cli-reference.d.ts +0 -58
  131. package/dist/src/docs/cli-reference.js +0 -77
  132. package/dist/src/docs/discover.d.ts +0 -40
  133. package/dist/src/docs/discover.js +2 -60
  134. package/dist/src/docs/portal.d.ts +0 -44
  135. package/dist/src/docs/portal.js +0 -49
  136. package/dist/src/docs/registry-docs.d.ts +0 -29
  137. package/dist/src/docs/registry-docs.js +0 -40
  138. package/dist/src/docs/schema.d.ts +0 -36
  139. package/dist/src/docs/schema.js +0 -52
  140. package/dist/src/docs/serve.d.ts +0 -18
  141. package/dist/src/docs/serve.js +2 -30
  142. package/dist/src/docs/start.d.ts +0 -42
  143. package/dist/src/docs/start.js +0 -70
  144. package/dist/src/eval/agent-eval.d.mts +0 -12
  145. package/dist/src/eval/agent-eval.mjs +0 -22
  146. package/dist/src/index.d.ts +0 -6
  147. package/dist/src/index.js.map +0 -1
  148. package/dist/src/program.d.ts +0 -20
  149. package/dist/src/program.js +0 -24
  150. package/dist/src/utils/auth-preflight.d.ts +0 -137
  151. package/dist/src/utils/auth-preflight.js +0 -149
  152. package/dist/src/utils/aws-auth.d.ts +0 -163
  153. package/dist/src/utils/aws-auth.js +3 -232
  154. package/dist/src/utils/bastion.d.ts +0 -27
  155. package/dist/src/utils/bastion.js +0 -28
  156. package/dist/src/utils/build-info.d.ts +0 -22
  157. package/dist/src/utils/build-info.js +0 -27
  158. package/dist/src/utils/cache-home.d.ts +0 -23
  159. package/dist/src/utils/cache-home.js +0 -25
  160. package/dist/src/utils/capture-history.d.ts +0 -54
  161. package/dist/src/utils/capture-history.js +1 -55
  162. package/dist/src/utils/context.d.ts +0 -23
  163. package/dist/src/utils/context.js +0 -43
  164. package/dist/src/utils/credentials.d.ts +0 -38
  165. package/dist/src/utils/credentials.js +0 -39
  166. package/dist/src/utils/deploy-preflight.d.ts +0 -38
  167. package/dist/src/utils/deploy-preflight.js +1 -71
  168. package/dist/src/utils/errors.d.ts +0 -53
  169. package/dist/src/utils/errors.js +0 -49
  170. package/dist/src/utils/first-party-contexts.d.ts +0 -38
  171. package/dist/src/utils/first-party-contexts.js +0 -44
  172. package/dist/src/utils/index.d.ts +0 -4
  173. package/dist/src/utils/index.js +0 -4
  174. package/dist/src/utils/kubeconfig.d.ts +0 -40
  175. package/dist/src/utils/kubeconfig.js +0 -44
  176. package/dist/src/utils/log.d.ts +0 -20
  177. package/dist/src/utils/log.js +0 -20
  178. package/dist/src/utils/mesh-json.d.ts +0 -46
  179. package/dist/src/utils/mesh-json.js +0 -39
  180. package/dist/src/utils/pid.d.ts +0 -42
  181. package/dist/src/utils/pid.js +0 -48
  182. package/dist/src/utils/pulumi-run.d.ts +0 -14
  183. package/dist/src/utils/pulumi-run.js +0 -42
  184. package/dist/src/utils/pulumi.d.ts +0 -34
  185. package/dist/src/utils/pulumi.js +0 -38
  186. package/dist/src/utils/reachability.d.ts +0 -27
  187. package/dist/src/utils/reachability.js +0 -40
  188. package/dist/src/utils/recover-conversation.d.ts +0 -110
  189. package/dist/src/utils/recover-conversation.js +0 -108
  190. package/dist/src/utils/registry-broker.d.ts +5 -84
  191. package/dist/src/utils/registry-broker.js +0 -62
  192. package/dist/src/utils/registry-identity.d.ts +0 -149
  193. package/dist/src/utils/registry-identity.js +0 -143
  194. package/dist/src/utils/scaffold-versions.d.ts +0 -41
  195. package/dist/src/utils/scaffold-versions.js +0 -42
  196. package/dist/src/utils/socks-forward.d.ts +0 -24
  197. package/dist/src/utils/socks-forward.js +2 -25
  198. package/dist/src/utils/stack-flag.d.ts +0 -13
  199. package/dist/src/utils/stack-flag.js +0 -21
  200. package/dist/src/utils/tailscale-targets.d.ts +0 -37
  201. package/dist/src/utils/tailscale-targets.js +0 -33
  202. package/dist/src/utils/tailscale.d.ts +0 -177
  203. package/dist/src/utils/tailscale.js +6 -337
  204. package/dist/src/utils/temporal-auth.d.ts +0 -41
  205. package/dist/src/utils/temporal-auth.js +0 -52
  206. package/dist/src/utils/temporal-codec.d.ts +0 -89
  207. package/dist/src/utils/temporal-codec.js.map +0 -1
  208. package/dist/src/utils/tunnel-ownership.d.ts +0 -79
  209. package/dist/src/utils/tunnel-ownership.js +0 -74
  210. package/dist/src/utils/vpn-join.d.ts +0 -16
  211. package/dist/src/utils/vpn-join.js +0 -17
  212. package/dist/src/utils/vpn.d.ts +0 -42
  213. package/dist/src/utils/vpn.js +0 -52
  214. package/dist/src/utils/workflow-fingerprint.d.ts +0 -39
  215. package/dist/src/utils/workflow-fingerprint.js +1 -63
  216. package/dist/src/utils/worktree-identity.d.ts +0 -62
  217. package/dist/src/utils/worktree-identity.js +0 -88
  218. package/package.json +1 -1
  219. package/skills/core/SKILL.md +10 -11
  220. package/templates/api-auth/api/src/index.ts.hbs +1 -1
  221. package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +2 -2
  222. package/templates/api-role-gating/api/src/authn.ts.hbs +2 -2
  223. package/templates/api-role-gating/index.ts.hbs +1 -1
  224. package/templates/api-role-gating/skills/develop/SKILL.md.hbs +1 -1
  225. package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +2 -2
  226. package/templates/platform-repo/platform/index.ts +1 -1
  227. package/dist/bin/mesh.d.ts.map +0 -1
  228. package/dist/src/commands/agent-api-client.d.ts.map +0 -1
  229. package/dist/src/commands/agent-api-client.js.map +0 -1
  230. package/dist/src/commands/app-check.d.ts.map +0 -1
  231. package/dist/src/commands/app-check.js.map +0 -1
  232. package/dist/src/commands/artifacts.d.ts.map +0 -1
  233. package/dist/src/commands/artifacts.js.map +0 -1
  234. package/dist/src/commands/cluster.d.ts.map +0 -1
  235. package/dist/src/commands/cluster.js.map +0 -1
  236. package/dist/src/commands/conversations.d.ts.map +0 -1
  237. package/dist/src/commands/conversations.js.map +0 -1
  238. package/dist/src/commands/create-app.d.ts.map +0 -1
  239. package/dist/src/commands/create-app.js.map +0 -1
  240. package/dist/src/commands/db/connect.d.ts.map +0 -1
  241. package/dist/src/commands/db/connect.js.map +0 -1
  242. package/dist/src/commands/db/credentials.d.ts.map +0 -1
  243. package/dist/src/commands/db/credentials.js.map +0 -1
  244. package/dist/src/commands/db/env.d.ts.map +0 -1
  245. package/dist/src/commands/db/env.js.map +0 -1
  246. package/dist/src/commands/db/exec.d.ts.map +0 -1
  247. package/dist/src/commands/db/exec.js.map +0 -1
  248. package/dist/src/commands/db/index.d.ts.map +0 -1
  249. package/dist/src/commands/db/index.js.map +0 -1
  250. package/dist/src/commands/db/psql.d.ts.map +0 -1
  251. package/dist/src/commands/db/psql.js.map +0 -1
  252. package/dist/src/commands/deploy.d.ts.map +0 -1
  253. package/dist/src/commands/deploy.js.map +0 -1
  254. package/dist/src/commands/dev-doctor.d.ts.map +0 -1
  255. package/dist/src/commands/dev-doctor.js.map +0 -1
  256. package/dist/src/commands/dev-launch.d.ts.map +0 -1
  257. package/dist/src/commands/dev-launch.js.map +0 -1
  258. package/dist/src/commands/dev-token-server.d.ts.map +0 -1
  259. package/dist/src/commands/dev-token-server.js.map +0 -1
  260. package/dist/src/commands/dev.d.ts.map +0 -1
  261. package/dist/src/commands/dev.js.map +0 -1
  262. package/dist/src/commands/docs.d.ts.map +0 -1
  263. package/dist/src/commands/docs.js.map +0 -1
  264. package/dist/src/commands/hub/index.d.ts.map +0 -1
  265. package/dist/src/commands/hub/index.js.map +0 -1
  266. package/dist/src/commands/init/wizard.d.ts.map +0 -1
  267. package/dist/src/commands/init/wizard.js.map +0 -1
  268. package/dist/src/commands/init.d.ts.map +0 -1
  269. package/dist/src/commands/init.js.map +0 -1
  270. package/dist/src/commands/install-shim.d.ts.map +0 -1
  271. package/dist/src/commands/install-shim.js.map +0 -1
  272. package/dist/src/commands/local/auth-provision.d.ts.map +0 -1
  273. package/dist/src/commands/local/auth-provision.js.map +0 -1
  274. package/dist/src/commands/local/dev-local.d.ts.map +0 -1
  275. package/dist/src/commands/local/dev-local.js.map +0 -1
  276. package/dist/src/commands/local/docker-runner.d.ts.map +0 -1
  277. package/dist/src/commands/local/docker-runner.js.map +0 -1
  278. package/dist/src/commands/local/helpers.d.ts.map +0 -1
  279. package/dist/src/commands/local/helpers.js.map +0 -1
  280. package/dist/src/commands/local/hub-local.d.ts.map +0 -1
  281. package/dist/src/commands/local/hub-local.js.map +0 -1
  282. package/dist/src/commands/local/index.d.ts.map +0 -1
  283. package/dist/src/commands/local/index.js.map +0 -1
  284. package/dist/src/commands/local/mocks.d.ts.map +0 -1
  285. package/dist/src/commands/local/mocks.js.map +0 -1
  286. package/dist/src/commands/local/seed-hub-catalog.d.ts.map +0 -1
  287. package/dist/src/commands/local/seed-hub-catalog.js.map +0 -1
  288. package/dist/src/commands/local/seed-zitadel.d.ts.map +0 -1
  289. package/dist/src/commands/local/seed-zitadel.js.map +0 -1
  290. package/dist/src/commands/local/seed.d.ts.map +0 -1
  291. package/dist/src/commands/local/seed.js.map +0 -1
  292. package/dist/src/commands/local/stack.d.ts.map +0 -1
  293. package/dist/src/commands/local/stack.js.map +0 -1
  294. package/dist/src/commands/login.d.ts.map +0 -1
  295. package/dist/src/commands/login.js.map +0 -1
  296. package/dist/src/commands/peer-addressing.d.ts.map +0 -1
  297. package/dist/src/commands/peer-addressing.js.map +0 -1
  298. package/dist/src/commands/port-ownership.d.ts.map +0 -1
  299. package/dist/src/commands/port-ownership.js.map +0 -1
  300. package/dist/src/commands/registry-publish.d.ts.map +0 -1
  301. package/dist/src/commands/registry-publish.js.map +0 -1
  302. package/dist/src/commands/registry.d.ts.map +0 -1
  303. package/dist/src/commands/registry.js.map +0 -1
  304. package/dist/src/commands/secrets/exec.d.ts.map +0 -1
  305. package/dist/src/commands/secrets/exec.js.map +0 -1
  306. package/dist/src/commands/secrets/index.d.ts.map +0 -1
  307. package/dist/src/commands/secrets/index.js.map +0 -1
  308. package/dist/src/commands/secrets/migrate-config.d.ts.map +0 -1
  309. package/dist/src/commands/secrets/migrate-config.js.map +0 -1
  310. package/dist/src/commands/secrets/reindex.d.ts.map +0 -1
  311. package/dist/src/commands/secrets/reindex.js.map +0 -1
  312. package/dist/src/commands/secrets/set.d.ts.map +0 -1
  313. package/dist/src/commands/secrets/set.js.map +0 -1
  314. package/dist/src/commands/site.d.ts.map +0 -1
  315. package/dist/src/commands/site.js.map +0 -1
  316. package/dist/src/commands/skills.d.ts.map +0 -1
  317. package/dist/src/commands/skills.js.map +0 -1
  318. package/dist/src/commands/stack.d.ts.map +0 -1
  319. package/dist/src/commands/stack.js.map +0 -1
  320. package/dist/src/commands/temporal.d.ts.map +0 -1
  321. package/dist/src/commands/tenant.d.ts.map +0 -1
  322. package/dist/src/commands/tenant.js.map +0 -1
  323. package/dist/src/commands/tunnel/index.d.ts.map +0 -1
  324. package/dist/src/commands/tunnel/index.js.map +0 -1
  325. package/dist/src/commands/vcs/clone.d.ts.map +0 -1
  326. package/dist/src/commands/vcs/clone.js.map +0 -1
  327. package/dist/src/commands/vcs/common.d.ts.map +0 -1
  328. package/dist/src/commands/vcs/common.js.map +0 -1
  329. package/dist/src/commands/vcs/drafts.d.ts.map +0 -1
  330. package/dist/src/commands/vcs/drafts.js.map +0 -1
  331. package/dist/src/commands/vcs/get.d.ts.map +0 -1
  332. package/dist/src/commands/vcs/get.js.map +0 -1
  333. package/dist/src/commands/vcs/index.d.ts.map +0 -1
  334. package/dist/src/commands/vcs/index.js.map +0 -1
  335. package/dist/src/commands/vcs/propose.d.ts.map +0 -1
  336. package/dist/src/commands/vcs/propose.js.map +0 -1
  337. package/dist/src/commands/vcs/review.d.ts.map +0 -1
  338. package/dist/src/commands/vcs/review.js.map +0 -1
  339. package/dist/src/commands/vcs/rm.d.ts.map +0 -1
  340. package/dist/src/commands/vcs/rm.js.map +0 -1
  341. package/dist/src/commands/vcs-enrich.d.ts.map +0 -1
  342. package/dist/src/commands/vcs-enrich.js.map +0 -1
  343. package/dist/src/commands/vpn/index.d.ts.map +0 -1
  344. package/dist/src/commands/vpn/index.js.map +0 -1
  345. package/dist/src/commands/vpn/tunnel.d.ts.map +0 -1
  346. package/dist/src/commands/vpn/tunnel.js.map +0 -1
  347. package/dist/src/commands/workflow.d.ts.map +0 -1
  348. package/dist/src/commands/workflow.js.map +0 -1
  349. package/dist/src/docs/assemble.d.ts.map +0 -1
  350. package/dist/src/docs/assemble.js.map +0 -1
  351. package/dist/src/docs/cli-reference.d.ts.map +0 -1
  352. package/dist/src/docs/cli-reference.js.map +0 -1
  353. package/dist/src/docs/discover.d.ts.map +0 -1
  354. package/dist/src/docs/discover.js.map +0 -1
  355. package/dist/src/docs/portal.d.ts.map +0 -1
  356. package/dist/src/docs/portal.js.map +0 -1
  357. package/dist/src/docs/registry-docs.d.ts.map +0 -1
  358. package/dist/src/docs/registry-docs.js.map +0 -1
  359. package/dist/src/docs/schema.d.ts.map +0 -1
  360. package/dist/src/docs/schema.js.map +0 -1
  361. package/dist/src/docs/serve.d.ts.map +0 -1
  362. package/dist/src/docs/serve.js.map +0 -1
  363. package/dist/src/docs/start.d.ts.map +0 -1
  364. package/dist/src/docs/start.js.map +0 -1
  365. package/dist/src/eval/agent-eval.d.mts.map +0 -1
  366. package/dist/src/eval/agent-eval.mjs.map +0 -1
  367. package/dist/src/index.d.ts.map +0 -1
  368. package/dist/src/program.d.ts.map +0 -1
  369. package/dist/src/program.js.map +0 -1
  370. package/dist/src/utils/auth-preflight.d.ts.map +0 -1
  371. package/dist/src/utils/auth-preflight.js.map +0 -1
  372. package/dist/src/utils/aws-auth.d.ts.map +0 -1
  373. package/dist/src/utils/aws-auth.js.map +0 -1
  374. package/dist/src/utils/bastion.d.ts.map +0 -1
  375. package/dist/src/utils/bastion.js.map +0 -1
  376. package/dist/src/utils/build-info.d.ts.map +0 -1
  377. package/dist/src/utils/build-info.js.map +0 -1
  378. package/dist/src/utils/cache-home.d.ts.map +0 -1
  379. package/dist/src/utils/cache-home.js.map +0 -1
  380. package/dist/src/utils/capture-history.d.ts.map +0 -1
  381. package/dist/src/utils/capture-history.js.map +0 -1
  382. package/dist/src/utils/context.d.ts.map +0 -1
  383. package/dist/src/utils/context.js.map +0 -1
  384. package/dist/src/utils/credentials.d.ts.map +0 -1
  385. package/dist/src/utils/credentials.js.map +0 -1
  386. package/dist/src/utils/deploy-preflight.d.ts.map +0 -1
  387. package/dist/src/utils/deploy-preflight.js.map +0 -1
  388. package/dist/src/utils/errors.d.ts.map +0 -1
  389. package/dist/src/utils/errors.js.map +0 -1
  390. package/dist/src/utils/first-party-contexts.d.ts.map +0 -1
  391. package/dist/src/utils/first-party-contexts.js.map +0 -1
  392. package/dist/src/utils/index.d.ts.map +0 -1
  393. package/dist/src/utils/index.js.map +0 -1
  394. package/dist/src/utils/kubeconfig.d.ts.map +0 -1
  395. package/dist/src/utils/kubeconfig.js.map +0 -1
  396. package/dist/src/utils/log.d.ts.map +0 -1
  397. package/dist/src/utils/log.js.map +0 -1
  398. package/dist/src/utils/mesh-json.d.ts.map +0 -1
  399. package/dist/src/utils/mesh-json.js.map +0 -1
  400. package/dist/src/utils/pid.d.ts.map +0 -1
  401. package/dist/src/utils/pid.js.map +0 -1
  402. package/dist/src/utils/pulumi-run.d.ts.map +0 -1
  403. package/dist/src/utils/pulumi-run.js.map +0 -1
  404. package/dist/src/utils/pulumi.d.ts.map +0 -1
  405. package/dist/src/utils/pulumi.js.map +0 -1
  406. package/dist/src/utils/reachability.d.ts.map +0 -1
  407. package/dist/src/utils/reachability.js.map +0 -1
  408. package/dist/src/utils/recover-conversation.d.ts.map +0 -1
  409. package/dist/src/utils/recover-conversation.js.map +0 -1
  410. package/dist/src/utils/registry-broker.d.ts.map +0 -1
  411. package/dist/src/utils/registry-broker.js.map +0 -1
  412. package/dist/src/utils/registry-identity.d.ts.map +0 -1
  413. package/dist/src/utils/registry-identity.js.map +0 -1
  414. package/dist/src/utils/scaffold-versions.d.ts.map +0 -1
  415. package/dist/src/utils/scaffold-versions.js.map +0 -1
  416. package/dist/src/utils/socks-forward.d.ts.map +0 -1
  417. package/dist/src/utils/socks-forward.js.map +0 -1
  418. package/dist/src/utils/stack-flag.d.ts.map +0 -1
  419. package/dist/src/utils/stack-flag.js.map +0 -1
  420. package/dist/src/utils/tailscale-targets.d.ts.map +0 -1
  421. package/dist/src/utils/tailscale-targets.js.map +0 -1
  422. package/dist/src/utils/tailscale.d.ts.map +0 -1
  423. package/dist/src/utils/tailscale.js.map +0 -1
  424. package/dist/src/utils/temporal-auth.d.ts.map +0 -1
  425. package/dist/src/utils/temporal-auth.js.map +0 -1
  426. package/dist/src/utils/temporal-codec.d.ts.map +0 -1
  427. package/dist/src/utils/tunnel-ownership.d.ts.map +0 -1
  428. package/dist/src/utils/tunnel-ownership.js.map +0 -1
  429. package/dist/src/utils/vpn-join.d.ts.map +0 -1
  430. package/dist/src/utils/vpn-join.js.map +0 -1
  431. package/dist/src/utils/vpn.d.ts.map +0 -1
  432. package/dist/src/utils/vpn.js.map +0 -1
  433. package/dist/src/utils/workflow-fingerprint.d.ts.map +0 -1
  434. package/dist/src/utils/workflow-fingerprint.js.map +0 -1
  435. package/dist/src/utils/worktree-identity.d.ts.map +0 -1
  436. package/dist/src/utils/worktree-identity.js.map +0 -1
@@ -1,18 +1,3 @@
1
- /**
2
- * mesh dev — Pulumi-output-driven local development orchestrator
3
- *
4
- * Reads `pulumi stack output dev --json` to discover services and VPC endpoints,
5
- * then launches each service's dev command in a tmux window.
6
- * Requires VPN connection for direct access to VPC resources.
7
- *
8
- * Usage:
9
- * mesh dev Start all services, attach tmux
10
- * mesh dev --headless Start without attaching
11
- * mesh dev --kill Stop everything
12
- * mesh dev --status Show service health (--json for structured)
13
- * mesh dev logs <service> Tail a service's output
14
- * mesh dev restart <svc> Restart a single service
15
- */
16
1
  import { execFileSync, spawn, spawnSync } from 'child_process';
17
2
  import * as fs from 'fs';
18
3
  import * as net from 'net';
@@ -44,16 +29,6 @@ import { resolveStackOption } from '../utils/stack-flag.js';
44
29
  import { rewriteClusterHostsToLocal } from './peer-addressing.js';
45
30
  import { ALL_CHECKS, aggregateStatus, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
46
31
  import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
47
- /**
48
- * The app's own services, as blackbox probe targets, so local uptime is real.
49
- *
50
- * Mock stand-ins are excluded: `--externals` already registers each of those
51
- * under its own external-service labels, and probing them twice would show one
52
- * dependency as two rows — one of which disappears when the mock does.
53
- *
54
- * Best-effort. A probe file is monitoring, not the app: failing to write one
55
- * must never take down a dev session that is otherwise working.
56
- */
57
32
  export function registerServiceProbes(devOutput, tenant, probeFiles) {
58
33
  const services = {};
59
34
  for (const [name, service] of Object.entries(devOutput.services)) {
@@ -75,20 +50,9 @@ export function registerServiceProbes(devOutput, tenant, probeFiles) {
75
50
  logWarn(`Could not register uptime probes for this session (${err instanceof Error ? err.message : err}) — the Hub will show no uptime for these services.`);
76
51
  }
77
52
  }
78
- /**
79
- * tmux session name for a dev launch. The primary checkout keeps the historical
80
- * `${projectName}-dev`; linked worktrees get a `${projectName}-${slug}` so two
81
- * worktrees of the same app don't share (and kill) each other's session, state,
82
- * and env-file dir (MESH-2013).
83
- */
84
53
  function deriveSessionName(projectName, wt) {
85
54
  return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
86
55
  }
87
- /**
88
- * Render the resolved per-worktree plan (`mesh dev --dry-run`): identity,
89
- * session/state/env paths, and the port assignment — everything that must be
90
- * collision-free across concurrent worktrees, without launching anything.
91
- */
92
56
  function printDevPlan(sessionName, appRoot, wt, devOutput) {
93
57
  const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
94
58
  console.log(`\nmesh dev plan — ${kind}\n`);
@@ -139,16 +103,6 @@ function loadSessionState(sessionName) {
139
103
  return null;
140
104
  }
141
105
  }
142
- /**
143
- * Relaunch safety (MESH-2017): if the workflow source changed since this session
144
- * launched, restarting the worker replays any in-flight conversations against the
145
- * NEW code — a replay-incompatible change strands them. Warn and require `--force`.
146
- *
147
- * Silent no-op when there's no prior session, no stored fingerprint (pre-feature
148
- * session), or nothing changed — so a restart of unchanged code never nags.
149
- * Returns `true` if the caller should ABORT (changed && !force).
150
- */
151
- /** Pure: did the workflow source change since launch (→ risk of stranding)? */
152
106
  export function workflowChanged(prior, current) {
153
107
  return Boolean(prior) && Boolean(current) && current !== prior;
154
108
  }
@@ -171,11 +125,6 @@ function workflowChangeWouldStrand(priorState, appRoot, force) {
171
125
  logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
172
126
  return true;
173
127
  }
174
- /**
175
- * Per-session directory holding one sourceable env file per service
176
- * (`<service>.env.sh`, mode 0600). Written at launch; `mesh dev restart`
177
- * sources the same file so a restarted service gets a byte-identical env.
178
- */
179
128
  function getSessionEnvDir(sessionName) {
180
129
  return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
181
130
  }
@@ -192,28 +141,6 @@ function removeSessionState(sessionName) {
192
141
  }
193
142
  catch { }
194
143
  }
195
- /**
196
- * Check whether a TCP port is available — probing the way services actually bind.
197
- *
198
- * This used to bind `127.0.0.1` only, and that made it blind to exactly the
199
- * processes it exists to find. Node's `listen(port)` (and `@hono/node-server`,
200
- * which every agent-api uses) binds the WILDCARD address `::` in dual-stack mode,
201
- * holding the port on every interface — yet a probe bound to `127.0.0.1` still
202
- * succeeds against such a holder. So the check returned `true` for a port already
203
- * held by a prior `mesh dev` service, `allocatePorts` kept the preferred port, and
204
- * the service then died on `EADDRINUSE` at its real bind.
205
- *
206
- * Observed 2026-07-31: the `studio-api` pane died on `EADDRINUSE :::40161` while a
207
- * stale agent-api from an earlier boot kept serving on the same port, so the Studio
208
- * UI drove a process from a previous session's configuration and the conversation
209
- * list came back empty. Reproduced in isolation — holder on `::`, loopback probe
210
- * reports free, real bind fails (MESH-2590).
211
- *
212
- * Both addresses are probed: the wildcard catches all-interface holders, and the
213
- * loopback probe still catches a loopback-only squatter. A false "busy" is cheap —
214
- * `allocatePorts` simply picks another port — while a false "free" crashes the
215
- * service, so this deliberately errs toward busy.
216
- */
217
144
  export async function isPortFree(port) {
218
145
  const bindSucceeds = (host) => new Promise((resolve) => {
219
146
  const server = net.createServer();
@@ -224,14 +151,10 @@ export async function isPortFree(port) {
224
151
  else
225
152
  server.listen(port, host, onListening);
226
153
  });
227
- // Wildcard first: it is how the services bind, so it is the probe that matters.
228
154
  if (!(await bindSucceeds()))
229
155
  return false;
230
156
  return bindSucceeds('127.0.0.1');
231
157
  }
232
- /**
233
- * Ask the OS for an ephemeral free port.
234
- */
235
158
  async function findFreePort() {
236
159
  return new Promise((resolve, reject) => {
237
160
  const server = net.createServer();
@@ -242,7 +165,6 @@ async function findFreePort() {
242
165
  });
243
166
  });
244
167
  }
245
- /** Resolve true if something is accepting connections on 127.0.0.1:port. */
246
168
  function isPortListening(port) {
247
169
  return new Promise((resolve) => {
248
170
  const s = new net.Socket();
@@ -261,16 +183,6 @@ function isPortListening(port) {
261
183
  s.connect(port, '127.0.0.1');
262
184
  });
263
185
  }
264
- /**
265
- * Rewrite a service's OWN listen-port env var(s) from `oldPort` to `newPort`
266
- * when `allocatePorts` reassigns its port (e.g. a worktree block). Matches any
267
- * port-ish var (`PORT` or `*_PORT`, e.g. `VCS_PORT`/`AGENT_API_PORT`) whose value
268
- * is exactly the old port — so a service that reads its own env var still binds
269
- * the reassigned port instead of its deploy-time default (which would collide
270
- * with another worktree/session on that default). The name + exact-value match
271
- * keeps this from touching cross-service references or unrelated vars.
272
- * Mutates `service.env`. Pure otherwise — exported for unit testing. (MESH-2088)
273
- */
274
186
  export function rewriteOwnServicePort(service, oldPort, newPort) {
275
187
  for (const [key, spec] of Object.entries(service.env ?? {})) {
276
188
  if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {
@@ -278,30 +190,15 @@ export function rewriteOwnServicePort(service, oldPort, newPort) {
278
190
  }
279
191
  }
280
192
  }
281
- /**
282
- * Clone devOutput and assign each service a local port.
283
- *
284
- * The PRIMARY checkout keeps the app's preferred ports (falling back to an
285
- * OS-ephemeral port only if one is busy). LINKED worktrees draw from a reserved
286
- * port block keyed on (worktree, app) — MESH-2013 for the worktree axis, #2889
287
- * for the app axis — so concurrent sessions don't collide, where the
288
- * `EADDRINUSE on 8080/8081/9000` class only happened because every worktree
289
- * reused the same preferred ports.
290
- *
291
- * Disjoint by DEFAULT, not by construction: the block is a hash bucket, so two
292
- * (worktree, app) pairs can share one. The in-block scan below plus the
293
- * ephemeral fallback are the backstop; MESH-2375 tracks making it deterministic.
294
- */
295
193
  async function allocatePorts(devOutput, worktree) {
296
194
  const allocated = JSON.parse(JSON.stringify(devOutput));
297
195
  const usedPorts = new Set();
298
196
  const portRemaps = new Map();
299
197
  const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);
300
- let blockCursor = 0; // next unused index within the service sub-range
198
+ let blockCursor = 0;
301
199
  for (const [name, service] of Object.entries(allocated.services)) {
302
200
  if (service.port <= 0)
303
201
  continue;
304
- // Skip deployed services (no dev command) — they run in K8s, no local port.
305
202
  if (!service.command || service.command.length === 0)
306
203
  continue;
307
204
  const preferred = service.port;
@@ -316,7 +213,6 @@ async function allocatePorts(devOutput, worktree) {
316
213
  }
317
214
  }
318
215
  else {
319
- // Reserved block: next free port in [blockBase, blockBase+SERVICE_SUBRANGE).
320
216
  for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
321
217
  const candidate = blockBase + i;
322
218
  if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
@@ -326,36 +222,21 @@ async function allocatePorts(devOutput, worktree) {
326
222
  }
327
223
  }
328
224
  if (target < 0) {
329
- // Block exhausted or contended — degrade to an OS-ephemeral port.
330
225
  target = await findFreePort();
331
226
  logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
332
227
  }
333
228
  }
334
229
  if (target !== preferred) {
335
- // Rewrite the service's OWN port env var(s) — not just a var literally
336
- // named `PORT`. Without this, a service reading `VCS_PORT`/`AGENT_API_PORT`
337
- // keeps its deploy-time default and collides across worktrees (MESH-2088).
338
230
  rewriteOwnServicePort(service, preferred, target);
339
231
  portRemaps.set(preferred, target);
340
232
  service.port = target;
341
233
  }
342
234
  usedPorts.add(target);
343
235
  }
344
- // Cross-service URL rewriting: when a service port is reassigned,
345
- // update any env var in other services that references the old port
346
- // (e.g., API_URL from a link() pointing to the API's original port).
347
236
  rewriteCrossServicePorts(allocated, portRemaps);
348
237
  rewriteClusterHostsToLocal(allocated);
349
238
  return allocated;
350
239
  }
351
- /**
352
- * Rewrite `localhost:${oldPort}` → `localhost:${newPort}` in EVERY service's env
353
- * values, for each `oldPort → newPort` entry in `portRemaps`. Mutates the given
354
- * `devOutput` in place — callers pass a clone they own, so this stays safe.
355
- *
356
- * Shared by `allocatePorts` (launch-time reallocation) and `mergePinnedPorts`
357
- * (refresh-time re-pinning) so both apply identical cross-service rewrites.
358
- */
359
240
  function rewriteCrossServicePorts(devOutput, portRemaps) {
360
241
  if (portRemaps.size === 0)
361
242
  return;
@@ -371,42 +252,10 @@ function rewriteCrossServicePorts(devOutput, portRemaps) {
371
252
  }
372
253
  }
373
254
  }
374
- /**
375
- * Merge a session's PINNED ports/tunnels into a freshly-read DevOutput.
376
- *
377
- * `mesh dev restart <svc> --refresh-env` re-reads `pulumi stack output` to pick
378
- * up config/deploy changes, but the running session already allocated local
379
- * ports (`allocatePorts`) and opened SSM tunnels. Re-reading the raw stack
380
- * output would carry the DEPLOYED (pre-allocation) ports, so writing those into
381
- * the env file would point the service — and its peers — at the wrong ports and
382
- * break the live tunnels.
383
- *
384
- * This returns `fresh` (so new/changed non-port env values win) but overrides:
385
- * - EVERY service present in both `fresh` and `pinned` keeps its pinned/
386
- * allocated PORT (both `service.port` and `service.env.PORT.value`) — not
387
- * just the refreshed one, so a regenerated service's peer references land on
388
- * the session-allocated ports, and
389
- * - every tunnel present in `pinned` with the pinned host:port (the live SSM
390
- * endpoints); tunnels that exist only in `fresh` survive untouched.
391
- *
392
- * It then re-applies BOTH of the peer-addressing rewrites `allocatePorts` does —
393
- * there are two now, and naming only one here is how they drift apart:
394
- * - PORT: for each service whose port differs between `fresh` (preferred) and
395
- * `pinned` (allocated), `localhost:${freshPort}` → `localhost:${pinnedPort}`
396
- * across EVERY service's env (e.g. a `ui`'s `API_URL` pointing at `api`).
397
- * - HOST: an in-cluster address for a service running LOCALLY becomes its local
398
- * one. `pulumi stack output` carries cluster hosts verbatim, so a refresh
399
- * without this restores exactly the value MESH-2597 exists to remove.
400
- *
401
- * Pure: deep-clones and never mutates its arguments.
402
- */
403
255
  export function mergePinnedPorts(fresh, pinned) {
404
256
  const merged = JSON.parse(JSON.stringify(fresh));
405
- // Pinned tunnels win (live SSM/VPC endpoints); fresh-only tunnels survive.
406
257
  const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {}));
407
258
  merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };
408
- // Pin EVERY service's allocated port (never re-allocate). Track fresh→pinned
409
- // port remaps so peer references (localhost:<freshPort>) can be rewritten.
410
259
  const portRemaps = new Map();
411
260
  for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {
412
261
  const pinnedSvc = pinned.services?.[name];
@@ -421,35 +270,18 @@ export function mergePinnedPorts(fresh, pinned) {
421
270
  portRemaps.set(freshPort, pinnedSvc.port);
422
271
  }
423
272
  }
424
- // Re-apply cross-service URL rewrite (mirrors allocatePorts): peer references
425
- // to a preferred port become references to the session-allocated port.
426
273
  rewriteCrossServicePorts(merged, portRemaps);
427
- // BOTH rewrites, or `--refresh-env` undoes the other one. This path re-reads
428
- // `pulumi stack output`, which carries in-cluster hosts verbatim, so without
429
- // this a refresh restores `AGENT_API_INTERNAL_URL` to
430
- // `http://studio-api.<ns>.svc.cluster.local:8080` for a service running locally
431
- // — reintroducing MESH-2597 on the command whose entire purpose is refreshing
432
- // env.
433
274
  rewriteClusterHostsToLocal(merged);
434
275
  return merged;
435
276
  }
436
- // ============================================================================
437
- // Pulumi helpers
438
- // ============================================================================
439
- /**
440
- * Find the monorepo root by walking up from cwd looking for the root
441
- * package.json (has "workspaces" or the well-known repo markers).
442
- */
443
277
  function findMonorepoRoot() {
444
278
  let dir = process.cwd();
445
279
  const root = path.parse(dir).root;
446
280
  while (dir !== root) {
447
- // Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)
448
281
  if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||
449
282
  fs.existsSync(path.join(dir, 'turbo.json'))) {
450
283
  return dir;
451
284
  }
452
- // .git as fallback — but only if it's a directory (not a worktree file)
453
285
  const gitPath = path.join(dir, '.git');
454
286
  if (fs.existsSync(gitPath)) {
455
287
  return dir;
@@ -458,15 +290,8 @@ function findMonorepoRoot() {
458
290
  }
459
291
  return null;
460
292
  }
461
- /**
462
- * Discover Pulumi apps in the monorepo.
463
- * Scans:
464
- * {tenants,tests/tenants}/<tenant>/apps/<app>/Pulumi.yaml
465
- * apps/<app>/Pulumi.yaml (flat layout, tenant from stack config)
466
- */
467
293
  function discoverApps(monorepoRoot) {
468
294
  const apps = [];
469
- // Helper: extract stacks and add an app entry
470
295
  function addApp(appDir, tenant) {
471
296
  const projectName = getProjectName(appDir);
472
297
  const stacks = fs
@@ -481,7 +306,6 @@ function discoverApps(monorepoRoot) {
481
306
  stacks,
482
307
  });
483
308
  }
484
- // 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/
485
309
  const searchDirs = ['tenants', 'tests/tenants'];
486
310
  for (const searchDir of searchDirs) {
487
311
  const base = path.join(monorepoRoot, searchDir);
@@ -499,7 +323,6 @@ function discoverApps(monorepoRoot) {
499
323
  }
500
324
  }
501
325
  }
502
- // 2. Flat layout: apps/<app>/ (tenant read from first stack config)
503
326
  const flatAppsDir = path.join(monorepoRoot, 'apps');
504
327
  if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {
505
328
  for (const app of fs.readdirSync(flatAppsDir)) {
@@ -508,7 +331,6 @@ function discoverApps(monorepoRoot) {
508
331
  continue;
509
332
  if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
510
333
  continue;
511
- // Read tenant from first stack config's mesh:tenant
512
334
  let tenant = 'unknown';
513
335
  const stackConfigs = fs
514
336
  .readdirSync(appDir)
@@ -527,16 +349,11 @@ function discoverApps(monorepoRoot) {
527
349
  }
528
350
  return apps;
529
351
  }
530
- /**
531
- * Find the Pulumi app root by walking up from cwd looking for Pulumi.yaml.
532
- */
533
352
  function findAppRoot(appPath) {
534
353
  if (appPath) {
535
- // Resolve relative to cwd or monorepo root
536
354
  const resolved = path.resolve(appPath);
537
355
  if (fs.existsSync(path.join(resolved, 'Pulumi.yaml')))
538
356
  return resolved;
539
- // Try relative to monorepo root
540
357
  const mono = findMonorepoRoot();
541
358
  if (mono) {
542
359
  const fromMono = path.resolve(mono, appPath);
@@ -556,9 +373,6 @@ function findAppRoot(appPath) {
556
373
  }
557
374
  return process.cwd();
558
375
  }
559
- /**
560
- * Read the Pulumi project name from Pulumi.yaml.
561
- */
562
376
  function getProjectName(appRoot) {
563
377
  const yamlPath = path.join(appRoot, 'Pulumi.yaml');
564
378
  if (!fs.existsSync(yamlPath))
@@ -567,13 +381,6 @@ function getProjectName(appRoot) {
567
381
  const match = content.match(/^name:\s*(.+)$/m);
568
382
  return match?.[1]?.trim() ?? path.basename(appRoot);
569
383
  }
570
- /**
571
- * Detect the current Pulumi stack from:
572
- * 1. --stage flag
573
- * 2. MESH_STAGE env var
574
- * 3. `pulumi stack --show-name` (currently-selected stack)
575
- * 4. Pulumi.<stack>.yaml files in the app root (if exactly one, use it)
576
- */
577
384
  function detectStack(appRoot, stageArg) {
578
385
  if (stageArg)
579
386
  return stageArg;
@@ -589,9 +396,7 @@ function detectStack(appRoot, stageArg) {
589
396
  return result;
590
397
  }
591
398
  catch {
592
- // Fall through
593
399
  }
594
- // Infer from Pulumi.<stack>.yaml config files
595
400
  try {
596
401
  const files = fs
597
402
  .readdirSync(appRoot)
@@ -605,17 +410,10 @@ function detectStack(appRoot, stageArg) {
605
410
  }
606
411
  }
607
412
  catch {
608
- // Fall through
609
413
  }
610
414
  return 'dev';
611
415
  }
612
- /**
613
- * Build the `--stack <name>` args for Pulumi commands.
614
- * Newer Pulumi versions require fully-qualified names (organization/project/stack)
615
- * with `--stack`. If the stack is already selected, we can omit the flag entirely.
616
- */
617
416
  function stackArgs(appRoot, stack) {
618
- // Check if the requested stack is already the selected one — if so, skip --stack
619
417
  try {
620
418
  const selected = execFileSync('pulumi', ['stack', '--show-name'], {
621
419
  cwd: appRoot,
@@ -626,9 +424,7 @@ function stackArgs(appRoot, stack) {
626
424
  return [];
627
425
  }
628
426
  catch {
629
- // ignore
630
427
  }
631
- // Try selecting the stack first (works with short names), then omit --stack
632
428
  try {
633
429
  execFileSync('pulumi', ['stack', 'select', stack], {
634
430
  cwd: appRoot,
@@ -638,22 +434,9 @@ function stackArgs(appRoot, stack) {
638
434
  return [];
639
435
  }
640
436
  catch {
641
- // Fall through — pass --stack and hope the name is fully qualified
642
437
  }
643
438
  return ['--stack', stack];
644
439
  }
645
- /**
646
- * Read the dev output from Pulumi stack.
647
- * Tries `app` output first (new env.register() format), falls back to `dev`.
648
- */
649
- /**
650
- * Thrown by `getDevOutput` when no readable Pulumi stack output exists (usually a
651
- * fresh app that hasn't been `stack init` + `deploy`ed yet). Typed so each caller
652
- * presents it in its OWN context: only the launch path can conclude "nothing to
653
- * run" and print first-run guidance; the `--refresh-env`/restart handlers must
654
- * keep the running session's env untouched (MESH-2097). Carries the underlying
655
- * read error so an auth/network/JSON failure isn't misdiagnosed as "fresh app".
656
- */
657
440
  class MissingStackOutputError extends Error {
658
441
  stackName;
659
442
  constructor(stackName, cause) {
@@ -664,7 +447,6 @@ class MissingStackOutputError extends Error {
664
447
  }
665
448
  function getDevOutput(appRoot, stack, awsEnv) {
666
449
  const sa = stackArgs(appRoot, stack);
667
- // Try new format: export const app = env.register() → { dev: { ... } }
668
450
  try {
669
451
  const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);
670
452
  const appOutput = JSON.parse(result);
@@ -673,25 +455,15 @@ function getDevOutput(appRoot, stack, awsEnv) {
673
455
  }
674
456
  }
675
457
  catch {
676
- // Fall through to legacy format
677
458
  }
678
- // Legacy format: export const dev = ...
679
459
  try {
680
460
  const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);
681
461
  return JSON.parse(result);
682
462
  }
683
463
  catch (err) {
684
- // No readable stack output. Throw a TYPED error so each caller decides how to
685
- // present it (MESH-2097): the launch path prints first-run guidance + exits;
686
- // the --refresh-env/restart handlers keep the running session untouched.
687
- // (process.exit() here previously hijacked control flow past those fail-closed
688
- // catches, and a bare `throw` escaped as an uncaught node stacktrace.)
689
464
  throw new MissingStackOutputError(stack, err);
690
465
  }
691
466
  }
692
- // ============================================================================
693
- // tmux helpers
694
- // ============================================================================
695
467
  function hasTmux() {
696
468
  try {
697
469
  execFileSync('which', ['tmux'], { stdio: 'ignore' });
@@ -732,9 +504,6 @@ function applyAwsProfileOverride(profile) {
732
504
  delete process.env[key];
733
505
  }
734
506
  }
735
- /**
736
- * Collect AWS credentials from the current environment.
737
- */
738
507
  function getAwsEnvVars() {
739
508
  const result = {};
740
509
  const vars = [
@@ -752,28 +521,6 @@ function getAwsEnvVars() {
752
521
  }
753
522
  return result;
754
523
  }
755
- /**
756
- * Build the AWS env vars to hand to long-lived child services (ui, api,
757
- * ai-assistant) and SSM tunnels.
758
- *
759
- * For the AWS SSO credential path, returns AWS_PROFILE + AWS_REGION only so
760
- * the AWS SDK's default provider chain can auto-refresh credentials from the
761
- * SSO cache (~8h session) as short-lived STS tokens expire. Without this,
762
- * child processes inherit a frozen STS triple and start throwing
763
- * ExpiredTokenException ~1h into a dev session.
764
- *
765
- * For the Zitadel web-identity path, when `opts` is supplied we write an AWS
766
- * config file whose `credential_process` re-invokes `mesh login … --credential-process`,
767
- * so the AWS SDK's default provider chain auto-refreshes the underlying STS
768
- * triple (the `mesh login` process re-issues the Zitadel token as needed).
769
- * This removes the ~1h forced `mesh dev` restart. When `opts` is absent we
770
- * fall back to the frozen STS triple (used by CI/`environment` and any caller
771
- * that doesn't thread the context/role through).
772
- *
773
- * For the remaining non-SSO paths (CI/CD env keys, or no deployer role),
774
- * returns the explicit STS triple — there is no profile to refresh from. The
775
- * user must restart `mesh dev` when those tokens expire.
776
- */
777
524
  export function buildChildAwsEnv(awsEnv, method, profile, opts) {
778
525
  const result = {};
779
526
  if (awsEnv.AWS_REGION)
@@ -784,8 +531,6 @@ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
784
531
  result.AWS_PROFILE = profile;
785
532
  return result;
786
533
  }
787
- // Zitadel path with a credential_process profile: hand children a profile
788
- // that self-refreshes via `mesh login`, never a frozen triple.
789
534
  if (method === 'zitadel' && opts) {
790
535
  const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');
791
536
  const profileName = `mesh-dev-${sanitizedContext}`;
@@ -799,12 +544,6 @@ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
799
544
  region,
800
545
  meshBin: opts.meshBin,
801
546
  });
802
- // UPSERT a managed section; never rewrite the file. This file has a second
803
- // writer — `mesh login --export` (the worker-start recipe evals it) — and
804
- // the AWS SDK reads it ONCE per process, so a wholesale write here deletes
805
- // that profile under a running worker, which then fails every LLM call until
806
- // it is restarted. The key carries "(mesh dev)" so the two writers own two
807
- // sections rather than replacing each other's.
808
547
  const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
809
548
  const next = upsertManagedAwsConfigSection(stripBareProfile(existing, profileName), `${opts.context} (mesh dev)`, profileBlock);
810
549
  if (next !== existing)
@@ -826,20 +565,6 @@ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
826
565
  }
827
566
  return result;
828
567
  }
829
- /**
830
- * Resolve a service's source dir onto the CURRENT worktree (MESH-2013).
831
- *
832
- * `mesh dev` reads the service `src` from `pulumi stack output`. Two shapes exist:
833
- * - New (post relative-src fix in infra-components): a monorepo-RELATIVE dir
834
- * like `packages/agent-sdk` → just resolve it against this worktree's root.
835
- * - Legacy: an ABSOLUTE path baked at the deploying `pulumi up`, e.g.
836
- * `/…/mesh-platform/.worktrees/streaming-ui-builder/packages/agent-sdk`. The
837
- * old logic stripped only up to `/mesh-platform/`, keeping the
838
- * `.worktrees/<name>/` segment — so it re-rooted to a *sibling worktree* that
839
- * also exists, silently running the wrong worktree's code. We now reduce to
840
- * the monorepo-relative portion (dropping any `.worktrees/<name>/`) and
841
- * re-root here, so a stack deployed from any worktree runs from THIS one.
842
- */
843
568
  function rebaseServiceSrc(src, monorepoRoot) {
844
569
  if (!monorepoRoot)
845
570
  return src;
@@ -849,32 +574,7 @@ function rebaseServiceSrc(src, monorepoRoot) {
849
574
  const rebased = path.join(monorepoRoot, rel);
850
575
  return fs.existsSync(rebased) ? rebased : src;
851
576
  }
852
- /**
853
- * Is `serviceDir` a package this app DEV-LINKED, rather than a stale sibling
854
- * worktree?
855
- *
856
- * Both look identical to the wrong-worktree guard — a source directory outside
857
- * the app's worktree — but only one is a mistake. A module that contributes a
858
- * dev service (`InfraContribution.devServices`) reports `resolvePackageRoot()`,
859
- * which is the package's REAL path; when that package is dev-linked from another
860
- * repo it legitimately lives there. Refusing it means a devService can run only
861
- * while its package is published — i.e. never while you are developing it, which
862
- * is the only time you need it.
863
- *
864
- * The test is proof, not a heuristic: read the directory's own package name and
865
- * ask whether THIS app resolves that name to THIS directory. A stale sibling
866
- * worktree cannot satisfy that — nothing in the app's `node_modules` points at
867
- * it — so the wrong-worktree guard keeps its teeth.
868
- *
869
- * @example
870
- * isLinkedDependencyDir("/repo/libs/infra-components/src/components/ai/redis", "/repo/apps/demo");
871
- * // → true when the app resolves @mesh-tech/infra-components to that directory
872
- */
873
577
  export function isLinkedDependencyDir(serviceDir, appRoot) {
874
- // The service source is often INSIDE a package rather than its root — a redis
875
- // component ships as `<infra-components>/src/components/ai/redis`. So find the
876
- // enclosing package first (nearest ancestor with a named manifest), then ask
877
- // whether the app resolves THAT name to THAT directory.
878
578
  let pkgRoot = null;
879
579
  let name = '';
880
580
  for (let dir = serviceDir;; dir = path.dirname(dir)) {
@@ -887,7 +587,6 @@ export function isLinkedDependencyDir(serviceDir, appRoot) {
887
587
  }
888
588
  }
889
589
  catch {
890
- /* no manifest here; keep walking up */
891
590
  }
892
591
  const parent = path.dirname(dir);
893
592
  if (parent === dir)
@@ -895,44 +594,27 @@ export function isLinkedDependencyDir(serviceDir, appRoot) {
895
594
  }
896
595
  if (!pkgRoot)
897
596
  return false;
898
- // Resolve from the app outward, so a package linked for a DIFFERENT app in the
899
- // same repo does not vouch for this one.
900
597
  for (let dir = appRoot;; dir = path.dirname(dir)) {
901
598
  try {
902
599
  if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot)
903
600
  return true;
904
601
  }
905
602
  catch {
906
- /* not resolvable here; keep walking up */
907
603
  }
908
604
  const parent = path.dirname(dir);
909
605
  if (parent === dir)
910
606
  return false;
911
607
  }
912
608
  }
913
- /**
914
- * Reduce a service `src` to its monorepo-relative form (pure; no filesystem).
915
- * - relative (`./packages/x` or `packages/x`) → normalized `packages/x`
916
- * - legacy absolute (`/…/mesh-platform[/.worktrees/<name>]/packages/x`) →
917
- * `packages/x` (drops the repo root AND any deploying-worktree segment)
918
- * Returns null when an absolute path has no recognizable monorepo marker.
919
- */
920
609
  export function monorepoRelativeSrc(src) {
921
610
  if (!path.isAbsolute(src))
922
611
  return src.replace(/^\.\//, '') || '.';
923
- // Match the LAST `/mesh-platform` at a segment boundary — covers the repo root
924
- // itself (nothing follows → '.'), a nested `.worktrees/<name>/`, and normal
925
- // subpaths. A path ending at the repo root (e.g. a service whose src IS the
926
- // monorepo root) previously slipped through and stayed absolute (MESH-2069).
927
612
  const m = src.match(/^.*\/mesh-platform(?:\/(.*))?$/);
928
613
  if (!m)
929
614
  return null;
930
615
  const rest = (m[1] ?? '').replace(/^\.worktrees\/[^/]+(?:\/|$)/, '');
931
616
  return rest === '' ? '.' : rest;
932
617
  }
933
- /**
934
- * Resolve env vars for a service, rewriting tunnel references to VPC endpoints.
935
- */
936
618
  function getServiceEnvVars(service, tunnels) {
937
619
  const result = {};
938
620
  if (service.env) {
@@ -941,7 +623,6 @@ function getServiceEnvVars(service, tunnels) {
941
623
  if (spec.tunnel) {
942
624
  const tunnel = tunnels[spec.tunnel];
943
625
  if (tunnel) {
944
- // Rewrite to VPC endpoint (VPN provides direct access)
945
626
  try {
946
627
  const url = new URL(value);
947
628
  if (url.hostname) {
@@ -963,24 +644,11 @@ function getServiceEnvVars(service, tunnels) {
963
644
  }
964
645
  return result;
965
646
  }
966
- /**
967
- * Set env vars on a tmux session. New windows inherit them.
968
- *
969
- * Use for truly shared vars (AWS creds, secrets). For per-service
970
- * vars like PORT, use envPrefix() instead to avoid cross-service leaks.
971
- */
972
647
  function setTmuxEnv(sessionName, vars) {
973
648
  for (const [key, value] of Object.entries(vars)) {
974
649
  execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
975
650
  }
976
651
  }
977
- /**
978
- * Build an `env K=V …` prefix string for per-service env vars.
979
- *
980
- * This scopes variables to the process instead of polluting the
981
- * tmux session environment (where the last service's PORT would
982
- * leak to every other service on restart).
983
- */
984
652
  function envPrefix(vars) {
985
653
  const entries = Object.entries(vars);
986
654
  if (entries.length === 0)
@@ -988,21 +656,11 @@ function envPrefix(vars) {
988
656
  const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);
989
657
  return `env ${parts.join(' ')} `;
990
658
  }
991
- /** Escape a value for shell interpolation. */
992
659
  function shellEscape(s) {
993
- // If the value is safe, return as-is
994
660
  if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(s))
995
661
  return s;
996
- // Otherwise single-quote it, escaping any embedded single quotes
997
662
  return `'${s.replace(/'/g, "'\\''")}'`;
998
663
  }
999
- // ============================================================================
1000
- // Secret resolution
1001
- // ============================================================================
1002
- /**
1003
- * Resolve secrets from Secrets Manager and return env var key→value pairs.
1004
- * VPN provides direct access — no URL rewriting needed.
1005
- */
1006
664
  async function resolveSecrets(secrets) {
1007
665
  const client = new SecretsManagerClient({});
1008
666
  const resolved = {};
@@ -1026,16 +684,6 @@ async function resolveSecrets(secrets) {
1026
684
  }
1027
685
  return resolved;
1028
686
  }
1029
- /**
1030
- * Resolve the Temporal encoding key from the K8s secret in the app namespace.
1031
- *
1032
- * The Pulumi-generated devEncodingKey is a placeholder that differs from the
1033
- * deployed key, causing decryption failures when the local worker encounters
1034
- * existing Temporal payloads. Reading the real key from K8s ensures local dev
1035
- * can decrypt data created by deployed workers.
1036
- *
1037
- * Returns the encoding key value, or undefined if not available.
1038
- */
1039
687
  function resolveTemporalEncodingKey(tenant, env, appName) {
1040
688
  const namespace = `${tenant}-${env}-${appName}`;
1041
689
  const secretName = `${namespace}-temporal-encoding-key`;
@@ -1056,23 +704,12 @@ function resolveTemporalEncodingKey(tenant, env, appName) {
1056
704
  }
1057
705
  }
1058
706
  catch {
1059
- // Non-fatal — secret may not exist yet or kubectl may not have access
1060
707
  }
1061
708
  return undefined;
1062
709
  }
1063
- /**
1064
- * Re-resolve the session-level Temporal auth vars the SAME way launch does
1065
- * (see startServices): kubeconfig from SSM, M2M auth, encoding key from the
1066
- * K8s secret, and TEMPORAL_ADDRESS from the (pinned) tunnel. Used by
1067
- * `mesh dev restart --refresh-env` so a regenerated env file carries the live
1068
- * resolved values, not the dev-output placeholders (which would desync payload
1069
- * encryption between services).
1070
- */
1071
710
  async function resolveTemporalAuthVars(sessionName, devOutput) {
1072
711
  const tenant = devOutput.platform?.tenant ?? 'mesh';
1073
712
  const platformEnv = devOutput.platform?.env ?? 'dev';
1074
- // Hub platform name — the EKS export lives under it, not under a hardcoded
1075
- // "mesh" (MESH-2515); same axis as the bastion resolution.
1076
713
  const platformName = resolveHubPlatformName(devOutput.platform);
1077
714
  const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
1078
715
  onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
@@ -1082,25 +719,13 @@ async function resolveTemporalAuthVars(sessionName, devOutput) {
1082
719
  setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
1083
720
  }
1084
721
  else if (!process.env.KUBECONFIG) {
1085
- // ensureKubeconfig couldn't re-resolve the cluster from SSM (transient /
1086
- // stale creds). Fall back to the session kubeconfig written at launch so the
1087
- // kubectl calls below don't default to ~/.kube/config — which macOS TCC
1088
- // denies for shells without Full Disk Access ("operation not permitted").
1089
722
  const existing = sessionKubeconfigPath(sessionName);
1090
723
  if (fs.existsSync(existing)) {
1091
- // Set only this process's env — deliberately NOT setTmuxEnv: this is a
1092
- // best-effort fallback to a possibly-stale last-good kubeconfig, so we use
1093
- // it for the kubectl calls below without persisting it into the session
1094
- // (a later successful re-resolve is the one that should win the session).
1095
724
  process.env.KUBECONFIG = existing;
1096
725
  }
1097
726
  }
1098
727
  let temporalAuthVars = {};
1099
728
  if (devOutput.tunnels['temporal']) {
1100
- // Deliberately NOT `platformName` from above: the Temporal-auth fallback is
1101
- // `?? tenant` (dedicated tenants may run their own Temporal), while the EKS
1102
- // fallback is `?? 'mesh'`. Whether that difference is correct is MESH-2551;
1103
- // don't "fix" either default to match the other without reading it.
1104
729
  temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
1105
730
  const appName = devOutput.app ?? '';
1106
731
  if (appName) {
@@ -1114,43 +739,6 @@ async function resolveTemporalAuthVars(sessionName, devOutput) {
1114
739
  }
1115
740
  return temporalAuthVars;
1116
741
  }
1117
- /**
1118
- * Resolve the dev-user identity vars (DEV_USER_*) from cached `mesh login`
1119
- * credentials for the platform context (e.g. 'mesh.dev'). Credentials are
1120
- * stored under the platform context, not the tenant name — all tenants share
1121
- * the same Zitadel instance.
1122
- *
1123
- * Services (e.g., Hub UI) use these as a fallback when the real oauth2-proxy
1124
- * `X-Forwarded-*` request headers are absent:
1125
- * DEV_USER_ID - subject from idToken, matches conversation ownership
1126
- * DEV_USER_EMAIL - user email from idToken
1127
- * DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
1128
- * DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
1129
- *
1130
- * Returned vars must be overlaid onto each service's env FILE (not just the
1131
- * tmux session env): the dev output may carry a placeholder value (e.g. hub
1132
- * ui's DEV_USER_ACCESS_TOKEN="dev-local"), and the sourced per-service env
1133
- * file wins over the session env, so a session-env-only injection gets
1134
- * clobbered and every tenant-scoped Hub view silently 401s.
1135
- */
1136
- /**
1137
- * The `mesh login` context that issues this app's dev-user identity.
1138
- *
1139
- * It is the PLATFORM's name and env — `trabian.dev`, `mesh.dev` — because the
1140
- * platform owns the Zitadel the app's services verify against, so a token from
1141
- * any other issuer is well-formed and rejected. Not every app runs on the `mesh`
1142
- * platform: a shared tenant on the `trabian` platform whose dev user came from
1143
- * `identity.dev.mesh-platform.trabian.com` would be verified against
1144
- * `identity.dev.trabian.meshtech.io`.
1145
- *
1146
- * That failure is invisible at injection time and only surfaces at the far end
1147
- * of a cross-app call, as `Missing or invalid Authorization header — expected
1148
- * Bearer token`: the token is well-formed and unexpired, just from the wrong
1149
- * issuer.
1150
- *
1151
- * `name` is optional on the dev output, so fall back to `mesh`, which keeps
1152
- * mesh-platform apps on the context they already use.
1153
- */
1154
742
  function loginContextFor(platform) {
1155
743
  return `${platform.name ?? 'mesh'}.${platform.env}`;
1156
744
  }
@@ -1174,54 +762,18 @@ function resolveDevUserVars(devOutput) {
1174
762
  vars.DEV_USER_EMAIL = payload.email;
1175
763
  }
1176
764
  catch {
1177
- /* ignore decode errors */
1178
765
  }
1179
766
  if (meshCreds.accessToken) {
1180
767
  vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;
1181
768
  }
1182
769
  return vars;
1183
770
  }
1184
- // ============================================================================
1185
- // SSM tunnel fallback (when VPN is not connected)
1186
- // ============================================================================
1187
- /**
1188
- * Base port for SSM tunnels. Preferred ports use 20000 + original_port to
1189
- * avoid collisions with dev services (1000-9999 range). If the preferred
1190
- * port is already reserved or occupied, mesh dev walks upward until it finds
1191
- * an available port.
1192
- *
1193
- * Examples:
1194
- * PostgreSQL 5432 → 25432
1195
- * Temporal 7233 → 27233
1196
- * Temporal UI 8080 → 28080
1197
- */
1198
771
  const SSM_TUNNEL_BASE_PORT = 20000;
1199
- /** How long to wait for an SSM tunnel's local port to accept TCP before failing. */
1200
772
  const TUNNEL_READY_TIMEOUT_MS = 60_000;
1201
- /**
1202
- * Build the client-facing `host:port` for an SSM tunnel.
1203
- *
1204
- * The `aws ssm start-session` port-forwarder binds IPv4 `127.0.0.1` only.
1205
- * Advertising `localhost` lets Node/grpc-js resolve to IPv6 `::1` first, which
1206
- * the tunnel isn't listening on — so e.g. the Temporal gRPC client fails with
1207
- * `ECONNREFUSED ::1:<port>` and the Hub's Automations/workflow lists never load.
1208
- * Maps the literal `localhost` (how tunnels are advertised today) to `127.0.0.1`
1209
- * so clients hit the bound listener. Any other host — VPN-direct VPC endpoints,
1210
- * or a different loopback literal like `::1`/`127.0.0.1` — passes through unchanged.
1211
- */
1212
773
  export function tunnelClientAddress(tunnel) {
1213
774
  const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;
1214
775
  return `${host}:${tunnel.port}`;
1215
776
  }
1216
- /**
1217
- * Render the Connections section (live tunnel health) as status-window lines.
1218
- *
1219
- * The status window refreshes every ~2s, so this is where a mid-session tunnel
1220
- * death surfaces (MESH-2016): a dead tunnel shows `⚠ … DOWN` and a SINGLE
1221
- * remediation line explains the `ECONNREFUSED` retry spam the dependent service
1222
- * windows emit — instead of leaving the operator to decode that spam themselves.
1223
- * Returns `[]` when there are no tunnels (nothing to show).
1224
- */
1225
777
  export function formatTunnelHealth(health) {
1226
778
  if (health.length === 0)
1227
779
  return [];
@@ -1240,32 +792,13 @@ export function formatTunnelHealth(health) {
1240
792
  }
1241
793
  return lines;
1242
794
  }
1243
- /** Probe every tunnel's client address in parallel and resolve its health. */
1244
795
  async function probeTunnelHealth(tunnels) {
1245
796
  return Promise.all(Object.entries(tunnels).map(async ([name, tun]) => ({
1246
797
  name,
1247
798
  address: tunnelClientAddress(tun),
1248
- // Dial the SAME address the client uses (localhost→127.0.0.1) so the probe
1249
- // sees exactly what a dependent service would. Use the connection-HOLDS
1250
- // probe, not a bare accept: a forwarder binds its local port even when its
1251
- // upstream leg is dead (a dropped SSM session, or a Tailscale SOCKS bridge
1252
- // dialing a stale port — MESH-2169), so "port accepts" would report a dead
1253
- // tunnel as healthy. Holding briefly catches the accept-then-close teardown.
1254
799
  reachable: await probeConnectionHolds(tun.host === 'localhost' ? '127.0.0.1' : tun.host, tun.port, 800),
1255
800
  })));
1256
801
  }
1257
- /**
1258
- * Probe the routing-critical tunnel of a (Tailscale-rewritten) devOutput and
1259
- * return its name if the forwarder binds but does not ROUTE, else null.
1260
- *
1261
- * Only `temporal` is gated — it's the load-bearing VPC dependency (services die
1262
- * on a gRPC deadline when it's dead), and keying on it mirrors the vpn-direct
1263
- * reachability gate ({@link gateVpnDirectReachability}). A Tailscale forwarder
1264
- * accepts its local port even when its SOCKS upstream is dead or the joined
1265
- * tailnet doesn't route the VPC; such a forwarder tears the connection down
1266
- * within the grace window, so it fails the connection-HOLDS probe that a bare
1267
- * accept (the readiness gate's `waitForPort`) would pass (MESH-2169/MESH-2268).
1268
- */
1269
802
  export async function firstUnroutableTunnel(tunnels) {
1270
803
  const temporal = tunnels['temporal'];
1271
804
  if (!temporal || temporal.host !== 'localhost')
@@ -1273,15 +806,6 @@ export async function firstUnroutableTunnel(tunnels) {
1273
806
  const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);
1274
807
  return holds ? null : 'temporal';
1275
808
  }
1276
- /**
1277
- * Resolve which tunnel backing `mesh dev` should use.
1278
- *
1279
- * An explicit `--transport` flag wins. Both `auto` and the default (unset)
1280
- * prefer Tailscale when it's available (brew `tailscaled` present), else fall
1281
- * back to the VPN-direct/SSM choice: VPN-direct when the VPN is connected, else
1282
- * SSM when the session-manager plugin is present. Where Tailscale isn't
1283
- * available the behavior is unchanged from before this feature.
1284
- */
1285
809
  export function resolveTransport(flag, ctx) {
1286
810
  if (flag === "tailscale" || flag === "ssm")
1287
811
  return flag;
@@ -1291,15 +815,6 @@ export function resolveTransport(flag, ctx) {
1291
815
  const legacy = ctx.vpnConnected ? "vpn-direct" : ctx.hasSsmPlugin ? "ssm" : "vpn-direct";
1292
816
  return ctx.tailscaleAvailable ? "tailscale" : legacy;
1293
817
  }
1294
- /**
1295
- * Correct an auto-selected `vpn-direct` transport against ACTUAL reachability of
1296
- * the Temporal VPC endpoint (MESH-2016). `resolveTransport` uses a presence check
1297
- * (any Tailscale/VPN up) that's tailnet-blind — Tailscale-for-SSH looks connected
1298
- * but doesn't route the mesh VPC. Only `vpn-direct` is reachability-gated (it's
1299
- * never an explicit choice); every other transport passes through unchanged.
1300
- *
1301
- * Pure so the fallback logic is testable independently of the TCP probe.
1302
- */
1303
818
  export function reachabilityFallbackTransport(transport, temporalReachable, hasSsmPlugin) {
1304
819
  if (transport !== "vpn-direct")
1305
820
  return { transport, reason: "not-vpn-direct" };
@@ -1309,17 +824,6 @@ export function reachabilityFallbackTransport(transport, temporalReachable, hasS
1309
824
  return { transport: "ssm", reason: "fallback-ssm" };
1310
825
  return { transport, reason: "unreachable-no-plugin" };
1311
826
  }
1312
- /**
1313
- * Apply the reachability gate to a (possibly auto-selected) transport: if it's
1314
- * `vpn-direct`, TCP-dial the Temporal VPC endpoint and downgrade to SSM when it's
1315
- * actually unreachable, logging the decision. Returns the effective transport;
1316
- * a no-op for any non-`vpn-direct` transport.
1317
- *
1318
- * Called on BOTH auto-select paths — the normal resolve result AND the
1319
- * Tailscale→legacy fallback (when no mesh login config exists for the derived
1320
- * context) — so a `vpn-direct` can never reach `startServices` unprobed
1321
- * (MESH-2016).
1322
- */
1323
827
  async function gateVpnDirectReachability(transport, devOutput) {
1324
828
  if (transport !== "vpn-direct")
1325
829
  return transport;
@@ -1344,11 +848,6 @@ async function gateVpnDirectReachability(transport, devOutput) {
1344
848
  }
1345
849
  return decision.transport;
1346
850
  }
1347
- /**
1348
- * Derive the Headscale login-server URL from a login context's issuer
1349
- * (`identity.<env>.<domain>` → `vpn.<env>.<domain>`). Used for the Tailscale
1350
- * transport's OIDC join. Returns null if the context has no cached login config.
1351
- */
1352
851
  function deriveLoginServer(context) {
1353
852
  const cfg = getContextConfig(context);
1354
853
  if (!cfg?.issuer)
@@ -1370,13 +869,6 @@ export function preferredSsmLocalPort(remotePort) {
1370
869
  }
1371
870
  return preferred;
1372
871
  }
1373
- /**
1374
- * Reserve a deterministic local-port candidate for an SSM tunnel.
1375
- *
1376
- * This prevents two tunnels with the same remote port (common for HTTPS
1377
- * endpoints) from being assigned the same localhost port before the AWS CLI
1378
- * has a chance to bind it.
1379
- */
1380
872
  export function reserveSsmLocalPortCandidate(remotePort, reservedPorts, startAt = preferredSsmLocalPort(remotePort)) {
1381
873
  for (let port = startAt; port <= 65535; port += 1) {
1382
874
  if (!reservedPorts.has(port)) {
@@ -1397,9 +889,6 @@ async function allocateSsmLocalPort(remotePort, reservedPorts) {
1397
889
  }
1398
890
  throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`);
1399
891
  }
1400
- /**
1401
- * Check if the session-manager-plugin is installed (required for SSM tunnels).
1402
- */
1403
892
  function hasSessionManagerPlugin() {
1404
893
  try {
1405
894
  execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });
@@ -1409,24 +898,9 @@ function hasSessionManagerPlugin() {
1409
898
  return false;
1410
899
  }
1411
900
  }
1412
- /**
1413
- * Start SSM port-forwarding tunnels as tmux windows within the dev session.
1414
- *
1415
- * For each tunnel defined in devOutput (e.g., temporal, rds), this:
1416
- * 1. Reads the bastion instance ID and service endpoints from SSM
1417
- * 2. Spawns `aws ssm start-session` in a dedicated tmux window
1418
- * 3. Rewrites devOutput.tunnels to point at localhost:2XXXX
1419
- *
1420
- * Returns the rewritten devOutput with tunnels pointing to localhost.
1421
- */
1422
901
  async function startSsmTunnels(sessionName, devOutput) {
1423
902
  const platformEnv = devOutput.platform?.env ?? 'dev';
1424
- // The bastion is a HUB platform resource (/mesh-platform/<hub>/<env>/platform-bastion),
1425
- // not an app-tenant resource — devOutput.platform.tenant is the APP tenant (e.g. acme),
1426
- // which for shared tenants has no bastion. Resolve under the platform/hub name; default
1427
- // to "mesh" (matching how resolveTemporalAuth reads platform-level params).
1428
903
  const bastionTenant = devOutput.platform?.name ?? 'mesh';
1429
- // Read bastion info from SSM
1430
904
  const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
1431
905
  if (!bastion) {
1432
906
  logError('Could not read platform bastion info from SSM.');
@@ -1441,8 +915,6 @@ async function startSsmTunnels(sessionName, devOutput) {
1441
915
  return rewritten;
1442
916
  }
1443
917
  logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
1444
- // Map devOutput tunnel names to bastion service keys
1445
- // devOutput uses names like "temporal", "rds"; bastion uses "temporal-frontend", "rds"
1446
918
  const tunnelToBastionKey = {
1447
919
  temporal: 'temporal-frontend',
1448
920
  'temporal-ui': 'temporal-ui',
@@ -1450,8 +922,6 @@ async function startSsmTunnels(sessionName, devOutput) {
1450
922
  db: 'rds',
1451
923
  database: 'rds',
1452
924
  };
1453
- // Observability tunnels are non-fatal under SSM fallback — skip with a warning
1454
- // rather than aborting the dev loop when the bastion doesn't expose them.
1455
925
  const OPTIONAL_TUNNEL_NAMES = new Set([
1456
926
  'loki',
1457
927
  'prometheus',
@@ -1467,10 +937,6 @@ async function startSsmTunnels(sessionName, devOutput) {
1467
937
  const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
1468
938
  const endpoint = bastion.services[bastionKey];
1469
939
  if (!endpoint) {
1470
- // Observability tunnels degrade gracefully under SSM fallback: without
1471
- // them the consuming app's monitoring views (logs/metrics/traces) won't
1472
- // work, but they must not block the core dev loop. Only critical tunnels
1473
- // (temporal, rds, ...) are fatal when the bastion can't reach them.
1474
940
  if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
1475
941
  skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
1476
942
  }
@@ -1502,7 +968,6 @@ async function startSsmTunnels(sessionName, devOutput) {
1502
968
  throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
1503
969
  }
1504
970
  for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
1505
- // Create a tmux window for this tunnel
1506
971
  const windowName = `tunnel-${tunnelName}`;
1507
972
  const ssmCmd = [
1508
973
  'aws',
@@ -1520,10 +985,8 @@ async function startSsmTunnels(sessionName, devOutput) {
1520
985
  })}'`,
1521
986
  ].join(' ');
1522
987
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
1523
- // Keep window alive so tunnel errors are visible
1524
988
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
1525
989
  execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);
1526
- // Rewrite the tunnel to point at localhost
1527
990
  rewritten.tunnels[tunnelName] = {
1528
991
  host: 'localhost',
1529
992
  port: localPort,
@@ -1532,20 +995,12 @@ async function startSsmTunnels(sessionName, devOutput) {
1532
995
  }
1533
996
  return rewritten;
1534
997
  }
1535
- /**
1536
- * Update the DevBox host's Caddy reverse proxy config.
1537
- *
1538
- * Generates a Caddyfile so services are accessible at
1539
- * http://{service}.{stack}.{app}.{tenant}-{env}.mesh.local
1540
- * from the developer's Mac (via Tailscale + dnsmasq).
1541
- */
1542
998
  async function updateDevboxProxy(devOutput, appRoot) {
1543
999
  const appName = devOutput.app ?? path.basename(appRoot);
1544
1000
  const tenant = devOutput.platform?.tenant ?? 'mesh';
1545
1001
  const env = devOutput.platform?.env ?? 'dev';
1546
1002
  const stack = devOutput.stack ?? 'dev';
1547
1003
  const baseDomain = `${tenant}-${env}.mesh.local`;
1548
- // Build hostname → port map
1549
1004
  const services = {};
1550
1005
  for (const [name, service] of Object.entries(devOutput.services)) {
1551
1006
  if (service.port) {
@@ -1559,7 +1014,6 @@ async function updateDevboxProxy(devOutput, appRoot) {
1559
1014
  }
1560
1015
  if (Object.keys(services).length === 0)
1561
1016
  return;
1562
- // Generate Caddyfile
1563
1017
  const lines = [
1564
1018
  '# Auto-generated by mesh dev — do not edit manually.',
1565
1019
  '',
@@ -1575,8 +1029,6 @@ async function updateDevboxProxy(devOutput, appRoot) {
1575
1029
  lines.push('');
1576
1030
  }
1577
1031
  const caddyfile = lines.join('\n');
1578
- // Write to host's /etc/caddy via a throwaway container with a host volume mount.
1579
- // The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.
1580
1032
  execFileSync('docker', [
1581
1033
  'run',
1582
1034
  '--rm',
@@ -1587,7 +1039,6 @@ async function updateDevboxProxy(devOutput, appRoot) {
1587
1039
  '-c',
1588
1040
  `cat > /etc/caddy/Caddyfile << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
1589
1041
  ], { stdio: 'pipe' });
1590
- // Reload Caddy on the host via nsenter into PID 1's mount namespace
1591
1042
  execFileSync('docker', [
1592
1043
  'run',
1593
1044
  '--rm',
@@ -1612,17 +1063,14 @@ async function updateDevboxProxy(devOutput, appRoot) {
1612
1063
  }
1613
1064
  }
1614
1065
  async function setupSubdomainRouting(devOutput, appRoot) {
1615
- // 1. Get Tailscale info
1616
1066
  const tsInfo = await getTailscaleInfo();
1617
1067
  if (!tsInfo)
1618
1068
  return null;
1619
1069
  const appName = devOutput.app ?? path.basename(appRoot);
1620
1070
  const stack = devOutput.stack ?? 'dev';
1621
1071
  const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
1622
- // 2. Derive Headscale DNS config from platform context (same convention as `mesh vpn`)
1623
1072
  const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
1624
1073
  const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);
1625
- // 3. Build DNS records for all services + tunnels
1626
1074
  const records = [];
1627
1075
  for (const [name, service] of Object.entries(devOutput.services)) {
1628
1076
  if (service.port) {
@@ -1632,21 +1080,15 @@ async function setupSubdomainRouting(devOutput, appRoot) {
1632
1080
  for (const [name] of Object.entries(devOutput.tunnels)) {
1633
1081
  records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
1634
1082
  }
1635
- // 4. Remove stale DNS records (best-effort)
1636
1083
  try {
1637
1084
  unregisterDnsRecords(dnsConfig, baseDomain);
1638
1085
  }
1639
1086
  catch {
1640
- /* ignore */
1641
1087
  }
1642
- // 5. Generate Caddyfile
1643
1088
  const caddyDir = '/tmp/mesh-dev-caddy';
1644
1089
  const caddyfile = path.join(caddyDir, 'Caddyfile');
1645
1090
  if (!fs.existsSync(caddyDir))
1646
1091
  fs.mkdirSync(caddyDir, { recursive: true });
1647
- // Build Caddy config — bind directly to the Tailscale IP on port 80.
1648
- // In kernel tun mode, tailscale0 is a real interface so Caddy can listen
1649
- // on it without any tailscale serve layer in between.
1650
1092
  const caddyLines = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];
1651
1093
  for (const [name, service] of Object.entries(devOutput.services)) {
1652
1094
  if (service.port) {
@@ -1665,14 +1107,12 @@ async function setupSubdomainRouting(devOutput, appRoot) {
1665
1107
  caddyLines.push('');
1666
1108
  }
1667
1109
  fs.writeFileSync(caddyfile, caddyLines.join('\n'));
1668
- // 6. Start or reload Caddy
1669
1110
  let caddyRunning = false;
1670
1111
  try {
1671
1112
  execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });
1672
1113
  caddyRunning = true;
1673
1114
  }
1674
1115
  catch {
1675
- /* not running */
1676
1116
  }
1677
1117
  if (caddyRunning) {
1678
1118
  try {
@@ -1700,7 +1140,6 @@ async function setupSubdomainRouting(devOutput, appRoot) {
1700
1140
  });
1701
1141
  caddy.unref();
1702
1142
  }
1703
- // 7. Register DNS records (after Caddy is confirmed running)
1704
1143
  try {
1705
1144
  const existing = readDnsRecords(dnsConfig);
1706
1145
  const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));
@@ -1713,19 +1152,11 @@ async function setupSubdomainRouting(devOutput, appRoot) {
1713
1152
  }
1714
1153
  return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };
1715
1154
  }
1716
- // ============================================================================
1717
- // Core actions
1718
- // ============================================================================
1719
- /**
1720
- * Start all services in tmux windows.
1721
- */
1722
1155
  async function startServices(sessionName, appRoot, devOutput, headless, awsEnv, tunnelPlan = { transport: 'vpn-direct' }, worktreeRoot, taskQueueSuffix = '') {
1723
1156
  if (!hasTmux()) {
1724
1157
  logError('tmux is required. Install with: brew install tmux');
1725
1158
  process.exit(1);
1726
1159
  }
1727
- // Always kill any existing session and recreate for a clean start.
1728
- // Tunnels (if any) are set up as part of this flow below.
1729
1160
  if (sessionExists(sessionName)) {
1730
1161
  logInfo(`Killing existing session: ${sessionName}`);
1731
1162
  killSession(sessionName);
@@ -1735,54 +1166,28 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1735
1166
  logWarn('No services defined in dev output.');
1736
1167
  return devOutput;
1737
1168
  }
1738
- // Create tmux session with a status window (always fresh).
1739
1169
  logInfo(`Creating tmux session: ${sessionName}`);
1740
1170
  execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
1741
- // Set AWS credentials on the session BEFORE starting anything that needs them
1742
- // (SSM tunnels, services). tmux windows inherit the session environment.
1743
1171
  setTmuxEnv(sessionName, awsEnv);
1744
- // Bring up VPC tunnels (rewriting devOutput.tunnels to localhost:PORT) via the
1745
- // chosen backing. Tailscale (shared per-tenant userspace daemon): an explicit
1746
- // `--transport tailscale` now hard-fails on any bring-up error (see below); only
1747
- // an auto-resolved tailscale choice falls back to SSM. vpn-direct needs no tunnels.
1748
1172
  const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
1749
1173
  let effectiveTransport = tunnelPlan.transport;
1750
1174
  if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
1751
1175
  try {
1752
1176
  const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);
1753
- // Verify the forwarders actually ROUTE, not just bind (MESH-2268). Transport
1754
- // selection picks tailscale on PRESENCE (a tailscaled binary exists), so an
1755
- // auto-selected daemon can come up joined to a tailnet that doesn't route the
1756
- // mesh VPC, or with a dead SOCKS upstream (MESH-2169) — the forwarder binds
1757
- // its port either way and the bare-accept readiness gate below (waitForPort)
1758
- // would pass it, letting services start and then die on a gRPC deadline.
1759
- // Throw on a dead critical tunnel so the catch below applies the same
1760
- // explicit-hard-fail-vs-auto-SSM-fallback policy as a bring-up failure — and
1761
- // only commit the rewrite (reassign devOutput) once it holds, so an SSM
1762
- // fallback runs startSsmTunnels on the ORIGINAL, un-rewritten tunnels.
1763
1177
  const dead = await firstUnroutableTunnel(rewritten.tunnels);
1764
1178
  if (dead) {
1765
1179
  throw new Error(`forwarder for '${dead}' bound but does not route the VPC ` +
1766
1180
  `(dead SOCKS upstream or non-routing tailnet)`);
1767
1181
  }
1768
1182
  devOutput = rewritten;
1769
- // Announce ONLY after the join + forwarders actually came up — the previous
1770
- // pre-flight "Using userspace-Tailscale tunnels" printed before this even ran,
1771
- // so a fallback still read as success (MESH-2098).
1772
1183
  logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');
1773
1184
  }
1774
1185
  catch (err) {
1775
1186
  const reason = err instanceof Error ? err.message : String(err);
1776
1187
  if (tunnelPlan.explicit) {
1777
- // The user explicitly asked for --transport=tailscale. Don't silently
1778
- // override that with SSM — surface the real reason (often a pending VPN
1779
- // node registration whose URL was printed above) and let them act.
1780
1188
  logError(`Tailscale transport failed: ${reason}`);
1781
1189
  logInfo('If a VPN registration URL was shown above, open it to authorize this machine, then re-run.');
1782
1190
  logInfo('Or switch backing explicitly: mesh dev --transport ssm');
1783
- // We already killed the prior session and created a fresh (empty) one
1784
- // above; tear it down so an explicit-tailscale failure leaves no orphaned
1785
- // half-session behind.
1786
1191
  killSession(sessionName);
1787
1192
  process.exit(1);
1788
1193
  }
@@ -1794,12 +1199,9 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1794
1199
  devOutput = await startSsmTunnels(sessionName, devOutput);
1795
1200
  }
1796
1201
  if (hasTunnels && effectiveTransport !== 'vpn-direct') {
1797
- // Gate service start on tunnel readiness: poll each tunnel's local port
1798
- // until it accepts TCP. Without this, Temporal-connected services (api,
1799
- // worker, vcs) raced the SSM tunnel and died connection-refused at launch.
1800
1202
  for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
1801
1203
  if (tunnel.host !== 'localhost')
1802
- continue; // not SSM-rewritten
1204
+ continue;
1803
1205
  logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);
1804
1206
  const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);
1805
1207
  if (!ready) {
@@ -1812,9 +1214,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1812
1214
  }
1813
1215
  }
1814
1216
  const tunnelNames = Object.keys(devOutput.tunnels);
1815
- // Set up subdomain routing if Tailscale is available
1816
1217
  const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);
1817
- // Log VPC connections
1818
1218
  if (tunnelNames.length > 0) {
1819
1219
  const isSSM = Object.values(devOutput.tunnels).some((t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT);
1820
1220
  logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');
@@ -1827,22 +1227,14 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1827
1227
  }
1828
1228
  }
1829
1229
  }
1830
- // Resolve secrets from Secrets Manager
1831
1230
  let secretEnvVars = {};
1832
1231
  if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
1833
1232
  secretEnvVars = await resolveSecrets(devOutput.secrets);
1834
1233
  }
1835
- // Resolve Temporal auth credentials from SSM when a temporal endpoint is configured.
1836
1234
  const tenant = devOutput.platform?.tenant ?? 'mesh';
1837
1235
  const platformEnv = devOutput.platform?.env ?? 'dev';
1838
- // Write a session-scoped kubeconfig (aws eks get-token) so the kubectl calls
1839
- // below (encoding-key read, temporal-pod env fallback) don't depend on an
1840
- // ambient ~/.kube/config. Best-effort: continue without it if unresolved.
1841
- // The local platform (`mesh start`) has no cluster — skip entirely.
1842
1236
  const isLocalPlatform = tenant === 'local';
1843
1237
  if (!isLocalPlatform) {
1844
- // Hub platform name — the EKS export lives under it, not under a hardcoded
1845
- // "mesh" (MESH-2515); same axis as the bastion resolution.
1846
1238
  const platformName = resolveHubPlatformName(devOutput.platform);
1847
1239
  const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
1848
1240
  onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
@@ -1858,12 +1250,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1858
1250
  }
1859
1251
  let temporalAuthVars = {};
1860
1252
  if (devOutput.tunnels['temporal']) {
1861
- // Deliberately NOT the `?? 'mesh'` fallback the EKS path above uses: the
1862
- // Temporal-auth fallback is `?? tenant` (dedicated tenants may run their
1863
- // own Temporal). Whether that difference is correct is MESH-2551.
1864
1253
  temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
1865
- // Resolve the encoding key from K8s so local dev can decrypt
1866
- // payloads created by deployed workers.
1867
1254
  const appName = devOutput.app ?? '';
1868
1255
  if (appName) {
1869
1256
  const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
@@ -1871,29 +1258,11 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1871
1258
  temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
1872
1259
  }
1873
1260
  }
1874
- // Set TEMPORAL_ADDRESS to the VPC endpoint for direct VPN access (or the
1875
- // pinned loopback address under SSM fallback — see tunnelClientAddress).
1876
1261
  const temporalTunnel = devOutput.tunnels['temporal'];
1877
1262
  temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
1878
1263
  }
1879
- // Set shared env vars (secrets + temporal auth) on the tmux session.
1880
- // AWS creds were already set earlier (before SSM tunnels started).
1881
1264
  setTmuxEnv(sessionName, secretEnvVars);
1882
1265
  setTmuxEnv(sessionName, temporalAuthVars);
1883
- // Inject dev user identity from mesh login credentials so services can
1884
- // emulate the oauth2-proxy auth headers locally.
1885
- //
1886
- // The credentials are stored under the platform context (e.g. 'mesh.dev'),
1887
- // not the tenant name. Use 'mesh' as the platform name since all tenants
1888
- // share the same Zitadel instance.
1889
- //
1890
- // Services (e.g., Hub UI) use these env vars as a fallback when the real
1891
- // oauth2-proxy `X-Forwarded-*` request headers are absent:
1892
- // DEV_USER_ID - subject from idToken, matches conversation ownership
1893
- // DEV_USER_EMAIL - user email from idToken
1894
- // DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
1895
- // DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
1896
- // Local platform creds live under the `local` context (mesh login local).
1897
1266
  const credContext = devOutput.platform
1898
1267
  ? isLocalPlatform
1899
1268
  ? 'local'
@@ -1903,10 +1272,6 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1903
1272
  if (Object.keys(devUserVars).length > 0) {
1904
1273
  setTmuxEnv(sessionName, devUserVars);
1905
1274
  logSuccess(`Dev user injected from mesh login: ${credContext}`);
1906
- // Serve a continuously-fresh dev-user token so services survive past the
1907
- // ~1h Zitadel horizon without a restart (MESH-2039). The static vars above
1908
- // remain the fallback when this URL is absent. App-agnostic: gated on the
1909
- // platform context, not the app — any `mesh dev` app inherits it.
1910
1275
  const tokenPort = await findFreePort();
1911
1276
  const tokenUrl = `http://127.0.0.1:${tokenPort}`;
1912
1277
  setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
@@ -1919,7 +1284,6 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1919
1284
  `npx mesh dev __token-server ${tokenPort} ${credContext}`,
1920
1285
  'Enter',
1921
1286
  ]);
1922
- // Best-effort readiness so the first requests land (non-fatal).
1923
1287
  for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {
1924
1288
  await new Promise((r) => setTimeout(r, 200));
1925
1289
  }
@@ -1933,21 +1297,14 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1933
1297
  const monorepoRoot = findMonorepoRoot();
1934
1298
  for (const [name, service] of Object.entries(devOutput.services)) {
1935
1299
  const cmd = service.command.join(' ');
1936
- // Skip services with no dev command (e.g., deployed-only like ApiDocs)
1937
1300
  if (!cmd) {
1938
1301
  logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
1939
1302
  continue;
1940
1303
  }
1941
1304
  const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
1942
- // Wrong-worktree guard (MESH-2013): if `rebaseServiceSrc` couldn't re-root the
1943
- // deploy-time absolute path onto THIS worktree, `serviceDir` can point into a
1944
- // DIFFERENT worktree — silently running stale code. Refuse rather than launch it.
1945
1305
  if (worktreeRoot && fs.existsSync(serviceDir)) {
1946
1306
  const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
1947
1307
  const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);
1948
- // A dev-LINKED package is outside the worktree by construction, and is what
1949
- // this app runs either way — see `isLinkedDependencyDir`. Only an
1950
- // unexplained outside path is the wrong-worktree failure.
1951
1308
  if (outside && isLinkedDependencyDir(serviceDir, appRoot)) {
1952
1309
  logInfo(`${name}: source is a dev-linked package (${serviceDir})`);
1953
1310
  }
@@ -1959,74 +1316,28 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
1959
1316
  process.exit(1);
1960
1317
  }
1961
1318
  }
1962
- // Skip services whose source directory doesn't exist in this worktree.
1963
- // This happens when the Pulumi stack was deployed from a different branch
1964
- // that added a service not yet present here (e.g., hub-worker added on
1965
- // feature/hub-credential-management but absent from feature/app-detail-redesign).
1966
1319
  if (!fs.existsSync(serviceDir)) {
1967
1320
  logWarn(`Skipped: ${name} (source directory not found: ${serviceDir}` +
1968
1321
  `${serviceDir !== service.src ? ` — rebased from ${service.src}` : ''})`);
1969
1322
  continue;
1970
1323
  }
1971
- // Per-service env vars are written to a 0600 env file and `source`d by
1972
- // the launch command, scoping them to the process (no cross-service
1973
- // leaks through the tmux session environment) while keeping the
1974
- // send-keys string short — inlining the full env used to intermittently
1975
- // exceed what tmux send-keys delivers intact, silently truncating the
1976
- // command so the service never started.
1977
- //
1978
- // Overlay the RESOLVED Temporal vars (real encoding key + tunnel address +
1979
- // M2M auth, fetched from K8s/SSM) on top of the dev-output env. The dev
1980
- // output carries placeholder values from the Agent's Temporal link (e.g.
1981
- // a `dev-encoding-key-*` placeholder), and the per-service env would
1982
- // otherwise shadow the session-level resolved values — desyncing services
1983
- // that DO carry the placeholder (worker) from those that don't (api),
1984
- // which breaks payload encryption (mismatched encoding keys → decode null).
1985
1324
  const serviceVars = {
1986
1325
  ...getServiceEnvVars(service, devOutput.tunnels),
1987
1326
  ...temporalAuthVars,
1988
- // Overlay the resolved dev-user identity for the same reason as the
1989
- // Temporal vars: the dev output may carry a placeholder (e.g. hub ui's
1990
- // DEV_USER_ACCESS_TOKEN="dev-local"), and since the sourced env file
1991
- // wins over the tmux session env, the placeholder would clobber the
1992
- // real token injected above — silently 401ing tenant-scoped Hub views.
1993
1327
  ...devUserVars,
1994
- // Per-worktree Temporal task-queue isolation (MESH-2013 Slice 2): agent
1995
- // workers/apps append this to their task-queue names so concurrent
1996
- // worktrees don't steal each other's workflow tasks. Empty on the primary
1997
- // checkout → omitted → unchanged historical behavior.
1998
1328
  ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
1999
- // AWS, for the same reason as the Temporal and dev-user overlays above: the
2000
- // sourced env file WINS over the tmux session env, so a service that reads this
2001
- // file gets whatever the file says and nothing else.
2002
- //
2003
- // It matters beyond the service's own process. A module dev service can hand
2004
- // credentials to something it starts — the workspaces manager gives each
2005
- // workspace's sidecar an AWS profile so Claude Code can reach Bedrock — and with
2006
- // no AWS_* here it silently has none to give. `buildChildAwsEnv` has already
2007
- // chosen the SELF-REFRESHING shape (a profile with `credential_process`, not a
2008
- // frozen STS triple), which is exactly what a long-lived child needs.
2009
1329
  ...awsEnv,
2010
1330
  };
2011
1331
  const envFilePath = getServiceEnvFilePath(sessionName, name);
2012
1332
  writeEnvFile(envFilePath, serviceVars);
2013
- // Local mode ships the service's stdout to the platform's log pipeline
2014
- // via OTLP (assets/log-shipper.mjs): the OTEL_* vars in the env file
2015
- // carry the hosted k8s_* attribution, so the Hub's logs view covers
2016
- // local runs with zero file plumbing.
2017
1333
  const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
2018
1334
  execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
2019
- // Keep window alive on exit so errors are visible
2020
1335
  execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
2021
1336
  stdio: 'ignore',
2022
1337
  });
2023
1338
  execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);
2024
- // Log the ACTUAL launch dir (`serviceDir`, after the worktree rebase), not the
2025
- // raw deploy-time `service.src` — printing the pre-rebase path made a correctly
2026
- // re-rooted service look like it was running from a different (stale) worktree.
2027
1339
  logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);
2028
1340
  }
2029
- // Generate reverse proxy config for DevBox hostname-based routing.
2030
1341
  if (process.env.DEVCONTAINER === '1') {
2031
1342
  try {
2032
1343
  await updateDevboxProxy(devOutput, appRoot);
@@ -2035,7 +1346,6 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
2035
1346
  logWarn(`Could not update devbox proxy: ${e.message}`);
2036
1347
  }
2037
1348
  }
2038
- // Run live status updates in the status window
2039
1349
  const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
2040
1350
  execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
2041
1351
  console.log('');
@@ -2086,13 +1396,8 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
2086
1396
  console.log(`Stop with: mesh dev --kill`);
2087
1397
  console.log('');
2088
1398
  }
2089
- // Return the (possibly SSM-tunnel-rewritten) devOutput so the caller can
2090
- // persist it in session state for --status / restart.
2091
1399
  return devOutput;
2092
1400
  }
2093
- /**
2094
- * Show status of running services.
2095
- */
2096
1401
  async function showStatus(sessionName, devOutput, asJson) {
2097
1402
  if (!sessionExists(sessionName)) {
2098
1403
  if (asJson) {
@@ -2103,14 +1408,12 @@ async function showStatus(sessionName, devOutput, asJson) {
2103
1408
  }
2104
1409
  return;
2105
1410
  }
2106
- // Get tmux window list
2107
1411
  let windows = [];
2108
1412
  try {
2109
1413
  const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
2110
1414
  windows = raw.trim().split('\n');
2111
1415
  }
2112
1416
  catch {
2113
- // Session might have disappeared
2114
1417
  }
2115
1418
  const windowStatus = {};
2116
1419
  for (const line of windows) {
@@ -2119,8 +1422,6 @@ async function showStatus(sessionName, devOutput, asJson) {
2119
1422
  windowStatus[name] = dead === '1' ? 'exited' : 'running';
2120
1423
  }
2121
1424
  const tunnelNames = Object.keys(devOutput.tunnels);
2122
- // Live-probe each tunnel so a mid-session tunnel death shows as DOWN here
2123
- // (this render runs every ~2s in the status window) — MESH-2016.
2124
1425
  const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
2125
1426
  const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
2126
1427
  if (asJson) {
@@ -2173,9 +1474,6 @@ async function showStatus(sessionName, devOutput, asJson) {
2173
1474
  console.log('tmux: Ctrl+b n/p switch windows, d detach');
2174
1475
  }
2175
1476
  }
2176
- /**
2177
- * Restart a single service window.
2178
- */
2179
1477
  async function restartService(sessionName, serviceName, appRoot, devOutput, awsEnv, opts) {
2180
1478
  let service = devOutput.services[serviceName];
2181
1479
  if (!service) {
@@ -2187,15 +1485,11 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
2187
1485
  logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
2188
1486
  process.exit(1);
2189
1487
  }
2190
- // --refresh-env: regenerate this service's env file from CURRENT stack/SSM
2191
- // outputs BEFORE killing the running process, so a failed read leaves the
2192
- // good env file (and the running service) untouched (fail-closed).
2193
1488
  if (opts?.refreshEnv) {
2194
1489
  if (!opts.stack) {
2195
1490
  logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
2196
1491
  return;
2197
1492
  }
2198
- // The session's allocated devOutput pins the ports/tunnels to reuse.
2199
1493
  const pinned = opts.sessionState?.devOutput ?? devOutput;
2200
1494
  let fresh;
2201
1495
  try {
@@ -2213,51 +1507,32 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
2213
1507
  return;
2214
1508
  }
2215
1509
  const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);
2216
- // Recompute the per-worktree suffix (deterministic from the worktree path,
2217
- // so it matches what launch wrote) — a restarted service must keep polling
2218
- // this worktree's isolated task queue (MESH-2013 Slice 2).
2219
1510
  const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
2220
1511
  const serviceVars = {
2221
1512
  ...getServiceEnvVars(mergedService, merged.tunnels),
2222
1513
  ...temporalAuthVars,
2223
- // Same overlay as launch: without it, --refresh-env rewrites the env
2224
- // file from dev output and its DEV_USER_* placeholders clobber the real
2225
- // token from `mesh login` (the "restart silently 401s the Hub" trap).
2226
1514
  ...resolveDevUserVars(merged),
2227
1515
  ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
2228
- // Same overlay as launch — a refresh that dropped these would take a working
2229
- // service's credentials away on restart, which is the hardest kind of change to
2230
- // attribute afterwards.
2231
1516
  ...awsEnv,
2232
1517
  };
2233
1518
  writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);
2234
- // Relaunch from the refreshed service definition (command/src may have changed).
2235
1519
  service = mergedService;
2236
1520
  logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
2237
1521
  }
2238
1522
  const target = `${sessionName}:${serviceName}`;
2239
- // Kill the current pane process and respawn
2240
1523
  try {
2241
1524
  execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
2242
1525
  }
2243
1526
  catch {
2244
- // Window might not exist yet
2245
1527
  logError(`Window '${serviceName}' not found in session.`);
2246
1528
  process.exit(1);
2247
1529
  }
2248
- // Refresh shared env (AWS creds may have rotated) on the session.
2249
1530
  setTmuxEnv(sessionName, awsEnv);
2250
1531
  const cmd = service.command.join(' ');
2251
- // Source the SAME env file written at launch — it is the session's env
2252
- // contract for this service, guaranteeing a byte-identical environment.
2253
- // Reconstructing env here has historically diverged from launch (e.g.
2254
- // TEMPORAL_ENCODING_KEY drift → "cannot decrypt payload" / missing key →
2255
- // "Unknown encoding: binary/encrypted").
2256
1532
  const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
2257
1533
  let restartCmd;
2258
1534
  if (fs.existsSync(envFilePath)) {
2259
1535
  const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
2260
- // Keep the local log pipeline attached across restarts.
2261
1536
  restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
2262
1537
  }
2263
1538
  else {
@@ -2269,9 +1544,6 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
2269
1544
  execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
2270
1545
  logSuccess(`Restarted: ${serviceName}`);
2271
1546
  }
2272
- /**
2273
- * Capture and display logs from a service window.
2274
- */
2275
1547
  function showLogs(sessionName, serviceName, tail) {
2276
1548
  if (!sessionExists(sessionName)) {
2277
1549
  logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
@@ -2290,14 +1562,6 @@ function showLogs(sessionName, serviceName, tail) {
2290
1562
  process.exit(1);
2291
1563
  }
2292
1564
  }
2293
- // ============================================================================
2294
- // Command registration
2295
- // ============================================================================
2296
- /**
2297
- * Assemble a {@link DoctorContext} for the current app/stack/session, resolving
2298
- * the deployer role the same way the `restart`/launch paths do (default role +
2299
- * Zitadel-claim admin selection). Used by `mesh dev doctor`.
2300
- */
2301
1565
  function buildDoctorContext(appRoot, stack, sessionName) {
2302
1566
  const state = loadSessionState(sessionName);
2303
1567
  const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
@@ -2342,23 +1606,16 @@ export function registerDevCommand(program) {
2342
1606
  .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.")
2343
1607
  .option('--mock [names]', 'Alias for --externals.')
2344
1608
  .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');
2345
- // Hidden: the dev-user token-server (MESH-2039). Launched into its own tmux
2346
- // window by the dev launcher; serves a continuously-fresh dev-user token.
2347
1609
  dev
2348
1610
  .command('__token-server <port> <context>', { hidden: true })
2349
1611
  .action(async (port, context) => {
2350
1612
  const { startTokenServer } = await import('./dev-token-server.js');
2351
1613
  await startTokenServer(Number(port), context);
2352
1614
  });
2353
- // Default action: start or show status
2354
1615
  dev.action(async (options) => {
2355
- // An explicit --profile must win over stale shell-exported STS credentials.
2356
- // The AWS SDK/CLI prefer AWS_ACCESS_KEY_ID over AWS_PROFILE, so clear any
2357
- // static credentials before resolving the deployer role.
2358
1616
  if (options.profile) {
2359
1617
  applyAwsProfileOverride(options.profile);
2360
1618
  }
2361
- // Fast-path: --status/--kill with explicit --session can skip app root resolution
2362
1619
  if (options.session && (options.status || options.kill)) {
2363
1620
  const sessionName = options.session;
2364
1621
  if (loadSessionState(sessionName)?.runner === 'docker') {
@@ -2377,7 +1634,6 @@ export function registerDevCommand(program) {
2377
1634
  if (options.kill) {
2378
1635
  composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
2379
1636
  localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
2380
- // Clean up DNS records for this app
2381
1637
  try {
2382
1638
  const devOutput = loadSessionState(sessionName)?.devOutput;
2383
1639
  if (devOutput) {
@@ -2392,7 +1648,6 @@ export function registerDevCommand(program) {
2392
1648
  }
2393
1649
  }
2394
1650
  catch {
2395
- /* best effort */
2396
1651
  }
2397
1652
  if (sessionExists(sessionName)) {
2398
1653
  killSession(sessionName);
@@ -2404,23 +1659,16 @@ export function registerDevCommand(program) {
2404
1659
  removeSessionState(sessionName);
2405
1660
  return;
2406
1661
  }
2407
- // --status with --session: use state file, no Pulumi needed
2408
1662
  const state = loadSessionState(sessionName);
2409
1663
  if (state) {
2410
1664
  await showStatus(sessionName, state.devOutput, !!options.json);
2411
1665
  return;
2412
1666
  }
2413
- // No state — fall through to normal flow
2414
1667
  }
2415
1668
  const appRoot = findAppRoot(options.app);
2416
1669
  const projectName = getProjectName(appRoot);
2417
- // Port block scoped to (worktree, app): concurrent sibling-app sessions
2418
- // must draw disjoint ports (probing races slow boots). Scoped once here so
2419
- // local and tethered modes resolve the SAME plan — every other field stays
2420
- // worktree-scoped, so session naming and task queues are unaffected.
2421
1670
  const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);
2422
1671
  const sessionName = options.session ?? deriveSessionName(projectName, worktree);
2423
- // --kill
2424
1672
  if (options.kill) {
2425
1673
  composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
2426
1674
  localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
@@ -2430,11 +1678,7 @@ export function registerDevCommand(program) {
2430
1678
  logSuccess(`Killed docker dev session: ${sessionName}`);
2431
1679
  return;
2432
1680
  }
2433
- // Tailscale VPN tunnels are machine-global + shared across sessions, so
2434
- // they persist on purpose after a kill. Capture the tenant before state
2435
- // is removed so we can point at the explicit stop below.
2436
1681
  const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';
2437
- // Clean up DNS records for this app
2438
1682
  try {
2439
1683
  const devOutput = loadSessionState(sessionName)?.devOutput;
2440
1684
  if (devOutput) {
@@ -2449,7 +1693,6 @@ export function registerDevCommand(program) {
2449
1693
  }
2450
1694
  }
2451
1695
  catch {
2452
- /* best effort */
2453
1696
  }
2454
1697
  if (sessionExists(sessionName)) {
2455
1698
  killSession(sessionName);
@@ -2464,17 +1707,8 @@ export function registerDevCommand(program) {
2464
1707
  }
2465
1708
  return;
2466
1709
  }
2467
- // ── Local mode (design §5.2) ─────────────────────────────────────
2468
- // Explicit --local, or auto when the app has no Pulumi program at all
2469
- // (hasStackBacking also checks the monorepo ROOT — apps like apps/hub
2470
- // deploy via a root program and must keep the tethered default).
2471
- // Synthesizes DevOutput from repo conventions and wires services to the
2472
- // `mesh start` platform: no AWS credentials, no VPN, no Pulumi state.
2473
1710
  const localMode = !!options.local || !hasStackBacking(appRoot);
2474
1711
  if (localMode) {
2475
- // Read-only status query: report and exit BEFORE any side effect
2476
- // (platform boot, Zitadel provisioning, registry writes) — parity
2477
- // with the tethered path's early --status short-circuit.
2478
1712
  if (options.status) {
2479
1713
  const state = loadSessionState(sessionName);
2480
1714
  if (state?.runner === 'docker') {
@@ -2494,20 +1728,8 @@ export function registerDevCommand(program) {
2494
1728
  await ensureLocalPlatformRunning();
2495
1729
  const localTenant = detectLocalTenant(appRoot);
2496
1730
  logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);
2497
- // --externals (alias --mock): resolve the requested subset of the
2498
- // app's declared externals (package.json → mesh.externals). Entries
2499
- // are name[=mode] — an explicit mode overrides the declaration's
2500
- // default for this run (materialized onto the decl so every
2501
- // downstream externalMode() call agrees). Mock-mode externals join
2502
- // the same orchestration as services named mock-{name}; all
2503
- // realizations get their credential secret seeded after ports are
2504
- // final.
2505
1731
  const externalsRequest = options.externals ?? options.mock;
2506
1732
  let selectedMocks = {};
2507
- // The externals the developer NAMED (empty for the bare flag, which
2508
- // selects everything declared). Failing to realize one of these is
2509
- // fatal; failing to realize one the developer never asked for by name
2510
- // only costs that external.
2511
1733
  const requestedExplicitly = new Set();
2512
1734
  if (externalsRequest) {
2513
1735
  const declared = readLocalMocks(appRoot);
@@ -2532,22 +1754,14 @@ export function registerDevCommand(program) {
2532
1754
  name,
2533
1755
  overrides.has(name) ? { ...declared[name], mode: overrides.get(name) } : declared[name],
2534
1756
  ]));
2535
- // Validate every declaration's resolved mode up front — a bad decl
2536
- // (or an override the decl can't satisfy) should fail here, not
2537
- // after half the orchestration is up.
2538
1757
  for (const [name, decl] of Object.entries(selectedMocks))
2539
1758
  externalMode(name, decl);
2540
1759
  }
2541
1760
  const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {
2542
1761
  mocks: selectedMocks,
2543
1762
  });
2544
- // --dry-run (worktree-plan parity with tethered mode): print the
2545
- // resolved session/port plan and exit before any side effect —
2546
- // no Zitadel provisioning, no registry writes, no launch.
2547
1763
  if (options.dryRun) {
2548
1764
  printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));
2549
- // Non-service realizations (local/remote) don't appear in the port
2550
- // plan — list them so the dry run shows the whole picture.
2551
1765
  for (const [name, decl] of Object.entries(selectedMocks)) {
2552
1766
  const mode = externalMode(name, decl);
2553
1767
  if (mode === 'local') {
@@ -2560,26 +1774,14 @@ export function registerDevCommand(program) {
2560
1774
  return;
2561
1775
  }
2562
1776
  let signInServices = [];
2563
- /** The app's own version — stamped on the registration like a deploy. */
2564
1777
  let appVersion;
2565
- // Auto-provision the app tenant's auth config (approved Zitadel
2566
- // tenant-auth pattern: org per tenant → project per app → application
2567
- // per service + credential secret). Best-effort: the dev loop still
2568
- // works if Zitadel isn't seeded yet.
2569
1778
  try {
2570
1779
  const tenant = localTenant;
2571
1780
  const app = rawDevOutput.app ?? projectName;
2572
1781
  logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'…`);
2573
1782
  const services = Object.keys(rawDevOutput.services);
2574
- // Mocks are stand-ins, not services of the app — no Zitadel
2575
- // application for them (the registry still lists them for the Hub).
2576
1783
  const authServices = services.filter((name) => !name.startsWith('mock-'));
2577
- // App-declared project roles (package.json → mesh.auth.roles): created
2578
- // on the project and granted to the local `{app}-m2m` caller, so
2579
- // role-scoped services (roles claim → scopes) accept its tokens.
2580
1784
  let authRoles = [];
2581
- // Services carrying a BROWSER login (package.json → mesh.auth.signIn).
2582
- // Registered after ports are allocated — the redirect URI is the port.
2583
1785
  signInServices = [];
2584
1786
  try {
2585
1787
  const appPkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'));
@@ -2593,12 +1795,8 @@ export function registerDevCommand(program) {
2593
1795
  appVersion = appPkg.version;
2594
1796
  }
2595
1797
  catch {
2596
- // no package.json / unparsable — roles stay empty
2597
1798
  }
2598
1799
  await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });
2599
- // The app's own Temporal namespace ({tenant}-{env}-{app}) — the same
2600
- // name the cloud provisioner creates and the Hub's per-app workflow
2601
- // view resolves.
2602
1800
  await ensureTemporalNamespace(localAppNamespace(tenant, app));
2603
1801
  }
2604
1802
  catch (err) {
@@ -2606,10 +1804,6 @@ export function registerDevCommand(program) {
2606
1804
  ` — if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`);
2607
1805
  }
2608
1806
  const devOutput = await allocatePorts(rawDevOutput, worktree);
2609
- // Browser sign-in AFTER port allocation: the callback URL is the port a
2610
- // service actually listens on, and a stale URI is the difference between
2611
- // a working login and an "invalid redirect_uri" nobody can debug from
2612
- // the app side.
2613
1807
  for (const service of signInServices) {
2614
1808
  const port = devOutput.services[service]?.port;
2615
1809
  if (!port) {
@@ -2629,9 +1823,6 @@ export function registerDevCommand(program) {
2629
1823
  ` — the Hub's Access → Sign-in tab will report this app has no login.`);
2630
1824
  }
2631
1825
  }
2632
- // Register AFTER allocatePorts: the registry must carry the ports the
2633
- // services actually listen on, or StackPort/Hub (and anything reading
2634
- // /mesh-platform/…/stacks/local) points at dead URLs.
2635
1826
  try {
2636
1827
  await registerLocalApp({
2637
1828
  tenant: localTenant,
@@ -2640,8 +1831,6 @@ export function registerDevCommand(program) {
2640
1831
  services: Object.keys(devOutput.services),
2641
1832
  ports: Object.fromEntries(Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port])),
2642
1833
  kinds: Object.fromEntries(Object.entries(devOutput.services).flatMap(([name, svc]) => svc.kind ? [[name, svc.kind]] : [])),
2643
- // ExternalService.link() parity: consuming services carry the
2644
- // external names, so the Hub shows consumers + per-app uptime.
2645
1834
  links: Object.values(selectedMocks)
2646
1835
  .map((decl) => decl.external)
2647
1836
  .filter((n) => !!n),
@@ -2650,13 +1839,6 @@ export function registerDevCommand(program) {
2650
1839
  catch (err) {
2651
1840
  logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);
2652
1841
  }
2653
- // Wire realizations AFTER ports are final: realize each declaration
2654
- // by mode, then seed the external's credential secret and register it
2655
- // in the Hub — the app's resolveCredentials() path runs unchanged.
2656
- // mock → mock-{name} service URL (Prism / mock process)
2657
- // local → docker compose up -d --wait, endpoint from decl.port
2658
- // remote → actual-service credentials seeded (inline, or pulled
2659
- // from the app's tenant environment)
2660
1842
  const composeExternals = [];
2661
1843
  const externalProbeFiles = [];
2662
1844
  for (const [name, decl] of Object.entries(selectedMocks)) {
@@ -2665,8 +1847,6 @@ export function registerDevCommand(program) {
2665
1847
  let probeFile;
2666
1848
  if (mode === 'local') {
2667
1849
  logInfo(`Starting docker external '${name}' (${decl.compose})…`);
2668
- // undefined = adopted a realization another checkout owns —
2669
- // not recorded, so --kill leaves it running.
2670
1850
  const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);
2671
1851
  if (composeRef)
2672
1852
  composeExternals.push(composeRef);
@@ -2706,28 +1886,20 @@ export function registerDevCommand(program) {
2706
1886
  externalProbeFiles.push(probeFile);
2707
1887
  }
2708
1888
  catch (err) {
2709
- // A remote external the developer didn't name costs only itself:
2710
- // the bare `--externals` selects everything declared, and a
2711
- // machine with no AWS identity (or no vendor key in the shell)
2712
- // must still get its mock and local externals — that's the
2713
- // zero-access local loop. Naming it keeps the failure fatal.
2714
1889
  if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {
2715
1890
  logWarn(`External '${name}': skipped — ${err.message}` +
2716
1891
  ` (re-run with \`mesh dev --externals ${name}=remote\` to make this fatal).`);
2717
1892
  continue;
2718
1893
  }
2719
1894
  if (err instanceof MeshCliError)
2720
- throw err; // bad decl / missing env var / failed remote pull — hard stop
1895
+ throw err;
2721
1896
  if (mode === 'local') {
2722
- // The app's services depend on this external — starting them
2723
- // against a dead dependency helps nobody.
2724
1897
  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` } });
2725
1898
  }
2726
1899
  logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);
2727
1900
  }
2728
1901
  }
2729
1902
  if (dockerRunner) {
2730
- // C2: render the same DevOutput as a docker compose project.
2731
1903
  const composePath = writeDevCompose(sessionName, appRoot, devOutput.services);
2732
1904
  logInfo(`Docker runner: ${composePath}`);
2733
1905
  dockerDevUp(sessionName);
@@ -2753,8 +1925,7 @@ export function registerDevCommand(program) {
2753
1925
  console.log('Stop with: mesh dev --kill');
2754
1926
  return;
2755
1927
  }
2756
- const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(), // ministack wiring — the only "AWS" children need
2757
- { transport: 'vpn-direct' });
1928
+ const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(), { transport: 'vpn-direct' });
2758
1929
  registerServiceProbes(finalDevOutput, localTenant, externalProbeFiles);
2759
1930
  saveSessionState(sessionName, {
2760
1931
  appRoot,
@@ -2768,15 +1939,10 @@ export function registerDevCommand(program) {
2768
1939
  return;
2769
1940
  }
2770
1941
  if (options.mock || options.externals) {
2771
- logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet — MESH-1885). Continuing without them.');
1942
+ logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet). Continuing without them.');
2772
1943
  }
2773
1944
  const stack = detectStack(appRoot, resolveStackOption(options));
2774
1945
  logInfo(`Project: ${projectName}, Stack: ${stack}`);
2775
- // Detect if the user accidentally ran `mesh dev` from the monorepo root
2776
- // instead of an app directory. The monorepo root has a Pulumi.yaml but
2777
- // no services/apps to run. Standalone apps (create-app scaffolds) ARE
2778
- // their own workspace root — an api/ or worker/ package right here means
2779
- // this is an app, not a monorepo root.
2780
1946
  const monorepoRoot = findMonorepoRoot();
2781
1947
  const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) => fs.existsSync(path.join(appRoot, dir, 'package.json')));
2782
1948
  if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {
@@ -2791,19 +1957,8 @@ export function registerDevCommand(program) {
2791
1957
  logInfo(' mesh dev list');
2792
1958
  process.exit(1);
2793
1959
  }
2794
- // ── Prerequisite checks ──────────────────────────────────────────
2795
- // Check all prerequisites upfront so the user gets one clear list of
2796
- // what needs fixing, rather than failing one step at a time.
2797
1960
  const issues = [];
2798
1961
  const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
2799
- // AWS role + credential resolution.
2800
- // The stack can declare two role ARNs:
2801
- // - `mesh:deployerRole` — the default per-tenant deployer
2802
- // - `mesh:adminDeployerRole` — optional admin variant assumed when the
2803
- // caller's Zitadel JWT carries an admin-tier role (e.g.
2804
- // `mesh.platform:admin`). selectRoleForCaller picks between them.
2805
- // tmux + credential validation/diagnosis is handled below by the shared
2806
- // doctor preflight so `mesh dev` and `mesh dev doctor` agree.
2807
1962
  const platformContext = derivePlatformContext(appRoot, stack);
2808
1963
  const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
2809
1964
  const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
@@ -2821,9 +1976,6 @@ export function registerDevCommand(program) {
2821
1976
  }
2822
1977
  let awsEnv;
2823
1978
  let credMethod = 'ambient';
2824
- // Method fed to the doctor context: the resolved method on success, or
2825
- // `null` on a failed resolution so the `creds` check probes for the exact
2826
- // reason (no-session / expired / assume-denied / stale-env-override).
2827
1979
  let credMethodForDoctor = 'ambient';
2828
1980
  if (deployerRole) {
2829
1981
  const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);
@@ -2841,10 +1993,7 @@ export function registerDevCommand(program) {
2841
1993
  awsEnv = getAwsEnvVars();
2842
1994
  credMethodForDoctor = 'ambient';
2843
1995
  }
2844
- // 3. VPN or SSM fallback
2845
1996
  const vpnConnected = await isVpnConnected();
2846
- // `let`: the auto-selected `vpn-direct` may be downgraded to `ssm` below by a
2847
- // reachability probe once devOutput's Temporal endpoint is known (MESH-2016).
2848
1997
  let transport = resolveTransport(options.transport, {
2849
1998
  vpnConnected,
2850
1999
  hasSsmPlugin: hasSessionManagerPlugin(),
@@ -2871,14 +2020,8 @@ export function registerDevCommand(program) {
2871
2020
  }
2872
2021
  }
2873
2022
  else if (transport === 'tailscale') {
2874
- // The daemon bring-up + join happen in startServices; announce success there,
2875
- // not here — otherwise a fallback to SSM still reads as "using Tailscale"
2876
- // (MESH-2098). A pending node registration prints its own URL during the join.
2877
2023
  logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)…');
2878
2024
  }
2879
- // Shared doctor preflight: the same checks as `mesh dev doctor`, so launch
2880
- // and on-demand diagnosis name failures identically. Errors block launch;
2881
- // warnings (config/worktree) print and proceed.
2882
2025
  const preflightCtx = {
2883
2026
  appRoot,
2884
2027
  stack,
@@ -2899,26 +2042,11 @@ export function registerDevCommand(program) {
2899
2042
  console.log(issue);
2900
2043
  process.exit(1);
2901
2044
  }
2902
- // ── Credentials resolved, inject into environment ──────────────
2903
- // Ensure AWS_REGION is set from the Pulumi stack config. Without this, AWS
2904
- // SDK clients (SSM, Secrets Manager) would default to us-east-1.
2905
2045
  const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
2906
2046
  if (awsRegion && !awsEnv.AWS_REGION) {
2907
2047
  awsEnv.AWS_REGION = awsRegion;
2908
2048
  }
2909
2049
  Object.assign(process.env, awsEnv);
2910
- // Build the env that long-lived child services (ui, api, ai-assistant) and
2911
- // SSM tunnels will see. When credentials came from AWS SSO, prefer passing
2912
- // AWS_PROFILE + AWS_REGION so the AWS SDK v3 default provider chain can
2913
- // auto-refresh from the SSO cache (~8h session) as short-lived STS tokens
2914
- // expire. Without this, the hub API starts throwing ExpiredTokenException
2915
- // about an hour in and tenants disappear from the UI. For non-SSO paths
2916
- // (Zitadel JWT, CI/CD env keys) there is no profile to fall back to, so
2917
- // pass the STS keys directly and accept that the user must restart
2918
- // `mesh dev` when they expire.
2919
- // For the Zitadel deployer path, hand children a self-refreshing
2920
- // credential_process profile (context + role from the resolution above,
2921
- // written into this session's scratch dir) instead of a frozen STS triple.
2922
2050
  const zitadelOpts = credMethod === 'zitadel' && deployerRole
2923
2051
  ? (() => {
2924
2052
  const context = derivePlatformContext(appRoot, stack);
@@ -2939,18 +2067,12 @@ export function registerDevCommand(program) {
2939
2067
  }
2940
2068
  catch (err) {
2941
2069
  if (err instanceof MissingStackOutputError) {
2942
- // Only the launch path can conclude "nothing to run" — print the
2943
- // forward-chaining first-run fix and exit cleanly (MESH-2097). The
2944
- // suggested commands carry --stack because the bare forms error right
2945
- // after `stack init` (matching stack.ts's next-step output).
2946
2070
  logError(`No Pulumi stack output for '${stack}' — mesh dev has nothing to run.`);
2947
2071
  logInfo('If this is a new app, initialize and materialize it first:');
2948
2072
  logInfo(' mesh stack init # create your personal dev stack');
2949
2073
  logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);
2950
2074
  logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \`app\` or \`dev\` output.)`);
2951
2075
  logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');
2952
- // Surface the underlying read error so an auth/network/JSON failure isn't
2953
- // silently misdiagnosed as "fresh app" with zero forensics.
2954
2076
  const cause = err.cause?.message;
2955
2077
  if (cause)
2956
2078
  logInfo(`(underlying stack-read error: ${cause})`);
@@ -2958,30 +2080,17 @@ export function registerDevCommand(program) {
2958
2080
  }
2959
2081
  throw err;
2960
2082
  }
2961
- // --status — use persisted state (has allocated ports), fall back to raw
2962
2083
  if (options.status) {
2963
2084
  const state = loadSessionState(sessionName);
2964
2085
  await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
2965
2086
  return;
2966
2087
  }
2967
- // Allocate dynamic ports (avoid collisions with other running apps).
2968
- // devOutput is passed by value and may be rewritten inside startServices
2969
- // if SSM tunnels are used (to point tunnels at localhost:2XXXX).
2970
2088
  const devOutput = await allocatePorts(rawDevOutput, worktree);
2971
- // Reachability gate (MESH-2016): the transport chosen above used a PRESENCE
2972
- // check (isVpnConnected → "is any Tailscale/VPN up"), which is tailnet-blind —
2973
- // Tailscale-for-SSH looks connected but doesn't route the mesh VPC. Now that
2974
- // devOutput is known, probe the real Temporal VPC endpoint and downgrade an
2975
- // auto-selected vpn-direct to SSM when it's unreachable.
2976
2089
  transport = await gateVpnDirectReachability(transport, devOutput);
2977
- // --dry-run: show the resolved worktree/session/port plan, launch nothing.
2978
2090
  if (options.dryRun) {
2979
2091
  printDevPlan(sessionName, appRoot, worktree, devOutput);
2980
2092
  return;
2981
2093
  }
2982
- // Build the tunnel plan for the chosen transport. For Tailscale, resolve the
2983
- // shared per-tenant daemon context (tenant = hub platform name, since the
2984
- // bastion is a hub resource); reuse the daemon's SOCKS port if it's already up.
2985
2094
  let tunnelPlan = { transport };
2986
2095
  if (transport === 'tailscale') {
2987
2096
  const tsTenant = devOutput.platform?.name ?? 'mesh';
@@ -2989,8 +2098,6 @@ export function registerDevCommand(program) {
2989
2098
  const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;
2990
2099
  const loginServer = deriveLoginServer(tsContext);
2991
2100
  if (!loginServer) {
2992
- // Implicit selection (auto/unset) must degrade to the legacy transport,
2993
- // not dead-end. Only an explicit --transport=tailscale may hard-error.
2994
2101
  const msg = `No login config for context '${tsContext}' — run: mesh login ${tsContext}`;
2995
2102
  if (options.transport === 'tailscale') {
2996
2103
  logError(msg);
@@ -3001,10 +2108,6 @@ export function registerDevCommand(program) {
3001
2108
  : hasSessionManagerPlugin()
3002
2109
  ? 'ssm'
3003
2110
  : 'vpn-direct';
3004
- // Reachability-gate this legacy fallback too (MESH-2016): otherwise a
3005
- // machine with tailscaled (→ auto picks tailscale) but no mesh login
3006
- // config, with any tailnet up (vpnConnected), would land on an UNPROBED
3007
- // vpn-direct and reproduce the exact ECONNREFUSED this feature fixes.
3008
2111
  const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);
3009
2112
  logWarn(`${msg} — falling back to ${gatedLegacy} transport.`);
3010
2113
  tunnelPlan = { transport: gatedLegacy };
@@ -3012,15 +2115,9 @@ export function registerDevCommand(program) {
3012
2115
  else {
3013
2116
  const prior = readTailscaleState(tsTenant);
3014
2117
  const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';
3015
- // Reuse the running daemon's real SOCKS port (daemon.json survives
3016
- // `down`); never mint a fresh one the daemon isn't listening on.
3017
2118
  const socksPort = daemonUp
3018
2119
  ? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))
3019
2120
  : await findFreePort();
3020
- // Zero-touch VPN join (MESH-2099): try to mint a single-use pre-auth key
3021
- // from the platform's VpnJoinBroker so `tailscale up --authkey` joins
3022
- // silently. Any failure leaves preAuthKey undefined → the interactive
3023
- // browser-relay join (MESH-2098) is used instead.
3024
2121
  let preAuthKey;
3025
2122
  const brokerUrl = resolveVpnJoinBroker(tsContext);
3026
2123
  if (brokerUrl) {
@@ -3046,19 +2143,10 @@ export function registerDevCommand(program) {
3046
2143
  };
3047
2144
  }
3048
2145
  }
3049
- // Relaunch safety (MESH-2017): a relaunch recreates the worker onto current
3050
- // code. If the workflow source changed since this session started, warn before
3051
- // it replays in-flight conversations against it (require --force). Checked
3052
- // BEFORE startServices, which kills+recreates the session.
3053
2146
  if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {
3054
2147
  process.exit(1);
3055
2148
  }
3056
- // Start services. startServices handles tmux session creation, tunnel setup
3057
- // (SSM or Tailscale, per tunnelPlan), and service windows. It returns the
3058
- // final devOutput with tunnels rewritten to localhost ports.
3059
2149
  const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, childAwsEnv, tunnelPlan, worktree.worktreeRoot, worktree.taskQueueSuffix);
3060
- // Persist session state so --status and restart see allocated ports
3061
- // and the final tunnel endpoints (localhost for SSM, VPC for VPN).
3062
2150
  saveSessionState(sessionName, {
3063
2151
  appRoot,
3064
2152
  stack,
@@ -3067,13 +2155,11 @@ export function registerDevCommand(program) {
3067
2155
  workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
3068
2156
  });
3069
2157
  });
3070
- // Subcommand: logs
3071
2158
  dev
3072
2159
  .command('logs <service>')
3073
2160
  .description('Show logs for a service')
3074
2161
  .option('--tail <lines>', 'Number of lines', '100')
3075
2162
  .action((service, opts) => {
3076
- // --session is on the parent dev command
3077
2163
  const sessionName = dev.opts().session;
3078
2164
  const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
3079
2165
  if (loadSessionState(resolvedSession)?.runner === 'docker') {
@@ -3089,7 +2175,6 @@ export function registerDevCommand(program) {
3089
2175
  showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));
3090
2176
  }
3091
2177
  });
3092
- // Subcommand: restart
3093
2178
  dev
3094
2179
  .command('restart <service>')
3095
2180
  .description('Restart a service')
@@ -3098,11 +2183,8 @@ export function registerDevCommand(program) {
3098
2183
  .option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
3099
2184
  .option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')
3100
2185
  .action(async (service, opts) => {
3101
- // Hoisted so the catch can name the right login context (not a hardcoded
3102
- // one) even when a throw happens before cred resolution. MESH-2089.
3103
2186
  let ctx = null;
3104
2187
  try {
3105
- // --session is on the parent dev command
3106
2188
  const parentSession = dev.opts().session;
3107
2189
  const parentProfile = dev.opts().profile;
3108
2190
  if (parentProfile) {
@@ -3117,22 +2199,11 @@ export function registerDevCommand(program) {
3117
2199
  logSuccess(`Restarted: ${service}`);
3118
2200
  return;
3119
2201
  }
3120
- // Relaunch safety (MESH-2017): restarting the WORKER onto changed workflow
3121
- // code strands in-flight conversations; restarting other services (ui/api)
3122
- // doesn't. Gate the worker restart on the workflow-source fingerprint.
3123
2202
  if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {
3124
2203
  process.exit(1);
3125
2204
  }
3126
2205
  const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
3127
2206
  ctx = derivePlatformContext(appRoot, stack);
3128
- // Resolve credentials BEFORE reading any Pulumi/SSM/kubeconfig output
3129
- // (MESH-2090). With ambient AWS_* cleared (the documented restart
3130
- // hygiene), the parent-side AWS calls the restart path makes — the
3131
- // getDevOutput fallback below, and under --refresh-env the
3132
- // resolveTemporalAuthVars/ensureKubeconfig calls — read the default SDK
3133
- // chain (process.env). Resolving first lets us thread the resolved creds
3134
- // into this process's env, and lets the precise login preflight fire
3135
- // before the devOutput fallback (which was the MESH-2089 crash site).
3136
2207
  const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
3137
2208
  const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
3138
2209
  let deployerRole = defaultDeployerRole;
@@ -3145,10 +2216,6 @@ export function registerDevCommand(program) {
3145
2216
  });
3146
2217
  }
3147
2218
  const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;
3148
- // Login preflight (MESH-2089): if a deployer role is configured but creds
3149
- // couldn't be resolved AND there are no ambient AWS creds to fall back on,
3150
- // the restart would proceed with no credentials and fail downstream. Name
3151
- // the real problem (e.g. "run mesh login <ctx> --device") instead.
3152
2219
  if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {
3153
2220
  const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;
3154
2221
  logError(pf?.message ??
@@ -3156,15 +2223,10 @@ export function registerDevCommand(program) {
3156
2223
  process.exit(1);
3157
2224
  }
3158
2225
  const baseAwsEnv = resolved?.env ?? getAwsEnvVars();
3159
- // Ensure AWS_REGION is set from stack config so parent-side SDK clients
3160
- // (SSM, EKS) don't default to us-east-1. Mirrors the launch path.
3161
2226
  const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
3162
2227
  if (awsRegion && !baseAwsEnv.AWS_REGION) {
3163
2228
  baseAwsEnv.AWS_REGION = awsRegion;
3164
2229
  }
3165
- // Inject the resolved creds into THIS process's env so the parent-side
3166
- // AWS calls below run credentialed even when ambient AWS_* was cleared
3167
- // (MESH-2090). Mirrors the launch path's inject (see startServices).
3168
2230
  Object.assign(process.env, baseAwsEnv);
3169
2231
  const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
3170
2232
  const zitadelOpts = resolved?.method === 'zitadel' && deployerRole && ctx
@@ -3176,18 +2238,12 @@ export function registerDevCommand(program) {
3176
2238
  }
3177
2239
  : undefined;
3178
2240
  const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
3179
- // Use persisted state (has allocated ports), fall back to raw Pulumi
3180
- // output — now credentialed via the resolved env (MESH-2090).
3181
2241
  const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
3182
2242
  await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
3183
2243
  refreshEnv: !!opts.refreshEnv,
3184
2244
  stack,
3185
2245
  sessionState: state,
3186
2246
  });
3187
- // Re-baseline the workflow fingerprint after a worker restart (MESH-2017):
3188
- // the worker now runs the current code, so a subsequent unchanged restart
3189
- // must NOT nag — otherwise a good-citizen `--force` earns a session of
3190
- // false warnings, training the reflex this guard exists to prevent.
3191
2247
  if (/worker/i.test(service) && state) {
3192
2248
  saveSessionState(sessionName, {
3193
2249
  ...state,
@@ -3196,16 +2252,12 @@ export function registerDevCommand(program) {
3196
2252
  }
3197
2253
  }
3198
2254
  catch (err) {
3199
- // Never crash uncaught (MESH-2089): a cred/kubeconfig/tmux failure should
3200
- // name itself, not dump a bare Node stack. The underlying error is logged
3201
- // so the exact cause is pinned if this recurs.
3202
2255
  logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
3203
2256
  logInfo(`If this is a credential/kubeconfig issue, run \`mesh login ${ctx ?? '<your platform context>'} --device\`, ` +
3204
2257
  'or `mesh dev` to relaunch the session cleanly.');
3205
2258
  process.exit(1);
3206
2259
  }
3207
2260
  });
3208
- // Subcommand: doctor — diagnose the dev session
3209
2261
  dev
3210
2262
  .command('doctor')
3211
2263
  .description('Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) — names the fix')
@@ -3222,19 +2274,15 @@ export function registerDevCommand(program) {
3222
2274
  const sessionName = dev.opts().session ?? `${projectName}-dev`;
3223
2275
  const state = loadSessionState(sessionName);
3224
2276
  const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
3225
- // `--json` may land on either the subcommand or the parent `dev`
3226
- // command (both declare it), so honor both.
3227
2277
  const asJson = !!opts.json || !!dev.opts().json;
3228
2278
  const ctx = buildDoctorContext(appRoot, stack, sessionName);
3229
2279
  const status = await runDoctor(ctx, { json: asJson });
3230
2280
  process.exit(status === 'error' ? 1 : 0);
3231
2281
  });
3232
- // Subcommand: list — discover apps in the monorepo
3233
2282
  dev
3234
2283
  .command('list')
3235
2284
  .description('List Pulumi apps in the monorepo')
3236
2285
  .action(() => {
3237
- // --json is on the parent dev command
3238
2286
  const asJson = dev.opts().json;
3239
2287
  const mono = findMonorepoRoot();
3240
2288
  if (!mono) {
@@ -3243,7 +2291,6 @@ export function registerDevCommand(program) {
3243
2291
  }
3244
2292
  const apps = discoverApps(mono);
3245
2293
  if (asJson) {
3246
- // Include running state for each app
3247
2294
  const result = apps.map((app) => {
3248
2295
  const sessionName = `${app.name}-dev`;
3249
2296
  return {
@@ -3271,7 +2318,6 @@ export function registerDevCommand(program) {
3271
2318
  console.log('');
3272
2319
  }
3273
2320
  });
3274
- // Subcommand: test-user — retrieve Temporal test user credentials
3275
2321
  dev
3276
2322
  .command('test-user [name]')
3277
2323
  .description('Get Temporal test user credentials (from Pulumi-managed test users)')
@@ -3283,7 +2329,6 @@ export function registerDevCommand(program) {
3283
2329
  const ssm = new SSMClient({ region: opts.region });
3284
2330
  const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;
3285
2331
  if (!name) {
3286
- // List all test users
3287
2332
  try {
3288
2333
  const resp = await ssm.send(new GetParametersByPathCommand({
3289
2334
  Path: basePath,
@@ -3317,7 +2362,6 @@ export function registerDevCommand(program) {
3317
2362
  }
3318
2363
  }
3319
2364
  else {
3320
- // Get a specific test user
3321
2365
  try {
3322
2366
  const resp = await ssm.send(new GetParameterCommand({
3323
2367
  Name: `${basePath}/${name}`,
@@ -3328,7 +2372,6 @@ export function registerDevCommand(program) {
3328
2372
  return;
3329
2373
  }
3330
2374
  const creds = JSON.parse(resp.Parameter.Value);
3331
- // Check if --json is set on parent
3332
2375
  const asJson = dev.opts().json;
3333
2376
  if (asJson) {
3334
2377
  console.log(JSON.stringify(creds, null, 2));
@@ -3357,4 +2400,3 @@ export function registerDevCommand(program) {
3357
2400
  }
3358
2401
  });
3359
2402
  }
3360
- //# sourceMappingURL=dev.js.map