@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
@@ -1,30 +1,32 @@
1
1
  /**
2
- * mesh login — Authenticate with Zitadel via OIDC PKCE flow.
2
+ * mesh login — Authenticate with Zitadel via OIDC PKCE or Device Code flow.
3
3
  *
4
- * Opens browser to Zitadel, receives auth code via localhost callback,
5
- * exchanges for tokens, and caches at ~/.config/mesh/credentials.json.
4
+ * Opens browser to Zitadel (PKCE) or prints a code to enter in the browser
5
+ * (Device Code). Exchanges for tokens and caches at ~/.config/mesh/credentials.json.
6
6
  *
7
7
  * Config is read from ~/.config/mesh/config.json, keyed by context
8
- * (e.g., "mesh.dev"). Example:
8
+ * (e.g., "mesh.dev"). After deploying the platform, copy the CLI client ID
9
+ * from the Pulumi stack output into the config file.
9
10
  *
10
- * {
11
- * "mesh.dev": {
12
- * "issuer": "https://identity.<env>.<your-platform-domain>",
13
- * "clientId": "<cli-oidc-client-id>"
14
- * }
15
- * }
11
+ * In containers/SSH sessions, device code flow is used automatically.
16
12
  *
17
13
  * Usage:
18
14
  * mesh login mesh.dev — authenticate to mesh dev
19
- * mesh login mesh.dev --statusshow current auth state
15
+ * mesh login mesh.dev --deviceforce device code flow
16
+ * mesh login mesh.dev --status — show current auth state (Zitadel session if
17
+ * there is one, otherwise whatever the AWS
18
+ * credential chain resolves — SSO or key pair)
20
19
  * mesh logout mesh.dev — clear cached credentials
21
20
  */
22
21
  import * as http from "http";
23
22
  import * as crypto from "crypto";
24
23
  import * as fs from "fs";
25
24
  import * as path from "path";
26
- import { execSync } from "child_process";
25
+ import { execFileSync } from "child_process";
27
26
  import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js";
27
+ import { assumeRoleCredentials, assumeRoleWithWebIdentity, describeAssumeFailure, probeAwsIdentity, renderCredentialProcessProfile, tokenIssuer, resolveStableMeshBin, selectRoleForCaller, toCredentialProcessJson, upsertManagedAwsConfigSection, } from "../utils/aws-auth.js";
28
+ import { firstPartyDomainFor } from "../utils/first-party-contexts.js";
29
+ import { tailscaleBackendState } from "./vpn/index.js";
28
30
  // =============================================================================
29
31
  // Paths (XDG Base Directory Specification)
30
32
  // =============================================================================
@@ -44,10 +46,152 @@ function readConfig() {
44
46
  return {};
45
47
  }
46
48
  }
49
+ export function writeContextConfig(context, config) {
50
+ const existing = readConfig();
51
+ existing[context] = config;
52
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
53
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));
54
+ }
47
55
  function getContextConfig(context) {
48
56
  const config = readConfig();
49
57
  return config[context] ?? null;
50
58
  }
59
+ /**
60
+ * Parse a mesh context like "mesh.dev" or "acme.uat" into tenant + env.
61
+ * Returns null if the context doesn't match the expected {tenant}.{env} pattern.
62
+ */
63
+ function parseTenantEnv(context) {
64
+ const parts = context.split(".");
65
+ if (parts.length !== 2 || !parts[0] || !parts[1])
66
+ return null;
67
+ return { tenant: parts[0], env: parts[1] };
68
+ }
69
+ /**
70
+ * Attempt auto-discovery of platform config from SSM Parameter Store.
71
+ *
72
+ * Uses ambient AWS credentials to read the zitadel config export at
73
+ * /mesh-platform/{tenant}/{env}/platform/zitadel. This is the preferred
74
+ * discovery path for users who already have AWS SSO access to the platform.
75
+ *
76
+ * Returns null if AWS credentials are unavailable, SSM read fails, or the
77
+ * parameter doesn't contain the required fields.
78
+ */
79
+ async function discoverConfigFromSsm(context) {
80
+ const parsed = parseTenantEnv(context);
81
+ if (!parsed)
82
+ return null;
83
+ const { tenant, env } = parsed;
84
+ const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;
85
+ logInfo(`Attempting SSM discovery from ${ssmPath}...`);
86
+ try {
87
+ const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm");
88
+ const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2";
89
+ const ssm = new SSMClient({ region });
90
+ const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath }));
91
+ const raw = resp.Parameter?.Value;
92
+ if (!raw) {
93
+ logWarn(`SSM parameter ${ssmPath} has no value`);
94
+ return null;
95
+ }
96
+ const data = JSON.parse(raw);
97
+ const issuer = data.endpoint;
98
+ const clientId = data.cliClientId;
99
+ if (typeof issuer !== "string" || !issuer) {
100
+ logWarn("SSM zitadel entry missing 'endpoint' field");
101
+ return null;
102
+ }
103
+ if (typeof clientId !== "string" || !clientId) {
104
+ logWarn("SSM zitadel entry missing 'cliClientId' field.\n" +
105
+ " The platform needs to be deployed with an updated ZitadelPlatformIdentity\n" +
106
+ " that exports cliClientId. Until then, contact your platform admin.");
107
+ return null;
108
+ }
109
+ const config = { issuer, clientId };
110
+ if (typeof data.vpn === "string")
111
+ config.vpn = data.vpn;
112
+ if (typeof data.vpnJoinBroker === "string")
113
+ config.vpnJoinBroker = data.vpnJoinBroker;
114
+ if (typeof data.registryBroker === "string")
115
+ config.registryBroker = data.registryBroker;
116
+ writeContextConfig(context, config);
117
+ logSuccess(`Discovered platform configuration for ${context} via SSM`);
118
+ return config;
119
+ }
120
+ catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err);
122
+ if (message.includes("ExpiredToken") || message.includes("credentials")) {
123
+ logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);
124
+ // Do NOT send someone to `aws sso login` when an anonymous path is about
125
+ // to be tried — on the install journey that is the exact misdirection
126
+ // this feature removes. Only mention AWS when nothing else can resolve.
127
+ if (firstPartyDomainFor(context) || context.split(".").length - 1 >= 2) {
128
+ logInfo(` Falling back to anonymous HTTPS discovery — no AWS needed.`);
129
+ }
130
+ else {
131
+ logInfo(` If you have an AWS account: aws sso login --profile <profile>`);
132
+ logInfo(` Otherwise use the platform's full domain, e.g. mesh login dev.<tenant>.meshtech.io`);
133
+ }
134
+ }
135
+ else if (message.includes("ParameterNotFound")) {
136
+ logWarn(`SSM parameter not found: ${ssmPath}`);
137
+ logInfo(" This platform context may not be deployed.");
138
+ }
139
+ else {
140
+ logWarn(`SSM discovery failed: ${message}`);
141
+ }
142
+ return null;
143
+ }
144
+ }
145
+ /**
146
+ * Attempt auto-discovery of platform config from a well-known endpoint.
147
+ * Returns null if discovery fails for any reason (network, timeout, bad data).
148
+ *
149
+ * `contextKey` is the name the resulting config is CACHED under, which is not
150
+ * always the domain probed: a pinned first-party alias (`mesh.dev`) probes
151
+ * `dev.platform.meshtech.io` but must be cached under `mesh.dev`, or the very
152
+ * next command re-runs discovery and the alias buys nothing.
153
+ */
154
+ async function discoverConfig(domain, contextKey = domain) {
155
+ const url = `https://cli.${domain}/.well-known/mesh.json`;
156
+ logInfo(`Attempting discovery from ${url}...`);
157
+ try {
158
+ const controller = new AbortController();
159
+ const timeout = setTimeout(() => controller.abort(), 5000);
160
+ const resp = await fetch(url, { signal: controller.signal });
161
+ clearTimeout(timeout);
162
+ if (!resp.ok) {
163
+ logWarn(`Discovery endpoint returned ${resp.status}`);
164
+ return null;
165
+ }
166
+ const data = (await resp.json());
167
+ const issuer = data.issuer;
168
+ const clientId = data.clientId;
169
+ if (typeof issuer !== "string" || !issuer || typeof clientId !== "string" || !clientId) {
170
+ logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
171
+ return null;
172
+ }
173
+ const config = { issuer, clientId };
174
+ if (typeof data.vpn === "string")
175
+ config.vpn = data.vpn;
176
+ if (typeof data.vpnJoinBroker === "string")
177
+ config.vpnJoinBroker = data.vpnJoinBroker;
178
+ if (typeof data.registryBroker === "string")
179
+ config.registryBroker = data.registryBroker;
180
+ writeContextConfig(contextKey, config);
181
+ logSuccess(`Discovered platform configuration for ${contextKey}`);
182
+ return config;
183
+ }
184
+ catch (err) {
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ if (message.includes("abort")) {
187
+ logWarn("Discovery timed out");
188
+ }
189
+ else {
190
+ logWarn(`Discovery failed: ${message}`);
191
+ }
192
+ return null;
193
+ }
194
+ }
51
195
  function readAllCredentials() {
52
196
  if (!fs.existsSync(CREDENTIALS_FILE))
53
197
  return {};
@@ -61,13 +205,43 @@ function readAllCredentials() {
61
205
  function readCredentials(context) {
62
206
  return readAllCredentials()[context] ?? null;
63
207
  }
208
+ /** Monotonic counter mixed into temp-file names alongside the pid, so two
209
+ * writes from the same process in the same tick (e.g. rapid retries) never
210
+ * collide on a temp path. */
211
+ let atomicWriteCounter = 0;
212
+ /**
213
+ * Write `data` to `path` atomically: write to a temp file in the SAME
214
+ * directory as `path` (so the subsequent rename is same-filesystem, hence
215
+ * atomic), then `fs.renameSync` it into place. This guarantees concurrent
216
+ * readers (e.g. other `credential_process` invocations racing a refresh)
217
+ * only ever observe a fully-written file — never a truncated one from an
218
+ * interleaved `fs.writeFileSync`.
219
+ *
220
+ * On any error, best-effort removes the temp file before rethrowing.
221
+ *
222
+ * Exported for unit testing.
223
+ */
224
+ export function atomicWriteFileSync(path, data, mode) {
225
+ const tmpPath = `${path}.${process.pid}.${atomicWriteCounter++}.tmp`;
226
+ try {
227
+ fs.writeFileSync(tmpPath, data, { mode });
228
+ fs.renameSync(tmpPath, path);
229
+ }
230
+ catch (err) {
231
+ try {
232
+ fs.unlinkSync(tmpPath);
233
+ }
234
+ catch {
235
+ // best-effort cleanup — ignore (e.g. write itself never created the file)
236
+ }
237
+ throw err;
238
+ }
239
+ }
64
240
  function writeCredentials(context, creds) {
65
241
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
66
242
  const all = readAllCredentials();
67
243
  all[context] = creds;
68
- fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), {
69
- mode: 0o600,
70
- });
244
+ atomicWriteFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), 0o600);
71
245
  }
