@mesh-tech/mesh-cli 0.1.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (504) hide show
  1. package/assets/intent/agents-fence.md +10 -0
  2. package/assets/intent/intent-claude-gate.mjs +255 -0
  3. package/assets/log-shipper.mjs +94 -0
  4. package/bin/mesh.mjs +80 -0
  5. package/dist/bin/mesh.d.ts +7 -9
  6. package/dist/bin/mesh.d.ts.map +1 -1
  7. package/dist/bin/mesh.js +20878 -37
  8. package/dist/bin/mesh.js.map +7 -1
  9. package/dist/build-info.json +4 -0
  10. package/dist/src/commands/agent-api-client.d.ts +79 -0
  11. package/dist/src/commands/agent-api-client.d.ts.map +1 -0
  12. package/dist/src/commands/agent-api-client.js +170 -0
  13. package/dist/src/commands/agent-api-client.js.map +1 -0
  14. package/dist/src/commands/artifacts.d.ts +13 -0
  15. package/dist/src/commands/artifacts.d.ts.map +1 -0
  16. package/dist/src/commands/artifacts.js +140 -0
  17. package/dist/src/commands/artifacts.js.map +1 -0
  18. package/dist/src/commands/cluster.d.ts +45 -0
  19. package/dist/src/commands/cluster.d.ts.map +1 -0
  20. package/dist/src/commands/cluster.js +259 -0
  21. package/dist/src/commands/cluster.js.map +1 -0
  22. package/dist/src/commands/conversations.d.ts +61 -0
  23. package/dist/src/commands/conversations.d.ts.map +1 -0
  24. package/dist/src/commands/conversations.js +239 -0
  25. package/dist/src/commands/conversations.js.map +1 -0
  26. package/dist/src/commands/create-app.d.ts +26 -3
  27. package/dist/src/commands/create-app.d.ts.map +1 -1
  28. package/dist/src/commands/create-app.js +465 -107
  29. package/dist/src/commands/create-app.js.map +1 -1
  30. package/dist/src/commands/deploy.d.ts +19 -0
  31. package/dist/src/commands/deploy.d.ts.map +1 -1
  32. package/dist/src/commands/deploy.js +83 -142
  33. package/dist/src/commands/deploy.js.map +1 -1
  34. package/dist/src/commands/dev-doctor.d.ts +137 -0
  35. package/dist/src/commands/dev-doctor.d.ts.map +1 -0
  36. package/dist/src/commands/dev-doctor.js +671 -0
  37. package/dist/src/commands/dev-doctor.js.map +1 -0
  38. package/dist/src/commands/dev-launch.d.ts +59 -0
  39. package/dist/src/commands/dev-launch.d.ts.map +1 -0
  40. package/dist/src/commands/dev-launch.js +118 -0
  41. package/dist/src/commands/dev-launch.js.map +1 -0
  42. package/dist/src/commands/dev-token-server.d.ts +32 -0
  43. package/dist/src/commands/dev-token-server.d.ts.map +1 -0
  44. package/dist/src/commands/dev-token-server.js +68 -0
  45. package/dist/src/commands/dev-token-server.js.map +1 -0
  46. package/dist/src/commands/dev.d.ts +289 -2
  47. package/dist/src/commands/dev.d.ts.map +1 -1
  48. package/dist/src/commands/dev.js +2501 -574
  49. package/dist/src/commands/dev.js.map +1 -1
  50. package/dist/src/commands/docs.d.ts +3 -0
  51. package/dist/src/commands/docs.d.ts.map +1 -0
  52. package/dist/src/commands/docs.js +262 -0
  53. package/dist/src/commands/docs.js.map +1 -0
  54. package/dist/src/commands/hub/index.d.ts +99 -0
  55. package/dist/src/commands/hub/index.d.ts.map +1 -0
  56. package/dist/src/commands/hub/index.js +452 -0
  57. package/dist/src/commands/hub/index.js.map +1 -0
  58. package/dist/src/commands/init.d.ts +65 -0
  59. package/dist/src/commands/init.d.ts.map +1 -0
  60. package/dist/src/commands/init.js +370 -0
  61. package/dist/src/commands/init.js.map +1 -0
  62. package/dist/src/commands/install-shim.d.ts +28 -0
  63. package/dist/src/commands/install-shim.d.ts.map +1 -0
  64. package/dist/src/commands/install-shim.js +191 -0
  65. package/dist/src/commands/install-shim.js.map +1 -0
  66. package/dist/src/commands/local/auth-provision.d.ts +71 -0
  67. package/dist/src/commands/local/auth-provision.d.ts.map +1 -0
  68. package/dist/src/commands/local/auth-provision.js +286 -0
  69. package/dist/src/commands/local/auth-provision.js.map +1 -0
  70. package/dist/src/commands/local/dev-local.d.ts +97 -0
  71. package/dist/src/commands/local/dev-local.d.ts.map +1 -0
  72. package/dist/src/commands/local/dev-local.js +306 -0
  73. package/dist/src/commands/local/dev-local.js.map +1 -0
  74. package/dist/src/commands/local/docker-runner.d.ts +37 -0
  75. package/dist/src/commands/local/docker-runner.d.ts.map +1 -0
  76. package/dist/src/commands/local/docker-runner.js +104 -0
  77. package/dist/src/commands/local/docker-runner.js.map +1 -0
  78. package/dist/src/commands/local/helpers.d.ts +22 -0
  79. package/dist/src/commands/local/helpers.d.ts.map +1 -0
  80. package/dist/src/commands/local/helpers.js +46 -0
  81. package/dist/src/commands/local/helpers.js.map +1 -0
  82. package/dist/src/commands/local/hub-local.d.ts +51 -0
  83. package/dist/src/commands/local/hub-local.d.ts.map +1 -0
  84. package/dist/src/commands/local/hub-local.js +197 -0
  85. package/dist/src/commands/local/hub-local.js.map +1 -0
  86. package/dist/src/commands/local/index.d.ts +23 -0
  87. package/dist/src/commands/local/index.d.ts.map +1 -0
  88. package/dist/src/commands/local/index.js +386 -0
  89. package/dist/src/commands/local/index.js.map +1 -0
  90. package/dist/src/commands/local/mocks.d.ts +361 -0
  91. package/dist/src/commands/local/mocks.d.ts.map +1 -0
  92. package/dist/src/commands/local/mocks.js +756 -0
  93. package/dist/src/commands/local/mocks.js.map +1 -0
  94. package/dist/src/commands/local/seed-zitadel.d.ts +151 -0
  95. package/dist/src/commands/local/seed-zitadel.d.ts.map +1 -0
  96. package/dist/src/commands/local/seed-zitadel.js +408 -0
  97. package/dist/src/commands/local/seed-zitadel.js.map +1 -0
  98. package/dist/src/commands/local/seed.d.ts +106 -0
  99. package/dist/src/commands/local/seed.d.ts.map +1 -0
  100. package/dist/src/commands/local/seed.js +252 -0
  101. package/dist/src/commands/local/seed.js.map +1 -0
  102. package/dist/src/commands/local/stack.d.ts +173 -0
  103. package/dist/src/commands/local/stack.d.ts.map +1 -0
  104. package/dist/src/commands/local/stack.js +499 -0
  105. package/dist/src/commands/local/stack.js.map +1 -0
  106. package/dist/src/commands/login.d.ts +256 -12
  107. package/dist/src/commands/login.d.ts.map +1 -1
  108. package/dist/src/commands/login.js +978 -50
  109. package/dist/src/commands/login.js.map +1 -1
  110. package/dist/src/commands/peer-addressing.d.ts +51 -0
  111. package/dist/src/commands/peer-addressing.d.ts.map +1 -0
  112. package/dist/src/commands/peer-addressing.js +64 -0
  113. package/dist/src/commands/peer-addressing.js.map +1 -0
  114. package/dist/src/commands/port-ownership.d.ts +69 -0
  115. package/dist/src/commands/port-ownership.d.ts.map +1 -0
  116. package/dist/src/commands/port-ownership.js +161 -0
  117. package/dist/src/commands/port-ownership.js.map +1 -0
  118. package/dist/src/commands/registry-publish.d.ts +3 -0
  119. package/dist/src/commands/registry-publish.d.ts.map +1 -0
  120. package/dist/src/commands/registry-publish.js +161 -0
  121. package/dist/src/commands/registry-publish.js.map +1 -0
  122. package/dist/src/commands/registry.d.ts +146 -2
  123. package/dist/src/commands/registry.d.ts.map +1 -1
  124. package/dist/src/commands/registry.js +541 -85
  125. package/dist/src/commands/registry.js.map +1 -1
  126. package/dist/src/commands/secrets/index.d.ts.map +1 -1
  127. package/dist/src/commands/secrets/index.js +15 -1
  128. package/dist/src/commands/secrets/index.js.map +1 -1
  129. package/dist/src/commands/secrets/migrate-config.d.ts +24 -0
  130. package/dist/src/commands/secrets/migrate-config.d.ts.map +1 -0
  131. package/dist/src/commands/secrets/migrate-config.js +277 -0
  132. package/dist/src/commands/secrets/migrate-config.js.map +1 -0
  133. package/dist/src/commands/secrets/set.d.ts +1 -0
  134. package/dist/src/commands/secrets/set.d.ts.map +1 -1
  135. package/dist/src/commands/secrets/set.js +96 -2
  136. package/dist/src/commands/secrets/set.js.map +1 -1
  137. package/dist/src/commands/skills.d.ts +34 -0
  138. package/dist/src/commands/skills.d.ts.map +1 -0
  139. package/dist/src/commands/skills.js +207 -0
  140. package/dist/src/commands/skills.js.map +1 -0
  141. package/dist/src/commands/stack.d.ts.map +1 -1
  142. package/dist/src/commands/stack.js +361 -23
  143. package/dist/src/commands/stack.js.map +1 -1
  144. package/dist/src/commands/temporal.d.ts +15 -0
  145. package/dist/src/commands/temporal.d.ts.map +1 -0
  146. package/dist/src/commands/temporal.js +781 -0
  147. package/dist/src/commands/temporal.js.map +1 -0
  148. package/dist/src/commands/tenant.d.ts +56 -0
  149. package/dist/src/commands/tenant.d.ts.map +1 -0
  150. package/dist/src/commands/tenant.js +233 -0
  151. package/dist/src/commands/tenant.js.map +1 -0
  152. package/dist/src/commands/tunnel/index.d.ts +49 -3
  153. package/dist/src/commands/tunnel/index.d.ts.map +1 -1
  154. package/dist/src/commands/tunnel/index.js +331 -8
  155. package/dist/src/commands/tunnel/index.js.map +1 -1
  156. package/dist/src/commands/vcs/clone.d.ts +3 -0
  157. package/dist/src/commands/vcs/clone.d.ts.map +1 -0
  158. package/dist/src/commands/vcs/clone.js +20 -0
  159. package/dist/src/commands/vcs/clone.js.map +1 -0
  160. package/dist/src/commands/vcs/common.d.ts +21 -0
  161. package/dist/src/commands/vcs/common.d.ts.map +1 -0
  162. package/dist/src/commands/vcs/common.js +62 -0
  163. package/dist/src/commands/vcs/common.js.map +1 -0
  164. package/dist/src/commands/vcs/drafts.d.ts +13 -0
  165. package/dist/src/commands/vcs/drafts.d.ts.map +1 -0
  166. package/dist/src/commands/vcs/drafts.js +45 -0
  167. package/dist/src/commands/vcs/drafts.js.map +1 -0
  168. package/dist/src/commands/vcs/get.d.ts +8 -0
  169. package/dist/src/commands/vcs/get.d.ts.map +1 -0
  170. package/dist/src/commands/vcs/get.js +61 -0
  171. package/dist/src/commands/vcs/get.js.map +1 -0
  172. package/dist/src/commands/vcs/index.d.ts +3 -0
  173. package/dist/src/commands/vcs/index.d.ts.map +1 -0
  174. package/dist/src/commands/vcs/index.js +58 -0
  175. package/dist/src/commands/vcs/index.js.map +1 -0
  176. package/dist/src/commands/vcs/propose.d.ts +23 -0
  177. package/dist/src/commands/vcs/propose.d.ts.map +1 -0
  178. package/dist/src/commands/vcs/propose.js +94 -0
  179. package/dist/src/commands/vcs/propose.js.map +1 -0
  180. package/dist/src/commands/vcs/review.d.ts +15 -0
  181. package/dist/src/commands/vcs/review.d.ts.map +1 -0
  182. package/dist/src/commands/vcs/review.js +35 -0
  183. package/dist/src/commands/vcs/review.js.map +1 -0
  184. package/dist/src/commands/vcs-enrich.d.ts +15 -0
  185. package/dist/src/commands/vcs-enrich.d.ts.map +1 -0
  186. package/dist/src/commands/vcs-enrich.js +28 -0
  187. package/dist/src/commands/vcs-enrich.js.map +1 -0
  188. package/dist/src/commands/vpn/index.d.ts +24 -0
  189. package/dist/src/commands/vpn/index.d.ts.map +1 -0
  190. package/dist/src/commands/vpn/index.js +543 -0
  191. package/dist/src/commands/vpn/index.js.map +1 -0
  192. package/dist/src/commands/vpn/tunnel.d.ts +11 -0
  193. package/dist/src/commands/vpn/tunnel.d.ts.map +1 -0
  194. package/dist/src/commands/vpn/tunnel.js +188 -0
  195. package/dist/src/commands/vpn/tunnel.js.map +1 -0
  196. package/dist/src/commands/workflow.d.ts +24 -0
  197. package/dist/src/commands/workflow.d.ts.map +1 -0
  198. package/dist/src/commands/workflow.js +577 -0
  199. package/dist/src/commands/workflow.js.map +1 -0
  200. package/dist/src/docs/assemble.d.ts +217 -0
  201. package/dist/src/docs/assemble.d.ts.map +1 -0
  202. package/dist/src/docs/assemble.js +739 -0
  203. package/dist/src/docs/assemble.js.map +1 -0
  204. package/dist/src/docs/cli-reference.d.ts +93 -0
  205. package/dist/src/docs/cli-reference.d.ts.map +1 -0
  206. package/dist/src/docs/cli-reference.js +256 -0
  207. package/dist/src/docs/cli-reference.js.map +1 -0
  208. package/dist/src/docs/discover.d.ts +60 -0
  209. package/dist/src/docs/discover.d.ts.map +1 -0
  210. package/dist/src/docs/discover.js +233 -0
  211. package/dist/src/docs/discover.js.map +1 -0
  212. package/dist/src/docs/portal.d.ts +79 -0
  213. package/dist/src/docs/portal.d.ts.map +1 -0
  214. package/dist/src/docs/portal.js +242 -0
  215. package/dist/src/docs/portal.js.map +1 -0
  216. package/dist/src/docs/registry-docs.d.ts +43 -0
  217. package/dist/src/docs/registry-docs.d.ts.map +1 -0
  218. package/dist/src/docs/registry-docs.js +138 -0
  219. package/dist/src/docs/registry-docs.js.map +1 -0
  220. package/dist/src/docs/schema.d.ts +48 -0
  221. package/dist/src/docs/schema.d.ts.map +1 -0
  222. package/dist/src/docs/schema.js +96 -0
  223. package/dist/src/docs/schema.js.map +1 -0
  224. package/dist/src/docs/serve.d.ts +27 -0
  225. package/dist/src/docs/serve.d.ts.map +1 -0
  226. package/dist/src/docs/serve.js +102 -0
  227. package/dist/src/docs/serve.js.map +1 -0
  228. package/dist/src/docs/start.d.ts +80 -0
  229. package/dist/src/docs/start.d.ts.map +1 -0
  230. package/dist/src/docs/start.js +313 -0
  231. package/dist/src/docs/start.js.map +1 -0
  232. package/dist/src/eval/agent-eval.d.mts +14 -0
  233. package/dist/src/eval/agent-eval.d.mts.map +1 -0
  234. package/dist/src/eval/agent-eval.mjs +273 -0
  235. package/dist/src/eval/agent-eval.mjs.map +1 -0
  236. package/dist/src/index.js +624 -7
  237. package/dist/src/index.js.map +7 -1
  238. package/dist/src/program.d.ts +26 -0
  239. package/dist/src/program.d.ts.map +1 -0
  240. package/dist/src/program.js +76 -0
  241. package/dist/src/program.js.map +1 -0
  242. package/dist/src/utils/auth-preflight.d.ts +159 -0
  243. package/dist/src/utils/auth-preflight.d.ts.map +1 -0
  244. package/dist/src/utils/auth-preflight.js +298 -0
  245. package/dist/src/utils/auth-preflight.js.map +1 -0
  246. package/dist/src/utils/aws-auth.d.ts +181 -0
  247. package/dist/src/utils/aws-auth.d.ts.map +1 -0
  248. package/dist/src/utils/aws-auth.js +527 -0
  249. package/dist/src/utils/aws-auth.js.map +1 -0
  250. package/dist/src/utils/bastion.d.ts +1 -1
  251. package/dist/src/utils/bastion.d.ts.map +1 -1
  252. package/dist/src/utils/bastion.js +29 -8
  253. package/dist/src/utils/bastion.js.map +1 -1
  254. package/dist/src/utils/build-info.d.ts +33 -0
  255. package/dist/src/utils/build-info.d.ts.map +1 -0
  256. package/dist/src/utils/build-info.js +102 -0
  257. package/dist/src/utils/build-info.js.map +1 -0
  258. package/dist/src/utils/capture-history.d.ts +62 -0
  259. package/dist/src/utils/capture-history.d.ts.map +1 -0
  260. package/dist/src/utils/capture-history.js +94 -0
  261. package/dist/src/utils/capture-history.js.map +1 -0
  262. package/dist/src/utils/deploy-preflight.d.ts +52 -0
  263. package/dist/src/utils/deploy-preflight.d.ts.map +1 -0
  264. package/dist/src/utils/deploy-preflight.js +183 -0
  265. package/dist/src/utils/deploy-preflight.js.map +1 -0
  266. package/dist/src/utils/errors.d.ts +75 -0
  267. package/dist/src/utils/errors.d.ts.map +1 -0
  268. package/dist/src/utils/errors.js +106 -0
  269. package/dist/src/utils/errors.js.map +1 -0
  270. package/dist/src/utils/first-party-contexts.d.ts +40 -0
  271. package/dist/src/utils/first-party-contexts.d.ts.map +1 -0
  272. package/dist/src/utils/first-party-contexts.js +53 -0
  273. package/dist/src/utils/first-party-contexts.js.map +1 -0
  274. package/dist/src/utils/index.d.ts +2 -0
  275. package/dist/src/utils/index.d.ts.map +1 -1
  276. package/dist/src/utils/index.js +2 -0
  277. package/dist/src/utils/index.js.map +1 -1
  278. package/dist/src/utils/kubeconfig.d.ts +62 -0
  279. package/dist/src/utils/kubeconfig.d.ts.map +1 -0
  280. package/dist/src/utils/kubeconfig.js +118 -0
  281. package/dist/src/utils/kubeconfig.js.map +1 -0
  282. package/dist/src/utils/log.d.ts +27 -1
  283. package/dist/src/utils/log.d.ts.map +1 -1
  284. package/dist/src/utils/log.js +59 -5
  285. package/dist/src/utils/log.js.map +1 -1
  286. package/dist/src/utils/pid.d.ts +47 -0
  287. package/dist/src/utils/pid.d.ts.map +1 -0
  288. package/dist/src/utils/pid.js +83 -0
  289. package/dist/src/utils/pid.js.map +1 -0
  290. package/dist/src/utils/pulumi-run.d.ts +23 -0
  291. package/dist/src/utils/pulumi-run.d.ts.map +1 -0
  292. package/dist/src/utils/pulumi-run.js +112 -0
  293. package/dist/src/utils/pulumi-run.js.map +1 -0
  294. package/dist/src/utils/pulumi.d.ts +15 -0
  295. package/dist/src/utils/pulumi.d.ts.map +1 -1
  296. package/dist/src/utils/pulumi.js +35 -0
  297. package/dist/src/utils/pulumi.js.map +1 -1
  298. package/dist/src/utils/reachability.d.ts +29 -0
  299. package/dist/src/utils/reachability.d.ts.map +1 -0
  300. package/dist/src/utils/reachability.js +81 -0
  301. package/dist/src/utils/reachability.js.map +1 -0
  302. package/dist/src/utils/recover-conversation.d.ts +154 -0
  303. package/dist/src/utils/recover-conversation.d.ts.map +1 -0
  304. package/dist/src/utils/recover-conversation.js +314 -0
  305. package/dist/src/utils/recover-conversation.js.map +1 -0
  306. package/dist/src/utils/registry-broker.d.ts +148 -0
  307. package/dist/src/utils/registry-broker.d.ts.map +1 -0
  308. package/dist/src/utils/registry-broker.js +180 -0
  309. package/dist/src/utils/registry-broker.js.map +1 -0
  310. package/dist/src/utils/socks-forward.d.ts +40 -0
  311. package/dist/src/utils/socks-forward.d.ts.map +1 -0
  312. package/dist/src/utils/socks-forward.js +97 -0
  313. package/dist/src/utils/socks-forward.js.map +1 -0
  314. package/dist/src/utils/stack-flag.d.ts +23 -0
  315. package/dist/src/utils/stack-flag.d.ts.map +1 -0
  316. package/dist/src/utils/stack-flag.js +41 -0
  317. package/dist/src/utils/stack-flag.js.map +1 -0
  318. package/dist/src/utils/tailscale-targets.d.ts +52 -0
  319. package/dist/src/utils/tailscale-targets.d.ts.map +1 -0
  320. package/dist/src/utils/tailscale-targets.js +93 -0
  321. package/dist/src/utils/tailscale-targets.js.map +1 -0
  322. package/dist/src/utils/tailscale.d.ts +277 -0
  323. package/dist/src/utils/tailscale.d.ts.map +1 -0
  324. package/dist/src/utils/tailscale.js +1007 -0
  325. package/dist/src/utils/tailscale.js.map +1 -0
  326. package/dist/src/utils/temporal-auth.d.ts +43 -0
  327. package/dist/src/utils/temporal-auth.d.ts.map +1 -0
  328. package/dist/src/utils/temporal-auth.js +176 -0
  329. package/dist/src/utils/temporal-auth.js.map +1 -0
  330. package/dist/src/utils/temporal-codec.d.ts +101 -0
  331. package/dist/src/utils/temporal-codec.d.ts.map +1 -0
  332. package/dist/src/utils/temporal-codec.js +319 -0
  333. package/dist/src/utils/temporal-codec.js.map +1 -0
  334. package/dist/src/utils/tunnel-ownership.d.ts +130 -0
  335. package/dist/src/utils/tunnel-ownership.d.ts.map +1 -0
  336. package/dist/src/utils/tunnel-ownership.js +274 -0
  337. package/dist/src/utils/tunnel-ownership.js.map +1 -0
  338. package/dist/src/utils/vpn-join.d.ts +32 -0
  339. package/dist/src/utils/vpn-join.d.ts.map +1 -0
  340. package/dist/src/utils/vpn-join.js +62 -0
  341. package/dist/src/utils/vpn-join.js.map +1 -0
  342. package/dist/src/utils/vpn.d.ts +62 -0
  343. package/dist/src/utils/vpn.d.ts.map +1 -0
  344. package/dist/src/utils/vpn.js +183 -0
  345. package/dist/src/utils/vpn.js.map +1 -0
  346. package/dist/src/utils/workflow-fingerprint.d.ts +49 -0
  347. package/dist/src/utils/workflow-fingerprint.d.ts.map +1 -0
  348. package/dist/src/utils/workflow-fingerprint.js +135 -0
  349. package/dist/src/utils/workflow-fingerprint.js.map +1 -0
  350. package/dist/src/utils/worktree-identity.d.ts +86 -0
  351. package/dist/src/utils/worktree-identity.d.ts.map +1 -0
  352. package/dist/src/utils/worktree-identity.js +164 -0
  353. package/dist/src/utils/worktree-identity.js.map +1 -0
  354. package/fragments/agents/AGENTS.md.hbs +92 -0
  355. package/fragments/agents/CLAUDE.md.hbs +8 -0
  356. package/fragments/base/Pulumi.dev.yaml.hbs +10 -0
  357. package/fragments/base/Pulumi.yaml.hbs +9 -0
  358. package/fragments/base/index.ts.hbs +107 -0
  359. package/fragments/base/package.json.hbs +22 -0
  360. package/{templates/api-web-db/api → fragments/base}/tsconfig.json +3 -4
  361. package/fragments/bucket/api/src/storage.ts.hbs +81 -0
  362. package/fragments/database/api/src/db.ts.hbs +23 -0
  363. package/fragments/database/prisma/schema.prisma.hbs +18 -0
  364. package/{templates/api-web-db → fragments/service}/api/.dockerignore +2 -0
  365. package/fragments/service/api/package.json.hbs +40 -0
  366. package/fragments/service/api/src/index.ts.hbs +58 -0
  367. package/{templates/api-web-db → fragments/service/api}/tsconfig.json +5 -3
  368. package/fragments/temporal/worker/.dockerignore +4 -0
  369. package/fragments/temporal/worker/Dockerfile +15 -0
  370. package/{templates/temporal-api-worker → fragments/temporal}/worker/package.json.hbs +3 -2
  371. package/fragments/temporal/worker/src/activities.ts.hbs +14 -0
  372. package/fragments/temporal/worker/src/worker.ts.hbs +27 -0
  373. package/fragments/temporal/worker/src/workflows.ts.hbs +24 -0
  374. package/fragments/temporal/worker/tsconfig.json +13 -0
  375. package/package.json +36 -22
  376. package/skills/core/SKILL.md +365 -0
  377. package/skills/pull-remote-agent-context/SKILL.md +194 -0
  378. package/stack/docker-compose.hub.yml +134 -0
  379. package/stack/docker-compose.yml +447 -0
  380. package/stack/hub/Dockerfile.api +18 -0
  381. package/stack/hub/Dockerfile.auth +10 -0
  382. package/stack/hub/Dockerfile.ui +10 -0
  383. package/stack/init/01-databases.sql +12 -0
  384. package/stack/observability/blackbox.yml +20 -0
  385. package/stack/observability/otel-collector.yaml +73 -0
  386. package/stack/observability/prometheus.yml +36 -0
  387. package/stack/observability/tempo.yaml +25 -0
  388. package/stack/temporal/local.yaml +44 -0
  389. package/templates/api-auth/AGENTS.md.hbs +16 -0
  390. package/templates/api-auth/Pulumi.dev.yaml.hbs +8 -0
  391. package/templates/api-auth/Pulumi.yaml.hbs +8 -0
  392. package/templates/api-auth/README.md.hbs +67 -0
  393. package/templates/api-auth/api/package.json.hbs +28 -0
  394. package/templates/api-auth/api/src/index.ts.hbs +96 -0
  395. package/templates/api-auth/api/src/schema.ts.hbs +35 -0
  396. package/templates/api-auth/api/src/seed-authz.ts.hbs +48 -0
  397. package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +89 -0
  398. package/templates/api-auth/index.ts.hbs +65 -0
  399. package/templates/api-auth/package.json.hbs +20 -0
  400. package/templates/api-auth/skills/develop/SKILL.md.hbs +32 -0
  401. package/templates/api-role-gating/AGENTS.md.hbs +20 -0
  402. package/templates/api-role-gating/Pulumi.dev.yaml.hbs +8 -0
  403. package/templates/api-role-gating/Pulumi.yaml.hbs +8 -0
  404. package/templates/api-role-gating/README.md.hbs +64 -0
  405. package/templates/api-role-gating/api/Dockerfile +17 -0
  406. package/templates/api-role-gating/api/package.json.hbs +28 -0
  407. package/templates/api-role-gating/api/src/authn.ts.hbs +29 -0
  408. package/templates/api-role-gating/api/src/authz.ts.hbs +12 -0
  409. package/templates/api-role-gating/api/src/main.ts.hbs +58 -0
  410. package/templates/api-role-gating/api/src/schema.ts.hbs +22 -0
  411. package/templates/api-role-gating/api/tsconfig.json +13 -0
  412. package/templates/api-role-gating/index.ts.hbs +107 -0
  413. package/templates/api-role-gating/package.json.hbs +21 -0
  414. package/templates/api-role-gating/skills/develop/SKILL.md.hbs +60 -0
  415. package/templates/api-role-gating/tsconfig.json +10 -0
  416. package/templates/apps-repo/README.md.hbs +24 -0
  417. package/templates/apps-repo/gitignore +41 -0
  418. package/templates/apps-repo/npmrc.hbs +7 -0
  419. package/templates/apps-repo/package.json.hbs +15 -0
  420. package/templates/apps-repo/pnpm-workspace.yaml +3 -0
  421. package/templates/apps-repo/tsconfig.json +18 -0
  422. package/templates/external-service/AGENTS.md.hbs +20 -0
  423. package/templates/external-service/Pulumi.dev.yaml.hbs +8 -0
  424. package/templates/external-service/Pulumi.yaml.hbs +8 -0
  425. package/templates/external-service/README.md.hbs +56 -0
  426. package/templates/external-service/api/Dockerfile +15 -0
  427. package/templates/external-service/api/package.json.hbs +25 -0
  428. package/templates/external-service/api/src/index.ts.hbs +81 -0
  429. package/templates/external-service/index.ts.hbs +82 -0
  430. package/templates/external-service/mocks/vendorpay/package.json.hbs +9 -0
  431. package/templates/external-service/mocks/vendorpay/server.mjs.hbs +61 -0
  432. package/templates/external-service/package.json.hbs +34 -0
  433. package/templates/external-service/skills/develop/SKILL.md.hbs +55 -0
  434. package/templates/external-service/tsconfig.json +10 -0
  435. package/templates/platform-repo/AGENTS.md.hbs +18 -0
  436. package/templates/platform-repo/README.md.hbs +38 -0
  437. package/templates/platform-repo/core/Pulumi.yaml.hbs +12 -0
  438. package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +57 -0
  439. package/templates/platform-repo/core/index.ts +62 -0
  440. package/templates/platform-repo/core/package.json.hbs +22 -0
  441. package/templates/platform-repo/core/tsconfig.json +8 -0
  442. package/templates/platform-repo/package.json.hbs +15 -0
  443. package/templates/platform-repo/platform/Pulumi.yaml.hbs +12 -0
  444. package/templates/platform-repo/platform/Pulumi.{{tenant}}-{{env}}.yaml.hbs +59 -0
  445. package/templates/platform-repo/platform/index.ts +95 -0
  446. package/templates/platform-repo/platform/package.json.hbs +26 -0
  447. package/templates/platform-repo/platform/tsconfig.json +8 -0
  448. package/templates/platform-repo/pnpm-workspace.yaml +3 -0
  449. package/templates/workflow/AGENTS.md.hbs +15 -0
  450. package/templates/workflow/Pulumi.dev.yaml.hbs +8 -0
  451. package/templates/workflow/Pulumi.yaml.hbs +8 -0
  452. package/templates/workflow/README.md.hbs +45 -0
  453. package/templates/workflow/api/.dockerignore +4 -0
  454. package/templates/workflow/api/Dockerfile +15 -0
  455. package/templates/{temporal-api-worker → workflow}/api/package.json.hbs +8 -6
  456. package/templates/workflow/api/src/index.ts.hbs +130 -0
  457. package/templates/workflow/api/tsconfig.json +14 -0
  458. package/templates/workflow/index.ts.hbs +67 -0
  459. package/templates/workflow/package.json.hbs +20 -0
  460. package/templates/workflow/skills/develop/SKILL.md.hbs +32 -0
  461. package/templates/workflow/tsconfig.json +10 -0
  462. package/templates/workflow/worker/.dockerignore +4 -0
  463. package/templates/workflow/worker/Dockerfile +13 -0
  464. package/templates/workflow/worker/package.json.hbs +25 -0
  465. package/templates/workflow/worker/src/worker.ts.hbs +24 -0
  466. package/templates/workflow/worker/tsconfig.json +14 -0
  467. package/dist/src/commands/devbox.d.ts +0 -22
  468. package/dist/src/commands/devbox.d.ts.map +0 -1
  469. package/dist/src/commands/devbox.js +0 -463
  470. package/dist/src/commands/devbox.js.map +0 -1
  471. package/templates/api-web-db/api/Dockerfile +0 -16
  472. package/templates/api-web-db/api/package.json.hbs +0 -21
  473. package/templates/api-web-db/api/src/index.ts.hbs +0 -123
  474. package/templates/api-web-db/package.json.hbs +0 -27
  475. package/templates/api-web-db/sst.config.ts.hbs +0 -151
  476. package/templates/api-web-db/web/.dockerignore +0 -3
  477. package/templates/api-web-db/web/Dockerfile +0 -17
  478. package/templates/api-web-db/web/app/lib/db.server.ts +0 -36
  479. package/templates/api-web-db/web/app/root.tsx +0 -29
  480. package/templates/api-web-db/web/app/routes/home.tsx.hbs +0 -114
  481. package/templates/api-web-db/web/app/routes.ts +0 -3
  482. package/templates/api-web-db/web/package.json.hbs +0 -30
  483. package/templates/api-web-db/web/prisma/schema.prisma +0 -25
  484. package/templates/api-web-db/web/prisma.config.ts +0 -18
  485. package/templates/api-web-db/web/react-router.config.ts +0 -5
  486. package/templates/api-web-db/web/tsconfig.json +0 -19
  487. package/templates/api-web-db/web/vite.config.ts +0 -9
  488. package/templates/temporal-api-worker/README.md.hbs +0 -133
  489. package/templates/temporal-api-worker/api/src/index.ts.hbs +0 -145
  490. package/templates/temporal-api-worker/package.json.hbs +0 -29
  491. package/templates/temporal-api-worker/sst.config.ts.hbs +0 -161
  492. package/templates/temporal-api-worker/worker/Dockerfile +0 -11
  493. package/templates/temporal-api-worker/worker/src/bundle-workflows.ts +0 -38
  494. package/templates/temporal-api-worker/worker/src/worker.ts.hbs +0 -93
  495. /package/templates/{temporal-api-worker → api-auth}/api/.dockerignore +0 -0
  496. /package/templates/{temporal-api-worker → api-auth}/api/Dockerfile +0 -0
  497. /package/templates/{temporal-api-worker → api-auth}/api/tsconfig.json +0 -0
  498. /package/templates/{temporal-api-worker → api-auth}/tsconfig.json +0 -0
  499. /package/templates/{temporal-api-worker/worker → api-role-gating/api}/.dockerignore +0 -0
  500. /package/templates/{temporal-api-worker/worker → external-service/api}/tsconfig.json +0 -0
  501. /package/templates/{temporal-api-worker → workflow}/api/src/encryption-codec.ts +0 -0
  502. /package/templates/{temporal-api-worker → workflow}/worker/src/activities.ts.hbs +0 -0
  503. /package/templates/{temporal-api-worker → workflow}/worker/src/encryption-codec.ts +0 -0
  504. /package/templates/{temporal-api-worker → workflow}/worker/src/workflows.ts.hbs +0 -0
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * Userspace-Tailscale daemon management + tunnel-session state for
3
+ * `mesh vpn tunnel` (MESH-2040 / Slice 1 of MESH-2014).
4
+ *
5
+ * A per-tenant `tailscaled` runs in userspace-networking mode (brew formula,
6
+ * NOT the GUI app) exposing a SOCKS5 proxy; forwarders bridge `localhost:PORT`
7
+ * to VPC services through it. State lives under `~/.config/mesh/tailscale/<tenant>/`
8
+ * (r+w-granted under safehouse — see the MESH-2036 spike).
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as net from "node:net";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { spawn, execFileSync } from "node:child_process";
15
+ import { CONFIG_DIR, atomicWriteFileSync } from "../commands/login.js";
16
+ import { resolveStableMeshBin } from "./aws-auth.js";
17
+ import { logInfo, logWarn } from "./log.js";
18
+ import { MeshCliError, renderErrorBody } from "./errors.js";
19
+ import { assertSignalablePid } from "./pid.js";
20
+ import { getPlatformBastionInfo } from "./bastion.js";
21
+ import { resolveTunnelTargets, resolveElbIp, tenantPortIndex, PORT_BLOCK } from "./tailscale-targets.js";
22
+ import { createForwarder } from "./socks-forward.js";
23
+ import { mintRunnerToken, readRunnerManifest, writeRunnerManifest, clearRunnerManifest, getProcessGroupId, discoverRunnerPids, startControlServer, verifyRunnerOwnership, pollOwnership, findSquattedPorts, buildSquatterError, } from "./tunnel-ownership.js";
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ // ============================================================================
26
+ // Paths
27
+ // ============================================================================
28
+ export function tenantStateDir(tenant) {
29
+ return path.join(CONFIG_DIR, "tailscale", tenant);
30
+ }
31
+ export function socketPath(tenant) {
32
+ return path.join(tenantStateDir(tenant), "tailscaled.sock");
33
+ }
34
+ export function logPath(tenant) {
35
+ return path.join(tenantStateDir(tenant), "tailscaled.log");
36
+ }
37
+ export function statePath(tenant) {
38
+ return path.join(tenantStateDir(tenant), "tunnel-state.json");
39
+ }
40
+ /**
41
+ * Daemon-scoped metadata, written by `ensureDaemon` when it actually spawns the
42
+ * daemon. Unlike `tunnel-state.json` (which `down` clears), this survives `down`
43
+ * so a later `up` reuses the RUNNING daemon's SOCKS port instead of minting a
44
+ * fresh one the daemon isn't listening on (which would leave every forwarder
45
+ * dialing a dead port). Also records the daemon pid so `stopDaemon` can kill it.
46
+ */
47
+ export function daemonMetaPath(tenant) {
48
+ return path.join(tenantStateDir(tenant), "daemon.json");
49
+ }
50
+ /**
51
+ * Ownership manifest written by the RUNNER (the process that binds the
52
+ * forwarder ports) — pid, process-group id, per-run token, control port. The
53
+ * ownership handshake (MESH-2407) verifies against it; teardown signals the
54
+ * recorded process group.
55
+ */
56
+ export function runnerManifestPath(tenant) {
57
+ return path.join(tenantStateDir(tenant), "runner.json");
58
+ }
59
+ export function readTunnelState(tenant) {
60
+ const p = statePath(tenant);
61
+ if (!fs.existsSync(p))
62
+ return null;
63
+ try {
64
+ return JSON.parse(fs.readFileSync(p, "utf8"));
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ export function writeTunnelState(tenant, state) {
71
+ fs.mkdirSync(tenantStateDir(tenant), { recursive: true });
72
+ atomicWriteFileSync(statePath(tenant), JSON.stringify(state, null, 2), 0o600);
73
+ }
74
+ export function clearTunnelState(tenant) {
75
+ try {
76
+ fs.unlinkSync(statePath(tenant));
77
+ }
78
+ catch {
79
+ /* already gone */
80
+ }
81
+ }
82
+ export function readDaemonMeta(tenant) {
83
+ const p = daemonMetaPath(tenant);
84
+ if (!fs.existsSync(p))
85
+ return null;
86
+ try {
87
+ return JSON.parse(fs.readFileSync(p, "utf8"));
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ export function writeDaemonMeta(tenant, meta) {
94
+ fs.mkdirSync(tenantStateDir(tenant), { recursive: true });
95
+ atomicWriteFileSync(daemonMetaPath(tenant), JSON.stringify(meta, null, 2), 0o600);
96
+ }
97
+ export function clearDaemonMeta(tenant) {
98
+ try {
99
+ fs.unlinkSync(daemonMetaPath(tenant));
100
+ }
101
+ catch {
102
+ /* already gone */
103
+ }
104
+ }
105
+ // ============================================================================
106
+ // Daemon status parsing
107
+ // ============================================================================
108
+ /** Parse `tailscale status --json` → the fields we care about. */
109
+ export function parseDaemonState(statusJson) {
110
+ try {
111
+ const j = JSON.parse(statusJson);
112
+ return { backendState: j.BackendState ?? "Unknown", selfName: j.Self?.HostName };
113
+ }
114
+ catch {
115
+ return { backendState: "Unknown" };
116
+ }
117
+ }
118
+ // ============================================================================
119
+ // Binary resolution (macOS / brew — Slice 1)
120
+ // ============================================================================
121
+ /** The brew `tailscaled` (NOT the GUI app, which ships no standalone daemon). */
122
+ export function tailscaledBinPath() {
123
+ return resolveBrewBin("tailscaled");
124
+ }
125
+ /** The brew `tailscale` CLI (prefer it over the GUI shim, which aborts under sandbox). */
126
+ export function tailscaleBinPath() {
127
+ return resolveBrewBin("tailscale");
128
+ }
129
+ function resolveBrewBin(name) {
130
+ const brew = `/opt/homebrew/bin/${name}`;
131
+ if (fs.existsSync(brew))
132
+ return brew;
133
+ try {
134
+ const p = execFileSync("which", [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
135
+ if (p)
136
+ return p;
137
+ }
138
+ catch {
139
+ /* fall through */
140
+ }
141
+ throw new Error(`${name} not found — install the standalone binary: brew install tailscale`);
142
+ }
143
+ // ============================================================================
144
+ // Daemon lifecycle
145
+ // ============================================================================
146
+ /** Backend state via the tenant's control socket; "Down" when no daemon answers. */
147
+ export function daemonState(tenant) {
148
+ if (!fs.existsSync(socketPath(tenant)))
149
+ return { backendState: "Down" };
150
+ try {
151
+ const out = execFileSync(tailscaleBinPath(), ["--socket", socketPath(tenant), "status", "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
152
+ return parseDaemonState(out);
153
+ }
154
+ catch {
155
+ return { backendState: "Down" };
156
+ }
157
+ }
158
+ /**
159
+ * Extract the `--socks5-server` port from a `tailscaled` command line. Pure so
160
+ * it's unit-testable. Accepts both `--socks5-server=127.0.0.1:PORT` and the
161
+ * space-separated form, and a `localhost` host literal. Returns null if absent
162
+ * or malformed.
163
+ */
164
+ export function parseSocksPortFromArgv(argv) {
165
+ const m = argv.match(/--socks5-server[=\s]+(?:127\.0\.0\.1|localhost):(\d+)/);
166
+ if (!m)
167
+ return null;
168
+ const port = Number(m[1]);
169
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
170
+ }
171
+ /**
172
+ * Find the running userspace `tailscaled` for a tenant by its unique
173
+ * `--socket=<path>` argument, returning its pid and the SOCKS port it ACTUALLY
174
+ * bound (parsed from argv). Authoritative when `daemon.json` is missing or stale
175
+ * (e.g. a daemon left by an older build that predates the meta file). Returns
176
+ * null when no matching process is found or `ps` is unavailable.
177
+ */
178
+ export function findRunningDaemon(tenant) {
179
+ const sock = socketPath(tenant);
180
+ let out;
181
+ try {
182
+ out = execFileSync("ps", ["ax", "-o", "pid=,command="], {
183
+ encoding: "utf8",
184
+ stdio: ["ignore", "pipe", "ignore"],
185
+ });
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ for (const line of out.split("\n")) {
191
+ // Match this tenant's daemon by its unique --socket path (skips the ps line,
192
+ // other tenants' daemons, and the GUI app).
193
+ if (!line.includes("tailscaled") || !line.includes(`--socket=${sock}`))
194
+ continue;
195
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
196
+ if (!m)
197
+ continue;
198
+ return { pid: Number(m[1]), socksPort: parseSocksPortFromArgv(m[2]) };
199
+ }
200
+ return null;
201
+ }
202
+ /** True if something accepts TCP on 127.0.0.1:port within `timeoutMs`. */
203
+ export function portAccepts(port, timeoutMs = 400) {
204
+ return new Promise((resolve) => {
205
+ const s = new net.Socket();
206
+ let done = false;
207
+ const fin = (v) => {
208
+ if (done)
209
+ return;
210
+ done = true;
211
+ s.destroy();
212
+ resolve(v);
213
+ };
214
+ s.setTimeout(timeoutMs);
215
+ s.once("connect", () => fin(true));
216
+ s.once("timeout", () => fin(false));
217
+ s.once("error", () => fin(false));
218
+ s.connect(port, "127.0.0.1");
219
+ });
220
+ }
221
+ /**
222
+ * Ensure a userspace `tailscaled` is running for the tenant and return the SOCKS
223
+ * port the forwarders should dial. Idempotent — reuses a live daemon, but
224
+ * RECONCILES the port: a daemon that's up on a different port than requested
225
+ * (older build with no `daemon.json`, or stale meta) would otherwise leave every
226
+ * forwarder dialing a dead port (MESH-2169). Trust `opts.socksPort` only if it
227
+ * truly accepts; else adopt the running daemon's real port; else restart fresh.
228
+ */
229
+ export async function ensureDaemon(tenant, opts) {
230
+ if (daemonState(tenant).backendState !== "Down") {
231
+ // The daemon's SOCKS proxy is only useful on the exact port the forwarders
232
+ // will dial. If the requested port already accepts, record it and use it.
233
+ if (await portAccepts(opts.socksPort)) {
234
+ writeDaemonMeta(tenant, {
235
+ socksPort: opts.socksPort,
236
+ pid: readDaemonMeta(tenant)?.pid ?? findRunningDaemon(tenant)?.pid ?? 0,
237
+ });
238
+ return opts.socksPort;
239
+ }
240
+ // Requested port is dead — the daemon is on a different one. Discover its
241
+ // real SOCKS port from argv and adopt it (no restart → no severing other
242
+ // sessions), rewriting daemon.json so future runs read the right port.
243
+ const running = findRunningDaemon(tenant);
244
+ if (running?.socksPort && (await portAccepts(running.socksPort))) {
245
+ writeDaemonMeta(tenant, { socksPort: running.socksPort, pid: running.pid });
246
+ return running.socksPort;
247
+ }
248
+ // Can't discover/verify a working SOCKS port — the daemon is unusable to us.
249
+ // Tear it down (state dir keeps the node identity → no re-login) and fall
250
+ // through to a fresh start on opts.socksPort.
251
+ stopDaemon(tenant);
252
+ for (let i = 0; i < 12 && daemonState(tenant).backendState !== "Down"; i++) {
253
+ await sleep(250);
254
+ }
255
+ }
256
+ const dir = tenantStateDir(tenant);
257
+ fs.mkdirSync(dir, { recursive: true });
258
+ const log = fs.openSync(logPath(tenant), "a");
259
+ try {
260
+ const child = spawn(tailscaledBinPath(), [
261
+ "--tun=userspace-networking",
262
+ `--socket=${socketPath(tenant)}`,
263
+ `--statedir=${dir}`,
264
+ `--socks5-server=127.0.0.1:${opts.socksPort}`,
265
+ "--port=0",
266
+ ], {
267
+ detached: true,
268
+ stdio: ["ignore", log, log],
269
+ env: { ...process.env, AWS_REGION: opts.region },
270
+ });
271
+ child.unref();
272
+ // Persist the socks port + pid so `down` → `up` reuses this daemon's real
273
+ // port (not a fresh one it isn't listening on) and `stopDaemon` can kill it.
274
+ if (child.pid)
275
+ writeDaemonMeta(tenant, { socksPort: opts.socksPort, pid: child.pid });
276
+ }
277
+ finally {
278
+ fs.closeSync(log);
279
+ }
280
+ for (let i = 0; i < 40; i++) {
281
+ await sleep(250);
282
+ if (daemonState(tenant).backendState !== "Down")
283
+ return opts.socksPort;
284
+ }
285
+ throw new Error(`tailscaled did not start for tenant '${tenant}' — see ${logPath(tenant)}`);
286
+ }
287
+ /**
288
+ * Stop the detached `tailscaled` for a tenant (SIGTERM its pid) and remove the
289
+ * socket + daemon metadata. Leaves the Headscale node identity in `tailscaled.state`
290
+ * so a later `up` re-spawns and rejoins without an interactive re-login — unless
291
+ * the caller logged out first.
292
+ *
293
+ * Best-effort, and that includes a corrupt `daemon.json`: an unsignalable
294
+ * recorded pid is REFUSED (never handed to `kill`) but only warns — the socket
295
+ * and meta are still cleared. `ensureDaemon` calls this on the UP path to
296
+ * recycle a daemon it can't verify, so throwing here would dead-end
297
+ * `mesh vpn tunnel up` / `mesh dev` on the very file this function deletes.
298
+ * Safe to call when nothing is running.
299
+ */
300
+ export function stopDaemon(tenant) {
301
+ // Prefer the recorded pid; fall back to discovering it from argv so a daemon
302
+ // left without a daemon.json (older build / cleared meta) can still be killed.
303
+ const meta = readDaemonMeta(tenant) ?? (() => {
304
+ const running = findRunningDaemon(tenant);
305
+ return running ? { socksPort: running.socksPort ?? 0, pid: running.pid } : null;
306
+ })();
307
+ // `ensureDaemon` records `pid: 0` when it adopts a running daemon whose pid it
308
+ // could not attribute, so absent-or-0 is this file's "no pid known" sentinel —
309
+ // skip it, don't signal and don't fail. EVERY other unsignalable value (1, a
310
+ // negative, a fraction, a string from a truncated/hand-edited daemon.json) is
311
+ // corruption and must be refused rather than handed to kill. See utils/pid.ts.
312
+ if (meta?.pid) {
313
+ try {
314
+ assertSignalablePid(meta.pid, {
315
+ what: `the VPN daemon pid recorded for tenant '${tenant}'`,
316
+ file: daemonMetaPath(tenant),
317
+ });
318
+ process.kill(meta.pid, "SIGTERM");
319
+ }
320
+ catch (err) {
321
+ // Refusing to SIGNAL is the whole point; refusing to CLEAN UP is not.
322
+ // The unlink + clearDaemonMeta below are what make the corrupt file
323
+ // self-healing, and `ensureDaemon` (the UP path) depends on them. Warn
324
+ // with the `rm` remediation intact and fall through.
325
+ if (err instanceof MeshCliError)
326
+ logWarn(renderErrorBody(err));
327
+ /* else: ESRCH/EPERM from process.kill — already gone, as before */
328
+ }
329
+ }
330
+ for (const p of [socketPath(tenant)]) {
331
+ try {
332
+ fs.unlinkSync(p);
333
+ }
334
+ catch {
335
+ /* already gone */
336
+ }
337
+ }
338
+ clearDaemonMeta(tenant);
339
+ }
340
+ /**
341
+ * Build the `tailscale up` argument vector. Pure so it can be unit-tested and so
342
+ * the interactive (browser-register) and zero-touch (pre-auth key) forms differ
343
+ * only by the trailing `--authkey`.
344
+ */
345
+ export function buildTailscaleUpArgs(opts) {
346
+ const args = [
347
+ "--socket", opts.socket, "up",
348
+ "--login-server", opts.loginServer,
349
+ "--accept-routes",
350
+ "--hostname", opts.hostname,
351
+ ];
352
+ if (opts.preAuthKey)
353
+ args.push("--authkey", opts.preAuthKey);
354
+ return args;
355
+ }
356
+ /**
357
+ * Join the tenant's Headscale tailnet via Zitadel OIDC if not already Running.
358
+ * Prints the device-flow registration URL and polls until registered. When a
359
+ * pre-auth key is supplied the join is non-interactive: no `/register/` URL is
360
+ * emitted, so the scrape/relay is skipped and we just wait for Running.
361
+ */
362
+ export async function joinHeadscale(tenant, loginServer, opts = {}) {
363
+ if (daemonState(tenant).backendState === "Running")
364
+ return;
365
+ const hostname = `mesh-${tenant}-${os.hostname().split(".")[0]}`;
366
+ const child = spawn(tailscaleBinPath(), buildTailscaleUpArgs({ socket: socketPath(tenant), loginServer, hostname, preAuthKey: opts.preAuthKey }), { stdio: ["ignore", "pipe", "pipe"] });
367
+ if (!opts.preAuthKey) {
368
+ let buf = "";
369
+ let urlShown = false;
370
+ const capture = (d) => {
371
+ buf += d.toString();
372
+ if (urlShown)
373
+ return;
374
+ const m = buf.match(/https?:\/\/\S+\/register\/\S+/);
375
+ if (m) {
376
+ urlShown = true;
377
+ // Relay the registration URL for headless/SSH machines (there's no local
378
+ // browser to pop). One-time per machine — the node registration persists.
379
+ logInfo(`\n This machine isn't registered with the platform VPN yet.\n` +
380
+ ` Open this URL in a browser to authorize it (one-time, sign in with Zitadel):\n\n` +
381
+ ` ${m[0]}\n\n` +
382
+ ` Waiting for approval (up to 5 min)…`);
383
+ }
384
+ };
385
+ child.stdout.on("data", capture);
386
+ child.stderr.on("data", capture);
387
+ }
388
+ // Poll up to ~5 min — the join is an interactive browser approval.
389
+ for (let i = 0; i < 600; i++) {
390
+ await sleep(500);
391
+ if (daemonState(tenant).backendState === "Running") {
392
+ child.kill();
393
+ return;
394
+ }
395
+ }
396
+ child.kill();
397
+ throw new Error(`VPN join not completed for tenant '${tenant}' within 5 min. Re-run \`mesh vpn tunnel up\` to resume — the daemon is still up.`);
398
+ }
399
+ /** Log the tenant daemon out of Headscale (disconnect). Daemon process persists. */
400
+ export function logout(tenant) {
401
+ try {
402
+ execFileSync(tailscaleBinPath(), ["--socket", socketPath(tenant), "logout"], { stdio: "ignore" });
403
+ }
404
+ catch {
405
+ /* best-effort */
406
+ }
407
+ }
408
+ // ============================================================================
409
+ // Forwarder supervisor (shared per tenant) + devOutput rewrite
410
+ // ============================================================================
411
+ /**
412
+ * True iff two target sets are identical down to targetIp + remotePort. A live
413
+ * runner pins each forwarder's target at startup, so if the ELB IP rotated (or a
414
+ * port changed) the running forwarders point at a dead address and must be
415
+ * respawned, not reused. Pid-agnostic (a launchd runner records no supervisor pid).
416
+ */
417
+ export function tunnelTargetsEqual(a, b) {
418
+ const key = (ts) => ts.map((t) => `${t.name}:${t.localPort}:${t.targetIp}:${t.remotePort}`).sort().join(",");
419
+ return key(a) === key(b);
420
+ }
421
+ /** True iff `state` has a supervisor pid and its target set equals `targets`. */
422
+ export function supervisorTargetsMatch(state, targets) {
423
+ if (!state || !state.supervisorPid)
424
+ return false;
425
+ return tunnelTargetsEqual(state.tunnels, targets);
426
+ }
427
+ function pidAlive(pid) {
428
+ if (!pid)
429
+ return false;
430
+ try {
431
+ process.kill(pid, 0);
432
+ return true;
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ }
438
+ /**
439
+ * SIGTERM the supervisor's whole process GROUP, not the bare pid.
440
+ *
441
+ * `state.supervisorPid` is the pid of the detached spawn — which, under the
442
+ * canonical source launcher, is `bin/mesh.mjs`. That launcher blocks in
443
+ * `execFileSync` around the *real* runner (a `node --import tsx … mesh.ts`
444
+ * grandchild that actually binds the forwarder ports), and `execFileSync` does
445
+ * not forward signals. So signalling the bare pid kills only the launcher and
446
+ * ORPHANS the runner, which keeps its ports bound — the orphan then answers
447
+ * `allForwardersListening`'s ownership-less liveness dial and silently pins
448
+ * every tunnel to a stale target (the MESH-2169 / MESH-2014 failure class).
449
+ *
450
+ * The launcher is spawned `detached: true`, so it is a process-group leader
451
+ * (`pgid === pid`); `process.kill(-pid, …)` reaps the launcher and the runner
452
+ * together. Falls back to the bare pid for a legacy state file whose pid was
453
+ * not a group leader, and tolerates ESRCH (already gone).
454
+ *
455
+ * `pid` is always recovered — from `tunnel-state.json`, `runner.json`, or a
456
+ * `ps` scan — never constructed here, so it is guarded first. The guard rejects
457
+ * anything below 2, which is what makes the NEGATED form safe: `pid >= 2` ⇒
458
+ * `-pid <= -2`, a real process group, never `kill(0)` (this shell's own group)
459
+ * or `kill(-1)` (broadcast to everything the user can signal). See utils/pid.ts.
460
+ *
461
+ * @throws {MeshCliError} when `pid` is unsignalable. Callers reaping SEVERAL
462
+ * independently-recovered pids must catch per-pid (see `stopTunnelRunners`) —
463
+ * letting one refusal propagate would skip the other pids and leave a live
464
+ * runner holding ports.
465
+ */
466
+ export function killSupervisor(pid, source) {
467
+ // MUST be outside the try — both signals below are wrapped in catch-alls, so a
468
+ // guard placed inside would have its refusal swallowed and fall through to the
469
+ // bare-pid kill it was meant to prevent.
470
+ assertSignalablePid(pid, source ?? { what: "tunnel supervisor pid" });
471
+ try {
472
+ process.kill(-pid, "SIGTERM"); // detached spawn ⇒ pid is the group leader
473
+ }
474
+ catch {
475
+ try {
476
+ process.kill(pid, "SIGTERM"); // not a group leader (old state file) — best effort
477
+ }
478
+ catch {
479
+ /* already gone (ESRCH) */
480
+ }
481
+ }
482
+ }
483
+ /**
484
+ * The ownership handshake for a tenant's tunnel runner (MESH-2407): manifest →
485
+ * live pid → control endpoint → token + SOCKS port + port coverage. This is
486
+ * the ONLY readiness/reuse signal — a bare TCP dial proves a listener exists,
487
+ * not that it is ours, and adopting a foreign listener is exactly the
488
+ * silent-dead-tunnel class (MESH-2169 / MESH-2014 / #2991). As a bonus the
489
+ * handshake reads listening state from the runner's in-process servers, so it
490
+ * opens no SOCKS→VPC data connection the way port dials do.
491
+ */
492
+ export async function verifyTunnelOwnership(tenant, expected) {
493
+ return verifyRunnerOwnership({
494
+ manifest: readRunnerManifest(runnerManifestPath(tenant)),
495
+ expectedSocksPort: expected.socksPort,
496
+ expectedPorts: expected.ports,
497
+ });
498
+ }
499
+ /**
500
+ * Poll the ownership handshake until it passes or `deadlineMs` of WALL CLOCK
501
+ * elapses (each check can cost up to the control-query timeout, so the bound
502
+ * must be time, not rounds); returns the last check.
503
+ */
504
+ function awaitOwnership(tenant, expected, deadlineMs) {
505
+ return pollOwnership(() => verifyTunnelOwnership(tenant, expected), deadlineMs);
506
+ }
507
+ /**
508
+ * Tear down EVERY runner we can attribute to the tenant: the recorded
509
+ * supervisor pid (as a process group — see `killSupervisor`), the launchd
510
+ * agent, the manifest's process group, and any `__run`/`__supervise` process
511
+ * discovered from argv (a runner from an older build, or one whose state file
512
+ * was lost). This is the single teardown path — the #2991 lesson generalized:
513
+ * no kill path may leave a grandchild with ports still bound.
514
+ */
515
+ export function stopTunnelRunners(tenant, prior) {
516
+ if (prior?.managed === "launchd")
517
+ uninstallLaunchAgent(tenant);
518
+ // Each pid below is RECOVERED (two state files and a ps scan), so each names
519
+ // its own provenance — the refusal has to point at the exact file to delete,
520
+ // or a corrupt state dir bricks the very command that would have cleared it.
521
+ //
522
+ // The three sources are INDEPENDENT, so a refusal on one must not skip the
523
+ // others or `clearRunnerManifest`. Aborting would let one corrupt integer in
524
+ // `tunnel-state.json` strand a live, uncorrupted runner from `runner.json`
525
+ // with its forwarder ports still bound — the silent-squatter class this
526
+ // teardown exists to prevent ("no kill path may leave a grandchild with ports
527
+ // still bound", MESH-2407). Refuse the bad pid, reap the rest;
528
+ // `assertTunnelPortsFree` is the backstop that still fails loudly if an
529
+ // un-reaped runner really is holding ports.
530
+ const killOrWarn = (pid, source) => {
531
+ try {
532
+ killSupervisor(pid, source);
533
+ }
534
+ catch (err) {
535
+ if (!(err instanceof MeshCliError))
536
+ throw err;
537
+ logWarn(renderErrorBody(err));
538
+ }
539
+ };
540
+ if (prior?.supervisorPid)
541
+ killOrWarn(prior.supervisorPid, {
542
+ what: `the tunnel supervisor pid recorded for tenant '${tenant}'`,
543
+ file: statePath(tenant),
544
+ });
545
+ const manifest = readRunnerManifest(runnerManifestPath(tenant));
546
+ if (manifest)
547
+ killOrWarn(manifest.pgid || manifest.pid, {
548
+ what: `the tunnel runner process group recorded for tenant '${tenant}'`,
549
+ file: runnerManifestPath(tenant),
550
+ });
551
+ for (const pid of discoverRunnerPids(tenant, process.pid))
552
+ killOrWarn(pid, { what: `a tunnel runner pid discovered from 'ps' for tenant '${tenant}'` });
553
+ clearRunnerManifest(runnerManifestPath(tenant));
554
+ }
555
+ /**
556
+ * After teardown, wait for the tenant's forwarder ports to actually free. Any
557
+ * port still accepting is held by a process we could not kill — a foreign
558
+ * squatter — and spawning a runner over it would produce forwarders that
559
+ * silently fail to bind (the false-green readiness gate). Fail LOUDLY instead.
560
+ */
561
+ async function assertTunnelPortsFree(tenant, ports) {
562
+ let held = ports;
563
+ for (let i = 0; i < 8; i++) {
564
+ const stillHeld = [];
565
+ for (const p of held) {
566
+ if (await portAccepts(p, 250))
567
+ stillHeld.push(p);
568
+ }
569
+ held = stillHeld;
570
+ if (held.length === 0)
571
+ return;
572
+ await sleep(250);
573
+ }
574
+ const squatted = await findSquattedPorts(held, (p) => portAccepts(p, 250));
575
+ throw new Error(buildSquatterError(tenant, squatted, "still bound after teardown"));
576
+ }
577
+ /**
578
+ * Ensure the per-tenant forwarder supervisor is running for `state.tunnels`.
579
+ * Idempotent: reuses a live runner ONLY when it passes the ownership handshake
580
+ * (our manifest token, our SOCKS port, every target port listening in-process);
581
+ * otherwise tears down every attributable runner, verifies the ports actually
582
+ * freed (a still-held port is a foreign squatter → loud failure, never spawn
583
+ * over it), and (re)spawns a fresh runner — whose readiness is again the
584
+ * handshake, not a bare port dial. Persists the state and returns the pid.
585
+ */
586
+ export async function ensureForwarders(state) {
587
+ const prior = readTunnelState(state.tenant);
588
+ const expected = { socksPort: state.socksPort, ports: state.tunnels.map((t) => t.localPort) };
589
+ // Reuse-if-healthy must be mode-aware. A launchd runner records no supervisorPid
590
+ // (KeepAlive owns liveness), so gating reuse on pidAlive would tear down + rebind
591
+ // the shared tunnels on EVERY `up`/`mesh dev` — severing other concurrent sessions,
592
+ // the exact failure MESH-2014 exists to kill. Treat the launchd agent as "alive"
593
+ // when its plist is installed; keep the pid check for the detached path.
594
+ const runnerAlive = prior?.managed === "launchd"
595
+ ? fs.existsSync(launchAgentPath(state.tenant))
596
+ : pidAlive(prior?.supervisorPid);
597
+ if (prior &&
598
+ runnerAlive &&
599
+ tunnelTargetsEqual(prior.tunnels, state.tunnels) &&
600
+ // A prior supervisor spawned for a DIFFERENT SOCKS port still binds the same
601
+ // local ports but dials a dead upstream — reusing it silently strands every
602
+ // tunnel (MESH-2169). The port must match, and the runner must PROVE it is
603
+ // ours via the ownership handshake (liveness ≠ ownership — a port answering
604
+ // a dial can be another session's SSM tunnel or an orphaned runner, #2991).
605
+ // Poll briefly rather than sample once: `ports-not-listening` during the
606
+ // runner's own rebind window and `no-control-answer` under load are
607
+ // transient, and a single miss here would tear down a healthy runner SHARED
608
+ // by every concurrent session on the tenant (MESH-2014). This branch is
609
+ // only reached when the runner is alive with matching targets, so the
610
+ // retry costs nothing on cold-start or genuinely-dead paths.
611
+ prior.socksPort === state.socksPort &&
612
+ (await awaitOwnership(state.tenant, expected, 1000)).ok) {
613
+ state.supervisorPid = prior.supervisorPid;
614
+ state.managed = prior.managed;
615
+ writeTunnelState(state.tenant, state);
616
+ return prior.supervisorPid ?? 0;
617
+ }
618
+ // Tear down every attributable runner (state pid group, launchd agent,
619
+ // manifest process group, argv-discovered strays), then require the ports to
620
+ // actually free — a still-held port is foreign and must fail loudly here,
621
+ // not surface later as a forwarder that silently failed to bind.
622
+ stopTunnelRunners(state.tenant, prior);
623
+ await assertTunnelPortsFree(state.tenant, expected.ports);
624
+ // Write state first so the runner reads the correct targets on start.
625
+ state.supervisorPid = null;
626
+ // Prefer a launchd-supervised runner (survives reboot/crash); fall back to a
627
+ // detached runner where launchd isn't usable (e.g. sandboxed shells). Both run
628
+ // the same self-healing `__run`.
629
+ // argv[1] under the canonical source launcher is bin/mesh.ts, which plain
630
+ // node cannot execute (same failure class as the credential_process fix in
631
+ // aws-auth.ts) — resolve to the stable launcher, or the bare `mesh` fallback.
632
+ const meshBin = resolveStableMeshBin(process.argv[1]);
633
+ if (launchdAvailable()) {
634
+ state.managed = "launchd";
635
+ writeTunnelState(state.tenant, state);
636
+ // launchd does no PATH lookup, so the bare `mesh` fallback can't back a
637
+ // LaunchAgent (it would become `node mesh …`, crash-looping under KeepAlive).
638
+ // Require an absolute path; otherwise fall through to the detached runner,
639
+ // where spawn *does* resolve `mesh` on PATH.
640
+ if (path.isAbsolute(meshBin) && installLaunchAgent(state.tenant, meshBin)) {
641
+ // Installing the agent proves nothing about the forwarders — wait for the
642
+ // runner's ownership handshake (this path used to return success blind).
643
+ if ((await awaitOwnership(state.tenant, expected, 8000)).ok)
644
+ return 0;
645
+ uninstallLaunchAgent(state.tenant);
646
+ // `launchctl bootout` signals the job but doesn't wait for it — spawning
647
+ // the detached runner immediately would race the dying launchd runner for
648
+ // the ports (EADDRINUSE → a transient turned into a hard failure). Every
649
+ // teardown path must be followed by the ports-actually-freed check.
650
+ await assertTunnelPortsFree(state.tenant, expected.ports);
651
+ logInfo("launchd runner did not take ownership of the tunnel ports — using a detached runner.");
652
+ }
653
+ else {
654
+ logInfo("launchd install failed — using a detached self-healing runner.");
655
+ }
656
+ }
657
+ state.managed = "detached";
658
+ writeTunnelState(state.tenant, state);
659
+ // meshBin is the stable launcher, or the bare `mesh` fallback which spawn
660
+ // resolves on PATH here (no PATH-lookup ternary needed — that was a no-op).
661
+ const child = spawn(meshBin, ["vpn", "tunnel", "__run", state.tenant], {
662
+ detached: true,
663
+ stdio: "ignore",
664
+ });
665
+ // ENOENT from the bare `mesh` fallback (mesh-cli not installed globally)
666
+ // arrives asynchronously — without this handler an unhandled 'error' throws
667
+ // an uncaught exception and takes down `mesh dev` instead of degrading.
668
+ child.on("error", (err) => logWarn(`tunnel supervisor failed to start (${err.message}) — falling back to SSM.`));
669
+ child.unref();
670
+ state.supervisorPid = child.pid ?? null;
671
+ writeTunnelState(state.tenant, state);
672
+ // Readiness = the ownership handshake, not a port dial: the fresh runner must
673
+ // present its manifest token and report every forwarder listening from its
674
+ // own in-process servers. A dial would also pass for another session's SSM
675
+ // tunnel on the same 2XXXX port — the false-green this contract kills.
676
+ // Throwing here lands in startServices' catch → clean SSM fallback.
677
+ const last = await awaitOwnership(state.tenant, expected, 5000);
678
+ if (last.ok)
679
+ return child.pid ?? -1;
680
+ // Diagnose before failing: a port that accepts TCP while ownership failed is
681
+ // held by someone else — name it and say exactly what to do.
682
+ const squatted = await findSquattedPorts(expected.ports, (p) => portAccepts(p, 250));
683
+ if (squatted.length > 0) {
684
+ throw new Error(buildSquatterError(state.tenant, squatted, `ownership check: ${last.reason}`));
685
+ }
686
+ const bindErrors = Object.entries(last.resp?.bindErrors ?? {})
687
+ .map(([port, code]) => `port ${port}: ${code}`)
688
+ .join(", ");
689
+ throw new Error(`Tailscale forwarders did not take ownership for tenant '${state.tenant}' ` +
690
+ `(${last.reason}${bindErrors ? `; ${bindErrors}` : ""}) — see ${logPath(state.tenant)}.`);
691
+ }
692
+ /**
693
+ * Rewrite `devOutput.tunnels` so each resolved target points at its local
694
+ * forwarder (`localhost:localPort`) — the same shape `startSsmTunnels`
695
+ * produces. Unmatched tunnels are left untouched. Pure (no mutation of input).
696
+ */
697
+ export function rewriteTunnelsForTargets(devOutput, targets) {
698
+ const tunnels = { ...devOutput.tunnels };
699
+ for (const t of targets) {
700
+ tunnels[t.name] = { host: "localhost", port: t.localPort };
701
+ }
702
+ return { ...devOutput, tunnels };
703
+ }
704
+ /** True when the brew `tailscaled` binary is resolvable (Tailscale transport is usable). */
705
+ export function tailscaleAvailable() {
706
+ try {
707
+ tailscaledBinPath();
708
+ return true;
709
+ }
710
+ catch {
711
+ return false;
712
+ }
713
+ }
714
+ // ============================================================================
715
+ // Shared engine: ensure/start Tailscale tunnels (used by the command + mesh dev)
716
+ // ============================================================================
717
+ /**
718
+ * Ensure the tenant's Tailscale tunnels are fully up: the shared per-tenant
719
+ * daemon (started + OIDC-joined if needed), the resolved VPC targets, and the
720
+ * forwarder supervisor. Idempotent (shared across worktrees/invocations of the
721
+ * same tenant). Returns the resolved targets. Used by both `mesh vpn tunnel up`
722
+ * and `mesh dev --transport=tailscale`.
723
+ */
724
+ export async function ensureTailscaleTunnels(tenant, env, opts) {
725
+ // Reconcile the daemon's SOCKS port — an already-running daemon may be on a
726
+ // different port than requested; the forwarders MUST dial the real one (MESH-2169).
727
+ const socksPort = await ensureDaemon(tenant, { region: opts.region, socksPort: opts.socksPort });
728
+ await joinHeadscale(tenant, opts.loginServer, { preAuthKey: opts.preAuthKey });
729
+ // Pass the region explicitly rather than clobbering process.env.AWS_REGION —
730
+ // this runs mid `mesh dev` launch and must not mutate the global env.
731
+ const bastion = await getPlatformBastionInfo(tenant, env, opts.region);
732
+ if (!bastion) {
733
+ throw new Error(`Could not read the platform bastion for ${tenant}/${env} (check AWS credentials).`);
734
+ }
735
+ const portOffset = tenantPortIndex(tenant) * PORT_BLOCK;
736
+ const targets = resolveTunnelTargets(bastion.services, resolveElbIp, portOffset);
737
+ if (targets.length === 0) {
738
+ throw new Error(`No resolvable VPC tunnels found for ${tenant}/${env}.`);
739
+ }
740
+ const state = {
741
+ tenant,
742
+ env,
743
+ socksPort,
744
+ loginServer: opts.loginServer,
745
+ supervisorPid: null,
746
+ startedAt: new Date().toISOString(),
747
+ tunnels: targets,
748
+ };
749
+ await ensureForwarders(state);
750
+ return targets;
751
+ }
752
+ /**
753
+ * `startSsmTunnels` drop-in for `mesh dev`: ensure the tenant's Tailscale
754
+ * tunnels, then rewrite `devOutput.tunnels` to the local forwarder ports.
755
+ */
756
+ export async function startTailscaleTunnels(devOutput, ctx) {
757
+ const targets = await ensureTailscaleTunnels(ctx.tenant, ctx.env, {
758
+ region: ctx.region,
759
+ loginServer: ctx.loginServer,
760
+ socksPort: ctx.socksPort,
761
+ preAuthKey: ctx.preAuthKey,
762
+ });
763
+ return rewriteTunnelsForTargets(devOutput, targets);
764
+ }
765
+ // ============================================================================
766
+ // launchd supervision + self-heal (Slice 2a)
767
+ // ============================================================================
768
+ export function launchAgentLabel(tenant) {
769
+ return `tech.mesh.tunnel.${tenant}`;
770
+ }
771
+ /** Minimal XML-escape for plist string values (controlled inputs, but paths can vary). */
772
+ function xmlEscape(s) {
773
+ return s
774
+ .replace(/&/g, "&amp;")
775
+ .replace(/</g, "&lt;")
776
+ .replace(/>/g, "&gt;");
777
+ }
778
+ /** Render the per-tenant LaunchAgent plist (pure). */
779
+ export function renderLaunchAgentPlist(args) {
780
+ const pathEnv = args.pathEnv ?? "/usr/bin:/bin:/usr/sbin:/sbin";
781
+ return `<?xml version="1.0" encoding="UTF-8"?>
782
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
783
+ <plist version="1.0">
784
+ <dict>
785
+ <key>Label</key>
786
+ <string>${launchAgentLabel(args.tenant)}</string>
787
+ <key>ProgramArguments</key>
788
+ <array>
789
+ <string>${xmlEscape(args.nodeBin)}</string>
790
+ <string>${xmlEscape(args.meshBin)}</string>
791
+ <string>vpn</string>
792
+ <string>tunnel</string>
793
+ <string>__run</string>
794
+ <string>${xmlEscape(args.tenant)}</string>
795
+ </array>
796
+ <key>EnvironmentVariables</key>
797
+ <dict>
798
+ <key>PATH</key>
799
+ <string>${xmlEscape(pathEnv)}</string>
800
+ </dict>
801
+ <key>RunAtLoad</key>
802
+ <true/>
803
+ <key>KeepAlive</key>
804
+ <dict>
805
+ <key>SuccessfulExit</key>
806
+ <false/>
807
+ </dict>
808
+ <key>StandardOutPath</key>
809
+ <string>${xmlEscape(args.logPath)}</string>
810
+ <key>StandardErrorPath</key>
811
+ <string>${xmlEscape(args.logPath)}</string>
812
+ </dict>
813
+ </plist>
814
+ `;
815
+ }
816
+ /** Targets whose local forwarder port isn't currently accepting connections. */
817
+ export function forwardersNeedingRebind(targets, listening) {
818
+ return targets.filter((t) => !listening.has(t.localPort));
819
+ }
820
+ export function launchAgentPath(tenant) {
821
+ return path.join(os.homedir(), "Library", "LaunchAgents", `${launchAgentLabel(tenant)}.plist`);
822
+ }
823
+ function guiDomain() {
824
+ return `gui/${process.getuid?.() ?? 0}`;
825
+ }
826
+ let _launchdAvailable;
827
+ /** True when launchctl works for this user (blocked under the seatbelt sandbox → false). */
828
+ export function launchdAvailable() {
829
+ if (_launchdAvailable !== undefined)
830
+ return _launchdAvailable;
831
+ try {
832
+ execFileSync("which", ["launchctl"], { stdio: "ignore" });
833
+ execFileSync("launchctl", ["print", guiDomain()], { stdio: "ignore" });
834
+ _launchdAvailable = true;
835
+ }
836
+ catch {
837
+ _launchdAvailable = false;
838
+ }
839
+ return _launchdAvailable;
840
+ }
841
+ /**
842
+ * Write + (re)load the per-tenant LaunchAgent. Returns true iff it fully
843
+ * succeeded — any failure (plist write blocked by the sandbox, `launchctl`
844
+ * bootstrap denied, …) returns false so the caller falls back to a detached
845
+ * runner. Never throws.
846
+ */
847
+ export function installLaunchAgent(tenant, meshBin) {
848
+ const plistPath = launchAgentPath(tenant);
849
+ try {
850
+ // Resolve symlinks so the plist embeds a stable path (a since-deleted
851
+ // worktree symlink would leave a KeepAlive agent respawning a dead binary).
852
+ let realMeshBin = meshBin;
853
+ try {
854
+ realMeshBin = fs.realpathSync(meshBin);
855
+ }
856
+ catch {
857
+ /* keep the raw path */
858
+ }
859
+ // Bake a PATH that includes the tailscaled dir so the runner's daemon reheal
860
+ // works under launchd's minimal PATH (Intel installs live in /usr/local/bin).
861
+ let pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
862
+ try {
863
+ pathEnv = `${path.dirname(tailscaledBinPath())}:${pathEnv}`;
864
+ }
865
+ catch {
866
+ /* fall back to the default PATH */
867
+ }
868
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
869
+ fs.writeFileSync(plistPath, renderLaunchAgentPlist({
870
+ tenant,
871
+ meshBin: realMeshBin,
872
+ nodeBin: process.execPath,
873
+ logPath: path.join(tenantStateDir(tenant), "runner.log"),
874
+ pathEnv,
875
+ }));
876
+ try {
877
+ execFileSync("launchctl", ["bootout", guiDomain(), plistPath], { stdio: "ignore" });
878
+ }
879
+ catch {
880
+ /* not loaded — fine */
881
+ }
882
+ execFileSync("launchctl", ["bootstrap", guiDomain(), plistPath], { stdio: "ignore" });
883
+ return true;
884
+ }
885
+ catch {
886
+ return false;
887
+ }
888
+ }
889
+ /** Unload + remove the per-tenant LaunchAgent (best-effort). */
890
+ export function uninstallLaunchAgent(tenant) {
891
+ const plistPath = launchAgentPath(tenant);
892
+ try {
893
+ execFileSync("launchctl", ["bootout", guiDomain(), plistPath], { stdio: "ignore" });
894
+ }
895
+ catch {
896
+ /* not loaded */
897
+ }
898
+ try {
899
+ fs.unlinkSync(plistPath);
900
+ }
901
+ catch {
902
+ /* already gone */
903
+ }
904
+ }
905
+ // ============================================================================
906
+ // The runner (__run): forwarders + continuous self-heal
907
+ // ============================================================================
908
+ /**
909
+ * Long-lived runner for a tenant's tunnels — used both under launchd and as the
910
+ * detached fallback. Runs a forwarder per target and, every ~5s, self-heals:
911
+ * respawns the daemon if its process died, and rebinds any forwarder whose port
912
+ * stopped listening. As the process that actually binds the ports, it is the
913
+ * sole author of the ownership manifest + control endpoint (MESH-2407) that
914
+ * callers verify against. Needs no AWS (targets come from tunnel-state). Never
915
+ * returns.
916
+ */
917
+ export async function runTunnels(tenant) {
918
+ const st = readTunnelState(tenant);
919
+ if (!st) {
920
+ // State is gone (torn down). Under launchd's KeepAlive a non-zero exit would
921
+ // respawn every ~10s forever (surviving reboots) — so self-uninstall the
922
+ // agent (no-op when detached), drop any manifest a previous incarnation
923
+ // left (it describes a runner that no longer exists), and exit cleanly.
924
+ uninstallLaunchAgent(tenant);
925
+ clearRunnerManifest(runnerManifestPath(tenant));
926
+ process.exit(0);
927
+ }
928
+ const token = mintRunnerToken();
929
+ const servers = new Map();
930
+ // port → last bind error code. Surfaced through the control endpoint so a
931
+ // failed bind (EADDRINUSE from a squatted port) is a visible, attributable
932
+ // failure at the caller — never a silently-dead forwarder.
933
+ const bindErrors = new Map();
934
+ const spawnFor = (t) => {
935
+ servers.get(t.localPort)?.close();
936
+ bindErrors.delete(t.localPort);
937
+ const server = createForwarder({
938
+ listenPort: t.localPort,
939
+ socksPort: st.socksPort,
940
+ targetIp: t.targetIp,
941
+ targetPort: t.remotePort,
942
+ onError: (err) => {
943
+ bindErrors.set(t.localPort, err.code ?? err.message);
944
+ logWarn(`forwarder ${t.name} failed on 127.0.0.1:${t.localPort}: ${err.code ?? err.message}`);
945
+ },
946
+ });
947
+ server.on("listening", () => bindErrors.delete(t.localPort));
948
+ servers.set(t.localPort, server);
949
+ };
950
+ for (const t of st.tunnels)
951
+ spawnFor(t);
952
+ const control = await startControlServer(() => ({
953
+ token,
954
+ tenant,
955
+ pid: process.pid,
956
+ socksPort: st.socksPort,
957
+ listening: st.tunnels.filter((t) => servers.get(t.localPort)?.listening).map((t) => t.localPort),
958
+ bindErrors: Object.fromEntries([...bindErrors].map(([p, c]) => [String(p), c])),
959
+ }));
960
+ writeRunnerManifest(runnerManifestPath(tenant), {
961
+ tenant,
962
+ pid: process.pid,
963
+ pgid: getProcessGroupId(process.pid) ?? process.pid,
964
+ token,
965
+ controlPort: control.port,
966
+ socksPort: st.socksPort,
967
+ ports: st.tunnels.map((t) => t.localPort),
968
+ startedAt: new Date().toISOString(),
969
+ });
970
+ const shutdown = () => {
971
+ control.server.close();
972
+ for (const s of servers.values())
973
+ s.close();
974
+ // Token-guarded: never clobber the manifest of a newer runner that already
975
+ // replaced us (teardown kills groups, and signal delivery order is loose).
976
+ clearRunnerManifest(runnerManifestPath(tenant), token);
977
+ process.exit(0);
978
+ };
979
+ process.on("SIGTERM", shutdown);
980
+ process.on("SIGINT", shutdown);
981
+ for (;;) {
982
+ await sleep(5000);
983
+ try {
984
+ if (daemonState(tenant).backendState === "Down") {
985
+ await ensureDaemon(tenant, {
986
+ region: process.env.AWS_REGION ?? "us-east-2",
987
+ socksPort: st.socksPort,
988
+ });
989
+ }
990
+ // Check the in-process net.Server directly — do NOT dial the forwarder.
991
+ // A dial makes the forwarder open a real SOCKS→VPC-target connection, and
992
+ // the probe's clean FIN doesn't tear the upstream leg down, leaking a
993
+ // connection to Temporal/RDS/etc. every 5s per target.
994
+ const listening = new Set();
995
+ for (const t of st.tunnels) {
996
+ if (servers.get(t.localPort)?.listening)
997
+ listening.add(t.localPort);
998
+ }
999
+ for (const t of forwardersNeedingRebind(st.tunnels, listening))
1000
+ spawnFor(t);
1001
+ }
1002
+ catch {
1003
+ /* keep the loop alive across transient errors */
1004
+ }
1005
+ }
1006
+ }
1007
+ //# sourceMappingURL=tailscale.js.map