72
246
  function clearCredentials(context) {
73
247
  const all = readAllCredentials();
@@ -143,6 +317,11 @@ function login(context, config) {
143
317
  authUrl.searchParams.set("code_challenge", codeChallenge);
144
318
  authUrl.searchParams.set("code_challenge_method", "S256");
145
319
  authUrl.searchParams.set("state", state);
320
+ // Tracked teardown — without this `mesh login` hangs after success
321
+ // because (a) the 2-minute timeout below kept the event loop alive,
322
+ // and (b) the browser's keep-alive socket survived `server.close()`
323
+ // (which only stops new connections, not active ones).
324
+ let timeoutId;
146
325
  const server = http.createServer(async (req, res) => {
147
326
  try {
148
327
  const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
@@ -154,9 +333,10 @@ function login(context, config) {
154
333
  const error = url.searchParams.get("error");
155
334
  if (error) {
156
335
  const desc = url.searchParams.get("error_description") ?? error;
336
+ const safeDesc = desc.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
157
337
  res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
158
- res.end(`<html><body><h2>Login failed</h2><p>${desc}</p></body></html>`);
159
- server.close();
338
+ res.end(`<html><body><h2>Login failed</h2><p>${safeDesc}</p></body></html>`);
339
+ teardown();
160
340
  reject(new Error(desc));
161
341
  return;
162
342
  }
@@ -164,7 +344,7 @@ function login(context, config) {
164
344
  if (returnedState !== state) {
165
345
  res.writeHead(400);
166
346
  res.end("State mismatch");
167
- server.close();
347
+ teardown();
168
348
  reject(new Error("State mismatch"));
169
349
  return;
170
350
  }
@@ -172,7 +352,7 @@ function login(context, config) {
172
352
  if (!code) {
173
353
  res.writeHead(400);
174
354
  res.end("No code");
175
- server.close();
355
+ teardown();
176
356
  reject(new Error("No authorization code received"));
177
357
  return;
178
358
  }
@@ -209,23 +389,32 @@ function login(context, config) {
209
389
  if (tenants.length > 0) {
210
390
  logInfo(`Deployable tenants: ${tenants.join(", ")}`);
211
391
  }
212
- server.close();
392
+ hintVpnIfDisconnected(context);
393
+ teardown();
213
394
  resolve();
214
395
  }
215
396
  catch (err) {
216
- server.close();
397
+ teardown();
217
398
  reject(err);
218
399
  }
219
400
  });
401
+ // Defined after `server` so the closure captures it; covers
402
+ // success, error, and timeout paths.
403
+ const teardown = () => {
404
+ if (timeoutId)
405
+ clearTimeout(timeoutId);
406
+ server.closeAllConnections?.();
407
+ server.close();
408
+ };
220
409
  server.listen(REDIRECT_PORT, () => {
221
410
  logInfo(`Opening browser for authentication (${context})...`);
222
411
  const url = authUrl.toString();
223
412
  try {
224
413
  if (process.platform === "darwin") {
225
- execSync(`open "${url}"`, { stdio: "ignore" });
414
+ execFileSync("open", [url], { stdio: "ignore" });
226
415
  }
227
416
  else if (process.platform === "linux") {
228
- execSync(`xdg-open "${url}"`, { stdio: "ignore" });
417
+ execFileSync("xdg-open", [url], { stdio: "ignore" });
229
418
  }
230
419
  else {
231
420
  logInfo(`Open this URL in your browser:\n${url}`);
@@ -235,21 +424,272 @@ function login(context, config) {
235
424
  logInfo(`Open this URL in your browser:\n${url}`);
236
425
  }
237
426
  });
238
- // Timeout after 2 minutes
239
- setTimeout(() => {
240
- server.close();
427
+ // Timeout after 2 minutes. We keep a handle so success / error
428
+ // paths can clear it via `teardown()` otherwise the timer keeps
429
+ // the Node event loop alive for 2 minutes after a successful
430
+ // login and the CLI appears to hang.
431
+ timeoutId = setTimeout(() => {
432
+ teardown();
241
433
  reject(new Error("Login timed out (2 minutes)"));
242
434
  }, 120_000);
243
435
  });
244
436
  }
245
- // =============================================================================
246
- // Status
247
- // =============================================================================
437
+ /** Number of fresh device codes to issue before giving up. A code's TTL is
438
+ * short (a couple of minutes), so if the user is slow we quietly issue another
439
+ * rather than failing the whole login (we hit this repeatedly in practice). */
440
+ const MAX_DEVICE_CODES = 3;
441
+ /**
442
+ * Run device-code login, re-issuing a fresh code (up to `maxCodes`) if the user
443
+ * doesn't finish before a code expires. `attempt` performs ONE code's
444
+ * request+poll and resolves `"success"` or `"expired"`; it rejects only on a
445
+ * hard error (denied / network). Extracted + injectable so the reissue loop is
446
+ * unit-testable without real network calls.
447
+ */
448
+ export async function deviceLoginWithReissue(attempt, maxCodes = MAX_DEVICE_CODES) {
449
+ for (let n = 1; n <= maxCodes; n++) {
450
+ if (n > 1)
451
+ logWarn(`That code expired — issuing a fresh one (${n}/${maxCodes})…`);
452
+ if ((await attempt(n)) === "success")
453
+ return;
454
+ }
455
+ throw new Error(`Device login not completed after ${maxCodes} codes. Re-run the command when you're ready to authorize.`);
456
+ }
457
+ async function deviceCodeLogin(context, config) {
458
+ await deviceLoginWithReissue(() => attemptDeviceCode(context, config));
459
+ }
460
+ async function attemptDeviceCode(context, config) {
461
+ // Step 1: Request device authorization
462
+ const resp = await fetch(`${config.issuer}/oauth/v2/device_authorization`, {
463
+ method: "POST",
464
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
465
+ body: new URLSearchParams({
466
+ client_id: config.clientId,
467
+ scope: SCOPES,
468
+ }).toString(),
469
+ });
470
+ if (!resp.ok) {
471
+ const text = await resp.text();
472
+ throw new Error(`Device authorization request failed (${resp.status}): ${text}`);
473
+ }
474
+ const deviceAuth = await resp.json();
475
+ const openUrl = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri;
476
+ // Step 2: Display instructions
477
+ console.log();
478
+ logInfo(`Open this URL in your browser:\n`);
479
+ logInfo(` ${openUrl}\n`);
480
+ logInfo(`Code: ${deviceAuth.user_code}`);
481
+ console.log();
482
+ logInfo("Waiting for authorization...");
483
+ // Try to open browser (works on host, harmless failure in container)
484
+ try {
485
+ if (process.platform === "darwin") {
486
+ execFileSync("open", [openUrl], { stdio: "ignore" });
487
+ }
488
+ else if (process.platform === "linux") {
489
+ execFileSync("xdg-open", [openUrl], { stdio: "ignore" });
490
+ }
491
+ }
492
+ catch { /* ignore — user can open manually */ }
493
+ // Step 3: Poll for token
494
+ const deadline = Date.now() + deviceAuth.expires_in * 1000;
495
+ let interval = deviceAuth.interval * 1000;
496
+ while (Date.now() < deadline) {
497
+ await new Promise((r) => setTimeout(r, interval));
498
+ const tokenResp = await fetch(`${config.issuer}/oauth/v2/token`, {
499
+ method: "POST",
500
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
501
+ body: new URLSearchParams({
502
+ client_id: config.clientId,
503
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
504
+ device_code: deviceAuth.device_code,
505
+ }).toString(),
506
+ });
507
+ if (tokenResp.ok) {
508
+ const tokens = await tokenResp.json();
509
+ const idPayload = decodeJwtPayload(tokens.id_token);
510
+ const email = idPayload.email ?? idPayload.preferred_username ?? "unknown";
511
+ const sub = idPayload.sub;
512
+ let tenants = [];
513
+ try {
514
+ const accessPayload = decodeJwtPayload(tokens.access_token);
515
+ tenants = accessPayload["urn:mesh:tenants"] ?? [];
516
+ }
517
+ catch { /* opaque access token */ }
518
+ writeCredentials(context, {
519
+ idToken: tokens.id_token,
520
+ accessToken: tokens.access_token,
521
+ refreshToken: tokens.refresh_token,
522
+ expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
523
+ email,
524
+ sub,
525
+ });
526
+ logSuccess(`Logged in as ${email} (${context})`);
527
+ if (tenants.length > 0) {
528
+ logInfo(`Deployable tenants: ${tenants.join(", ")}`);
529
+ }
530
+ hintVpnIfDisconnected(context);
531
+ return "success";
532
+ }
533
+ const error = await tokenResp.json();
534
+ switch (error.error) {
535
+ case "authorization_pending":
536
+ continue;
537
+ case "slow_down":
538
+ interval += 1000;
539
+ continue;
540
+ case "expired_token":
541
+ return "expired"; // caller re-issues a fresh code
542
+ case "access_denied":
543
+ throw new Error("Authorization denied by user.");
544
+ default:
545
+ throw new Error(`Token exchange failed: ${error.error} — ${error.error_description ?? ""}`);
546
+ }
547
+ }
548
+ return "expired"; // deadline reached without authorization — re-issue
549
+ }
550
+ /**
551
+ * Detect an environment where the PKCE browser-callback flow won't work — a
552
+ * container, a remote/SSH session, or any non-interactive shell (piped output,
553
+ * headless, agent/CI-run). In all of these the callback flow has no browser to
554
+ * open and no interactive terminal, so it can only hang until the 2-minute
555
+ * timeout. Device-code flow (print a URL + code to complete elsewhere) is the
556
+ * correct fallback, so callers use it whenever this returns true.
557
+ *
558
+ * Exported for tests.
559
+ */
560
+ export function isRemoteEnvironment() {
561
+ // Docker / devcontainer
562
+ if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES)
563
+ return true;
564
+ if (fs.existsSync("/.dockerenv"))
565
+ return true;
566
+ // SSH session
567
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY)
568
+ return true;
569
+ // No interactive terminal on stdin/stdout — `isTTY` is `undefined` (falsy)
570
+ // when the stream is not a TTY. This is the case that bit us: an agent- or
571
+ // script-driven shell over Tailscale has none of the env vars above set, so
572
+ // the old check fell through to the browser flow and timed out. Device flow
573
+ // still works when a browser IS available (just copy the URL+code), so
574
+ // preferring it here is strictly safer than a guaranteed timeout.
575
+ if (!process.stdout.isTTY || !process.stdin.isTTY)
576
+ return true;
577
+ return false;
578
+ }
579
+ /**
580
+ * After a successful login, hint about VPN if Tailscale is not connected.
581
+ * Non-blocking — never fails or delays the login flow.
582
+ */
583
+ function hintVpnIfDisconnected(context) {
584
+ try {
585
+ const state = tailscaleBackendState();
586
+ if (state !== "Running") {
587
+ console.log("");
588
+ logInfo(`VPN not connected. To access dev services, run:`);
589
+ logInfo(` mesh vpn connect ${context}`);
590
+ }
591
+ }
592
+ catch {
593
+ // Tailscale not installed or not queryable — skip hint
594
+ }
595
+ }
596
+ /**
597
+ * The account id embedded in an IAM role ARN
598
+ * (`arn:aws:iam::123456789012:role/...`), or undefined when there is no ARN
599
+ * to read one from.
600
+ */
601
+ export function accountIdFromRoleArn(roleArn) {
602
+ return roleArn?.match(/^arn:aws:iam::(\d{12}):/)?.[1];
603
+ }
604
+ /**
605
+ * Decide everything `--status` says (and its exit code) when there is no
606
+ * usable Zitadel session — pure so the wording and the exit decision are
607
+ * pinned by tests.
608
+ *
609
+ * The probe only proves "some AWS identity works on this machine"; it says
610
+ * nothing about whether that identity belongs to `context`. So the claim is
611
+ * scoped to the credentials — unless the context config carries a role ARN,
612
+ * whose account id makes the context check free: verify when we can, say so
613
+ * when we can't.
614
+ */
615
+ export function renderAwsFallbackStatus(context, reason, identity, expectedAccountId) {
616
+ if (!identity) {
617
+ return {
618
+ lines: [
619
+ {
620
+ level: "warn",
621
+ text: `${reason}, and no working AWS credentials were found either.`,
622
+ },
623
+ { level: "info", text: "Authenticate with whichever you use:" },
624
+ { level: "info", text: ` mesh login ${context} (Zitadel SSO)` },
625
+ {
626
+ level: "info",
627
+ text: " aws sso login --profile … (AWS SSO profile)",
628
+ },
629
+ {
630
+ level: "info",
631
+ text: " export AWS_PROFILE=… (profile with a key/secret pair)",
632
+ },
633
+ ],
634
+ exitCode: 1,
635
+ };
636
+ }
637
+ const lines = [
638
+ {
639
+ level: "success",
640
+ text: `AWS credentials are working (${identity.source})`,
641
+ },
642
+ { level: "info", text: `Identity: ${identity.arn}` },
643
+ { level: "info", text: `Account: ${identity.accountId}` },
644
+ ];
645
+ if (expectedAccountId && identity.accountId !== expectedAccountId) {
646
+ lines.push({
647
+ level: "warn",
648
+ text: `This account does not match ${context}'s configured role account ` +
649
+ `(${expectedAccountId}) — commands against ${context} will likely ` +
650
+ `fail with AccessDenied. Check AWS_PROFILE / your exported credentials.`,
651
+ });
652
+ }
653
+ else if (expectedAccountId) {
654
+ lines.push({
655
+ level: "info",
656
+ text: `Account matches ${context}'s configured role account.`,
657
+ });
658
+ }
659
+ else {
660
+ lines.push({
661
+ level: "info",
662
+ text: `This is a working AWS identity, not a verified connection to ` +
663
+ `${context} — nothing here checked that this account is ${context}'s.`,
664
+ });
665
+ }
666
+ lines.push({
667
+ level: "info",
668
+ text: `${reason} — that only matters for the commands that need a *user* ` +
669
+ `identity (Hub SSO, \`mesh dev\` test users, zero-touch VPN join). ` +
670
+ `Deploys, registry auth and secrets work off these credentials.`,
671
+ });
672
+ return { lines, exitCode: 0 };
673
+ }
674
+ async function showAwsCredentialStatus(context, reason, config) {
675
+ const identity = await probeAwsIdentity();
676
+ const { lines, exitCode } = renderAwsFallbackStatus(context, reason, identity, accountIdFromRoleArn(config.defaultRole ?? config.adminRole));
677
+ for (const line of lines) {
678
+ if (line.level === "success")
679
+ logSuccess(line.text);
680
+ else if (line.level === "warn")
681
+ logWarn(line.text);
682
+ else
683
+ logInfo(line.text);
684
+ }
685
+ if (exitCode !== 0)
686
+ process.exit(exitCode);
687
+ }
248
688
  async function showStatus(context, config) {
249
689
  const creds = readCredentials(context);
250
690
  if (!creds) {
251
- logWarn(`Not logged in to ${context}. Run: mesh login ${context}`);
252
- process.exit(1);
691
+ await showAwsCredentialStatus(context, "No cached Zitadel session", config);
692
+ return;
253
693
  }
254
694
  const expired = new Date(creds.expiresAt) < new Date();
255
695
  if (expired && creds.refreshToken) {
@@ -269,13 +709,13 @@ async function showStatus(context, config) {
269
709
  return;
270
710
  }
271
711
  catch {
272
- logWarn(`Token expired and refresh failed. Run: mesh login ${context}`);
273
- process.exit(1);
712
+ await showAwsCredentialStatus(context, "Zitadel token expired and refresh failed", config);
713
+ return;
274
714
  }
275
715
  }
276
716
  if (expired) {
277
- logWarn(`Token expired at ${creds.expiresAt}. Run: mesh login ${context}`);
278
- process.exit(1);
717
+ await showAwsCredentialStatus(context, `Zitadel token expired at ${creds.expiresAt}`, config);
718
+ return;
279
719
  }
280
720
  // Parse tenants from access token
281
721
  let tenants = [];
@@ -299,33 +739,88 @@ async function showStatus(context, config) {
299
739
  export function registerLoginCommand(program) {
300
740
  program
301
741
  .command("login")
302
- .description("Authenticate with Zitadel (OIDC PKCE)")
742
+ .description("Authenticate with Zitadel (OIDC PKCE or Device Code)")
303
743
  .argument("<context>", 'Platform context (e.g., "mesh.dev")')
304
- .option("--status", "Show current authentication status")
744
+ .option("--status", "Show current authentication status — the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.")
745
+ .option("--device", "Force device code flow (no callback server needed)")
746
+ .option("--export", "After login, print a self-refreshing AWS credential_process profile as shell `export` statements (use with `eval`). The resulting shell auto-refreshes credentials via `mesh login`. Requires --role or MESH_AWS_ROLE.")
747
+ .option("--static", "With --export: print raw temporary AWS credentials (a frozen ~1h triple) instead of the default self-refreshing credential_process profile")
748
+ .option("--credential-process", "Print AWS credential_process JSON (used by mesh dev's temp profile); refreshes the Zitadel token as needed. Requires --role or a cached defaultRole.")
749
+ .option("--role <arn>", "IAM role ARN to assume via web identity (used with --export). Saved as the default for this context so future --export runs can omit it. Resolution order: --role > MESH_AWS_ROLE > cached defaultRole.")
750
+ .option("--region <region>", "AWS region to include in the exported AWS_REGION. Defaults to AWS_REGION env or us-east-2.")
751
+ .option("--no-registry", "Skip the CodeArtifact registry-auth step that runs automatically after login for {tenant}.{env} contexts")
305
752
  .action(async (context, opts) => {
306
- const config = getContextConfig(context);
753
+ let config = getContextConfig(context);
307
754
  if (!config) {
308
- logError(`No configuration found for "${context}".\n` +
309
- `Add it to ${CONFIG_FILE}:\n\n` +
310
- ` {\n` +
311
- ` "${context}": {\n` +
312
- ` "issuer": "https://identity.<env>.<your-platform-domain>",\n` +
313
- ` "clientId": "<cli-oidc-client-id>"\n` +
314
- ` }\n` +
315
- ` }\n`);
316
- process.exit(1);
755
+ // One chain, shared with `ensureLogin` — see discoverConfigGuarded.
756
+ // Deliberately NOT re-implemented here: this call site and that one
757
+ // drifted apart once already, and the difference was whether a clean
758
+ // laptop could resolve a short context at all.
759
+ config = await discoverConfigGuarded(context);
760
+ if (!config) {
761
+ const pinned = firstPartyDomainFor(context);
762
+ logError(`No configuration found for "${context}".\n\n` +
763
+ `Tried:\n` +
764
+ ` 1. SSM: /mesh-platform/${context.split(".")[0]}/${context.split(".")[1]}/platform/zitadel\n` +
765
+ ` (requires AWS SSO login + read access)\n` +
766
+ (pinned
767
+ ? ` 2. HTTPS: https://cli.${pinned}/.well-known/mesh.json (pinned first-party domain)\n\n`
768
+ : ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\n` +
769
+ ` (only attempted for a full domain, or a known first-party context)\n\n`) +
770
+ `To fix:\n` +
771
+ ` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\n` +
772
+ ` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\n` +
773
+ ` - Or add config manually to ${CONFIG_FILE}:\n\n` +
774
+ ` {\n` +
775
+ ` "${context}": {\n` +
776
+ ` "issuer": "https://identity.<env>.<your-platform-domain>",\n` +
777
+ ` "clientId": "<cli-oidc-client-id>"\n` +
778
+ ` }\n` +
779
+ ` }\n`);
780
+ process.exit(1);
781
+ }
317
782
  }
318
783
  if (opts.status) {
319
784
  await showStatus(context, config);
320
785
  return;
321
786
  }
787
+ if (opts.export) {
788
+ await exportAwsCredentials(context, config, opts);
789
+ return;
790
+ }
791
+ if (opts.credentialProcess) {
792
+ await credentialProcessAwsCredentials(context, config, opts);
793
+ return;
794
+ }
322
795
  try {
323
- await login(context, config);
796
+ const useDevice = opts.device || isRemoteEnvironment();
797
+ if (useDevice) {
798
+ logInfo("Using device code flow");
799
+ await deviceCodeLogin(context, config);
800
+ }
801
+ else {
802
+ await login(context, config);
803
+ }
324
804
  }
325
805
  catch (err) {
326
806
  logError(`Login failed: ${err.message}`);
327
807
  process.exit(1);
328
808
  }
809
+ // F2: login absorbs registry auth. CodeArtifact-backed contexts are the
810
+ // {tenant}.{env} ones — the local platform ('local') has no registry.
811
+ // Best-effort: a registry hiccup must not fail an otherwise good login.
812
+ if (opts.registry !== false && parseTenantEnv(context)) {
813
+ try {
814
+ logInfo("Refreshing CodeArtifact registry auth (skip with --no-registry)…");
815
+ const { runRegistryLogin } = await import("./registry.js");
816
+ await runRegistryLogin(context, {});
817
+ }
818
+ catch (err) {
819
+ logWarn(`Registry auth failed (login itself succeeded): ${err.message}` +
820
+ ` — retry with: mesh registry login ${context}`
821
+ + ` (add --profile mesh-dev only if you have an AWS account and the broker is unavailable)`);
822
+ }
823
+ }
329
824
  });
330
825
  program
331
826
  .command("logout")
@@ -337,8 +832,441 @@ export function registerLoginCommand(program) {
337
832
  });
338
833
  }
339
834
  // =============================================================================
340
- // Exports (for use by mesh deploy)
835
+ // Exports (for use by other mesh commands)
836
+ // =============================================================================
837
+ /**
838
+ * Get a valid access token for the given context, refreshing if needed.
839
+ *
840
+ * Returns the id token (a JWT with `sub` claim) suitable for
841
+ * Authorization: Bearer headers. Returns null if no credentials are
842
+ * cached or refresh fails.
843
+ */
844
+ /**
845
+ * True while a cached token is still valid, treating anything within `marginMs`
846
+ * of expiry as already stale. The margin lets pollers (e.g. the dev-user
847
+ * token-server) re-mint BEFORE expiry instead of handing out a token that dies
848
+ * before the caller's next poll.
849
+ */
850
+ export function tokenStillValid(expiresAt, marginMs = 0, now = Date.now()) {
851
+ return new Date(expiresAt).getTime() - marginMs > now;
852
+ }
853
+ async function getValidToken(context, opts = {}) {
854
+ const config = getContextConfig(context);
855
+ if (!config)
856
+ return null;
857
+ const creds = readCredentials(context);
858
+ if (!creds)
859
+ return null;
860
+ if (tokenStillValid(creds.expiresAt, opts.marginMs ?? 0)) {
861
+ return creds.idToken;
862
+ }
863
+ // Attempt refresh
864
+ if (!creds.refreshToken)
865
+ return null;
866
+ try {
867
+ const tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken);
868
+ const idPayload = decodeJwtPayload(tokens.id_token);
869
+ writeCredentials(context, {
870
+ ...creds,
871
+ idToken: tokens.id_token,
872
+ accessToken: tokens.access_token,
873
+ refreshToken: tokens.refresh_token ?? creds.refreshToken,
874
+ expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
875
+ email: idPayload.email ?? creds.email,
876
+ });
877
+ return tokens.id_token;
878
+ }
879
+ catch {
880
+ return null;
881
+ }
882
+ }
883
+ /**
884
+ * Probe deployer creds for `context`/`roleArn`. Order matters:
885
+ * 1. No cached Zitadel session → `no-session`.
886
+ * 2. Cached session AND ambient `AWS_*` env keys present → `stale-env-override`
887
+ * (the frozen triple would override the self-refreshing profile at
888
+ * runtime — the exact `mesh dev` trap). Guarded on a cached session so
889
+ * legitimate CI env-key credentials, which have no `mesh login` session,
890
+ * are not misreported.
891
+ * 3. Session can't be refreshed → `expired-session`.
892
+ * 4. AssumeRoleWithWebIdentity fails → `assume-denied` (valid identity, wrong
893
+ * IAM/role/policy).
894
+ * 5. Otherwise → `ok` with the STS TTL.
895
+ */
896
+ export async function probeCredentials(context, roleArn) {
897
+ const cached = readCredentials(context);
898
+ if (!cached)
899
+ return { state: "no-session" };
900
+ if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {
901
+ return { state: "stale-env-override" };
902
+ }
903
+ const token = await getValidToken(context);
904
+ if (!token)
905
+ return { state: "expired-session" };
906
+ const sessionName = (cached.email ?? "mesh-cli-doctor")
907
+ .replace(/[^a-zA-Z0-9=,.@-]/g, "_")
908
+ .slice(0, 64);
909
+ const sts = await assumeRoleCredentials(roleArn, token, sessionName);
910
+ if (!sts) {
911
+ return {
912
+ state: "assume-denied",
913
+ detail: describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }, "inline"),
914
+ };
915
+ }
916
+ const ttlSeconds = Math.max(0, Math.round((Date.parse(sts.Expiration) - Date.now()) / 1000));
917
+ return { state: "ok", ttlSeconds, expiresAt: sts.Expiration, email: cached.email };
918
+ }
919
+ // =============================================================================
920
+ // AWS credential export (--export flag)
921
+ // =============================================================================
922
+ /**
923
+ * Single-quote a value for shell-safe inclusion in an `export` statement.
924
+ * Single-quoted strings in POSIX shells don't expand `$` or other metachars;
925
+ * embedded single-quotes are escaped via the `'\''` trick.
926
+ */
927
+ function shellSingleQuote(value) {
928
+ return `'${value.replace(/'/g, "'\\''")}'`;
929
+ }
930
+ /**
931
+ * Render the `--static` output: raw temporary AWS credentials as shell
932
+ * `export` statements (the old frozen ~1h triple). Pure — no I/O. Includes a
933
+ * trailing newline. Exported for unit testing.
934
+ */
935
+ export function renderExportStaticLines(args) {
936
+ return (`export AWS_ACCESS_KEY_ID=${shellSingleQuote(args.accessKey)}\n` +
937
+ `export AWS_SECRET_ACCESS_KEY=${shellSingleQuote(args.secretKey)}\n` +
938
+ `export AWS_SESSION_TOKEN=${shellSingleQuote(args.sessionToken)}\n` +
939
+ `export AWS_REGION=${shellSingleQuote(args.region)}\n`);
940
+ }
941
+ /**
942
+ * Render the default `--export` output: point the shell at a self-refreshing
943
+ * `credential_process` profile. The leading `unset` clears any stale STS
944
+ * triple already in the shell so it can't shadow the profile. Pure — no I/O.
945
+ * Includes a trailing newline. Exported for unit testing.
946
+ *
947
+ * Deliberately does NOT export `AWS_CONFIG_FILE`: the profile lives in the
948
+ * user's REAL AWS config file (managed marker section — see
949
+ * `upsertManagedAwsConfigSection`), so their other profiles (e.g.
950
+ * `AWS_PROFILE=mesh-dev` InfraAdmin) keep working in the same shell.
951
+ * Redirecting AWS_CONFIG_FILE to a mesh-private file used to clobber
952
+ * ~/.aws/config for every subsequent AWS command.
953
+ */
954
+ export function renderExportProfileLines(args) {
955
+ return (`unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\n` +
956
+ `export AWS_PROFILE=${shellSingleQuote(args.profileName)}\n` +
957
+ `export AWS_REGION=${shellSingleQuote(args.region)}\n`);
958
+ }
959
+ /**
960
+ * The AWS config file the managed profile section is written into: the
961
+ * user's `$AWS_CONFIG_FILE` when THEY have set one, else `~/.aws/config` —
962
+ * the same file `aws configure sso` manages. Exported for unit testing.
963
+ */
964
+ export function resolveUserAwsConfigPath(env = process.env) {
965
+ return env.AWS_CONFIG_FILE ?? path.join(env.HOME ?? "~", ".aws", "config");
966
+ }
967
+ /** True if `dir` can be created (if needed) and written. Used to detect an
968
+ * unwritable `~/.aws` (e.g. a sandbox with no `~/.aws` grant) without throwing. */
969
+ function canWriteDir(dir) {
970
+ try {
971
+ fs.mkdirSync(dir, { recursive: true });
972
+ fs.accessSync(dir, fs.constants.W_OK);
973
+ return true;
974
+ }
975
+ catch {
976
+ return false;
977
+ }
978
+ }
979
+ /**
980
+ * Where to write the managed AWS profile, with a safehouse fallback. Normally
981
+ * the user's real `~/.aws/config` (augmented in place, so their other tools see
982
+ * the profile — hence `redirected: false`, we don't touch their `AWS_CONFIG_FILE`).
983
+ * But when `~/.aws` is not writable (a sandbox without a `~/.aws` grant), fall
984
+ * back to a mesh-managed file under `~/.config/mesh` we CAN write, and set
985
+ * `redirected: true` so the caller also exports `AWS_CONFIG_FILE` (otherwise the
986
+ * AWS SDK, looking only at `~/.aws/config`, would never find the profile). An
987
+ * explicit `AWS_CONFIG_FILE` always wins and is never redirected.
988
+ */
989
+ export function resolveAwsConfigTarget(env = process.env) {
990
+ if (env.AWS_CONFIG_FILE)
991
+ return { configPath: env.AWS_CONFIG_FILE, redirected: false };
992
+ const real = path.join(env.HOME ?? "~", ".aws", "config");
993
+ if (canWriteDir(path.dirname(real)))
994
+ return { configPath: real, redirected: false };
995
+ return { configPath: path.join(CONFIG_DIR, "aws-config"), redirected: true };
996
+ }
997
+ /**
998
+ * Resolve which IAM role ARN to use, honoring the same precedence in both
999
+ * `--export` and `--credential-process`: explicit `--role` wins, then
1000
+ * `MESH_AWS_ROLE`, then the context's cached `defaultRole`. Pure aside from
1001
+ * reading `process.env.MESH_AWS_ROLE` — returns an explanatory error instead
1002
+ * of throwing so callers can route it to stderr and exit non-zero.
1003
+ *
1004
+ * Exported for unit testing.
1005
+ */
1006
+ export function resolveRoleOrExplain(opts, config) {
1007
+ const role = opts.role ?? process.env.MESH_AWS_ROLE ?? config.defaultRole;
1008
+ if (!role) {
1009
+ return {
1010
+ error: "No IAM role available: pass --role <arn>, set MESH_AWS_ROLE, or run " +
1011
+ "`mesh login <context> --export --role <arn>` once to cache a default role for this context.",
1012
+ };
1013
+ }
1014
+ return { role };
1015
+ }
1016
+ /**
1017
+ * Ensure a valid Zitadel ID token is cached for the given context — refreshing
1018
+ * or running the full login flow as needed. Returns null on failure.
1019
+ */
1020
+ async function ensureValidToken(context, config, opts) {
1021
+ let token = await getValidToken(context);
1022
+ if (token)
1023
+ return token;
1024
+ logInfo(`No valid Zitadel session for ${context} — running login flow`);
1025
+ try {
1026
+ const useDevice = opts.device || isRemoteEnvironment();
1027
+ if (useDevice) {
1028
+ logInfo("Using device code flow");
1029
+ await deviceCodeLogin(context, config);
1030
+ }
1031
+ else {
1032
+ await login(context, config);
1033
+ }
1034
+ }
1035
+ catch (err) {
1036
+ logError(`Login failed: ${err.message}`);
1037
+ return null;
1038
+ }
1039
+ return await getValidToken(context);
1040
+ }
1041
+ /**
1042
+ * Ensure a valid login session for `context`, running the login flow (device
1043
+ * code in remote/non-TTY, browser callback otherwise) if none is cached. Returns
1044
+ * the cached credentials, or null if no config is available to log in with.
1045
+ *
1046
+ * For use by other commands (e.g. `secrets set`, the AWS-cred resolver) so they
1047
+ * can trigger login on demand instead of just failing with guidance.
1048
+ */
1049
+ export async function ensureLogin(context, opts = {}) {
1050
+ const existing = readCredentials(context);
1051
+ if (existing && new Date(existing.expiresAt) > new Date())
1052
+ return existing;
1053
+ // interactive: false → cached-session-or-nothing. Callers with their own
1054
+ // fallback chain (e.g. the AWS-cred resolver's SSO path) use this so an
1055
+ // expired cache doesn't stall headless runs in a device-code poll.
1056
+ if (opts.interactive === false)
1057
+ return null;
1058
+ // Same guarded discovery chain as the `login` command: SSM first, and the
1059
+ // well-known HTTPS endpoint only for domain-looking contexts (≥2 dots).
1060
+ // Hitting `https://cli.<short-name>/.well-known/mesh.json` for an uncached
1061
+ // context like "mesh.dev" would let whoever registers that domain seed our
1062
+ // cached issuer/clientId (login phishing).
1063
+ const config = getContextConfig(context) ?? (await discoverConfigGuarded(context));
1064
+ if (!config) {
1065
+ logWarn(`No login config for "${context}". Run: mesh login ${context}`);
1066
+ return null;
1067
+ }
1068
+ const token = await ensureValidToken(context, config, opts);
1069
+ if (!token)
1070
+ return null;
1071
+ return readCredentials(context);
1072
+ }
1073
+ /**
1074
+ * The one discovery chain, used by both the `login` command and `ensureLogin`:
1075
+ *
1076
+ * 1. SSM, when the context parses as `{tenant}.{env}` — the primary path for
1077
+ * internal users who already have AWS SSO access.
1078
+ * 2. The anonymous HTTPS well-known endpoint at the PINNED domain for a known
1079
+ * first-party short context (`mesh.dev` → `dev.platform.meshtech.io`). This
1080
+ * is what makes `mesh login mesh.dev` work on a laptop with no AWS at all
1081
+ * — the P0 case. The domain comes from a compiled-in table, never from the
1082
+ * context string, so it is not a phishing surface.
1083
+ * 3. The anonymous HTTPS endpoint at the context itself, only when it looks
1084
+ * like a real domain (≥2 dots).
1085
+ *
1086
+ * Never hits `https://cli.<short-name>/…` for an UNKNOWN short context — that
1087
+ * guard is the anti-phishing property and step 2 is deliberately a pinned
1088
+ * allow-list rather than a relaxation of it.
1089
+ */
1090
+ async function discoverConfigGuarded(context) {
1091
+ let config = null;
1092
+ if (parseTenantEnv(context)) {
1093
+ config = await discoverConfigFromSsm(context);
1094
+ }
1095
+ if (!config) {
1096
+ const pinned = firstPartyDomainFor(context);
1097
+ if (pinned) {
1098
+ logInfo(`"${context}" is a known Mesh platform — resolving via ${pinned} (no AWS needed)`);
1099
+ config = await discoverConfig(pinned, context);
1100
+ }
1101
+ }
1102
+ if (!config && context.split(".").length - 1 >= 2) {
1103
+ config = await discoverConfig(context);
1104
+ }
1105
+ return config;
1106
+ }
1107
+ /**
1108
+ * Resolve AWS credentials via Zitadel JWT → AssumeRoleWithWebIdentity, then
1109
+ * print them as `export` statements on stdout. All status/error output goes
1110
+ * to stderr so the caller can safely `eval "$(mesh login ... --export ...)"`.
1111
+ */
1112
+ async function exportAwsCredentials(context, config, opts) {
1113
+ const token = await ensureValidToken(context, config, opts);
1114
+ if (!token) {
1115
+ process.exit(1);
1116
+ }
1117
+ // Pick a role for the caller. `resolveRoleOrExplain` handles the
1118
+ // --role / MESH_AWS_ROLE / cached-defaultRole precedence (shared with
1119
+ // --credential-process); on top of that, when we fell back to the cached
1120
+ // default we ask `selectRoleForCaller` to upgrade to the context's
1121
+ // `adminRole` when the Zitadel JWT carries an admin claim.
1122
+ const resolvedRole = resolveRoleOrExplain(opts, config);
1123
+ if ("error" in resolvedRole) {
1124
+ logError("--export requires --role <arn> (or MESH_AWS_ROLE env var, or a cached defaultRole).\n" +
1125
+ " Example: mesh login mesh.dev --export --role arn:aws:iam::123456789012:role/mesh-developer\n" +
1126
+ " After the first run, the role is saved to ~/.config/mesh/config.json and --role can be omitted.");
1127
+ process.exit(1);
1128
+ }
1129
+ let roleArn = resolvedRole.role;
1130
+ if (!opts.role && !process.env.MESH_AWS_ROLE && config.defaultRole) {
1131
+ roleArn = selectRoleForCaller(token, {
1132
+ defaultRole: config.defaultRole,
1133
+ adminRole: config.adminRole,
1134
+ adminClaimRoles: config.adminClaimRoles,
1135
+ });
1136
+ }
1137
+ if (config.adminRole && roleArn === config.adminRole && !opts.role) {
1138
+ logInfo(`Caller has admin Zitadel role — assuming ${roleArn.split("/").pop()} (admin variant)`);
1139
+ }
1140
+ // Persist --role so subsequent runs can omit it.
1141
+ if (opts.role && opts.role !== config.defaultRole) {
1142
+ writeContextConfig(context, { ...config, defaultRole: opts.role });
1143
+ logInfo(`Saved default role for ${context}`);
1144
+ }
1145
+ const region = opts.region ??
1146
+ process.env.AWS_REGION ??
1147
+ process.env.AWS_DEFAULT_REGION ??
1148
+ "us-east-2";
1149
+ const roleName = roleArn.split("/").pop() ?? roleArn;
1150
+ // --static: resolve a frozen STS triple now (the old behavior). Consumers
1151
+ // that need raw credentials (no `mesh` binary on refresh) opt into this.
1152
+ if (opts.static) {
1153
+ const creds = readCredentials(context);
1154
+ const sessionName = (creds?.email ?? "mesh-cli-export")
1155
+ .replace(/[^a-zA-Z0-9=,.@-]/g, "_")
1156
+ .slice(0, 64);
1157
+ const env = await assumeRoleWithWebIdentity(roleArn, token, sessionName);
1158
+ if (!env) {
1159
+ logError(describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }));
1160
+ process.exit(1);
1161
+ }
1162
+ const accessKey = env.AWS_ACCESS_KEY_ID;
1163
+ const secretKey = env.AWS_SECRET_ACCESS_KEY;
1164
+ const sessionToken = env.AWS_SESSION_TOKEN;
1165
+ if (!accessKey || !secretKey || !sessionToken) {
1166
+ logError("AWS STS returned incomplete credentials");
1167
+ process.exit(1);
1168
+ }
1169
+ process.stdout.write(renderExportStaticLines({ accessKey, secretKey, sessionToken, region }));
1170
+ logSuccess(`Exported static AWS credentials for ${roleName} in ${region}`);
1171
+ return;
1172
+ }
1173
+ // Default: manage a `[profile mesh-<context>]` block inside the user's REAL
1174
+ // AWS config file ($AWS_CONFIG_FILE if they set one, else ~/.aws/config) as
1175
+ // an idempotent marker-delimited section — the same file `aws configure sso`
1176
+ // manages — and export only AWS_PROFILE. The profile's `credential_process`
1177
+ // re-invokes `mesh login … --credential-process`, so `eval`-ed shells
1178
+ // auto-refresh instead of dying ~1h in. We must NOT export AWS_CONFIG_FILE
1179
+ // pointing at a mesh-private file: that hides ~/.aws/config from the whole
1180
+ // shell and breaks the user's other profiles (e.g. AWS_PROFILE=mesh-dev).
1181
+ const meshBin = resolveStableMeshBin(process.argv[1]);
1182
+ const sanitized = context.replace(/[^A-Za-z0-9_-]/g, "-");
1183
+ const profileName = `mesh-${sanitized}`;
1184
+ // Safehouse-friendly: falls back to a mesh-managed config when ~/.aws is not
1185
+ // writable (and then `redirected` tells us to export AWS_CONFIG_FILE too).
1186
+ const { configPath, redirected } = resolveAwsConfigTarget();
1187
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
1188
+ // Read → build → atomic-write back-to-back (all sync) so the lost-update
1189
+ // window against concurrent writers (another `mesh login --export`, the
1190
+ // user's own editor, `aws configure`) is as small as possible; the temp+
1191
+ // rename write means concurrent READERS never observe a truncated file.
1192
+ const existedBefore = fs.existsSync(configPath);
1193
+ const existing = existedBefore ? fs.readFileSync(configPath, "utf-8") : "";
1194
+ // Preserve the user's existing mode (rename replaces the inode, so the
1195
+ // temp file's mode wins) — never chmod their config out from under them.
1196
+ // Restrict to 0600 only when WE create the file.
1197
+ const mode = existedBefore ? fs.statSync(configPath).mode & 0o777 : 0o600;
1198
+ atomicWriteFileSync(configPath, upsertManagedAwsConfigSection(existing, context, renderCredentialProcessProfile({
1199
+ profileName,
1200
+ context,
1201
+ roleArn,
1202
+ region,
1203
+ meshBin,
1204
+ })), mode);
1205
+ process.stdout.write(renderExportProfileLines({ profileName, region }));
1206
+ // When we redirected away from an unwritable ~/.aws, the profile lives in a
1207
+ // mesh-managed file — export AWS_CONFIG_FILE so the AWS SDK finds it.
1208
+ if (redirected) {
1209
+ process.stdout.write(`export AWS_CONFIG_FILE=${shellSingleQuote(configPath)}\n`);
1210
+ }
1211
+ logSuccess(`Exported self-refreshing AWS profile ${profileName} for ${roleName} in ${region} ` +
1212
+ `(managed section in ${configPath})`);
1213
+ }
1214
+ // =============================================================================
1215
+ // AWS credential_process (--credential-process flag)
341
1216
  // =============================================================================
1217
+ /**
1218
+ * Resolve AWS credentials via Zitadel JWT → AssumeRoleWithWebIdentity, then
1219
+ * print them as AWS `credential_process` JSON on stdout — nothing else may
1220
+ * reach stdout in this mode, since the AWS SDK parses it directly. All
1221
+ * status/error output goes to stderr.
1222
+ *
1223
+ * Unlike `--export`, this does NOT run an interactive login flow when no
1224
+ * session is cached (there's no terminal/browser to drive it from a child
1225
+ * process) — it only refreshes an existing session via its refresh token
1226
+ * (`getValidToken`). Callers must `mesh login <context>` at least once.
1227
+ */
1228
+ async function credentialProcessAwsCredentials(context, config, opts) {
1229
+ const token = await getValidToken(context);
1230
+ if (!token) {
1231
+ logError(`No valid Zitadel session for ${context} (missing, expired, or refresh failed). Run: mesh login ${context}`);
1232
+ process.exit(1);
1233
+ }
1234
+ const resolvedRole = resolveRoleOrExplain(opts, config);
1235
+ if ("error" in resolvedRole) {
1236
+ logError(resolvedRole.error);
1237
+ process.exit(1);
1238
+ }
1239
+ const creds = readCredentials(context);
1240
+ const sessionName = (creds?.email ?? "mesh-cli-credential-process")
1241
+ .replace(/[^a-zA-Z0-9=,.@-]/g, "_")
1242
+ .slice(0, 64);
1243
+ const stsCreds = await assumeRoleCredentials(resolvedRole.role, token, sessionName);
1244
+ if (!stsCreds) {
1245
+ logError(describeAssumeFailure({
1246
+ roleArn: resolvedRole.role,
1247
+ context,
1248
+ issuer: tokenIssuer(token),
1249
+ }));
1250
+ process.exit(1);
1251
+ }
1252
+ process.stdout.write(toCredentialProcessJson(stsCreds) + "\n");
1253
+ }
342
1254
  export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE };
343
- export { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload };
1255
+ /** Every cached context config, keyed by context name. */
1256
+ export function readAllContextConfigs() {
1257
+ return readConfig();
1258
+ }
1259
+ /**
1260
+ * The discovery chain on its own, without the browser flow that follows it.
1261
+ *
1262
+ * Exported so the clean-laptop bootstrap — SSM → pinned first-party alias →
1263
+ * full-domain, and the anti-phishing refusal for an unknown short context — can
1264
+ * be exercised end-to-end against the live well-known endpoint. The `login`
1265
+ * command's own path is otherwise only reachable by completing an interactive
1266
+ * sign-in, which is why this leg had no coverage above the unit tier.
1267
+ */
1268
+ export async function discoverConfigForContext(context) {
1269
+ return getContextConfig(context) ?? (await discoverConfigGuarded(context));
1270
+ }
1271
+ export { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload, getValidToken };
344
1272
  //# sourceMappingURL=login.js.map