@geekmidas/cli 10.0.0-alpha.64 → 10.0.0-alpha.66

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 (243) hide show
  1. package/README.md +15 -1
  2. package/dist/{HostingerProvider-Bx2lHHvu.cjs → HostingerProvider-BznqRC2S.cjs} +5 -5
  3. package/dist/HostingerProvider-BznqRC2S.cjs.map +1 -0
  4. package/dist/{HostingerProvider-Cd4aS5Kj.mjs → HostingerProvider-CY_VpVud.mjs} +4 -4
  5. package/dist/HostingerProvider-CY_VpVud.mjs.map +1 -0
  6. package/dist/{LegacyStateStore-C2abV49N.cjs → LegacyStateStore-BFcTT2QL.cjs} +2 -2
  7. package/dist/{LegacyStateStore-C2abV49N.cjs.map → LegacyStateStore-BFcTT2QL.cjs.map} +1 -1
  8. package/dist/{LegacyStateStore-DIgkGaUP.mjs → LegacyStateStore-CFheFo62.mjs} +2 -2
  9. package/dist/{LegacyStateStore-DIgkGaUP.mjs.map → LegacyStateStore-CFheFo62.mjs.map} +1 -1
  10. package/dist/{LocalStateStore-DiJqKyPy.cjs → LocalStateStore-BVUexSvu.cjs} +2 -2
  11. package/dist/{LocalStateStore-DiJqKyPy.cjs.map → LocalStateStore-BVUexSvu.cjs.map} +1 -1
  12. package/dist/{LocalStateStore-Cy1ZBW7U.mjs → LocalStateStore-Bm3x9oOg.mjs} +2 -2
  13. package/dist/{LocalStateStore-Cy1ZBW7U.mjs.map → LocalStateStore-Bm3x9oOg.mjs.map} +1 -1
  14. package/dist/{OpenApiTsGenerator-BWuDRHe9.cjs → OpenApiTsGenerator-B2zrdyP4.cjs} +2 -2
  15. package/dist/{OpenApiTsGenerator-BWuDRHe9.cjs.map → OpenApiTsGenerator-B2zrdyP4.cjs.map} +1 -1
  16. package/dist/{OpenApiTsGenerator-C6v-IeHt.mjs → OpenApiTsGenerator-BmUAcR7d.mjs} +2 -2
  17. package/dist/{OpenApiTsGenerator-C6v-IeHt.mjs.map → OpenApiTsGenerator-BmUAcR7d.mjs.map} +1 -1
  18. package/dist/{Route53Provider-DVILBsvV.cjs → Route53Provider-Bllus2NR.cjs} +2 -2
  19. package/dist/Route53Provider-Bllus2NR.cjs.map +1 -0
  20. package/dist/{Route53Provider-Cvj40A8I.mjs → Route53Provider-QXxjkZO1.mjs} +2 -2
  21. package/dist/Route53Provider-QXxjkZO1.mjs.map +1 -0
  22. package/dist/{S3StateStore-D3x6ivBL.mjs → S3StateStore-BFHuctWM.mjs} +2 -2
  23. package/dist/{S3StateStore-D3x6ivBL.mjs.map → S3StateStore-BFHuctWM.mjs.map} +1 -1
  24. package/dist/{S3StateStore-Ds0V1Lgm.cjs → S3StateStore-CoXBhILd.cjs} +2 -2
  25. package/dist/{S3StateStore-Ds0V1Lgm.cjs.map → S3StateStore-CoXBhILd.cjs.map} +1 -1
  26. package/dist/{SSMStateStore-CLipvx47.cjs → SSMStateStore-BqCA9TP2.cjs} +2 -2
  27. package/dist/{SSMStateStore-CLipvx47.cjs.map → SSMStateStore-BqCA9TP2.cjs.map} +1 -1
  28. package/dist/{SSMStateStore-DfF174xQ.mjs → SSMStateStore-C1kvHScY.mjs} +2 -2
  29. package/dist/{SSMStateStore-DfF174xQ.mjs.map → SSMStateStore-C1kvHScY.mjs.map} +1 -1
  30. package/dist/{backup-provisioner-DIVCbfee.mjs → backup-provisioner-C8DnCmxK.mjs} +2 -2
  31. package/dist/backup-provisioner-C8DnCmxK.mjs.map +1 -0
  32. package/dist/{backup-provisioner-BmhIO_Yr.cjs → backup-provisioner-oDEsPj02.cjs} +2 -2
  33. package/dist/backup-provisioner-oDEsPj02.cjs.map +1 -0
  34. package/dist/{bundler-DYgm9xsd.mjs → bundler-Dvaiud0h.mjs} +4 -4
  35. package/dist/{bundler-DYgm9xsd.mjs.map → bundler-Dvaiud0h.mjs.map} +1 -1
  36. package/dist/{bundler-C8uJf324.cjs → bundler-orRqUJNZ.cjs} +4 -4
  37. package/dist/{bundler-C8uJf324.cjs.map → bundler-orRqUJNZ.cjs.map} +1 -1
  38. package/dist/{cli-Bogz-zEj.mjs → cli-CfHm0AO7.mjs} +7 -6
  39. package/dist/cli-CfHm0AO7.mjs.map +1 -0
  40. package/dist/{cli-D3jofJ6j.cjs → cli-D1JkkOwg.cjs} +8 -7
  41. package/dist/cli-D1JkkOwg.cjs.map +1 -0
  42. package/dist/compose-CNQL8BnO.mjs +1398 -0
  43. package/dist/compose-CNQL8BnO.mjs.map +1 -0
  44. package/dist/compose-jrLy7KGO.cjs +1427 -0
  45. package/dist/compose-jrLy7KGO.cjs.map +1 -0
  46. package/dist/{config-Be-4_I6H.d.cts → config-4XFxX1al.d.cts} +3 -3
  47. package/dist/{config-Be-4_I6H.d.cts.map → config-4XFxX1al.d.cts.map} +1 -1
  48. package/dist/{config-DqNRiQOp.d.mts → config-UtKzhpFb.d.mts} +3 -3
  49. package/dist/{config-DqNRiQOp.d.mts.map → config-UtKzhpFb.d.mts.map} +1 -1
  50. package/dist/config.cjs +3 -3
  51. package/dist/config.d.cts +3 -3
  52. package/dist/config.d.mts +3 -3
  53. package/dist/config.mjs +3 -3
  54. package/dist/{credentials-B70vmLcm.cjs → credentials-Bzbg23n8.cjs} +176 -3
  55. package/dist/credentials-Bzbg23n8.cjs.map +1 -0
  56. package/dist/{credentials-s0wMeZBU.mjs → credentials-Dq1MnWQf.mjs} +128 -3
  57. package/dist/credentials-Dq1MnWQf.mjs.map +1 -0
  58. package/dist/databases-C2p5LGAp.cjs +419 -0
  59. package/dist/databases-C2p5LGAp.cjs.map +1 -0
  60. package/dist/databases-hUHTuwhx.mjs +346 -0
  61. package/dist/databases-hUHTuwhx.mjs.map +1 -0
  62. package/dist/{define-CYs0PEfZ.mjs → define-DR8xGYfL.mjs} +5 -1
  63. package/dist/define-DR8xGYfL.mjs.map +1 -0
  64. package/dist/{define-zUsP72ym.cjs → define-DzRz8FgH.cjs} +5 -1
  65. package/dist/define-DzRz8FgH.cjs.map +1 -0
  66. package/dist/deploy/public.cjs +35 -26
  67. package/dist/deploy/public.cjs.map +1 -1
  68. package/dist/deploy/public.d.cts +140 -20
  69. package/dist/deploy/public.d.cts.map +1 -1
  70. package/dist/deploy/public.d.mts +140 -20
  71. package/dist/deploy/public.d.mts.map +1 -1
  72. package/dist/deploy/public.mjs +13 -13
  73. package/dist/{terminal-Bdv--jhY.cjs → deploy-B7aJi_Ah.cjs} +146 -116
  74. package/dist/deploy-B7aJi_Ah.cjs.map +1 -0
  75. package/dist/{terminal-D7Ntz6WE.mjs → deploy-DWn7YfIc.mjs} +137 -89
  76. package/dist/deploy-DWn7YfIc.mjs.map +1 -0
  77. package/dist/{discover-LMrqsWSU.mjs → discover-BFoJWKGD.mjs} +4 -2
  78. package/dist/discover-BFoJWKGD.mjs.map +1 -0
  79. package/dist/{discover-DHdECueV.cjs → discover-OEfZ9OkA.cjs} +4 -2
  80. package/dist/discover-OEfZ9OkA.cjs.map +1 -0
  81. package/dist/dokploy-B4LcYG1E.cjs +38 -0
  82. package/dist/dokploy-B4LcYG1E.cjs.map +1 -0
  83. package/dist/dokploy-DalwLML5.mjs +38 -0
  84. package/dist/dokploy-DalwLML5.mjs.map +1 -0
  85. package/dist/{encryption-C9X98u2F.cjs → encryption-3KlGS1sl.cjs} +6 -8
  86. package/dist/{encryption-C9X98u2F.cjs.map → encryption-3KlGS1sl.cjs.map} +1 -1
  87. package/dist/{encryption-CKHjMpCr.mjs → encryption-CPfENwWO.mjs} +3 -5
  88. package/dist/{encryption-CKHjMpCr.mjs.map → encryption-CPfENwWO.mjs.map} +1 -1
  89. package/dist/{deploy-ZRXIPp1c.mjs → engine-CunvOG3s.mjs} +2116 -1855
  90. package/dist/engine-CunvOG3s.mjs.map +1 -0
  91. package/dist/{deploy-55rJkdKz.cjs → engine-DR6KurWX.cjs} +2172 -1869
  92. package/dist/engine-DR6KurWX.cjs.map +1 -0
  93. package/dist/{github-Dd3tMa1u.cjs → github-BJ5gh9bw.cjs} +7 -7
  94. package/dist/{github-Dd3tMa1u.cjs.map → github-BJ5gh9bw.cjs.map} +1 -1
  95. package/dist/{github-D-byNI7T.mjs → github-MXA9v7Ge.mjs} +3 -3
  96. package/dist/{github-D-byNI7T.mjs.map → github-MXA9v7Ge.mjs.map} +1 -1
  97. package/dist/{index-CfE9Y885.d.cts → index-CyApaHVe.d.cts} +16 -3
  98. package/dist/index-CyApaHVe.d.cts.map +1 -0
  99. package/dist/{index-CJ2qsSpp.d.mts → index-DD1FfUHh.d.mts} +16 -3
  100. package/dist/index-DD1FfUHh.d.mts.map +1 -0
  101. package/dist/index.cjs +465 -1691
  102. package/dist/index.cjs.map +1 -1
  103. package/dist/index.mjs +405 -1619
  104. package/dist/index.mjs.map +1 -1
  105. package/dist/{local-C7wnrcYK.cjs → local-BdH4XN0E.cjs} +1 -178
  106. package/dist/local-BdH4XN0E.cjs.map +1 -0
  107. package/dist/{local-Cd8ZYQG3.mjs → local-CWR4Y_kW.mjs} +3 -126
  108. package/dist/local-CWR4Y_kW.mjs.map +1 -0
  109. package/dist/{migrate-B4gngTLe.mjs → migrate-BJBcm4sG.mjs} +7 -6
  110. package/dist/{migrate-B4gngTLe.mjs.map → migrate-BJBcm4sG.mjs.map} +1 -1
  111. package/dist/{migrate-tx9-P9uz.cjs → migrate-CF4J4GQS.cjs} +20 -19
  112. package/dist/{migrate-tx9-P9uz.cjs.map → migrate-CF4J4GQS.cjs.map} +1 -1
  113. package/dist/openapi.cjs +1 -1
  114. package/dist/openapi.d.cts +1 -1
  115. package/dist/openapi.d.mts +1 -1
  116. package/dist/openapi.mjs +1 -1
  117. package/dist/provider-B3Sv_a74.cjs +173 -0
  118. package/dist/provider-B3Sv_a74.cjs.map +1 -0
  119. package/dist/provider-C9_-45d5.cjs +56 -0
  120. package/dist/provider-C9_-45d5.cjs.map +1 -0
  121. package/dist/provider-D7rQzzzf.mjs +138 -0
  122. package/dist/provider-D7rQzzzf.mjs.map +1 -0
  123. package/dist/provider-q4lsYzYG.mjs +45 -0
  124. package/dist/provider-q4lsYzYG.mjs.map +1 -0
  125. package/dist/{ready-bQfu9GES.mjs → ready-2s3-CndS.mjs} +3 -3
  126. package/dist/{ready-bQfu9GES.mjs.map → ready-2s3-CndS.mjs.map} +1 -1
  127. package/dist/{ready-brEhh68f.cjs → ready-B6uHr4sm.cjs} +3 -3
  128. package/dist/{ready-brEhh68f.cjs.map → ready-B6uHr4sm.cjs.map} +1 -1
  129. package/dist/reconcile/public.cjs +1 -1
  130. package/dist/reconcile/public.d.cts +1 -1
  131. package/dist/reconcile/public.d.mts +1 -1
  132. package/dist/reconcile/public.mjs +1 -1
  133. package/dist/resolve-CVmch0wI.cjs +315 -0
  134. package/dist/resolve-CVmch0wI.cjs.map +1 -0
  135. package/dist/resolve-Db0tNlrW.mjs +262 -0
  136. package/dist/resolve-Db0tNlrW.mjs.map +1 -0
  137. package/dist/rollback-DvAo4OKS.mjs +133 -0
  138. package/dist/rollback-DvAo4OKS.mjs.map +1 -0
  139. package/dist/rollback-DxJK84hy.cjs +162 -0
  140. package/dist/rollback-DxJK84hy.cjs.map +1 -0
  141. package/dist/{runtime-CJEYK4Gz.d.mts → runtime-BRqFOvPM.d.cts} +2 -2
  142. package/dist/{runtime-CJEYK4Gz.d.mts.map → runtime-BRqFOvPM.d.cts.map} +1 -1
  143. package/dist/{runtime-Cjq1jlVX.d.cts → runtime-CEk5n4VH.d.mts} +2 -2
  144. package/dist/{runtime-Cjq1jlVX.d.cts.map → runtime-CEk5n4VH.d.mts.map} +1 -1
  145. package/dist/sandbox/discover-worker.cjs +1 -1
  146. package/dist/sandbox/discover-worker.mjs +1 -1
  147. package/dist/sandbox/migrate-worker.cjs +61 -0
  148. package/dist/sandbox/migrate-worker.cjs.map +1 -0
  149. package/dist/sandbox/migrate-worker.d.cts +1 -0
  150. package/dist/sandbox/migrate-worker.d.mts +1 -0
  151. package/dist/sandbox/migrate-worker.mjs +62 -0
  152. package/dist/sandbox/migrate-worker.mjs.map +1 -0
  153. package/dist/{schemas-D2LCF689.mjs → schemas-Cfhtpdo0.mjs} +10 -2
  154. package/dist/{schemas-Ckm_aBnF.cjs.map → schemas-Cfhtpdo0.mjs.map} +1 -1
  155. package/dist/{schemas-Ckm_aBnF.cjs → schemas-RUlKHu27.cjs} +15 -1
  156. package/dist/{schemas-D2LCF689.mjs.map → schemas-RUlKHu27.cjs.map} +1 -1
  157. package/dist/sniffer-5xmrkh2a.cjs +1742 -0
  158. package/dist/sniffer-5xmrkh2a.cjs.map +1 -0
  159. package/dist/sniffer-BAQhLEsI.mjs +1617 -0
  160. package/dist/sniffer-BAQhLEsI.mjs.map +1 -0
  161. package/dist/{sst-Cper040I.mjs → sst-B2HbrDR3.mjs} +4 -4
  162. package/dist/{sst-Cper040I.mjs.map → sst-B2HbrDR3.mjs.map} +1 -1
  163. package/dist/{sst-BdALKEas.cjs → sst-DcfsVH6x.cjs} +4 -4
  164. package/dist/{sst-BdALKEas.cjs.map → sst-DcfsVH6x.cjs.map} +1 -1
  165. package/dist/{sniffer-ByhbEW5A.cjs → state-Y6ATakMX.cjs} +1769 -2759
  166. package/dist/state-Y6ATakMX.cjs.map +1 -0
  167. package/dist/{sniffer-49KPbDgq.mjs → state-uuYqnRVO.mjs} +1763 -2764
  168. package/dist/state-uuYqnRVO.mjs.map +1 -0
  169. package/dist/target/public.cjs +6 -6
  170. package/dist/target/public.d.cts +2 -2
  171. package/dist/target/public.d.mts +2 -2
  172. package/dist/target/public.mjs +3 -3
  173. package/dist/terminal-CHy_MdjG.cjs +84 -0
  174. package/dist/terminal-CHy_MdjG.cjs.map +1 -0
  175. package/dist/terminal-DUhcZ1ND.mjs +84 -0
  176. package/dist/terminal-DUhcZ1ND.mjs.map +1 -0
  177. package/dist/{types-DkrDOMxw.d.cts → types-CSw2w69a.d.mts} +110 -20
  178. package/dist/types-CSw2w69a.d.mts.map +1 -0
  179. package/dist/{types-B_bEQvb9.d.mts → types-D5WIxRDP.d.cts} +111 -19
  180. package/dist/types-D5WIxRDP.d.cts.map +1 -0
  181. package/dist/vitest.cjs +1 -1
  182. package/dist/vitest.mjs +1 -1
  183. package/dist/workspace/index.cjs +3 -1
  184. package/dist/workspace/index.d.cts +3 -3
  185. package/dist/workspace/index.d.mts +3 -3
  186. package/dist/workspace/index.mjs +2 -2
  187. package/dist/{workspace-Bwit4q9O.mjs → workspace-Be6uBhlF.mjs} +36 -6
  188. package/dist/workspace-Be6uBhlF.mjs.map +1 -0
  189. package/dist/{workspace-BwgWJK7h.cjs → workspace-ON-bqiJz.cjs} +47 -5
  190. package/dist/workspace-ON-bqiJz.cjs.map +1 -0
  191. package/package.json +13 -13
  192. package/dist/HostingerProvider-Bx2lHHvu.cjs.map +0 -1
  193. package/dist/HostingerProvider-Cd4aS5Kj.mjs.map +0 -1
  194. package/dist/Route53Provider-Cvj40A8I.mjs.map +0 -1
  195. package/dist/Route53Provider-DVILBsvV.cjs.map +0 -1
  196. package/dist/backup-provisioner-BmhIO_Yr.cjs.map +0 -1
  197. package/dist/backup-provisioner-DIVCbfee.mjs.map +0 -1
  198. package/dist/cli-Bogz-zEj.mjs.map +0 -1
  199. package/dist/cli-D3jofJ6j.cjs.map +0 -1
  200. package/dist/credentials-B70vmLcm.cjs.map +0 -1
  201. package/dist/credentials-s0wMeZBU.mjs.map +0 -1
  202. package/dist/define-CYs0PEfZ.mjs.map +0 -1
  203. package/dist/define-zUsP72ym.cjs.map +0 -1
  204. package/dist/deploy-55rJkdKz.cjs.map +0 -1
  205. package/dist/deploy-Dc2LBJaK.mjs +0 -136
  206. package/dist/deploy-Dc2LBJaK.mjs.map +0 -1
  207. package/dist/deploy-ZRXIPp1c.mjs.map +0 -1
  208. package/dist/deploy-yUcR2OlW.cjs +0 -141
  209. package/dist/deploy-yUcR2OlW.cjs.map +0 -1
  210. package/dist/discover-DHdECueV.cjs.map +0 -1
  211. package/dist/discover-LMrqsWSU.mjs.map +0 -1
  212. package/dist/dokploy-CepdXuMl.mjs +0 -32
  213. package/dist/dokploy-CepdXuMl.mjs.map +0 -1
  214. package/dist/dokploy-D1d37rvM.cjs +0 -32
  215. package/dist/dokploy-D1d37rvM.cjs.map +0 -1
  216. package/dist/index-CJ2qsSpp.d.mts.map +0 -1
  217. package/dist/index-CfE9Y885.d.cts.map +0 -1
  218. package/dist/local-C7wnrcYK.cjs.map +0 -1
  219. package/dist/local-Cd8ZYQG3.mjs.map +0 -1
  220. package/dist/resolve-BXsNQjMR.cjs +0 -220
  221. package/dist/resolve-BXsNQjMR.cjs.map +0 -1
  222. package/dist/resolve-DZ2IJ0qY.mjs +0 -173
  223. package/dist/resolve-DZ2IJ0qY.mjs.map +0 -1
  224. package/dist/runtime-BGNDrHzh.cjs +0 -163
  225. package/dist/runtime-BGNDrHzh.cjs.map +0 -1
  226. package/dist/runtime-Cmn-FeYO.mjs +0 -134
  227. package/dist/runtime-Cmn-FeYO.mjs.map +0 -1
  228. package/dist/sniffer-49KPbDgq.mjs.map +0 -1
  229. package/dist/sniffer-ByhbEW5A.cjs.map +0 -1
  230. package/dist/storage-BzaUxmqe.mjs +0 -124
  231. package/dist/storage-BzaUxmqe.mjs.map +0 -1
  232. package/dist/storage-CSGoC3bK.cjs +0 -152
  233. package/dist/storage-CSGoC3bK.cjs.map +0 -1
  234. package/dist/store-BaaonhkJ.mjs +0 -415
  235. package/dist/store-BaaonhkJ.mjs.map +0 -1
  236. package/dist/store-D_2ByRtW.cjs +0 -510
  237. package/dist/store-D_2ByRtW.cjs.map +0 -1
  238. package/dist/terminal-Bdv--jhY.cjs.map +0 -1
  239. package/dist/terminal-D7Ntz6WE.mjs.map +0 -1
  240. package/dist/types-B_bEQvb9.d.mts.map +0 -1
  241. package/dist/types-DkrDOMxw.d.cts.map +0 -1
  242. package/dist/workspace-BwgWJK7h.cjs.map +0 -1
  243. package/dist/workspace-Bwit4q9O.mjs.map +0 -1
@@ -1,13 +1,13 @@
1
- const require_sniffer = require("./sniffer-ByhbEW5A.cjs");
2
- const require_local = require("./local-C7wnrcYK.cjs");
3
- const require_discover = require("./discover-DHdECueV.cjs");
1
+ const require_local = require("./local-BdH4XN0E.cjs");
2
+ const require_credentials = require("./credentials-Bzbg23n8.cjs");
3
+ const require_schemas = require("./schemas-RUlKHu27.cjs");
4
+ const require_discover = require("./discover-OEfZ9OkA.cjs");
5
+ const require_sandbox = require("./sandbox-CL--ZDui.cjs");
4
6
  const require_derive = require("./derive-DAbo49sm.cjs");
5
- const require_workspace = require("./workspace-BwgWJK7h.cjs");
6
- require("./config.cjs");
7
- const require_storage = require("./storage-CSGoC3bK.cjs");
8
- const require_store = require("./store-D_2ByRtW.cjs");
9
- const require_credentials = require("./credentials-B70vmLcm.cjs");
10
- const require_terminal = require("./terminal-Bdv--jhY.cjs");
7
+ const require_workspace = require("./workspace-ON-bqiJz.cjs");
8
+ const require_state = require("./state-Y6ATakMX.cjs");
9
+ const require_sniffer = require("./sniffer-5xmrkh2a.cjs");
10
+ const require_provider = require("./provider-B3Sv_a74.cjs");
11
11
  let node_path = require("node:path");
12
12
  let node_fs_promises = require("node:fs/promises");
13
13
  let node_os = require("node:os");
@@ -17,1860 +17,1884 @@ let _geekmidas_db_pg_roles = require("@geekmidas/db/pg/roles");
17
17
  let pg = require("pg");
18
18
  let _geekmidas_cache_postgres = require("@geekmidas/cache/postgres");
19
19
  let node_dns_promises = require("node:dns/promises");
20
- //#region src/deploy/fromManifest.ts
20
+ //#region src/deploy/docker.ts
21
+ const logger$3 = require_discover.output;
21
22
  /**
22
- * The Dokploy target, read off the construct manifest.
23
+ * The id the Dockerfile templates mount the encrypted credentials under:
24
+ * `RUN --mount=type=secret,id=gkm_credentials`.
25
+ */
26
+ const CREDENTIALS_SECRET_ID = "gkm_credentials";
27
+ /**
28
+ * Get the full image reference
29
+ */
30
+ function getImageRef(registry, imageName, tag) {
31
+ if (registry) return `${registry}/${imageName}:${tag}`;
32
+ return `${imageName}:${tag}`;
33
+ }
34
+ /**
35
+ * The `docker build` argv for an image.
23
36
  *
24
- * The AWS target has done this since `fromManifest` landed: a table keyed by
25
- * declaration kind, each entry turning one declaration into infrastructure and
26
- * the env keys it resolves. Dokploy had no such thing — it resolved environment
27
- * from the *sniffer*, which walks application code for `get('X')` calls, and a
28
- * construct reads its own key inside `@geekmidas/constructs` where there is
29
- * nothing to find. So a declared app deployed here got none of the model, and
30
- * nothing reported the absence: the deploy succeeded and the first request that
31
- * needed a URL failed at runtime.
37
+ * One element per value, so nothing in a ref or a build arg is read by a
38
+ * shell, and `--opt=value` rather than `--opt value`, so a value that starts
39
+ * with `-` cannot be taken for one of docker's own flags.
32
40
  *
33
- * This is the same table, over the REST wrapper instead of over Pulumi.
41
+ * @internal Exported for testing
42
+ */
43
+ function dockerBuildArgs(options) {
44
+ const { dockerfilePath, imageRef, buildArgs = [], credentialsFile } = options;
45
+ return [
46
+ "build",
47
+ "--platform=linux/amd64",
48
+ `--file=${dockerfilePath}`,
49
+ `--tag=${imageRef}`,
50
+ ...buildArgs.map((arg) => `--build-arg=${arg}`),
51
+ ...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
52
+ "."
53
+ ];
54
+ }
55
+ /**
56
+ * Write the encrypted credentials where `docker build --secret` reads them.
34
57
  *
35
- * **Two phases, and the split is not incidental.** Creating a Postgres and
36
- * creating a role inside it are different kinds of act: the first is an API
37
- * call, the second needs a connection to a database that does not exist yet
38
- * when the call is made. So provisioners *accumulate* DDL rather than running
39
- * it, and it is applied once against a cluster that is up — which is exactly
40
- * what `DatabaseBootstrap` does on AWS, for exactly the same reason.
58
+ * Two lines — ciphertext, then IV — so a template can split them with `sed`
59
+ * in any base image. Owner-only, in a directory of its own, and the returned
60
+ * cleanup removes both whatever the build did.
41
61
  *
42
- * **The DDL itself is shared.** `roleStatements` and `cacheTableStatements` are
43
- * the same generators the local target and the AWS target use. Dokploy used to
44
- * write its own `DO $$` block, which meant three targets applying three
45
- * different definitions of "the role split" and no way to notice they had
46
- * drifted.
62
+ * @internal Exported for testing
63
+ */
64
+ async function writeCredentialsFile(credentials) {
65
+ const dir = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "gkm-credentials-"));
66
+ const path = (0, node_path.join)(dir, CREDENTIALS_SECRET_ID);
67
+ await (0, node_fs_promises.writeFile)(path, `${credentials.encrypted}\n${credentials.iv}\n`, { mode: 384 });
68
+ await (0, node_fs_promises.chmod)(path, 384);
69
+ return {
70
+ path,
71
+ cleanup: () => (0, node_fs_promises.rm)(dir, {
72
+ recursive: true,
73
+ force: true
74
+ })
75
+ };
76
+ }
77
+ /**
78
+ * Build Docker image
79
+ * @param imageRef - Full image reference (registry/name:tag)
80
+ * @param buildArgs - Build arguments to pass to docker build
81
+ * @param credentials - Encrypted credentials, passed as a build secret
82
+ */
83
+ async function buildImage(imageRef, buildArgs, credentials, { cwd, dockerfile, signal, stdio }) {
84
+ logger$3.log(`\n🔨 Building Docker image: ${imageRef}`);
85
+ logger$3.log(" Generating Dockerfile...");
86
+ await require_state.dockerCommand({
87
+ slim: true,
88
+ cwd
89
+ });
90
+ const dockerfilePath = dockerfile;
91
+ const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
92
+ try {
93
+ await require_local.run("docker", dockerBuildArgs({
94
+ dockerfilePath,
95
+ imageRef,
96
+ buildArgs,
97
+ credentialsFile: secret?.path
98
+ }), {
99
+ cwd,
100
+ env: {
101
+ ...process.env,
102
+ DOCKER_BUILDKIT: "1"
103
+ },
104
+ ...signal ? { signal } : {},
105
+ ...stdio ? { stdio } : {}
106
+ });
107
+ logger$3.log(`✅ Image built: ${imageRef}`);
108
+ } catch (error) {
109
+ throw new require_state.DockerBuildFailed(imageRef, error);
110
+ } finally {
111
+ await secret?.cleanup();
112
+ }
113
+ }
114
+ /**
115
+ * Push Docker image to registry
116
+ */
117
+ async function pushImage(imageRef, { cwd, signal, stdio }) {
118
+ logger$3.log(`\n☁️ Pushing image: ${imageRef}`);
119
+ try {
120
+ await require_local.run("docker", ["push", imageRef], {
121
+ cwd,
122
+ ...signal ? { signal } : {},
123
+ ...stdio ? { stdio } : {}
124
+ });
125
+ logger$3.log(`✅ Image pushed: ${imageRef}`);
126
+ } catch (error) {
127
+ throw new require_state.DockerPushFailed(imageRef, error);
128
+ }
129
+ }
130
+ /**
131
+ * The digest the registry gave a pushed image, `sha256:…`.
47
132
  *
48
- * Entries are deliberately small and free of I/O ordering: each takes what it
49
- * needs and returns what it resolved. That is what would let a Pulumi dynamic
50
- * provider wrap one later without the table itself changing — see §6b.
133
+ * A tag can be pushed over — by the next deploy, or by anything else that
134
+ * shares the repository — and the digest is what says which image a stage
135
+ * actually ran. `RepoDigests` has one entry per repository the image was
136
+ * pushed to, so the one for this ref's repository is picked by name.
137
+ *
138
+ * @internal Exported for testing
139
+ */
140
+ async function pushedDigest(imageRef, { cwd, signal } = { cwd: process.cwd() }) {
141
+ const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
142
+ const output = await require_local.runOutput("docker", [
143
+ "image",
144
+ "inspect",
145
+ "--format={{json .RepoDigests}}",
146
+ imageRef
147
+ ], {
148
+ cwd,
149
+ ...signal ? { signal } : {}
150
+ });
151
+ let digests;
152
+ try {
153
+ digests = JSON.parse(output.trim() || "[]");
154
+ } catch {
155
+ return;
156
+ }
157
+ if (!Array.isArray(digests)) return void 0;
158
+ return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
159
+ }
160
+ /**
161
+ * Deploy using Docker (build and optionally push image)
51
162
  */
163
+ async function deployDocker(options) {
164
+ const { stage, tag, skipPush, masterKey, config, buildArgs, credentials } = options;
165
+ const imageName = config.imageName;
166
+ const imageRef = require_state.validateImageRef(getImageRef(config.registry, imageName, tag));
167
+ const cwd = options.cwd ?? process.cwd();
168
+ const docker = {
169
+ cwd,
170
+ dockerfile: (0, node_path.join)(cwd, require_state.dockerfileOf(config.appName ?? imageName, options.appPath ?? ".")),
171
+ ...options.signal ? { signal: options.signal } : {},
172
+ ...options.stdio ? { stdio: options.stdio } : {}
173
+ };
174
+ await buildImage(imageRef, buildArgs, credentials, docker);
175
+ let digest;
176
+ if (!skipPush) {
177
+ if (!config.registry) logger$3.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
178
+ else {
179
+ await pushImage(imageRef, docker);
180
+ digest = await pushedDigest(imageRef, docker);
181
+ }
182
+ }
183
+ logger$3.log("\n✅ Docker deployment ready!");
184
+ logger$3.log(`\n📋 Deployment details:`);
185
+ logger$3.log(` Image: ${imageRef}`);
186
+ logger$3.log(` Stage: ${stage}`);
187
+ if (masterKey) logger$3.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${require_sniffer.keyFingerprint(masterKey)}.`);
188
+ return {
189
+ imageRef,
190
+ ...digest ? { digest } : {},
191
+ masterKey
192
+ };
193
+ }
194
+ //#endregion
195
+ //#region src/deploy/ownership.ts
52
196
  /**
53
- * The internal hostname a Dokploy Postgres answers on.
197
+ * Which Dokploy project a deploy is allowed to deploy into.
54
198
  *
55
- * Its own service name on the Docker network — not the server's public address.
56
- * An app reaching its database over the public internet would be a credential
57
- * crossing a network that did not need to see it.
199
+ * Deploy used to take any project whose name matched the workspace's, in any
200
+ * case — so a second workspace called `shop` (or `Shop`) on the same server
201
+ * deployed into the first one's project, over its images and applications. A
202
+ * project is now claimed by an ownership marker in its description, and a
203
+ * project with the right name and no marker is somebody else's.
58
204
  */
59
- function clusterHost(postgres) {
60
- return postgres.appName;
205
+ /**
206
+ * A Dokploy project with the name this deploy would use exists, and nothing
207
+ * says this workspace created it.
208
+ */
209
+ var ProjectNotOwned = class extends Error {
210
+ projectName;
211
+ projectId;
212
+ identity;
213
+ ownedBy;
214
+ constructor(projectName, projectId, identity, ownedBy) {
215
+ super(`The Dokploy project '${projectName}' (${projectId}) was not created by this workspace (${identity})${ownedBy ? ` — it is marked '${ownedBy}'` : ""}, so deploy will not use it. Set deploy.namespace in gkm.config.ts to deploy under a name of your own, or, if this project really is this workspace's, add 'gkm:${identity}' to its description in Dokploy.`);
216
+ this.projectName = projectName;
217
+ this.projectId = projectId;
218
+ this.identity = identity;
219
+ this.ownedBy = ownedBy;
220
+ this.name = "ProjectNotOwned";
221
+ }
222
+ };
223
+ /** A description with the marker added, keeping what was there. */
224
+ function withMarker(description, marker) {
225
+ const existing = (description ?? "").trim();
226
+ return existing ? `${existing}\n${marker}` : marker;
227
+ }
228
+ /** `project.one`, or `null` when Dokploy no longer has the project. */
229
+ async function projectById(api, projectId) {
230
+ try {
231
+ return await api.getProject(projectId) ?? null;
232
+ } catch (error) {
233
+ if (error instanceof require_sniffer.DokployApiError) return null;
234
+ throw error;
235
+ }
61
236
  }
62
237
  /**
63
- * What a resource is called in the provider.
238
+ * The project this identity owns, if there is one — read only, so a dry run
239
+ * asks the same question a deploy does:
64
240
  *
65
- * `cloudName` and nothing else — the same scoped kebab rule the AWS target
66
- * uses, so a name reads identically on both: `production-shop-orders`.
241
+ * 1. The one the stage's state names, if Dokploy still has it. State written
242
+ * before markers existed is trusted — it holds an id this workspace was
243
+ * given when it created the project.
244
+ * 2. One with this name *and* this identity's marker.
67
245
  *
68
- * It briefly carried a `-postgres` suffix on the theory that a Dokploy project
69
- * is one flat list where an application and its database would collide. It is
70
- * not: `project.one` returns typed collections — `applications`, `postgres`,
71
- * `redis`, `compose`, `mariadb`, `mongo`, `mysql` — so the kind is already in
72
- * the shape of the response and an application and a Postgres of the same name
73
- * are different objects with different ids. The suffix was answering a question
74
- * nobody had asked.
246
+ * A project that matches by name (in any case) but carries no marker, or
247
+ * another identity's, raises `ProjectNotOwned` — it is never adopted.
75
248
  */
76
- function serviceName(scope, id) {
77
- return (0, _geekmidas_manifest.cloudName)(scope, id);
78
- }
79
- const PROVISIONERS = {
80
- /**
81
- * A Postgres service, plus the roles that reach it.
82
- *
83
- * The service is `findOrCreate`, because a deploy that recreated the
84
- * database would be a deploy that lost the data. The roles are deferred:
85
- * they need a connection to a cluster this call has only just asked for.
86
- */
87
- database: async (declaration, context) => {
88
- if (declaration.kind !== "database") throw new WrongKind(declaration.kind);
89
- const name = require_sniffer.resourceName(declaration.id, declaration.kind, context.stage);
90
- const { postgres } = await context.api.findOrCreatePostgres(serviceName({
91
- stage: context.stage,
92
- app: context.scope ?? context.project
93
- }, declaration.id), context.projectId, context.environmentId, {
94
- databaseName: name,
95
- databasePassword: derivedPassword(context, `${name}:master`)
96
- });
97
- const host = clusterHost(postgres);
98
- context.clusters[postgres.databaseName] = postgres;
99
- const schema = declaration.schema ?? "app";
100
- const runtime = name;
101
- const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
102
- const reads = readsFrom(context.manifest, declaration.id);
103
- context.deferred.push(...statementsFor({
104
- id: declaration.id,
105
- database: postgres.databaseName,
106
- ownsDatabase: true,
107
- runtime,
108
- owner,
109
- ...reads ? { reader: (0, _geekmidas_db_pg_roles.readerRole)(runtime) } : {},
110
- schema,
111
- context
112
- }));
113
- return { provides: {
114
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), host, postgres.databaseName),
115
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), host, postgres.databaseName)
116
- } };
117
- },
118
- /**
119
- * A schema in the parent's database, with its own role and its own URL — so
120
- * the parent's role holds no grant on these tables at all.
121
- */
122
- "database-schema": async (declaration, context) => {
123
- if (declaration.kind !== "database-schema") throw new WrongKind(declaration.kind);
124
- const parent = context.provisioned[declaration.of];
125
- if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
126
- const runtime = require_sniffer.resourceName(declaration.id, declaration.kind, context.stage);
127
- const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
128
- const reads = readsFrom(context.manifest, declaration.id);
129
- const parentUrl = new URL(parent.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")] ?? "");
130
- context.deferred.push(...statementsFor({
131
- id: declaration.id,
132
- database: parentUrl.pathname.slice(1),
133
- runtime,
134
- owner,
135
- ...reads ? { reader: (0, _geekmidas_db_pg_roles.readerRole)(runtime) } : {},
136
- schema: declaration.schema,
137
- context
138
- }));
139
- return { provides: {
140
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), parentUrl.hostname, parentUrl.pathname.slice(1)),
141
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), parentUrl.hostname, parentUrl.pathname.slice(1))
142
- } };
143
- },
144
- /**
145
- * A read-only endpoint on the same cluster.
146
- *
147
- * Nothing is provisioned: a Dokploy Postgres has one endpoint, so this
148
- * resolves to the writer's address — and that is safe rather than a
149
- * loophole, because read-only is enforced by the reader role's grants and
150
- * not by which host the URL names. The role itself was created with its
151
- * parent.
152
- */
153
- "database-reader": async (declaration, context) => {
154
- if (declaration.kind !== "database-reader") throw new WrongKind(declaration.kind);
155
- const parent = context.provisioned[declaration.of];
156
- if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
157
- const parentUrl = new URL(parent.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")] ?? "");
158
- const reader = (0, _geekmidas_db_pg_roles.readerRole)(parentUrl.username);
159
- return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(reader, derivedPassword(context, reader), parentUrl.hostname, parentUrl.pathname.slice(1)) } };
160
- },
161
- /**
162
- * A cache, wherever the project said it lives.
163
- *
164
- * `db` is a table in a database the manifest already declares, so it
165
- * provisions no service and costs nothing to run — the same relationship
166
- * pg-boss has. The table's DDL is deferred with the roles, and the table
167
- * name travels in the URL because two caches in one database resolve the
168
- * same connection string.
169
- */
170
- cache: async (declaration, context) => {
171
- if (declaration.kind !== "cache") throw new WrongKind(declaration.kind);
172
- const parentId = declaration.of ?? (context.cache === "db" ? soleDatabase(context) : void 0);
173
- if (!parentId) throw new CacheNeedsAHome(declaration.id, context.cache);
174
- const parent = context.provisioned[parentId];
175
- if (!parent) throw new UnresolvedParent(declaration.id, parentId);
176
- const parentUrl = parent.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
177
- if (!parentUrl) throw new UnresolvedParent(declaration.id, parentId);
178
- const table = declaration.table ?? (0, _geekmidas_manifest.cacheTable)(declaration.id);
179
- const schema = schemaOf(context.manifest, parentId);
180
- const qualified = schema ? `${schema}.${table}` : table;
181
- context.deferred.push(...(0, _geekmidas_cache_postgres.cacheTableStatements)({ table: qualified }).map((statement) => ({
182
- id: declaration.id,
183
- database: new URL(parentUrl).pathname.slice(1),
184
- describe: statement.describe,
185
- sql: statement.sql,
186
- ...statement.exists ? { exists: statement.exists } : {}
187
- })), {
188
- id: declaration.id,
189
- database: new URL(parentUrl).pathname.slice(1),
190
- describe: `${qualified} is owned by its schema's owner`,
191
- sql: `ALTER TABLE ${quoted(qualified)} OWNER TO ${quoted((0, _geekmidas_db_pg_roles.ownerRole)(new URL(parentUrl).username))}`
192
- }, {
193
- id: declaration.id,
194
- database: new URL(parentUrl).pathname.slice(1),
195
- describe: `${new URL(parentUrl).username} may read and write ${qualified}`,
196
- sql: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoted(qualified)} TO ${quoted(new URL(parentUrl).username)}`
197
- });
198
- const url = new URL(parentUrl);
199
- url.searchParams.set("table", table);
200
- return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url.toString() } };
201
- },
202
- /**
203
- * A bucket, as a MinIO stack on the box.
204
- *
205
- * Dokploy has first-class primitives for Postgres and Redis and none for
206
- * object storage, so this is the one kind whose infrastructure this target
207
- * *writes* rather than configures — a compose file with one service in it.
208
- *
209
- * The service is named `cloudName(...)` like everything else, which is not
210
- * cosmetic here: containers on `dokploy-network` resolve each other by
211
- * service name, so the name is the address. Calling it `minio` would work
212
- * for exactly one project on the box and then collide.
213
- *
214
- * `s3://` carries no credentials, deliberately — deployed on AWS the SDK
215
- * reads them from an execution role, so a URL that embedded a key would be
216
- * one more thing to rotate and leak. There is no role here, so the same
217
- * chain reads `AWS_ACCESS_KEY_ID` beside the URL, exactly as it does
218
- * locally.
219
- */
220
- objects: async (declaration, context) => {
221
- if (declaration.kind !== "objects") throw new WrongKind(declaration.kind);
222
- const backend = context.storage ?? "minio";
223
- if (backend !== "minio") throw new UnprovisionableBucket(declaration.id, backend);
224
- const service = serviceName({
225
- stage: context.stage,
226
- app: context.scope ?? context.project
227
- }, declaration.id);
228
- const bucket = require_sniffer.resourceName(declaration.id, declaration.kind, context.stage);
229
- const user = `${bucket}-root`;
230
- const password = derivedPassword(context, `objects:${declaration.id}`);
231
- const { compose } = await context.api.findOrCreateCompose(service, context.projectId, context.environmentId, minioCompose({
232
- service,
233
- bucket,
234
- user,
235
- password
236
- }));
237
- await context.api.deployCompose(compose.composeId);
238
- return { provides: {
239
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: s3Url(service, bucket),
240
- AWS_ACCESS_KEY_ID: user,
241
- AWS_SECRET_ACCESS_KEY: password,
242
- AWS_REGION: MINIO_REGION
243
- } };
244
- },
245
- /**
246
- * The address a bucket's objects are served on.
247
- *
248
- * Nothing is provisioned: Dokploy runs Traefik, so a second reverse proxy
249
- * behind the first would terminate TLS twice — which is why the local
250
- * target's Caddy has no equivalent here. What this resolves is the address,
251
- * and the `open` prefixes are applied to the bucket rather than to an edge,
252
- * so a policy is what makes them public wherever the bucket lives.
253
- *
254
- * Until a Traefik rule exists it answers on MinIO directly, path-style,
255
- * which is honest about the shape it has today: the bucket is in the path
256
- * rather than fronted by a host of its own.
257
- */
258
- "file-server": async (declaration, context) => {
259
- if (declaration.kind !== "file-server") throw new WrongKind(declaration.kind);
260
- const parentUrl = context.provisioned[declaration.of]?.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")];
261
- if (!parentUrl) throw new UnresolvedParent(declaration.id, declaration.of);
262
- const endpoint = new URL(parentUrl).searchParams.get("endpoint");
263
- const bucket = new URL(parentUrl).hostname;
264
- if (!endpoint) throw new UnresolvedParent(declaration.id, declaration.of);
265
- return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: `${endpoint}/${bucket}` } };
266
- },
267
- /**
268
- * A topic, and below it a queue — the same broker either way.
269
- *
270
- * Under pg-boss there is nothing to create in Dokploy: the broker is a
271
- * schema tenant of the database the app already declared, exactly as it is
272
- * locally, so what this resolves is an address rather than a service. Which
273
- * is the point of putting the backend in config — the same handlers drain
274
- * pg-boss here and SQS on AWS because the string they were handed said so.
275
- *
276
- * The tenant gets its own role rather than the cluster master, for the
277
- * reason every other tenant does: pg-boss creates and owns its tables, and
278
- * a broker that could also read the application's is a broker with a grant
279
- * nothing asked for.
280
- */
281
- topic: async (declaration, context) => {
282
- if (declaration.kind !== "topic") throw new WrongKind(declaration.kind);
283
- return broker(declaration.id, context);
284
- },
285
- queue: async (declaration, context) => {
286
- if (declaration.kind !== "queue") throw new WrongKind(declaration.kind);
287
- return broker(declaration.id, context);
288
- },
289
- /**
290
- * A signing key, generated once and remembered in the stage's store.
291
- *
292
- * Random, never derived: a key computed from the project and stage names
293
- * signs sessions anyone reading the repo could forge. The deploy generates
294
- * it before provisioning (`withGeneratedSecrets`) and every later deploy
295
- * reads the same one, so live sessions survive a redeploy. Nothing is
296
- * created in Dokploy: a secret has no address.
297
- */
298
- secret: async (declaration, context) => {
299
- if (declaration.kind !== "secret") throw new WrongKind(declaration.kind);
300
- const key = declaration.provides?.[0];
301
- if (!key) return { provides: {} };
302
- return { provides: { [key]: supplied(declaration.id, key, context) } };
303
- },
304
- /**
305
- * The keyring the stage keeps in its secrets.
306
- *
307
- * Generated on the first deploy (`withGeneratedSecrets`) and rotated only
308
- * by `gkm encryption:rotate`, so a redeploy never changes a key and nothing
309
- * written becomes unreadable. Nothing is created in Dokploy: the process
310
- * holds the keyring.
311
- */
312
- encryption: async (declaration, context) => {
313
- if (declaration.kind !== "encryption") throw new WrongKind(declaration.kind);
314
- const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "url");
315
- return { provides: { [key]: supplied(declaration.id, key, context) } };
316
- },
317
- /**
318
- * A third party's credentials, as the stage was given them.
319
- *
320
- * Nothing is created and nothing derived: the value was issued by somebody
321
- * else. A stage that was never given one fails the deploy here, naming the
322
- * command that sets it — not the first request that needs it.
323
- */
324
- credential: async (declaration, context) => {
325
- if (declaration.kind !== "credential") throw new WrongKind(declaration.kind);
326
- const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
327
- return { provides: { [key]: supplied(declaration.id, key, context) } };
328
- },
329
- /**
330
- * An API somebody else runs: the URL this stage calls it at, and the
331
- * credentials it was issued for this stage.
332
- */
333
- "external-api": async (declaration, context) => {
334
- if (declaration.kind !== "external-api") throw new WrongKind(declaration.kind);
335
- const credentials = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
336
- return { provides: {
337
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: (0, _geekmidas_manifest.externalApiUrl)(declaration, context.stage),
338
- [credentials]: supplied(declaration.id, credentials, context)
339
- } };
340
- },
341
- /**
342
- * A surface, at the address whatever created its domain assigned.
343
- *
344
- * Nothing is provisioned here: the application and its domain are the
345
- * existing engine's, and Dokploy's own Traefik terminates TLS with a Let's
346
- * Encrypt certificate. What this adds is the *derivation* — who may call it,
347
- * and where a cookie it sets is readable — from the graph rather than from a
348
- * hand-maintained list.
349
- */
350
- "rest-api": async (declaration, context) => {
351
- if (declaration.kind !== "rest-api") throw new WrongKind(declaration.kind);
352
- const url = context.addresses?.[declaration.id];
353
- if (!url) throw new SurfaceHasNoAddress(declaration.id);
354
- const origins = [...new Set((0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).map((caller) => context.addresses?.[caller]).filter((address) => Boolean(address)))].sort();
355
- const domain = (0, _geekmidas_manifest.cookieDomain)([url, ...origins]);
356
- const schemes = (0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).flatMap((caller) => {
357
- const app = context.manifest[caller];
358
- return app?.kind === "mobile-app" ? (0, _geekmidas_manifest.mobileOrigins)((0, _geekmidas_manifest.schemeBase)(context.project, app.scheme)) : [];
359
- });
360
- return { provides: {
361
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url,
362
- [(0, _geekmidas_manifest.provideKey)(declaration.id, "trustedOrigins")]: [...origins, ...schemes].join(","),
363
- ...domain ? { [(0, _geekmidas_manifest.provideKey)(declaration.id, "cookieDomain")]: domain } : {}
364
- } };
365
- }
366
- };
367
- /** The provisioner for a kind, or nothing where this target has none yet. */
368
- function provisionerFor(kind) {
369
- return PROVISIONERS[kind];
370
- }
371
- /**
372
- * The role DDL for one database or tenant, from the shared generator.
373
- *
374
- * The same statements the local target applies and the same ones the AWS
375
- * bootstrap runs. Dokploy used to write its own, which is how three targets
376
- * came to hold three definitions of the same split.
377
- */
378
- function statementsFor(options) {
379
- const { id, database, runtime, owner, reader, schema, context } = options;
380
- return (0, _geekmidas_db_pg_roles.roleStatements)({
381
- runtime,
382
- owner,
383
- ...reader ? { reader } : {},
384
- schema,
385
- ...options.ownsDatabase ? { database } : {},
386
- passwords: {
387
- runtime: derivedPassword(context, runtime),
388
- owner: derivedPassword(context, owner),
389
- ...reader ? { reader: derivedPassword(context, reader) } : {}
390
- }
391
- }).map((statement) => ({
392
- ...statement,
393
- id,
394
- database
395
- }));
396
- }
397
- /**
398
- * The connection string a declared queue or topic publishes on.
399
- *
400
- * One broker for the whole project, which is what the local target does and
401
- * what the runtime expects: the generated pollers open a single connection and
402
- * subscribe each worker by name on it. So every carrier resolves to the same
403
- * address, and the DDL that backs it is pushed once however many are declared.
404
- *
405
- * Only pg-boss is answered here. SNS needs a topic ARN that has to exist
406
- * first, and RabbitMQ needs a broker Dokploy has no primitive for — both are
407
- * the §4.3 question, and composing a plausible URL for either would fail at the
408
- * first publish instead of here.
409
- */
410
- function broker(id, context) {
411
- const backend = context.events ?? "pgboss";
412
- if (backend !== "pgboss") throw new UnprovisionableCarrier(id, backend);
413
- const parentId = soleDatabase(context);
414
- if (!parentId) throw new BrokerNeedsADatabase(id);
415
- const parentUrl = context.provisioned[parentId]?.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
416
- if (!parentUrl) throw new UnresolvedParent(id, parentId);
417
- const url = new URL(parentUrl);
418
- const database = url.pathname.slice(1);
419
- const runtime = `${database}_${PGBOSS_SCHEMA}`;
420
- const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
421
- if (!context.provisioned[BROKER_ID]) {
422
- context.deferred.push(...statementsFor({
423
- id: BROKER_ID,
424
- database,
425
- runtime,
426
- owner,
427
- schema: PGBOSS_SCHEMA,
428
- context
429
- }));
430
- context.provisioned[BROKER_ID] = { provides: {} };
431
- }
432
- const connection = pgbossUrl(runtime, derivedPassword(context, runtime), url.host, database);
433
- return { provides: { [(0, _geekmidas_manifest.provideKey)(id, "publisherConnectionString")]: connection } };
434
- }
435
- /** The schema pg-boss creates its tables in, here as locally. */
436
- const PGBOSS_SCHEMA = "pgboss";
437
- /**
438
- * The id the broker's DDL is pushed under.
439
- *
440
- * Not a construct — no declaration names it — but the deferred statements need
441
- * an id, and every carrier in the project shares this one.
442
- */
443
- const BROKER_ID = "PgBoss";
444
- /**
445
- * A pg-boss address: a Postgres URL under a scheme that picks the transport.
446
- *
447
- * The protocol is what a producer branches on, so it never branches — the same
448
- * `.publish()` reaches pg-boss here and SQS on AWS.
449
- */
450
- function pgbossUrl(role, password, host, database) {
451
- return `pgboss://${role}:${encodeURIComponent(password)}@${host}/${database}?schema=${PGBOSS_SCHEMA}`;
452
- }
453
- /** The region MinIO is addressed with. It has no regions; the SDK wants one. */
454
- const MINIO_REGION = "us-east-1";
455
- /** An `s3://` URL pointing the same S3 client at MinIO on the internal network. */
456
- function s3Url(service, bucket) {
457
- return `s3://${bucket}?region=${MINIO_REGION}&endpoint=http://${service}:9000&forcePathStyle=true`;
458
- }
459
- /**
460
- * A one-service compose file for a bucket.
461
- *
462
- * `dokploy-network` is external and already exists — it is what every Dokploy
463
- * service is attached to, and joining it is what makes the app able to resolve
464
- * this one by name. The bucket itself is created by MinIO's entrypoint rather
465
- * than by a second call, so there is nothing to reconcile afterwards.
466
- */
467
- function minioCompose(options) {
468
- const { service, bucket, user, password } = options;
469
- return [
470
- "services:",
471
- ` ${service}:`,
472
- " image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
473
- ` command: server /data --console-address ":9001"`,
474
- " environment:",
475
- ` MINIO_ROOT_USER: ${user}`,
476
- ` MINIO_ROOT_PASSWORD: ${password}`,
477
- " volumes:",
478
- ` - ${service}-data:/data`,
479
- " networks:",
480
- " - dokploy-network",
481
- " healthcheck:",
482
- " test: [\"CMD\", \"mc\", \"ready\", \"local\"]",
483
- " interval: 10s",
484
- " retries: 5",
485
- ` ${service}-bucket:`,
486
- " image: minio/mc:latest",
487
- " depends_on:",
488
- ` ${service}:`,
489
- " condition: service_healthy",
490
- " entrypoint: >",
491
- ` /bin/sh -c "mc alias set s3 http://${service}:9000 ${user} ${password} &&`,
492
- ` mc mb --ignore-existing s3/${bucket}"`,
493
- " networks:",
494
- " - dokploy-network",
495
- "volumes:",
496
- ` ${service}-data:`,
497
- "networks:",
498
- " dokploy-network:",
499
- " external: true",
500
- ""
501
- ].join("\n");
502
- }
503
- /** Whether anything reads through a reader on this construct. */
504
- function readsFrom(manifest, id) {
505
- return Object.values(manifest).some((declaration) => declaration.kind === "database-reader" && declaration.of === id);
506
- }
507
- /** The schema a database or tenant pins on its roles. */
508
- function schemaOf(manifest, id) {
509
- const declaration = manifest[id];
510
- if (declaration?.kind === "database") return declaration.schema ?? "app";
511
- if (declaration?.kind === "database-schema") return declaration.schema;
512
- }
513
- /**
514
- * The one declared database, where `cache: 'db'` has to pick.
515
- *
516
- * Unambiguous with one and arbitrary with two, so two is an error rather than a
517
- * coin toss — a cache landing in a database nobody chose surfaces as entries
518
- * that are never found, long after the deploy reported success.
519
- */
520
- function soleDatabase(context) {
521
- const databases = Object.entries(context.manifest).filter(([, declaration]) => declaration.kind === "database").map(([id]) => id);
522
- if (databases.length > 1) throw new CacheIsAmbiguous(databases);
523
- return databases[0];
249
+ async function findProject(api, identity, stateProjectId) {
250
+ const name = require_state.projectName(identity);
251
+ const marker = require_state.ownershipMarker(identity);
252
+ if (stateProjectId) {
253
+ const known = await projectById(api, stateProjectId);
254
+ if (known) {
255
+ const claimed = require_state.markerOf(known.description);
256
+ if (claimed && claimed !== marker) throw new ProjectNotOwned(known.name, known.projectId, identity.key, claimed);
257
+ return {
258
+ projectId: known.projectId,
259
+ name: known.name,
260
+ description: known.description ?? null,
261
+ environments: known.environments ?? [],
262
+ via: "state",
263
+ needsClaim: !claimed
264
+ };
265
+ }
266
+ }
267
+ const projects = await api.listProjects();
268
+ const owned = projects.find((p) => p.name.toLowerCase() === name.toLowerCase() && require_state.isOwnedBy(p.description, identity));
269
+ if (owned) {
270
+ const details = await api.getProject(owned.projectId);
271
+ return {
272
+ projectId: owned.projectId,
273
+ name: owned.name,
274
+ description: owned.description ?? null,
275
+ environments: details.environments ?? [],
276
+ via: "marker",
277
+ needsClaim: false
278
+ };
279
+ }
280
+ const clash = projects.find((p) => p.name.toLowerCase() === name.toLowerCase());
281
+ if (clash) throw new ProjectNotOwned(clash.name, clash.projectId, identity.key, require_state.markerOf(clash.description));
282
+ return null;
524
283
  }
525
284
  /**
526
- * A password derived from the stage's seed, the project, the stage and the role.
527
- *
528
- * Derived rather than stored per role so a redeploy hands the running
529
- * application the password it already has, and two projects never share one.
530
- * The seed is what makes it secret: random, generated once and kept in the
531
- * stage's store. Without it the inputs are all in the repo, and so was every
532
- * production password.
285
+ * The project this deploy deploys into: the one {@link findProject} finds —
286
+ * claimed with this identity's marker if it carries none yet, so the next
287
+ * deploy without state still finds it — or a new one, marked.
533
288
  */
534
- function derivedPassword(context, role) {
535
- return (0, node_crypto.createHash)("sha256").update(`${context.seed}:${context.project}:${context.stage}:role:${role}`).digest("base64url").slice(0, 32);
536
- }
537
- /** A connection string for a role on a cluster. */
538
- function postgresUrl(role, password, host, database) {
539
- return `postgres://${role}:${encodeURIComponent(password)}@${host}:5432/${database}`;
540
- }
541
- /** Quote an identifier, per segment, for DDL that cannot be parameterised. */
542
- function quoted(name) {
543
- return name.split(".").map((part) => `"${part.replace(/"/g, "\"\"")}"`).join(".");
289
+ async function resolveProject(api, identity, stateProjectId, log = () => {}, hooks = {}) {
290
+ const marker = require_state.ownershipMarker(identity);
291
+ const found = await findProject(api, identity, stateProjectId);
292
+ if (found) {
293
+ if (found.needsClaim) {
294
+ await api.updateProject(found.projectId, {
295
+ name: found.name,
296
+ description: withMarker(found.description, marker)
297
+ });
298
+ log(` Claimed project ${found.name} for ${identity.key}`);
299
+ }
300
+ return {
301
+ projectId: found.projectId,
302
+ name: found.name,
303
+ environments: found.environments,
304
+ via: found.via
305
+ };
306
+ }
307
+ await hooks.beforeCreate?.();
308
+ const created = await api.createProject(require_state.projectName(identity), withMarker("Deployed by gkm.", marker));
309
+ return {
310
+ projectId: created.project.projectId,
311
+ name: created.project.name,
312
+ environments: [created.environment],
313
+ via: "created"
314
+ };
544
315
  }
545
- /** A provisioner was handed a declaration of the wrong kind. */
316
+ //#endregion
317
+ //#region src/deploy/registry.ts
546
318
  /**
547
- * A value only the stage's own secrets can hold.
319
+ * Which Dokploy registry a stage's images are pulled through.
548
320
  *
549
- * @throws {MissingSuppliedSecret} when the stage was never given it.
321
+ * It used to be one id stored per machine (`~/.gkm/credentials.json`), and
322
+ * when that was empty, the first registry Dokploy listed — so an image could be
323
+ * deployed with credentials for a registry nobody chose, and two workspaces
324
+ * deployed from one machine shared whichever was stored last. The id is now
325
+ * kept in the stage's state, and found only from what the workspace
326
+ * configured.
550
327
  */
551
- function supplied(id, key, context) {
552
- const value = context.supplied?.[key];
553
- if (value === void 0) throw new MissingSuppliedSecret(id, key, context.stage);
554
- return value;
555
- }
556
- /** A construct needs a value the stage was never given. */
557
- var MissingSuppliedSecret = class extends Error {
558
- id;
559
- key;
328
+ /** There is nowhere to push images to, so Dokploy has nothing to pull. */
329
+ var RegistryNotConfigured = class extends Error {
560
330
  stage;
561
- constructor(id, key, stage) {
562
- super(`'${id}' needs ${key}, and the stage '${stage}' has none. Nothing can derive it; set it in the stage's secrets: gkm secrets:set ${key} '…' --stage ${stage}`);
563
- this.id = id;
564
- this.key = key;
331
+ constructor(stage) {
332
+ super(`Deploying '${stage}' needs a container registry: Dokploy pulls each app's image from one. Set deploy.registry in gkm.config.ts (e.g. 'ghcr.io/acme'), and deploy.dokploy.registryId if Dokploy already holds that registry's credentials.`);
565
333
  this.stage = stage;
566
- this.name = "MissingSuppliedSecret";
567
- }
568
- };
569
- var WrongKind = class extends Error {
570
- kind;
571
- constructor(kind) {
572
- super(`No Dokploy provisioner handles '${kind}'`);
573
- this.kind = kind;
574
- this.name = "WrongKind";
334
+ this.name = "RegistryNotConfigured";
575
335
  }
576
336
  };
577
- /** A derived construct whose parent has not been provisioned. */
578
- var UnresolvedParent = class extends Error {
579
- id;
580
- parent;
581
- constructor(id, parent) {
582
- super(`'${id}' derives from '${parent}', which has not been provisioned. Provision in \`provisionOrder\`, which puts parents first.`);
583
- this.id = id;
584
- this.parent = parent;
585
- this.name = "UnresolvedParent";
337
+ /** `deploy.dokploy.registryId` names a registry Dokploy does not have. */
338
+ var RegistryNotFound = class extends Error {
339
+ registryId;
340
+ constructor(registryId) {
341
+ super(`deploy.dokploy.registryId '${registryId}' is not a registry on this Dokploy server. Find the right id under Settings → Docker Registry in Dokploy and set it in gkm.config.ts, or remove it to have deploy find the registry by its URL.`);
342
+ this.registryId = registryId;
343
+ this.name = "RegistryNotFound";
586
344
  }
587
345
  };
588
- /** A cache with nowhere to live. */
589
- var CacheNeedsAHome = class extends Error {
590
- id;
591
- backend;
592
- constructor(id, backend) {
593
- super(`'${id}' is a cache and nothing says where it lives. Declare it from its database — \`orders.cache('${id}')\`.` + (backend ? ` The deploy target's backend is '${backend}'.` : ""));
594
- this.id = id;
595
- this.backend = backend;
596
- this.name = "CacheNeedsAHome";
346
+ /** More than one Dokploy registry serves the configured registry. */
347
+ var RegistryAmbiguous = class extends Error {
348
+ registry;
349
+ registryIds;
350
+ constructor(registry, registryIds) {
351
+ super(`Dokploy has ${registryIds.length} registries for '${registry}' (${registryIds.join(", ")}). Set deploy.dokploy.registryId in gkm.config.ts to the one this workspace pushes with.`);
352
+ this.registry = registry;
353
+ this.registryIds = registryIds;
354
+ this.name = "RegistryAmbiguous";
597
355
  }
598
356
  };
599
- /** A `'db'` cache (a server target's default) with more than one database. */
600
- var CacheIsAmbiguous = class extends Error {
601
- databases;
602
- constructor(databases) {
603
- super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database rather than as a bare new Cache().`);
604
- this.databases = databases;
605
- this.name = "CacheIsAmbiguous";
357
+ /** `https://ghcr.io/acme/` → `{ host: 'ghcr.io', path: 'acme' }`. */
358
+ function parts(url) {
359
+ const [host = "", ...path] = url.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase().split("/");
360
+ return {
361
+ host,
362
+ path: path.join("/")
363
+ };
364
+ }
365
+ /**
366
+ * The Dokploy registries that serve `registry`: the same host, and — where
367
+ * Dokploy's own entry names one — the same path.
368
+ */
369
+ function serving(registries, registry) {
370
+ const wanted = parts(registry);
371
+ const sameHost = registries.filter((r) => parts(r.registryUrl ?? "").host === wanted.host);
372
+ if (sameHost.length <= 1) return sameHost;
373
+ return sameHost.filter((r) => {
374
+ return (parts(r.registryUrl ?? "").path || (r.imagePrefix ?? "").toLowerCase()) === wanted.path;
375
+ });
376
+ }
377
+ /** `registry.one`, or `null` when Dokploy says it has no such registry. */
378
+ async function registryById(api, registryId) {
379
+ try {
380
+ return await api.getRegistry(registryId) ?? null;
381
+ } catch (error) {
382
+ if (error instanceof require_sniffer.DokployApiError) return null;
383
+ throw error;
606
384
  }
607
- };
608
- /** A surface reached provisioning before its domain existed. */
609
- var SurfaceHasNoAddress = class extends Error {
610
- id;
611
- constructor(id) {
612
- super(`'${id}' is a surface and no address was supplied for it. The domain is created before the manifest is provisioned, so this means the two ran out of order.`);
613
- this.id = id;
614
- this.name = "SurfaceHasNoAddress";
385
+ }
386
+ /**
387
+ * The registry id for a stage, in order: the configured id, the one in the
388
+ * stage's state, the one Dokploy has for the configured registry URL, and
389
+ * otherwise a new one. Never a registry the workspace did not point at.
390
+ */
391
+ async function resolveRegistry(api, options) {
392
+ const { stage, registry, configuredId, stateId, log = () => {} } = options;
393
+ if (!registry) throw new RegistryNotConfigured(stage);
394
+ if (configuredId) {
395
+ const configured = await registryById(api, configuredId);
396
+ if (!configured) throw new RegistryNotFound(configuredId);
397
+ return configured;
615
398
  }
616
- };
617
- /** A queue or topic on a backend this target has no primitive for. */
618
- var UnprovisionableCarrier = class extends Error {
619
- id;
620
- backend;
621
- constructor(id, backend) {
622
- super(`'${id}' is carried by '${backend}', which this target cannot provision. SNS needs a topic ARN that has to exist first and RabbitMQ needs a broker Dokploy has no primitive for; pg-boss is a schema tenant of a database this app already declares, which is why it is the one that works here. Deploy this app to a target that has the backend.`);
623
- this.id = id;
624
- this.backend = backend;
625
- this.name = "UnprovisionableCarrier";
399
+ if (stateId) {
400
+ const known = await registryById(api, stateId);
401
+ if (known) return known;
402
+ log(` ⚠ The stage's registry ${stateId} no longer exists in Dokploy`);
626
403
  }
627
- };
628
- /** A pg-boss carrier in an app that declares no database to host it. */
629
- var BrokerNeedsADatabase = class extends Error {
630
- id;
631
- constructor(id) {
632
- super(`'${id}' is carried by pg-boss, which lives in the database the app declares — and this app declares none. Declare a database, or deploy to a target whose broker is its own.`);
633
- this.id = id;
634
- this.name = "BrokerNeedsADatabase";
404
+ const matches = serving(await api.listRegistries(), registry);
405
+ if (matches.length > 1) throw new RegistryAmbiguous(registry, matches.map((r) => r.registryId));
406
+ if (matches[0]) return matches[0];
407
+ return options.create(registry);
408
+ }
409
+ //#endregion
410
+ //#region src/deploy/secrets.ts
411
+ /**
412
+ * Filter secrets to only include the env vars that an app requires.
413
+ *
414
+ * @param stageSecrets - All secrets for the stage
415
+ * @param sniffedEnv - The sniffed environment requirements for the app
416
+ * @returns Filtered secrets with found/missing tracking
417
+ */
418
+ function filterSecretsForApp(stageSecrets, sniffedEnv) {
419
+ const allSecrets = require_state.toEmbeddableSecrets(stageSecrets);
420
+ const filtered = {};
421
+ const found = [];
422
+ const missing = [];
423
+ for (const key of sniffedEnv.requiredEnvVars) if (key in allSecrets) {
424
+ filtered[key] = allSecrets[key];
425
+ found.push(key);
426
+ } else missing.push(key);
427
+ return {
428
+ appName: sniffedEnv.appName,
429
+ secrets: filtered,
430
+ found: found.sort(),
431
+ missing: missing.sort()
432
+ };
433
+ }
434
+ /**
435
+ * Encrypt filtered secrets for an app.
436
+ * Generates an ephemeral master key that should be injected into Dokploy.
437
+ *
438
+ * @param filteredSecrets - The filtered secrets for the app
439
+ * @returns Encrypted payload with master key
440
+ */
441
+ function encryptSecretsForApp(filteredSecrets) {
442
+ const payload = require_sniffer.encryptSecrets(filteredSecrets.secrets);
443
+ return {
444
+ appName: filteredSecrets.appName,
445
+ payload,
446
+ masterKey: payload.masterKey,
447
+ secretCount: Object.keys(filteredSecrets.secrets).length,
448
+ missingSecrets: filteredSecrets.missing
449
+ };
450
+ }
451
+ /**
452
+ * Filter and encrypt secrets for an app in one step.
453
+ *
454
+ * @param stageSecrets - All secrets for the stage
455
+ * @param sniffedEnv - The sniffed environment requirements for the app
456
+ * @returns Encrypted secrets with master key
457
+ */
458
+ function prepareSecretsForApp(stageSecrets, sniffedEnv) {
459
+ return encryptSecretsForApp(filterSecretsForApp(stageSecrets, sniffedEnv));
460
+ }
461
+ /**
462
+ * Prepare secrets for multiple apps.
463
+ *
464
+ * @param stageSecrets - All secrets for the stage
465
+ * @param sniffedApps - Map of app name to sniffed environment
466
+ * @returns Map of app name to encrypted secrets
467
+ */
468
+ function prepareSecretsForAllApps(stageSecrets, sniffedApps) {
469
+ const results = /* @__PURE__ */ new Map();
470
+ for (const [appName, sniffedEnv] of sniffedApps) if (sniffedEnv.requiredEnvVars.length > 0) {
471
+ const encrypted = prepareSecretsForApp(stageSecrets, sniffedEnv);
472
+ results.set(appName, encrypted);
635
473
  }
636
- };
637
- /** A bucket on a backend this target has no primitive for. */
638
- var UnprovisionableBucket = class extends Error {
639
- id;
640
- backend;
641
- constructor(id, backend) {
642
- super(`'${id}' is stored on '${backend}', which this target cannot provision: an S3 or R2 bucket belongs to an account this deploy does not hold, the way an API key does. Supply the bucket's URL.`);
643
- this.id = id;
644
- this.backend = backend;
645
- this.name = "UnprovisionableBucket";
474
+ return results;
475
+ }
476
+ /**
477
+ * Generate a report on secrets preparation.
478
+ */
479
+ function generateSecretsReport(encryptedApps, sniffedApps) {
480
+ const appsWithSecrets = [];
481
+ const appsWithoutSecrets = [];
482
+ const appsWithMissingSecrets = [];
483
+ for (const [appName, sniffedEnv] of sniffedApps) {
484
+ if (sniffedEnv.requiredEnvVars.length === 0) {
485
+ appsWithoutSecrets.push(appName);
486
+ continue;
487
+ }
488
+ const encrypted = encryptedApps.get(appName);
489
+ if (encrypted) {
490
+ appsWithSecrets.push(appName);
491
+ if (encrypted.missingSecrets.length > 0) appsWithMissingSecrets.push({
492
+ appName,
493
+ missing: encrypted.missingSecrets
494
+ });
495
+ }
646
496
  }
647
- };
497
+ return {
498
+ totalApps: sniffedApps.size,
499
+ appsWithSecrets: appsWithSecrets.sort(),
500
+ appsWithoutSecrets: appsWithoutSecrets.sort(),
501
+ appsWithMissingSecrets
502
+ };
503
+ }
648
504
  //#endregion
649
- //#region src/deploy/declared.ts
505
+ //#region src/target/dokploy/fromManifest.ts
650
506
  /**
651
- * The declared half of a Dokploy deploy.
507
+ * The Dokploy target, read off the construct manifest.
652
508
  *
653
- * `fromManifest.ts` decides what each construct resolves to; this runs it —
654
- * discovers the manifest, walks it parents-first, and applies the DDL the
655
- * provisioners deferred.
509
+ * The AWS target has done this since `fromManifest` landed: a table keyed by
510
+ * declaration kind, each entry turning one declaration into infrastructure and
511
+ * the env keys it resolves. Dokploy had no such thing — it resolved environment
512
+ * from the *sniffer*, which walks application code for `get('X')` calls, and a
513
+ * construct reads its own key inside `@geekmidas/constructs` where there is
514
+ * nothing to find. So a declared app deployed here got none of the model, and
515
+ * nothing reported the absence: the deploy succeeded and the first request that
516
+ * needed a URL failed at runtime.
656
517
  *
657
- * It is deliberately separate from the engine in `index.ts`. That file deploys
658
- * *applications*: images, registries, domains, the things a project has whether
659
- * or not it declares anything. This is the part that exists because the app
660
- * declared it, and keeping the two apart is what lets a project adopt the model
661
- * without rewriting its deploy — and lets this one be tested without one.
518
+ * This is the same table, over the REST wrapper instead of over Pulumi.
519
+ *
520
+ * **Two phases, and the split is not incidental.** Creating a Postgres and
521
+ * creating a role inside it are different kinds of act: the first is an API
522
+ * call, the second needs a connection to a database that does not exist yet
523
+ * when the call is made. So provisioners *accumulate* DDL rather than running
524
+ * it, and it is applied once against a cluster that is up — which is exactly
525
+ * what `DatabaseBootstrap` does on AWS, for exactly the same reason.
526
+ *
527
+ * **The DDL itself is shared.** `roleStatements` and `cacheTableStatements` are
528
+ * the same generators the local target and the AWS target use. Dokploy used to
529
+ * write its own `DO $$` block, which meant three targets applying three
530
+ * different definitions of "the role split" and no way to notice they had
531
+ * drifted.
532
+ *
533
+ * Entries are deliberately small and free of I/O ordering: each takes what it
534
+ * needs and returns what it resolved. That is what would let a Pulumi dynamic
535
+ * provider wrap one later without the table itself changing — see §6b.
662
536
  */
663
537
  /**
664
- * Provision everything the workspace declares, and return what it resolved.
538
+ * The internal hostname a Dokploy Postgres answers on.
539
+ *
540
+ * Its own service name on the Docker network — not the server's public address.
541
+ * An app reaching its database over the public internet would be a credential
542
+ * crossing a network that did not need to see it.
665
543
  */
666
- async function provisionDeclared(options) {
667
- const { workspace } = options;
668
- const empty = {
669
- env: {},
670
- statements: [],
671
- provisioned: {},
672
- clusters: {}
673
- };
674
- const manifest = options.manifest ?? await require_discover.discover({
675
- patterns: require_sniffer.constructGlobs(workspace),
676
- cwd: workspace.root
677
- });
678
- if (Object.keys(manifest).length === 0) return empty;
679
- const context = {
680
- manifest,
681
- provisioned: {},
682
- api: options.api,
683
- projectId: options.projectId,
684
- environmentId: options.environmentId,
685
- stage: options.stage,
686
- project: workspace.name,
687
- scope: options.scope ?? workspace.name,
688
- cache: require_sniffer.cacheBackendFor(require_sniffer.providerOf(workspace)),
689
- events: require_sniffer.eventsBackendFor(require_sniffer.providerOf(workspace)),
690
- storage: require_sniffer.storageBackendFor(require_sniffer.providerOf(workspace)),
691
- addresses: surfaceAddresses(manifest, options.appUrls),
692
- seed: options.seed,
693
- ...options.supplied ? { supplied: options.supplied } : {},
694
- deferred: [],
695
- clusters: {}
696
- };
697
- const env = {};
698
- for (const id of carriersLast((0, _geekmidas_manifest.provisionOrder)(manifest), manifest)) {
699
- const declaration = manifest[id];
700
- if (!declaration) continue;
701
- const provisioner = provisionerFor(declaration.kind);
702
- if (!provisioner) continue;
703
- const result = await provisioner(declaration, context);
704
- context.provisioned[id] = result;
705
- Object.assign(env, result.provides);
706
- }
707
- return {
708
- env,
709
- statements: context.deferred.map((statement) => ({
710
- id: statement.id,
544
+ function clusterHost(postgres) {
545
+ return postgres.appName;
546
+ }
547
+ /**
548
+ * What a resource is called in the provider.
549
+ *
550
+ * `cloudName` and nothing else — the same scoped kebab rule the AWS target
551
+ * uses, so a name reads identically on both: `production-shop-orders`.
552
+ *
553
+ * It briefly carried a `-postgres` suffix on the theory that a Dokploy project
554
+ * is one flat list where an application and its database would collide. It is
555
+ * not: `project.one` returns typed collections — `applications`, `postgres`,
556
+ * `redis`, `compose`, `mariadb`, `mongo`, `mysql` — so the kind is already in
557
+ * the shape of the response and an application and a Postgres of the same name
558
+ * are different objects with different ids. The suffix was answering a question
559
+ * nobody had asked.
560
+ */
561
+ function serviceName(scope, id) {
562
+ return (0, _geekmidas_manifest.cloudName)(scope, id);
563
+ }
564
+ const PROVISIONERS = {
565
+ /**
566
+ * A Postgres service, plus the roles that reach it.
567
+ *
568
+ * The service is `findOrCreate`, because a deploy that recreated the
569
+ * database would be a deploy that lost the data. The roles are deferred:
570
+ * they need a connection to a cluster this call has only just asked for.
571
+ */
572
+ database: async (declaration, context) => {
573
+ if (declaration.kind !== "database") throw new WrongKind(declaration.kind);
574
+ const name = require_state.resourceName(declaration.id, declaration.kind, context.stage);
575
+ const { postgres } = await context.api.findOrCreatePostgres(serviceName({
576
+ stage: context.stage,
577
+ app: context.scope ?? context.project
578
+ }, declaration.id), context.projectId, context.environmentId, {
579
+ databaseName: name,
580
+ databasePassword: derivedPassword(context, `${name}:master`)
581
+ });
582
+ const host = clusterHost(postgres);
583
+ context.clusters[postgres.databaseName] = postgres;
584
+ const schema = declaration.schema ?? "app";
585
+ const runtime = name;
586
+ const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
587
+ const reads = readsFrom(context.manifest, declaration.id);
588
+ context.deferred.push(...statementsFor({
589
+ id: declaration.id,
590
+ database: postgres.databaseName,
591
+ ownsDatabase: true,
592
+ runtime,
593
+ owner,
594
+ ...reads ? { reader: (0, _geekmidas_db_pg_roles.readerRole)(runtime) } : {},
595
+ schema,
596
+ context
597
+ }));
598
+ return { provides: {
599
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), host, postgres.databaseName),
600
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), host, postgres.databaseName)
601
+ } };
602
+ },
603
+ /**
604
+ * A schema in the parent's database, with its own role and its own URL — so
605
+ * the parent's role holds no grant on these tables at all.
606
+ */
607
+ "database-schema": async (declaration, context) => {
608
+ if (declaration.kind !== "database-schema") throw new WrongKind(declaration.kind);
609
+ const parent = context.provisioned[declaration.of];
610
+ if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
611
+ const runtime = require_state.resourceName(declaration.id, declaration.kind, context.stage);
612
+ const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
613
+ const reads = readsFrom(context.manifest, declaration.id);
614
+ const parentUrl = new URL(parent.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")] ?? "");
615
+ context.deferred.push(...statementsFor({
616
+ id: declaration.id,
617
+ database: parentUrl.pathname.slice(1),
618
+ runtime,
619
+ owner,
620
+ ...reads ? { reader: (0, _geekmidas_db_pg_roles.readerRole)(runtime) } : {},
621
+ schema: declaration.schema,
622
+ context
623
+ }));
624
+ return { provides: {
625
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(runtime, derivedPassword(context, runtime), parentUrl.hostname, parentUrl.pathname.slice(1)),
626
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "ownerUrl")]: postgresUrl(owner, derivedPassword(context, owner), parentUrl.hostname, parentUrl.pathname.slice(1))
627
+ } };
628
+ },
629
+ /**
630
+ * A read-only endpoint on the same cluster.
631
+ *
632
+ * Nothing is provisioned: a Dokploy Postgres has one endpoint, so this
633
+ * resolves to the writer's address — and that is safe rather than a
634
+ * loophole, because read-only is enforced by the reader role's grants and
635
+ * not by which host the URL names. The role itself was created with its
636
+ * parent.
637
+ */
638
+ "database-reader": async (declaration, context) => {
639
+ if (declaration.kind !== "database-reader") throw new WrongKind(declaration.kind);
640
+ const parent = context.provisioned[declaration.of];
641
+ if (!parent) throw new UnresolvedParent(declaration.id, declaration.of);
642
+ const parentUrl = new URL(parent.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")] ?? "");
643
+ const reader = (0, _geekmidas_db_pg_roles.readerRole)(parentUrl.username);
644
+ return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: postgresUrl(reader, derivedPassword(context, reader), parentUrl.hostname, parentUrl.pathname.slice(1)) } };
645
+ },
646
+ /**
647
+ * A cache, wherever the project said it lives.
648
+ *
649
+ * `db` is a table in a database the manifest already declares, so it
650
+ * provisions no service and costs nothing to run — the same relationship
651
+ * pg-boss has. The table's DDL is deferred with the roles, and the table
652
+ * name travels in the URL because two caches in one database resolve the
653
+ * same connection string.
654
+ */
655
+ cache: async (declaration, context) => {
656
+ if (declaration.kind !== "cache") throw new WrongKind(declaration.kind);
657
+ const parentId = declaration.of ?? (context.cache === "db" ? soleDatabase(context) : void 0);
658
+ if (!parentId) throw new CacheNeedsAHome(declaration.id, context.cache);
659
+ const parent = context.provisioned[parentId];
660
+ if (!parent) throw new UnresolvedParent(declaration.id, parentId);
661
+ const parentUrl = parent.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
662
+ if (!parentUrl) throw new UnresolvedParent(declaration.id, parentId);
663
+ const table = declaration.table ?? (0, _geekmidas_manifest.cacheTable)(declaration.id);
664
+ const schema = schemaOf(context.manifest, parentId);
665
+ const qualified = schema ? `${schema}.${table}` : table;
666
+ context.deferred.push(...(0, _geekmidas_cache_postgres.cacheTableStatements)({ table: qualified }).map((statement) => ({
667
+ id: declaration.id,
668
+ database: new URL(parentUrl).pathname.slice(1),
711
669
  describe: statement.describe,
712
- ...statement.database ? { database: statement.database } : {},
713
- ...statement.exists ? { exists: statement.exists } : {},
714
- create: statement.sql
715
- })),
716
- provisioned: context.provisioned,
717
- clusters: context.clusters
718
- };
719
- }
720
- /**
721
- * Run the deferred DDL against a cluster that is now reachable.
722
- *
723
- * The same applier the local target uses: every statement asks whether it is
724
- * needed first, so a redeploy is free and a half-applied run recovers by being
725
- * run again.
726
- */
727
- async function applyDeclared(client, statements) {
728
- return (await require_sniffer.applyPostgres(client, statements)).filter((entry) => entry.created).length;
729
- }
730
- /**
731
- * The provisioning order, with queues and topics moved to the end.
732
- *
733
- * `provisionOrder` walks `of` edges, and a carrier has none: a topic is not
734
- * *derived from* a database, because under SNS it does not live in one. Where
735
- * it lives is a fact about the backend, which is config — so the dependency is
736
- * real under pg-boss and absent under SNS, and writing it into the declaration
737
- * would state it in the one place that cannot know.
738
- *
739
- * Here it can. This runs where the backend is known, and the rule needs no
740
- * branch on it: nothing depends on a carrier. A queue's worker and a topic's
741
- * subscribers are *bound* to it rather than dependent on it, which is why
742
- * neither holds a key of its own — so a carrier can always go last, and under
743
- * pg-boss that is exactly late enough for the database it reads to be there.
744
- */
745
- function carriersLast(ordered, manifest) {
746
- const carries = (id) => {
747
- const kind = manifest[id]?.kind;
748
- return kind === "queue" || kind === "topic";
749
- };
750
- return [...ordered.filter((id) => !carries(id)), ...ordered.filter(carries)];
751
- }
752
- /**
753
- * Where each declared surface answers: its own app's address.
754
- *
755
- * Every surface is an app of its own, keyed by its id the way the derivation
756
- * keyed it — `Api` is `api`, `Webhooks` is `webhooks` — and answers on that
757
- * app's host, `{subdomain}.{domain}`. It used to be handed the first backend's
758
- * address, so a workspace with two APIs pointed both at the same one.
759
- */
760
- function surfaceAddresses(manifest, appUrls) {
761
- const addresses = {};
762
- for (const [id, declaration] of Object.entries(manifest)) {
763
- if (declaration.kind !== "rest-api") continue;
764
- const url = appUrls[require_derive.appKey(id)];
765
- if (url) addresses[id] = url;
766
- }
767
- return addresses;
768
- }
769
- //#endregion
770
- //#region src/deploy/dns/DnsProvider.ts
771
- /**
772
- * Check if value is a DnsProvider implementation.
773
- */
774
- function isDnsProvider(value) {
775
- return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
776
- }
777
- /**
778
- * Create a DNS provider based on configuration.
779
- *
780
- * - 'hostinger': HostingerProvider
781
- * - 'route53': Route53Provider
782
- * - 'manual': Returns null (user handles DNS)
783
- * - Custom: Use provided DnsProvider implementation
784
- */
785
- async function createDnsProvider(options) {
786
- const { config } = options;
787
- if (config.provider === "manual") return null;
788
- if (isDnsProvider(config.provider)) return config.provider;
789
- const provider = config.provider;
790
- if (provider === "hostinger") {
791
- const { HostingerProvider } = await Promise.resolve().then(() => require("./HostingerProvider-Bx2lHHvu.cjs"));
792
- return new HostingerProvider();
793
- }
794
- if (provider === "route53") {
795
- const { Route53Provider } = await Promise.resolve().then(() => require("./Route53Provider-DVILBsvV.cjs"));
796
- const route53Config = config;
797
- return new Route53Provider({
798
- region: route53Config.region,
799
- profile: route53Config.profile,
800
- hostedZoneId: route53Config.hostedZoneId
670
+ sql: statement.sql,
671
+ ...statement.exists ? { exists: statement.exists } : {}
672
+ })), {
673
+ id: declaration.id,
674
+ database: new URL(parentUrl).pathname.slice(1),
675
+ describe: `${qualified} is owned by its schema's owner`,
676
+ sql: `ALTER TABLE ${quoted(qualified)} OWNER TO ${quoted((0, _geekmidas_db_pg_roles.ownerRole)(new URL(parentUrl).username))}`
677
+ }, {
678
+ id: declaration.id,
679
+ database: new URL(parentUrl).pathname.slice(1),
680
+ describe: `${new URL(parentUrl).username} may read and write ${qualified}`,
681
+ sql: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${quoted(qualified)} TO ${quoted(new URL(parentUrl).username)}`
682
+ });
683
+ const url = new URL(parentUrl);
684
+ url.searchParams.set("table", table);
685
+ return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url.toString() } };
686
+ },
687
+ /**
688
+ * A bucket, as a MinIO stack on the box.
689
+ *
690
+ * Dokploy has first-class primitives for Postgres and Redis and none for
691
+ * object storage, so this is the one kind whose infrastructure this target
692
+ * *writes* rather than configures — a compose file with one service in it.
693
+ *
694
+ * The service is named `cloudName(...)` like everything else, which is not
695
+ * cosmetic here: containers on `dokploy-network` resolve each other by
696
+ * service name, so the name is the address. Calling it `minio` would work
697
+ * for exactly one project on the box and then collide.
698
+ *
699
+ * `s3://` carries no credentials, deliberately — deployed on AWS the SDK
700
+ * reads them from an execution role, so a URL that embedded a key would be
701
+ * one more thing to rotate and leak. There is no role here, so the same
702
+ * chain reads `AWS_ACCESS_KEY_ID` beside the URL, exactly as it does
703
+ * locally.
704
+ */
705
+ objects: async (declaration, context) => {
706
+ if (declaration.kind !== "objects") throw new WrongKind(declaration.kind);
707
+ const backend = context.storage ?? "minio";
708
+ if (backend !== "minio") throw new UnprovisionableBucket(declaration.id, backend);
709
+ const service = serviceName({
710
+ stage: context.stage,
711
+ app: context.scope ?? context.project
712
+ }, declaration.id);
713
+ const bucket = require_state.resourceName(declaration.id, declaration.kind, context.stage);
714
+ const user = `${bucket}-root`;
715
+ const password = derivedPassword(context, `objects:${declaration.id}`);
716
+ const { compose } = await context.api.findOrCreateCompose(service, context.projectId, context.environmentId, minioCompose({
717
+ service,
718
+ bucket,
719
+ user,
720
+ password
721
+ }));
722
+ await context.api.deployCompose(compose.composeId);
723
+ return { provides: {
724
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: s3Url(service, bucket),
725
+ AWS_ACCESS_KEY_ID: user,
726
+ AWS_SECRET_ACCESS_KEY: password,
727
+ AWS_REGION: MINIO_REGION
728
+ } };
729
+ },
730
+ /**
731
+ * The address a bucket's objects are served on.
732
+ *
733
+ * Nothing is provisioned: Dokploy runs Traefik, so a second reverse proxy
734
+ * behind the first would terminate TLS twice — which is why the local
735
+ * target's Caddy has no equivalent here. What this resolves is the address,
736
+ * and the `open` prefixes are applied to the bucket rather than to an edge,
737
+ * so a policy is what makes them public wherever the bucket lives.
738
+ *
739
+ * Until a Traefik rule exists it answers on MinIO directly, path-style,
740
+ * which is honest about the shape it has today: the bucket is in the path
741
+ * rather than fronted by a host of its own.
742
+ */
743
+ "file-server": async (declaration, context) => {
744
+ if (declaration.kind !== "file-server") throw new WrongKind(declaration.kind);
745
+ const parentUrl = context.provisioned[declaration.of]?.provides[(0, _geekmidas_manifest.provideKey)(declaration.of, "url")];
746
+ if (!parentUrl) throw new UnresolvedParent(declaration.id, declaration.of);
747
+ const endpoint = new URL(parentUrl).searchParams.get("endpoint");
748
+ const bucket = new URL(parentUrl).hostname;
749
+ if (!endpoint) throw new UnresolvedParent(declaration.id, declaration.of);
750
+ return { provides: { [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: `${endpoint}/${bucket}` } };
751
+ },
752
+ /**
753
+ * A topic, and below it a queue — the same broker either way.
754
+ *
755
+ * Under pg-boss there is nothing to create in Dokploy: the broker is a
756
+ * schema tenant of the database the app already declared, exactly as it is
757
+ * locally, so what this resolves is an address rather than a service. Which
758
+ * is the point of putting the backend in config — the same handlers drain
759
+ * pg-boss here and SQS on AWS because the string they were handed said so.
760
+ *
761
+ * The tenant gets its own role rather than the cluster master, for the
762
+ * reason every other tenant does: pg-boss creates and owns its tables, and
763
+ * a broker that could also read the application's is a broker with a grant
764
+ * nothing asked for.
765
+ */
766
+ topic: async (declaration, context) => {
767
+ if (declaration.kind !== "topic") throw new WrongKind(declaration.kind);
768
+ return broker(declaration.id, context);
769
+ },
770
+ queue: async (declaration, context) => {
771
+ if (declaration.kind !== "queue") throw new WrongKind(declaration.kind);
772
+ return broker(declaration.id, context);
773
+ },
774
+ /**
775
+ * A signing key, generated once and remembered in the stage's store.
776
+ *
777
+ * Random, never derived: a key computed from the project and stage names
778
+ * signs sessions anyone reading the repo could forge. The deploy generates
779
+ * it before provisioning (`withGeneratedSecrets`) and every later deploy
780
+ * reads the same one, so live sessions survive a redeploy. Nothing is
781
+ * created in Dokploy: a secret has no address.
782
+ */
783
+ secret: async (declaration, context) => {
784
+ if (declaration.kind !== "secret") throw new WrongKind(declaration.kind);
785
+ const key = declaration.provides?.[0];
786
+ if (!key) return { provides: {} };
787
+ return { provides: { [key]: supplied(declaration.id, key, context) } };
788
+ },
789
+ /**
790
+ * The keyring the stage keeps in its secrets.
791
+ *
792
+ * Generated on the first deploy (`withGeneratedSecrets`) and rotated only
793
+ * by `gkm encryption:rotate`, so a redeploy never changes a key and nothing
794
+ * written becomes unreadable. Nothing is created in Dokploy: the process
795
+ * holds the keyring.
796
+ */
797
+ encryption: async (declaration, context) => {
798
+ if (declaration.kind !== "encryption") throw new WrongKind(declaration.kind);
799
+ const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "url");
800
+ return { provides: { [key]: supplied(declaration.id, key, context) } };
801
+ },
802
+ /**
803
+ * A third party's credentials, as the stage was given them.
804
+ *
805
+ * Nothing is created and nothing derived: the value was issued by somebody
806
+ * else. A stage that was never given one fails the deploy here, naming the
807
+ * command that sets it — not the first request that needs it.
808
+ */
809
+ credential: async (declaration, context) => {
810
+ if (declaration.kind !== "credential") throw new WrongKind(declaration.kind);
811
+ const key = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
812
+ return { provides: { [key]: supplied(declaration.id, key, context) } };
813
+ },
814
+ /**
815
+ * An API somebody else runs: the URL this stage calls it at, and the
816
+ * credentials it was issued for this stage.
817
+ */
818
+ "external-api": async (declaration, context) => {
819
+ if (declaration.kind !== "external-api") throw new WrongKind(declaration.kind);
820
+ const credentials = (0, _geekmidas_manifest.provideKey)(declaration.id, "credentials");
821
+ return { provides: {
822
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: (0, _geekmidas_manifest.externalApiUrl)(declaration, context.stage),
823
+ [credentials]: supplied(declaration.id, credentials, context)
824
+ } };
825
+ },
826
+ /**
827
+ * A surface, at the address whatever created its domain assigned.
828
+ *
829
+ * Nothing is provisioned here: the application and its domain are the
830
+ * existing engine's, and Dokploy's own Traefik terminates TLS with a Let's
831
+ * Encrypt certificate. What this adds is the *derivation* — who may call it,
832
+ * and where a cookie it sets is readable — from the graph rather than from a
833
+ * hand-maintained list.
834
+ */
835
+ "rest-api": async (declaration, context) => {
836
+ if (declaration.kind !== "rest-api") throw new WrongKind(declaration.kind);
837
+ const url = context.addresses?.[declaration.id];
838
+ if (!url) throw new SurfaceHasNoAddress(declaration.id);
839
+ const origins = [...new Set((0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).map((caller) => context.addresses?.[caller]).filter((address) => Boolean(address)))].sort();
840
+ const domain = (0, _geekmidas_manifest.cookieDomain)([url, ...origins]);
841
+ const schemes = (0, _geekmidas_manifest.dependentsOf)(context.manifest, declaration.id).flatMap((caller) => {
842
+ const app = context.manifest[caller];
843
+ return app?.kind === "mobile-app" ? (0, _geekmidas_manifest.mobileOrigins)((0, _geekmidas_manifest.schemeBase)(context.project, app.scheme)) : [];
801
844
  });
845
+ return { provides: {
846
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "url")]: url,
847
+ [(0, _geekmidas_manifest.provideKey)(declaration.id, "trustedOrigins")]: [...origins, ...schemes].join(","),
848
+ ...domain ? { [(0, _geekmidas_manifest.provideKey)(declaration.id, "cookieDomain")]: domain } : {}
849
+ } };
802
850
  }
803
- if (provider === "cloudflare") throw new Error("Cloudflare DNS provider not yet implemented");
804
- throw new Error(`Unknown DNS provider: ${JSON.stringify(config)}`);
805
- }
806
- //#endregion
807
- //#region src/deploy/dns/index.ts
808
- /**
809
- * DNS orchestration for deployments
810
- *
811
- * Handles automatic DNS record creation for deployed applications.
812
- */
813
- const logger$2 = require_discover.output;
814
- /**
815
- * Check if DNS config is legacy format (single domain with `domain` property)
816
- */
817
- function isLegacyDnsConfig(config) {
818
- return typeof config === "object" && config !== null && "provider" in config && "domain" in config;
819
- }
820
- /**
821
- * Normalize DNS config to new multi-domain format
822
- */
823
- function normalizeDnsConfig(config) {
824
- if (isLegacyDnsConfig(config)) {
825
- const { domain, ...providerConfig } = config;
826
- return { [domain]: providerConfig };
827
- }
828
- return config;
829
- }
830
- /**
831
- * Find the root domain for a hostname from available DNS configs
832
- *
833
- * @example
834
- * findRootDomain('api.geekmidas.com', { 'geekmidas.com': {...}, 'geekmidas.dev': {...} })
835
- * // Returns 'geekmidas.com'
836
- */
837
- function findRootDomain(hostname, dnsConfig) {
838
- const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
839
- for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
840
- return null;
841
- }
842
- /**
843
- * Group hostnames by their root domain
844
- */
845
- function groupHostnamesByDomain(appHostnames, dnsConfig) {
846
- const grouped = /* @__PURE__ */ new Map();
847
- for (const [appName, hostname] of appHostnames) {
848
- const rootDomain = findRootDomain(hostname, dnsConfig);
849
- if (!rootDomain) {
850
- logger$2.log(` ⚠ No DNS config found for hostname: ${hostname}`);
851
- continue;
852
- }
853
- if (!grouped.has(rootDomain)) grouped.set(rootDomain, /* @__PURE__ */ new Map());
854
- grouped.get(rootDomain).set(appName, hostname);
855
- }
856
- return grouped;
857
- }
858
- /**
859
- * Resolve IP address from a hostname
860
- */
861
- async function resolveHostnameToIp(hostname) {
862
- try {
863
- return (await (0, node_dns_promises.lookup)(hostname, { family: 4 })).address;
864
- } catch (error) {
865
- throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
866
- }
851
+ };
852
+ /** The provisioner for a kind, or nothing where this target has none yet. */
853
+ function provisionerFor(kind) {
854
+ return PROVISIONERS[kind];
867
855
  }
868
856
  /**
869
- * Extract subdomain from full hostname relative to root domain
857
+ * The role DDL for one database or tenant, from the shared generator.
870
858
  *
871
- * @example
872
- * extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
873
- * extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
874
- */
875
- function extractSubdomain(hostname, rootDomain) {
876
- if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
877
- return hostname.slice(0, -(rootDomain.length + 1)) || "@";
878
- }
879
- /**
880
- * Generate required DNS records for a deployment
881
- */
882
- function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
883
- const records = [];
884
- for (const [appName, hostname] of appHostnames) {
885
- const subdomain = extractSubdomain(hostname, rootDomain);
886
- records.push({
887
- hostname,
888
- subdomain,
889
- type: "A",
890
- value: serverIp,
891
- appName
892
- });
893
- }
894
- return records;
895
- }
896
- /**
897
- * Print DNS records table
898
- */
899
- function printDnsRecordsTable(records, rootDomain) {
900
- logger$2.log(`\n 📋 DNS Records for ${rootDomain}:`);
901
- logger$2.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
902
- logger$2.log(" │ Subdomain │ Type │ Value │ Status │");
903
- logger$2.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
904
- for (const record of records) {
905
- const subdomain = record.subdomain.padEnd(35);
906
- const type = record.type.padEnd(4);
907
- const value = record.value.padEnd(15);
908
- let status;
909
- if (record.error) status = "✗";
910
- else if (record.created) status = "✓ new";
911
- else if (record.existed) status = "✓";
912
- else status = "?";
913
- logger$2.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
914
- }
915
- logger$2.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
916
- }
917
- /**
918
- * Print DNS records in a simple format for manual setup
919
- */
920
- function printDnsRecordsSimple(records, rootDomain) {
921
- logger$2.log("\n 📋 Required DNS Records:");
922
- logger$2.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
923
- for (const record of records) logger$2.log(` ${record.subdomain} → ${record.value} (A record)`);
924
- logger$2.log("");
925
- }
926
- /**
927
- * Create DNS records for a single domain using its configured provider
859
+ * The same statements the local target applies and the same ones the AWS
860
+ * bootstrap runs. Dokploy used to write its own, which is how three targets
861
+ * came to hold three definitions of the same split.
928
862
  */
929
- async function createDnsRecordsForDomain(records, rootDomain, providerConfig) {
930
- const ttl = "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300;
931
- let provider;
932
- try {
933
- provider = await createDnsProvider({ config: providerConfig });
934
- } catch (error) {
935
- const message = error instanceof Error ? error.message : "Unknown error";
936
- logger$2.log(` ⚠ Failed to create DNS provider for ${rootDomain}: ${message}`);
937
- return records.map((r) => ({
938
- ...r,
939
- error: message
940
- }));
941
- }
942
- if (!provider) return records.map((r) => ({
943
- ...r,
944
- created: false,
945
- existed: false
946
- }));
947
- const results = [];
948
- const upsertRecords = records.map((r) => ({
949
- name: r.subdomain,
950
- type: r.type,
951
- ttl,
952
- value: r.value
953
- }));
954
- try {
955
- const upsertResults = await provider.upsertRecords(rootDomain, upsertRecords);
956
- for (const [i, record] of records.entries()) {
957
- const result = upsertResults[i];
958
- if (!result) {
959
- results.push({
960
- hostname: record.hostname,
961
- subdomain: record.subdomain,
962
- type: record.type,
963
- value: record.value,
964
- appName: record.appName,
965
- error: "No result returned from provider"
966
- });
967
- continue;
968
- }
969
- if (result.unchanged) results.push({
970
- hostname: record.hostname,
971
- subdomain: record.subdomain,
972
- type: record.type,
973
- value: record.value,
974
- appName: record.appName,
975
- existed: true,
976
- created: false
977
- });
978
- else results.push({
979
- hostname: record.hostname,
980
- subdomain: record.subdomain,
981
- type: record.type,
982
- value: record.value,
983
- appName: record.appName,
984
- created: result.created,
985
- existed: !result.created
986
- });
863
+ function statementsFor(options) {
864
+ const { id, database, runtime, owner, reader, schema, context } = options;
865
+ return (0, _geekmidas_db_pg_roles.roleStatements)({
866
+ runtime,
867
+ owner,
868
+ ...reader ? { reader } : {},
869
+ schema,
870
+ ...options.ownsDatabase ? { database } : {},
871
+ passwords: {
872
+ runtime: derivedPassword(context, runtime),
873
+ owner: derivedPassword(context, owner),
874
+ ...reader ? { reader: derivedPassword(context, reader) } : {}
987
875
  }
988
- } catch (error) {
989
- const message = error instanceof Error ? error.message : "Unknown error";
990
- logger$2.log(` ⚠ Failed to create DNS records for ${rootDomain}: ${message}`);
991
- return records.map((r) => ({
992
- hostname: r.hostname,
993
- subdomain: r.subdomain,
994
- type: r.type,
995
- value: r.value,
996
- appName: r.appName,
997
- error: message
998
- }));
999
- }
1000
- return results;
876
+ }).map((statement) => ({
877
+ ...statement,
878
+ id,
879
+ database
880
+ }));
1001
881
  }
1002
882
  /**
1003
- * Main DNS orchestration function for deployments
883
+ * The connection string a declared queue or topic publishes on.
1004
884
  *
1005
- * Supports both legacy single-domain format and new multi-domain format:
1006
- * - Legacy: { provider: 'hostinger', domain: 'example.com' }
1007
- * - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
885
+ * One broker for the whole project, which is what the local target does and
886
+ * what the runtime expects: the generated pollers open a single connection and
887
+ * subscribe each worker by name on it. So every carrier resolves to the same
888
+ * address, and the DDL that backs it is pushed once however many are declared.
1008
889
  *
1009
- * @param appHostnames - Map of app names to hostnames
1010
- * @param dnsConfig - DNS configuration (legacy or multi-domain)
1011
- * @param dokployEndpoint - Dokploy server endpoint to resolve IP from
1012
- * @param state - Optional state to save created records for later deletion
890
+ * Only pg-boss is answered here. SNS needs a topic ARN that has to exist
891
+ * first, and RabbitMQ needs a broker Dokploy has no primitive for — both are
892
+ * the §4.3 question, and composing a plausible URL for either would fail at the
893
+ * first publish instead of here.
1013
894
  */
1014
- async function orchestrateDns(appHostnames, dnsConfig, dokployEndpoint, state) {
1015
- if (!dnsConfig) return null;
1016
- const normalizedConfig = normalizeDnsConfig(dnsConfig);
1017
- logger$2.log("\n🌐 Setting up DNS records...");
1018
- let serverIp;
1019
- try {
1020
- const endpointUrl = new URL(dokployEndpoint);
1021
- serverIp = await resolveHostnameToIp(endpointUrl.hostname);
1022
- logger$2.log(` Server IP: ${serverIp} (from ${endpointUrl.hostname})`);
1023
- } catch (error) {
1024
- const message = error instanceof Error ? error.message : "Unknown error";
1025
- logger$2.log(` ⚠ Failed to resolve server IP: ${message}`);
1026
- return null;
1027
- }
1028
- const groupedHostnames = groupHostnamesByDomain(appHostnames, normalizedConfig);
1029
- if (groupedHostnames.size === 0) {
1030
- logger$2.log(" No DNS records needed (no hostnames match configured domains)");
1031
- return {
1032
- records: [],
1033
- success: true,
1034
- serverIp
1035
- };
1036
- }
1037
- const allRecords = [];
1038
- let hasFailures = false;
1039
- for (const [rootDomain, domainHostnames] of groupedHostnames) {
1040
- const providerConfig = normalizedConfig[rootDomain];
1041
- if (!providerConfig) {
1042
- logger$2.log(` ⚠ No provider config for ${rootDomain}`);
1043
- continue;
1044
- }
1045
- const providerName = typeof providerConfig.provider === "string" ? providerConfig.provider : "custom";
1046
- const requiredRecords = generateRequiredRecords(domainHostnames, rootDomain, serverIp);
1047
- if (requiredRecords.length === 0) continue;
1048
- logger$2.log(` Creating DNS records for ${rootDomain} (${providerName})...`);
1049
- const domainRecords = await createDnsRecordsForDomain(requiredRecords, rootDomain, providerConfig);
1050
- allRecords.push(...domainRecords);
1051
- const created = domainRecords.filter((r) => r.created).length;
1052
- const existed = domainRecords.filter((r) => r.existed).length;
1053
- const failed = domainRecords.filter((r) => r.error).length;
1054
- if (created > 0) logger$2.log(` ✓ Created ${created} DNS record(s) for ${rootDomain}`);
1055
- if (existed > 0) logger$2.log(` ✓ ${existed} record(s) already exist for ${rootDomain}`);
1056
- if (failed > 0) {
1057
- logger$2.log(` ⚠ ${failed} record(s) failed for ${rootDomain}`);
1058
- hasFailures = true;
1059
- }
1060
- if (state) {
1061
- for (const record of domainRecords) if (record.created || record.existed) require_sniffer.setDnsRecord(state, {
1062
- domain: rootDomain,
1063
- name: record.subdomain,
1064
- type: record.type,
1065
- value: record.value,
1066
- ttl: "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300
1067
- });
1068
- }
1069
- printDnsRecordsTable(domainRecords, rootDomain);
1070
- if (providerConfig.provider === "manual" || failed > 0) printDnsRecordsSimple(domainRecords.filter((r) => !r.created && !r.existed), rootDomain);
895
+ function broker(id, context) {
896
+ const backend = context.events ?? "pgboss";
897
+ if (backend !== "pgboss") throw new UnprovisionableCarrier(id, backend);
898
+ const parentId = soleDatabase(context);
899
+ if (!parentId) throw new BrokerNeedsADatabase(id);
900
+ const parentUrl = context.provisioned[parentId]?.provides[(0, _geekmidas_manifest.provideKey)(parentId, "url")];
901
+ if (!parentUrl) throw new UnresolvedParent(id, parentId);
902
+ const url = new URL(parentUrl);
903
+ const database = url.pathname.slice(1);
904
+ const runtime = `${database}_${PGBOSS_SCHEMA}`;
905
+ const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
906
+ if (!context.provisioned[BROKER_ID]) {
907
+ context.deferred.push(...statementsFor({
908
+ id: BROKER_ID,
909
+ database,
910
+ runtime,
911
+ owner,
912
+ schema: PGBOSS_SCHEMA,
913
+ context
914
+ }));
915
+ context.provisioned[BROKER_ID] = { provides: {} };
1071
916
  }
1072
- return {
1073
- records: allRecords,
1074
- success: !hasFailures,
1075
- serverIp
1076
- };
917
+ const connection = pgbossUrl(runtime, derivedPassword(context, runtime), url.host, database);
918
+ return { provides: { [(0, _geekmidas_manifest.provideKey)(id, "publisherConnectionString")]: connection } };
1077
919
  }
920
+ /** The schema pg-boss creates its tables in, here as locally. */
921
+ const PGBOSS_SCHEMA = "pgboss";
1078
922
  /**
1079
- * Verify DNS records resolve correctly after deployment.
923
+ * The id the broker's DDL is pushed under.
1080
924
  *
1081
- * This function:
1082
- * 1. Checks state for previously verified hostnames (skips if already verified with same IP)
1083
- * 2. Attempts to resolve each hostname to an IP
1084
- * 3. Compares resolved IP with expected server IP
1085
- * 4. Updates state with verification results
925
+ * Not a construct — no declaration names it — but the deferred statements need
926
+ * an id, and every carrier in the project shares this one.
927
+ */
928
+ const BROKER_ID = "PgBoss";
929
+ /**
930
+ * A pg-boss address: a Postgres URL under a scheme that picks the transport.
1086
931
  *
1087
- * @param appHostnames - Map of app names to hostnames
1088
- * @param serverIp - Expected IP address the hostnames should resolve to
1089
- * @param state - Deploy state for caching verification results
1090
- * @returns Array of verification results
932
+ * The protocol is what a producer branches on, so it never branches — the same
933
+ * `.publish()` reaches pg-boss here and SQS on AWS.
1091
934
  */
1092
- async function verifyDnsRecords(appHostnames, serverIp, state) {
1093
- const results = [];
1094
- logger$2.log("\n🔍 Verifying DNS records...");
1095
- for (const [appName, hostname] of appHostnames) {
1096
- if (require_sniffer.isDnsVerified(state, hostname, serverIp)) {
1097
- logger$2.log(` ✓ ${hostname} (previously verified)`);
1098
- results.push({
1099
- hostname,
1100
- appName,
1101
- verified: true,
1102
- expectedIp: serverIp,
1103
- skipped: true
1104
- });
1105
- continue;
1106
- }
1107
- try {
1108
- const resolvedIp = await resolveHostnameToIp(hostname);
1109
- if (resolvedIp === serverIp) {
1110
- require_sniffer.setDnsVerification(state, hostname, serverIp);
1111
- logger$2.log(` ✓ ${hostname} → ${resolvedIp}`);
1112
- results.push({
1113
- hostname,
1114
- appName,
1115
- verified: true,
1116
- resolvedIp,
1117
- expectedIp: serverIp
1118
- });
1119
- } else {
1120
- logger$2.log(` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`);
1121
- results.push({
1122
- hostname,
1123
- appName,
1124
- verified: false,
1125
- resolvedIp,
1126
- expectedIp: serverIp
1127
- });
1128
- }
1129
- } catch (error) {
1130
- const message = error instanceof Error ? error.message : "Unknown error";
1131
- logger$2.log(` ⚠ ${hostname} DNS not propagated (${message})`);
1132
- results.push({
1133
- hostname,
1134
- appName,
1135
- verified: false,
1136
- expectedIp: serverIp,
1137
- error: message
1138
- });
1139
- }
1140
- }
1141
- const verified = results.filter((r) => r.verified).length;
1142
- const skipped = results.filter((r) => r.skipped).length;
1143
- const pending = results.filter((r) => !r.verified).length;
1144
- if (pending > 0) {
1145
- logger$2.log(`\n ${verified} verified, ${pending} pending propagation`);
1146
- logger$2.log(" DNS changes may take 5-30 minutes to propagate");
1147
- } else if (skipped > 0) logger$2.log(` ${verified} verified (${skipped} from cache)`);
1148
- return results;
935
+ function pgbossUrl(role, password, host, database) {
936
+ return `pgboss://${role}:${encodeURIComponent(password)}@${host}/${database}?schema=${PGBOSS_SCHEMA}`;
937
+ }
938
+ /** The region MinIO is addressed with. It has no regions; the SDK wants one. */
939
+ const MINIO_REGION = "us-east-1";
940
+ /** An `s3://` URL pointing the same S3 client at MinIO on the internal network. */
941
+ function s3Url(service, bucket) {
942
+ return `s3://${bucket}?region=${MINIO_REGION}&endpoint=http://${service}:9000&forcePathStyle=true`;
1149
943
  }
1150
- //#endregion
1151
- //#region src/deploy/docker.ts
1152
- const logger$1 = require_discover.output;
1153
944
  /**
1154
- * The id the Dockerfile templates mount the encrypted credentials under:
1155
- * `RUN --mount=type=secret,id=gkm_credentials`.
945
+ * A one-service compose file for a bucket.
946
+ *
947
+ * `dokploy-network` is external and already exists — it is what every Dokploy
948
+ * service is attached to, and joining it is what makes the app able to resolve
949
+ * this one by name. The bucket itself is created by MinIO's entrypoint rather
950
+ * than by a second call, so there is nothing to reconcile afterwards.
1156
951
  */
1157
- const CREDENTIALS_SECRET_ID = "gkm_credentials";
952
+ function minioCompose(options) {
953
+ const { service, bucket, user, password } = options;
954
+ return [
955
+ "services:",
956
+ ` ${service}:`,
957
+ " image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
958
+ ` command: server /data --console-address ":9001"`,
959
+ " environment:",
960
+ ` MINIO_ROOT_USER: ${user}`,
961
+ ` MINIO_ROOT_PASSWORD: ${password}`,
962
+ " volumes:",
963
+ ` - ${service}-data:/data`,
964
+ " networks:",
965
+ " - dokploy-network",
966
+ " healthcheck:",
967
+ " test: [\"CMD\", \"mc\", \"ready\", \"local\"]",
968
+ " interval: 10s",
969
+ " retries: 5",
970
+ ` ${service}-bucket:`,
971
+ " image: minio/mc:latest",
972
+ " depends_on:",
973
+ ` ${service}:`,
974
+ " condition: service_healthy",
975
+ " entrypoint: >",
976
+ ` /bin/sh -c "mc alias set s3 http://${service}:9000 ${user} ${password} &&`,
977
+ ` mc mb --ignore-existing s3/${bucket}"`,
978
+ " networks:",
979
+ " - dokploy-network",
980
+ "volumes:",
981
+ ` ${service}-data:`,
982
+ "networks:",
983
+ " dokploy-network:",
984
+ " external: true",
985
+ ""
986
+ ].join("\n");
987
+ }
988
+ /** Whether anything reads through a reader on this construct. */
989
+ function readsFrom(manifest, id) {
990
+ return Object.values(manifest).some((declaration) => declaration.kind === "database-reader" && declaration.of === id);
991
+ }
992
+ /** The schema a database or tenant pins on its roles. */
993
+ function schemaOf(manifest, id) {
994
+ const declaration = manifest[id];
995
+ if (declaration?.kind === "database") return declaration.schema ?? "app";
996
+ if (declaration?.kind === "database-schema") return declaration.schema;
997
+ }
998
+ /**
999
+ * The one declared database, where `cache: 'db'` has to pick.
1000
+ *
1001
+ * Unambiguous with one and arbitrary with two, so two is an error rather than a
1002
+ * coin toss — a cache landing in a database nobody chose surfaces as entries
1003
+ * that are never found, long after the deploy reported success.
1004
+ */
1005
+ function soleDatabase(context) {
1006
+ const databases = Object.entries(context.manifest).filter(([, declaration]) => declaration.kind === "database").map(([id]) => id);
1007
+ if (databases.length > 1) throw new CacheIsAmbiguous(databases);
1008
+ return databases[0];
1009
+ }
1158
1010
  /**
1159
- * Get the full image reference
1011
+ * A password derived from the stage's seed, the project, the stage and the role.
1012
+ *
1013
+ * Derived rather than stored per role so a redeploy hands the running
1014
+ * application the password it already has, and two projects never share one.
1015
+ * The seed is what makes it secret: random, generated once and kept in the
1016
+ * stage's store. Without it the inputs are all in the repo, and so was every
1017
+ * production password.
1160
1018
  */
1161
- function getImageRef(registry, imageName, tag) {
1162
- if (registry) return `${registry}/${imageName}:${tag}`;
1163
- return `${imageName}:${tag}`;
1019
+ function derivedPassword(context, role) {
1020
+ return (0, node_crypto.createHash)("sha256").update(`${context.seed}:${context.project}:${context.stage}:role:${role}`).digest("base64url").slice(0, 32);
1021
+ }
1022
+ /** A connection string for a role on a cluster. */
1023
+ function postgresUrl(role, password, host, database) {
1024
+ return `postgres://${role}:${encodeURIComponent(password)}@${host}:5432/${database}`;
1025
+ }
1026
+ /** Quote an identifier, per segment, for DDL that cannot be parameterised. */
1027
+ function quoted(name) {
1028
+ return name.split(".").map((part) => `"${part.replace(/"/g, "\"\"")}"`).join(".");
1164
1029
  }
1030
+ /** A provisioner was handed a declaration of the wrong kind. */
1165
1031
  /**
1166
- * The `docker build` argv for an image.
1167
- *
1168
- * One element per value, so nothing in a ref or a build arg is read by a
1169
- * shell, and `--opt=value` rather than `--opt value`, so a value that starts
1170
- * with `-` cannot be taken for one of docker's own flags.
1032
+ * A value only the stage's own secrets can hold.
1171
1033
  *
1172
- * @internal Exported for testing
1034
+ * @throws {MissingSuppliedSecret} when the stage was never given it.
1173
1035
  */
1174
- function dockerBuildArgs(options) {
1175
- const { dockerfilePath, imageRef, buildArgs = [], credentialsFile } = options;
1176
- return [
1177
- "build",
1178
- "--platform=linux/amd64",
1179
- `--file=${dockerfilePath}`,
1180
- `--tag=${imageRef}`,
1181
- ...buildArgs.map((arg) => `--build-arg=${arg}`),
1182
- ...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
1183
- "."
1184
- ];
1036
+ function supplied(id, key, context) {
1037
+ const value = context.supplied?.[key];
1038
+ if (value === void 0) throw new MissingSuppliedSecret(id, key, context.stage);
1039
+ return value;
1185
1040
  }
1041
+ /** A construct needs a value the stage was never given. */
1042
+ var MissingSuppliedSecret = class extends Error {
1043
+ id;
1044
+ key;
1045
+ stage;
1046
+ constructor(id, key, stage) {
1047
+ super(`'${id}' needs ${key}, and the stage '${stage}' has none. Nothing can derive it; set it in the stage's secrets: gkm secrets:set ${key} '…' --stage ${stage}`);
1048
+ this.id = id;
1049
+ this.key = key;
1050
+ this.stage = stage;
1051
+ this.name = "MissingSuppliedSecret";
1052
+ }
1053
+ };
1054
+ var WrongKind = class extends Error {
1055
+ kind;
1056
+ constructor(kind) {
1057
+ super(`No Dokploy provisioner handles '${kind}'`);
1058
+ this.kind = kind;
1059
+ this.name = "WrongKind";
1060
+ }
1061
+ };
1062
+ /** A derived construct whose parent has not been provisioned. */
1063
+ var UnresolvedParent = class extends Error {
1064
+ id;
1065
+ parent;
1066
+ constructor(id, parent) {
1067
+ super(`'${id}' derives from '${parent}', which has not been provisioned. Provision in \`provisionOrder\`, which puts parents first.`);
1068
+ this.id = id;
1069
+ this.parent = parent;
1070
+ this.name = "UnresolvedParent";
1071
+ }
1072
+ };
1073
+ /** A cache with nowhere to live. */
1074
+ var CacheNeedsAHome = class extends Error {
1075
+ id;
1076
+ backend;
1077
+ constructor(id, backend) {
1078
+ super(`'${id}' is a cache and nothing says where it lives. Declare it from its database — \`orders.cache('${id}')\`.` + (backend ? ` The deploy target's backend is '${backend}'.` : ""));
1079
+ this.id = id;
1080
+ this.backend = backend;
1081
+ this.name = "CacheNeedsAHome";
1082
+ }
1083
+ };
1084
+ /** A `'db'` cache (a server target's default) with more than one database. */
1085
+ var CacheIsAmbiguous = class extends Error {
1086
+ databases;
1087
+ constructor(databases) {
1088
+ super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database rather than as a bare new Cache().`);
1089
+ this.databases = databases;
1090
+ this.name = "CacheIsAmbiguous";
1091
+ }
1092
+ };
1093
+ /** A surface reached provisioning before its domain existed. */
1094
+ var SurfaceHasNoAddress = class extends Error {
1095
+ id;
1096
+ constructor(id) {
1097
+ super(`'${id}' is a surface and no address was supplied for it. The domain is created before the manifest is provisioned, so this means the two ran out of order.`);
1098
+ this.id = id;
1099
+ this.name = "SurfaceHasNoAddress";
1100
+ }
1101
+ };
1102
+ /** A queue or topic on a backend this target has no primitive for. */
1103
+ var UnprovisionableCarrier = class extends Error {
1104
+ id;
1105
+ backend;
1106
+ constructor(id, backend) {
1107
+ super(`'${id}' is carried by '${backend}', which this target cannot provision. SNS needs a topic ARN that has to exist first and RabbitMQ needs a broker Dokploy has no primitive for; pg-boss is a schema tenant of a database this app already declares, which is why it is the one that works here. Deploy this app to a target that has the backend.`);
1108
+ this.id = id;
1109
+ this.backend = backend;
1110
+ this.name = "UnprovisionableCarrier";
1111
+ }
1112
+ };
1113
+ /** A pg-boss carrier in an app that declares no database to host it. */
1114
+ var BrokerNeedsADatabase = class extends Error {
1115
+ id;
1116
+ constructor(id) {
1117
+ super(`'${id}' is carried by pg-boss, which lives in the database the app declares — and this app declares none. Declare a database, or deploy to a target whose broker is its own.`);
1118
+ this.id = id;
1119
+ this.name = "BrokerNeedsADatabase";
1120
+ }
1121
+ };
1122
+ /** A bucket on a backend this target has no primitive for. */
1123
+ var UnprovisionableBucket = class extends Error {
1124
+ id;
1125
+ backend;
1126
+ constructor(id, backend) {
1127
+ super(`'${id}' is stored on '${backend}', which this target cannot provision: an S3 or R2 bucket belongs to an account this deploy does not hold, the way an API key does. Supply the bucket's URL.`);
1128
+ this.id = id;
1129
+ this.backend = backend;
1130
+ this.name = "UnprovisionableBucket";
1131
+ }
1132
+ };
1133
+ //#endregion
1134
+ //#region src/target/dokploy/declared.ts
1186
1135
  /**
1187
- * Write the encrypted credentials where `docker build --secret` reads them.
1136
+ * The declared half of a Dokploy deploy.
1188
1137
  *
1189
- * Two lines — ciphertext, then IV — so a template can split them with `sed`
1190
- * in any base image. Owner-only, in a directory of its own, and the returned
1191
- * cleanup removes both whatever the build did.
1138
+ * `fromManifest.ts` decides what each construct resolves to; this runs it —
1139
+ * discovers the manifest, walks it parents-first, and applies the DDL the
1140
+ * provisioners deferred.
1192
1141
  *
1193
- * @internal Exported for testing
1142
+ * It is deliberately separate from the engine in `index.ts`. That file deploys
1143
+ * *applications*: images, registries, domains, the things a project has whether
1144
+ * or not it declares anything. This is the part that exists because the app
1145
+ * declared it, and keeping the two apart is what lets a project adopt the model
1146
+ * without rewriting its deploy — and lets this one be tested without one.
1194
1147
  */
1195
- async function writeCredentialsFile(credentials) {
1196
- const dir = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "gkm-credentials-"));
1197
- const path = (0, node_path.join)(dir, CREDENTIALS_SECRET_ID);
1198
- await (0, node_fs_promises.writeFile)(path, `${credentials.encrypted}\n${credentials.iv}\n`, { mode: 384 });
1199
- await (0, node_fs_promises.chmod)(path, 384);
1148
+ /**
1149
+ * Provision everything the workspace declares, and return what it resolved.
1150
+ */
1151
+ async function provisionDeclared(options) {
1152
+ const { workspace } = options;
1153
+ const empty = {
1154
+ env: {},
1155
+ statements: [],
1156
+ provisioned: {},
1157
+ clusters: {}
1158
+ };
1159
+ const manifest = options.manifest ?? await require_discover.discover({
1160
+ patterns: require_state.constructGlobs(workspace),
1161
+ cwd: workspace.root
1162
+ });
1163
+ if (Object.keys(manifest).length === 0) return empty;
1164
+ const context = {
1165
+ manifest,
1166
+ provisioned: {},
1167
+ api: options.api,
1168
+ projectId: options.projectId,
1169
+ environmentId: options.environmentId,
1170
+ stage: options.stage,
1171
+ project: workspace.name,
1172
+ scope: options.scope ?? workspace.name,
1173
+ cache: require_state.cacheBackendFor(require_state.providerOf(workspace)),
1174
+ events: require_state.eventsBackendFor(require_state.providerOf(workspace)),
1175
+ storage: require_state.storageBackendFor(require_state.providerOf(workspace)),
1176
+ addresses: surfaceAddresses(manifest, options.appUrls),
1177
+ seed: options.seed,
1178
+ ...options.supplied ? { supplied: options.supplied } : {},
1179
+ deferred: [],
1180
+ clusters: {}
1181
+ };
1182
+ const env = {};
1183
+ for (const id of carriersLast((0, _geekmidas_manifest.provisionOrder)(manifest), manifest)) {
1184
+ const declaration = manifest[id];
1185
+ if (!declaration) continue;
1186
+ const provisioner = provisionerFor(declaration.kind);
1187
+ if (!provisioner) continue;
1188
+ const result = await provisioner(declaration, context);
1189
+ context.provisioned[id] = result;
1190
+ Object.assign(env, result.provides);
1191
+ }
1200
1192
  return {
1201
- path,
1202
- cleanup: () => (0, node_fs_promises.rm)(dir, {
1203
- recursive: true,
1204
- force: true
1205
- })
1193
+ env,
1194
+ statements: context.deferred.map((statement) => ({
1195
+ id: statement.id,
1196
+ describe: statement.describe,
1197
+ ...statement.database ? { database: statement.database } : {},
1198
+ ...statement.exists ? { exists: statement.exists } : {},
1199
+ create: statement.sql
1200
+ })),
1201
+ provisioned: context.provisioned,
1202
+ clusters: context.clusters
1206
1203
  };
1207
1204
  }
1208
1205
  /**
1209
- * Build Docker image
1210
- * @param imageRef - Full image reference (registry/name:tag)
1211
- * @param buildArgs - Build arguments to pass to docker build
1212
- * @param credentials - Encrypted credentials, passed as a build secret
1206
+ * Run the deferred DDL against a cluster that is now reachable.
1207
+ *
1208
+ * The same applier the local target uses: every statement asks whether it is
1209
+ * needed first, so a redeploy is free and a half-applied run recovers by being
1210
+ * run again.
1213
1211
  */
1214
- async function buildImage(imageRef, buildArgs, credentials, { cwd, signal, stdio }) {
1215
- logger$1.log(`\n🔨 Building Docker image: ${imageRef}`);
1216
- logger$1.log(" Generating Dockerfile for the pre-built bundle...");
1217
- await require_sniffer.dockerCommand({
1218
- slim: true,
1219
- cwd
1220
- });
1221
- const dockerfilePath = (0, node_path.join)(cwd, ".gkm", "docker", "Dockerfile");
1222
- const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
1223
- try {
1224
- await require_local.run("docker", dockerBuildArgs({
1225
- dockerfilePath,
1226
- imageRef,
1227
- buildArgs,
1228
- credentialsFile: secret?.path
1229
- }), {
1230
- cwd,
1231
- env: {
1232
- ...process.env,
1233
- DOCKER_BUILDKIT: "1"
1234
- },
1235
- ...signal ? { signal } : {},
1236
- ...stdio ? { stdio } : {}
1237
- });
1238
- logger$1.log(`✅ Image built: ${imageRef}`);
1239
- } catch (error) {
1240
- throw new require_sniffer.DockerBuildFailed(imageRef, error);
1241
- } finally {
1242
- await secret?.cleanup();
1243
- }
1212
+ async function applyDeclared(client, statements) {
1213
+ return (await require_state.applyPostgres(client, statements)).filter((entry) => entry.created).length;
1214
+ }
1215
+ /**
1216
+ * The provisioning order, with queues and topics moved to the end.
1217
+ *
1218
+ * `provisionOrder` walks `of` edges, and a carrier has none: a topic is not
1219
+ * *derived from* a database, because under SNS it does not live in one. Where
1220
+ * it lives is a fact about the backend, which is config — so the dependency is
1221
+ * real under pg-boss and absent under SNS, and writing it into the declaration
1222
+ * would state it in the one place that cannot know.
1223
+ *
1224
+ * Here it can. This runs where the backend is known, and the rule needs no
1225
+ * branch on it: nothing depends on a carrier. A queue's worker and a topic's
1226
+ * subscribers are *bound* to it rather than dependent on it, which is why
1227
+ * neither holds a key of its own — so a carrier can always go last, and under
1228
+ * pg-boss that is exactly late enough for the database it reads to be there.
1229
+ */
1230
+ function carriersLast(ordered, manifest) {
1231
+ const carries = (id) => {
1232
+ const kind = manifest[id]?.kind;
1233
+ return kind === "queue" || kind === "topic";
1234
+ };
1235
+ return [...ordered.filter((id) => !carries(id)), ...ordered.filter(carries)];
1244
1236
  }
1245
1237
  /**
1246
- * Push Docker image to registry
1238
+ * Where each declared surface answers: its own app's address.
1239
+ *
1240
+ * Every surface is an app of its own, keyed by its id the way the derivation
1241
+ * keyed it — `Api` is `api`, `Webhooks` is `webhooks` — and answers on that
1242
+ * app's host, `{subdomain}.{domain}`. It used to be handed the first backend's
1243
+ * address, so a workspace with two APIs pointed both at the same one.
1247
1244
  */
1248
- async function pushImage(imageRef, { cwd, signal, stdio }) {
1249
- logger$1.log(`\n☁️ Pushing image: ${imageRef}`);
1250
- try {
1251
- await require_local.run("docker", ["push", imageRef], {
1252
- cwd,
1253
- ...signal ? { signal } : {},
1254
- ...stdio ? { stdio } : {}
1255
- });
1256
- logger$1.log(`✅ Image pushed: ${imageRef}`);
1257
- } catch (error) {
1258
- throw new require_sniffer.DockerPushFailed(imageRef, error);
1245
+ function surfaceAddresses(manifest, appUrls) {
1246
+ const addresses = {};
1247
+ for (const [id, declaration] of Object.entries(manifest)) {
1248
+ if (declaration.kind !== "rest-api") continue;
1249
+ const url = appUrls[require_derive.appKey(id)];
1250
+ if (url) addresses[id] = url;
1259
1251
  }
1252
+ return addresses;
1260
1253
  }
1254
+ //#endregion
1255
+ //#region src/target/dokploy/dns/DnsProvider.ts
1261
1256
  /**
1262
- * The digest the registry gave a pushed image, `sha256:…`.
1263
- *
1264
- * A tag can be pushed over — by the next deploy, or by anything else that
1265
- * shares the repository — and the digest is what says which image a stage
1266
- * actually ran. `RepoDigests` has one entry per repository the image was
1267
- * pushed to, so the one for this ref's repository is picked by name.
1268
- *
1269
- * @internal Exported for testing
1257
+ * Check if value is a DnsProvider implementation.
1270
1258
  */
1271
- async function pushedDigest(imageRef, { cwd, signal } = { cwd: process.cwd() }) {
1272
- const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
1273
- const output = await require_local.runOutput("docker", [
1274
- "image",
1275
- "inspect",
1276
- "--format={{json .RepoDigests}}",
1277
- imageRef
1278
- ], {
1279
- cwd,
1280
- ...signal ? { signal } : {}
1281
- });
1282
- let digests;
1283
- try {
1284
- digests = JSON.parse(output.trim() || "[]");
1285
- } catch {
1286
- return;
1287
- }
1288
- if (!Array.isArray(digests)) return void 0;
1289
- return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
1259
+ function isDnsProvider(value) {
1260
+ return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
1290
1261
  }
1291
1262
  /**
1292
- * Deploy using Docker (build and optionally push image)
1263
+ * Create a DNS provider based on configuration.
1264
+ *
1265
+ * - 'hostinger': HostingerProvider
1266
+ * - 'route53': Route53Provider
1267
+ * - 'manual': Returns null (user handles DNS)
1268
+ * - Custom: Use provided DnsProvider implementation
1293
1269
  */
1294
- async function deployDocker(options) {
1295
- const { stage, tag, skipPush, masterKey, config, buildArgs, credentials } = options;
1296
- const imageName = config.imageName;
1297
- const imageRef = require_sniffer.validateImageRef(getImageRef(config.registry, imageName, tag));
1298
- const docker = {
1299
- cwd: options.cwd ?? process.cwd(),
1300
- ...options.signal ? { signal: options.signal } : {},
1301
- ...options.stdio ? { stdio: options.stdio } : {}
1302
- };
1303
- await buildImage(imageRef, buildArgs, credentials, docker);
1304
- let digest;
1305
- if (!skipPush) {
1306
- if (!config.registry) logger$1.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
1307
- else {
1308
- await pushImage(imageRef, docker);
1309
- digest = await pushedDigest(imageRef, docker);
1310
- }
1270
+ async function createDnsProvider(options) {
1271
+ const { config } = options;
1272
+ if (config.provider === "manual") return null;
1273
+ if (isDnsProvider(config.provider)) return config.provider;
1274
+ const provider = config.provider;
1275
+ if (provider === "hostinger") {
1276
+ const { HostingerProvider } = await Promise.resolve().then(() => require("./HostingerProvider-BznqRC2S.cjs"));
1277
+ return new HostingerProvider();
1311
1278
  }
1312
- logger$1.log("\n✅ Docker deployment ready!");
1313
- logger$1.log(`\n📋 Deployment details:`);
1314
- logger$1.log(` Image: ${imageRef}`);
1315
- logger$1.log(` Stage: ${stage}`);
1316
- if (masterKey) logger$1.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${require_sniffer.keyFingerprint(masterKey)}.`);
1317
- return {
1318
- imageRef,
1319
- ...digest ? { digest } : {},
1320
- masterKey
1321
- };
1279
+ if (provider === "route53") {
1280
+ const { Route53Provider } = await Promise.resolve().then(() => require("./Route53Provider-Bllus2NR.cjs"));
1281
+ const route53Config = config;
1282
+ return new Route53Provider({
1283
+ region: route53Config.region,
1284
+ profile: route53Config.profile,
1285
+ hostedZoneId: route53Config.hostedZoneId
1286
+ });
1287
+ }
1288
+ if (provider === "cloudflare") throw new Error("Cloudflare DNS provider not yet implemented");
1289
+ throw new Error(`Unknown DNS provider: ${JSON.stringify(config)}`);
1322
1290
  }
1323
1291
  //#endregion
1324
- //#region src/deploy/env-resolver.ts
1292
+ //#region src/target/dokploy/dns/index.ts
1325
1293
  /**
1326
- * Environment Variable Resolution for Dokploy Deployments
1294
+ * DNS orchestration for deployments
1327
1295
  *
1328
- * Resolves sniffed environment variables to actual values during deployment.
1329
- * Auto-supports common variables like DATABASE_URL, REDIS_URL, BETTER_AUTH_*,
1330
- * and falls back to user-provided secrets.
1296
+ * Handles automatic DNS record creation for deployed applications.
1331
1297
  */
1298
+ const logger$2 = require_discover.output;
1332
1299
  /**
1333
- * Generate a secure random secret (64 hex characters = 32 bytes)
1300
+ * Check if DNS config is legacy format (single domain with `domain` property)
1334
1301
  */
1335
- function generateSecret() {
1336
- return (0, node_crypto.randomBytes)(32).toString("hex");
1302
+ function isLegacyDnsConfig(config) {
1303
+ return typeof config === "object" && config !== null && "provider" in config && "domain" in config;
1337
1304
  }
1338
1305
  /**
1339
- * Get or generate a secret for an app.
1340
- * If the secret already exists in state, returns it.
1341
- * Otherwise generates a new one and stores it.
1306
+ * Normalize DNS config to new multi-domain format
1342
1307
  */
1343
- function getOrGenerateSecret(state, appName, secretName) {
1344
- const existing = require_sniffer.getGeneratedSecret(state, appName, secretName);
1345
- if (existing) return existing;
1346
- const generated = generateSecret();
1347
- require_sniffer.setGeneratedSecret(state, appName, secretName, generated);
1348
- return generated;
1308
+ function normalizeDnsConfig(config) {
1309
+ if (isLegacyDnsConfig(config)) {
1310
+ const { domain, ...providerConfig } = config;
1311
+ return { [domain]: providerConfig };
1312
+ }
1313
+ return config;
1349
1314
  }
1350
1315
  /**
1351
- * Resolve a single environment variable
1316
+ * Find the root domain for a hostname from available DNS configs
1317
+ *
1318
+ * @example
1319
+ * findRootDomain('api.geekmidas.com', { 'geekmidas.com': {...}, 'geekmidas.dev': {...} })
1320
+ * // Returns 'geekmidas.com'
1352
1321
  */
1353
- function resolveEnvVar(varName, context) {
1354
- switch (varName) {
1355
- case "PORT": return String(context.app.port);
1356
- case "NODE_ENV": return "production";
1357
- case "STAGE": return context.stage;
1358
- case "BETTER_AUTH_URL": return `https://${context.appHostname}`;
1359
- case "BETTER_AUTH_SECRET": return getOrGenerateSecret(context.state, context.appName, "BETTER_AUTH_SECRET");
1360
- case "BETTER_AUTH_TRUSTED_ORIGINS":
1361
- if (context.frontendUrls.length > 0) return context.frontendUrls.join(",");
1362
- break;
1363
- case "GKM_MASTER_KEY": if (context.masterKey) return context.masterKey;
1364
- }
1365
- if (context.dependencyUrls && varName.endsWith("_URL")) {
1366
- const depName = (require_workspace.stripPublicPrefix(varName) ?? varName).slice(0, -4).toLowerCase();
1367
- if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
1368
- }
1369
- if (context.userSecrets) {
1370
- if (context.userSecrets.custom[varName]) return context.userSecrets.custom[varName];
1371
- if (varName in context.userSecrets.urls) return context.userSecrets.urls[varName];
1372
- if (varName === "POSTGRES_PASSWORD" && context.userSecrets.services.postgres) return context.userSecrets.services.postgres.password;
1373
- if (varName === "REDIS_PASSWORD" && context.userSecrets.services.redis) return context.userSecrets.services.redis.password;
1374
- }
1322
+ function findRootDomain(hostname, dnsConfig) {
1323
+ const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
1324
+ for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
1325
+ return null;
1375
1326
  }
1376
1327
  /**
1377
- * Resolve all environment variables for an app
1328
+ * Group hostnames by their root domain
1378
1329
  */
1379
- function resolveEnvVars(requiredVars, context) {
1380
- const resolved = {};
1381
- const missing = [];
1382
- for (const varName of requiredVars) {
1383
- const value = resolveEnvVar(varName, context);
1384
- if (value !== void 0) resolved[varName] = value;
1385
- else missing.push(varName);
1330
+ function groupHostnamesByDomain(appHostnames, dnsConfig) {
1331
+ const grouped = /* @__PURE__ */ new Map();
1332
+ for (const [appName, hostname] of appHostnames) {
1333
+ const rootDomain = findRootDomain(hostname, dnsConfig);
1334
+ if (!rootDomain) {
1335
+ logger$2.log(` ⚠ No DNS config found for hostname: ${hostname}`);
1336
+ continue;
1337
+ }
1338
+ if (!grouped.has(rootDomain)) grouped.set(rootDomain, /* @__PURE__ */ new Map());
1339
+ grouped.get(rootDomain).set(appName, hostname);
1386
1340
  }
1387
- return {
1388
- resolved,
1389
- missing
1390
- };
1341
+ return grouped;
1391
1342
  }
1392
1343
  /**
1393
- * Format missing variables error message
1344
+ * Resolve IP address from a hostname
1394
1345
  */
1395
- function formatMissingVarsError(appName, missing, stage) {
1396
- return `Deployment failed: ${appName} is missing required environment variables:\n${missing.map((v) => ` - ${v}`).join("\n")}\n\nAdd them with:\n gkm secrets:set <VAR_NAME> <value> --stage ${stage}\n\nOr add them to the app's requiredEnv in gkm.config.ts to have them auto-resolved.`;
1346
+ async function resolveHostnameToIp(hostname) {
1347
+ try {
1348
+ return (await (0, node_dns_promises.lookup)(hostname, { family: 4 })).address;
1349
+ } catch (error) {
1350
+ throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
1351
+ }
1397
1352
  }
1398
1353
  /**
1399
- * Validate that all required environment variables can be resolved
1354
+ * Extract subdomain from full hostname relative to root domain
1355
+ *
1356
+ * @example
1357
+ * extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
1358
+ * extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
1400
1359
  */
1401
- function validateEnvVars(requiredVars, context) {
1402
- const { resolved, missing } = resolveEnvVars(requiredVars, context);
1403
- return {
1404
- valid: missing.length === 0,
1405
- missing,
1406
- resolved
1407
- };
1360
+ function extractSubdomain(hostname, rootDomain) {
1361
+ if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
1362
+ return hostname.slice(0, -(rootDomain.length + 1)) || "@";
1408
1363
  }
1409
- //#endregion
1410
- //#region src/deploy/journal.ts
1411
- var DeployJournal = class DeployJournal {
1412
- store;
1413
- stage;
1414
- state;
1415
- resources;
1416
- version;
1417
- existed;
1418
- constructor(store, stage, state, resources, version, existed) {
1419
- this.store = store;
1420
- this.stage = stage;
1421
- this.state = state;
1422
- this.resources = resources;
1423
- this.version = version;
1424
- this.existed = existed;
1425
- }
1426
- /**
1427
- * The stage's journal — its stored state, or `initial` written as the
1428
- * stage's first state, so there is somewhere to record the very first
1429
- * resource before it is created.
1430
- */
1431
- static async open(store, stage, initial) {
1432
- const stored = await store.read(stage);
1433
- if (stored) return new DeployJournal(store, stage, stored.state, stored.resources, stored.version, true);
1434
- const state = initial();
1435
- const version = await store.write(stage, state, { expectedVersion: null });
1436
- return new DeployJournal(store, stage, state, {}, version, false);
1437
- }
1438
- record(key) {
1439
- return this.resources[key];
1440
- }
1441
- /** The keys a previous run marked `pending` and never saw created. */
1442
- unfinished() {
1443
- return Object.values(this.resources).filter((record) => record.status === "pending").map((record) => record.key);
1444
- }
1445
- /** Records that `entry` is about to be created. */
1446
- async pending(entry) {
1447
- await this.put({
1448
- ...entry,
1449
- status: "pending"
1450
- });
1451
- }
1452
- /** Records `entry` as existing with `id`; a no-op if it already is. */
1453
- async ready(entry, id) {
1454
- const current = this.resources[entry.key];
1455
- if (current?.status === "ready" && current.id === id) return;
1456
- await this.put({
1457
- ...entry,
1458
- status: "ready",
1459
- id
1364
+ /**
1365
+ * Generate required DNS records for a deployment
1366
+ */
1367
+ function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
1368
+ const records = [];
1369
+ for (const [appName, hostname] of appHostnames) {
1370
+ const subdomain = extractSubdomain(hostname, rootDomain);
1371
+ records.push({
1372
+ hostname,
1373
+ subdomain,
1374
+ type: "A",
1375
+ value: serverIp,
1376
+ appName
1460
1377
  });
1461
1378
  }
1462
- /**
1463
- * The resource `entry` names: by its recorded id, else by looking it up,
1464
- * else created — with `pending` written before the create and `ready`
1465
- * after, so a run that dies in between leaves a record the next run
1466
- * resolves by looking the resource up rather than creating another.
1467
- */
1468
- async ensure(entry, steps) {
1469
- const record = this.resources[entry.key];
1470
- let staleId;
1471
- if (record?.status === "ready" && record.id && steps.get) {
1472
- const known = await steps.get(record.id);
1473
- if (known) {
1474
- await this.ready(entry, steps.id(known));
1475
- return {
1476
- resource: known,
1477
- via: "recorded"
1478
- };
1479
- }
1480
- staleId = record.id;
1481
- }
1482
- const existing = await steps.find();
1483
- if (existing) {
1484
- await this.ready(entry, steps.id(existing));
1485
- return {
1486
- resource: existing,
1487
- via: record?.status === "pending" ? "resumed" : "found",
1488
- ...staleId ? { staleId } : {}
1489
- };
1490
- }
1491
- await this.pending(entry);
1492
- const created = await steps.create();
1493
- await this.ready(entry, steps.id(created));
1494
- return {
1495
- resource: created,
1496
- via: "created",
1497
- ...staleId ? { staleId } : {}
1498
- };
1499
- }
1500
- /** Writes the stage's state, keeping its resource records. */
1501
- async save() {
1502
- this.state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
1503
- this.version = await this.store.write(this.stage, this.state, { expectedVersion: this.version });
1504
- }
1505
- async put(record) {
1506
- this.version = await this.store.putResource(this.stage, record, { expectedVersion: this.version });
1507
- this.resources = {
1508
- ...this.resources,
1509
- [record.key]: {
1510
- ...record,
1511
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1512
- }
1513
- };
1379
+ return records;
1380
+ }
1381
+ /**
1382
+ * Print DNS records table
1383
+ */
1384
+ function printDnsRecordsTable(records, rootDomain) {
1385
+ logger$2.log(`\n 📋 DNS Records for ${rootDomain}:`);
1386
+ logger$2.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
1387
+ logger$2.log(" │ Subdomain │ Type │ Value │ Status │");
1388
+ logger$2.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
1389
+ for (const record of records) {
1390
+ const subdomain = record.subdomain.padEnd(35);
1391
+ const type = record.type.padEnd(4);
1392
+ const value = record.value.padEnd(15);
1393
+ let status;
1394
+ if (record.error) status = "✗";
1395
+ else if (record.created) status = "✓ new";
1396
+ else if (record.existed) status = "✓";
1397
+ else status = "?";
1398
+ logger$2.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
1514
1399
  }
1515
- };
1516
- //#endregion
1517
- //#region src/deploy/ownership.ts
1400
+ logger$2.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
1401
+ }
1518
1402
  /**
1519
- * Which Dokploy project a deploy is allowed to deploy into.
1520
- *
1521
- * Deploy used to take any project whose name matched the workspace's, in any
1522
- * case — so a second workspace called `shop` (or `Shop`) on the same server
1523
- * deployed into the first one's project, over its images and applications. A
1524
- * project is now claimed by an ownership marker in its description, and a
1525
- * project with the right name and no marker is somebody else's.
1403
+ * Print DNS records in a simple format for manual setup
1526
1404
  */
1405
+ function printDnsRecordsSimple(records, rootDomain) {
1406
+ logger$2.log("\n 📋 Required DNS Records:");
1407
+ logger$2.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
1408
+ for (const record of records) logger$2.log(` ${record.subdomain} → ${record.value} (A record)`);
1409
+ logger$2.log("");
1410
+ }
1527
1411
  /**
1528
- * A Dokploy project with the name this deploy would use exists, and nothing
1529
- * says this workspace created it.
1412
+ * Create DNS records for a single domain using its configured provider
1530
1413
  */
1531
- var ProjectNotOwned = class extends Error {
1532
- projectName;
1533
- projectId;
1534
- identity;
1535
- ownedBy;
1536
- constructor(projectName, projectId, identity, ownedBy) {
1537
- super(`The Dokploy project '${projectName}' (${projectId}) was not created by this workspace (${identity})${ownedBy ? ` — it is marked '${ownedBy}'` : ""}, so deploy will not use it. Set deploy.namespace in gkm.config.ts to deploy under a name of your own, or, if this project really is this workspace's, add 'gkm:${identity}' to its description in Dokploy.`);
1538
- this.projectName = projectName;
1539
- this.projectId = projectId;
1540
- this.identity = identity;
1541
- this.ownedBy = ownedBy;
1542
- this.name = "ProjectNotOwned";
1414
+ async function createDnsRecordsForDomain(records, rootDomain, providerConfig) {
1415
+ const ttl = "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300;
1416
+ let provider;
1417
+ try {
1418
+ provider = await createDnsProvider({ config: providerConfig });
1419
+ } catch (error) {
1420
+ const message = error instanceof Error ? error.message : "Unknown error";
1421
+ logger$2.log(` ⚠ Failed to create DNS provider for ${rootDomain}: ${message}`);
1422
+ return records.map((r) => ({
1423
+ ...r,
1424
+ error: message
1425
+ }));
1543
1426
  }
1544
- };
1545
- /** A description with the marker added, keeping what was there. */
1546
- function withMarker(description, marker) {
1547
- const existing = (description ?? "").trim();
1548
- return existing ? `${existing}\n${marker}` : marker;
1549
- }
1550
- /** `project.one`, or `null` when Dokploy no longer has the project. */
1551
- async function projectById(api, projectId) {
1427
+ if (!provider) return records.map((r) => ({
1428
+ ...r,
1429
+ created: false,
1430
+ existed: false
1431
+ }));
1432
+ const results = [];
1433
+ const upsertRecords = records.map((r) => ({
1434
+ name: r.subdomain,
1435
+ type: r.type,
1436
+ ttl,
1437
+ value: r.value
1438
+ }));
1552
1439
  try {
1553
- return await api.getProject(projectId) ?? null;
1440
+ const upsertResults = await provider.upsertRecords(rootDomain, upsertRecords);
1441
+ for (const [i, record] of records.entries()) {
1442
+ const result = upsertResults[i];
1443
+ if (!result) {
1444
+ results.push({
1445
+ hostname: record.hostname,
1446
+ subdomain: record.subdomain,
1447
+ type: record.type,
1448
+ value: record.value,
1449
+ appName: record.appName,
1450
+ error: "No result returned from provider"
1451
+ });
1452
+ continue;
1453
+ }
1454
+ if (result.unchanged) results.push({
1455
+ hostname: record.hostname,
1456
+ subdomain: record.subdomain,
1457
+ type: record.type,
1458
+ value: record.value,
1459
+ appName: record.appName,
1460
+ existed: true,
1461
+ created: false
1462
+ });
1463
+ else results.push({
1464
+ hostname: record.hostname,
1465
+ subdomain: record.subdomain,
1466
+ type: record.type,
1467
+ value: record.value,
1468
+ appName: record.appName,
1469
+ created: result.created,
1470
+ existed: !result.created
1471
+ });
1472
+ }
1554
1473
  } catch (error) {
1555
- if (error instanceof require_sniffer.DokployApiError) return null;
1556
- throw error;
1474
+ const message = error instanceof Error ? error.message : "Unknown error";
1475
+ logger$2.log(` ⚠ Failed to create DNS records for ${rootDomain}: ${message}`);
1476
+ return records.map((r) => ({
1477
+ hostname: r.hostname,
1478
+ subdomain: r.subdomain,
1479
+ type: r.type,
1480
+ value: r.value,
1481
+ appName: r.appName,
1482
+ error: message
1483
+ }));
1557
1484
  }
1485
+ return results;
1558
1486
  }
1559
1487
  /**
1560
- * The project this identity owns, if there is one — read only, so a dry run
1561
- * asks the same question a deploy does:
1488
+ * Main DNS orchestration function for deployments
1562
1489
  *
1563
- * 1. The one the stage's state names, if Dokploy still has it. State written
1564
- * before markers existed is trusted — it holds an id this workspace was
1565
- * given when it created the project.
1566
- * 2. One with this name *and* this identity's marker.
1490
+ * Supports both legacy single-domain format and new multi-domain format:
1491
+ * - Legacy: { provider: 'hostinger', domain: 'example.com' }
1492
+ * - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
1567
1493
  *
1568
- * A project that matches by name (in any case) but carries no marker, or
1569
- * another identity's, raises `ProjectNotOwned` — it is never adopted.
1494
+ * @param appHostnames - Map of app names to hostnames
1495
+ * @param dnsConfig - DNS configuration (legacy or multi-domain)
1496
+ * @param dokployEndpoint - Dokploy server endpoint to resolve IP from
1497
+ * @param state - Optional state to save created records for later deletion
1570
1498
  */
1571
- async function findProject(api, identity, stateProjectId) {
1572
- const name = require_store.projectName(identity);
1573
- const marker = require_store.ownershipMarker(identity);
1574
- if (stateProjectId) {
1575
- const known = await projectById(api, stateProjectId);
1576
- if (known) {
1577
- const claimed = require_store.markerOf(known.description);
1578
- if (claimed && claimed !== marker) throw new ProjectNotOwned(known.name, known.projectId, identity.key, claimed);
1579
- return {
1580
- projectId: known.projectId,
1581
- name: known.name,
1582
- description: known.description ?? null,
1583
- environments: known.environments ?? [],
1584
- via: "state",
1585
- needsClaim: !claimed
1586
- };
1587
- }
1499
+ async function orchestrateDns(appHostnames, dnsConfig, dokployEndpoint, state) {
1500
+ if (!dnsConfig) return null;
1501
+ const normalizedConfig = normalizeDnsConfig(dnsConfig);
1502
+ logger$2.log("\n🌐 Setting up DNS records...");
1503
+ let serverIp;
1504
+ try {
1505
+ const endpointUrl = new URL(dokployEndpoint);
1506
+ serverIp = await resolveHostnameToIp(endpointUrl.hostname);
1507
+ logger$2.log(` Server IP: ${serverIp} (from ${endpointUrl.hostname})`);
1508
+ } catch (error) {
1509
+ const message = error instanceof Error ? error.message : "Unknown error";
1510
+ logger$2.log(` ⚠ Failed to resolve server IP: ${message}`);
1511
+ return null;
1588
1512
  }
1589
- const projects = await api.listProjects();
1590
- const owned = projects.find((p) => p.name.toLowerCase() === name.toLowerCase() && require_store.isOwnedBy(p.description, identity));
1591
- if (owned) {
1592
- const details = await api.getProject(owned.projectId);
1513
+ const groupedHostnames = groupHostnamesByDomain(appHostnames, normalizedConfig);
1514
+ if (groupedHostnames.size === 0) {
1515
+ logger$2.log(" No DNS records needed (no hostnames match configured domains)");
1593
1516
  return {
1594
- projectId: owned.projectId,
1595
- name: owned.name,
1596
- description: owned.description ?? null,
1597
- environments: details.environments ?? [],
1598
- via: "marker",
1599
- needsClaim: false
1517
+ records: [],
1518
+ success: true,
1519
+ serverIp
1600
1520
  };
1601
1521
  }
1602
- const clash = projects.find((p) => p.name.toLowerCase() === name.toLowerCase());
1603
- if (clash) throw new ProjectNotOwned(clash.name, clash.projectId, identity.key, require_store.markerOf(clash.description));
1604
- return null;
1522
+ const allRecords = [];
1523
+ let hasFailures = false;
1524
+ for (const [rootDomain, domainHostnames] of groupedHostnames) {
1525
+ const providerConfig = normalizedConfig[rootDomain];
1526
+ if (!providerConfig) {
1527
+ logger$2.log(` ⚠ No provider config for ${rootDomain}`);
1528
+ continue;
1529
+ }
1530
+ const providerName = typeof providerConfig.provider === "string" ? providerConfig.provider : "custom";
1531
+ const requiredRecords = generateRequiredRecords(domainHostnames, rootDomain, serverIp);
1532
+ if (requiredRecords.length === 0) continue;
1533
+ logger$2.log(` Creating DNS records for ${rootDomain} (${providerName})...`);
1534
+ const domainRecords = await createDnsRecordsForDomain(requiredRecords, rootDomain, providerConfig);
1535
+ allRecords.push(...domainRecords);
1536
+ const created = domainRecords.filter((r) => r.created).length;
1537
+ const existed = domainRecords.filter((r) => r.existed).length;
1538
+ const failed = domainRecords.filter((r) => r.error).length;
1539
+ if (created > 0) logger$2.log(` ✓ Created ${created} DNS record(s) for ${rootDomain}`);
1540
+ if (existed > 0) logger$2.log(` ✓ ${existed} record(s) already exist for ${rootDomain}`);
1541
+ if (failed > 0) {
1542
+ logger$2.log(` ⚠ ${failed} record(s) failed for ${rootDomain}`);
1543
+ hasFailures = true;
1544
+ }
1545
+ if (state) {
1546
+ for (const record of domainRecords) if (record.created || record.existed) require_state.setDnsRecord(state, {
1547
+ domain: rootDomain,
1548
+ name: record.subdomain,
1549
+ type: record.type,
1550
+ value: record.value,
1551
+ ttl: "ttl" in providerConfig && providerConfig.ttl ? providerConfig.ttl : 300
1552
+ });
1553
+ }
1554
+ printDnsRecordsTable(domainRecords, rootDomain);
1555
+ if (providerConfig.provider === "manual" || failed > 0) printDnsRecordsSimple(domainRecords.filter((r) => !r.created && !r.existed), rootDomain);
1556
+ }
1557
+ return {
1558
+ records: allRecords,
1559
+ success: !hasFailures,
1560
+ serverIp
1561
+ };
1605
1562
  }
1606
1563
  /**
1607
- * The project this deploy deploys into: the one {@link findProject} finds —
1608
- * claimed with this identity's marker if it carries none yet, so the next
1609
- * deploy without state still finds it — or a new one, marked.
1564
+ * Verify DNS records resolve correctly after deployment.
1565
+ *
1566
+ * This function:
1567
+ * 1. Checks state for previously verified hostnames (skips if already verified with same IP)
1568
+ * 2. Attempts to resolve each hostname to an IP
1569
+ * 3. Compares resolved IP with expected server IP
1570
+ * 4. Updates state with verification results
1571
+ *
1572
+ * @param appHostnames - Map of app names to hostnames
1573
+ * @param serverIp - Expected IP address the hostnames should resolve to
1574
+ * @param state - Deploy state for caching verification results
1575
+ * @returns Array of verification results
1610
1576
  */
1611
- async function resolveProject(api, identity, stateProjectId, log = () => {}, hooks = {}) {
1612
- const marker = require_store.ownershipMarker(identity);
1613
- const found = await findProject(api, identity, stateProjectId);
1614
- if (found) {
1615
- if (found.needsClaim) {
1616
- await api.updateProject(found.projectId, {
1617
- name: found.name,
1618
- description: withMarker(found.description, marker)
1577
+ async function verifyDnsRecords(appHostnames, serverIp, state) {
1578
+ const results = [];
1579
+ logger$2.log("\n🔍 Verifying DNS records...");
1580
+ for (const [appName, hostname] of appHostnames) {
1581
+ if (require_state.isDnsVerified(state, hostname, serverIp)) {
1582
+ logger$2.log(` ✓ ${hostname} (previously verified)`);
1583
+ results.push({
1584
+ hostname,
1585
+ appName,
1586
+ verified: true,
1587
+ expectedIp: serverIp,
1588
+ skipped: true
1589
+ });
1590
+ continue;
1591
+ }
1592
+ try {
1593
+ const resolvedIp = await resolveHostnameToIp(hostname);
1594
+ if (resolvedIp === serverIp) {
1595
+ require_state.setDnsVerification(state, hostname, serverIp);
1596
+ logger$2.log(` ✓ ${hostname} → ${resolvedIp}`);
1597
+ results.push({
1598
+ hostname,
1599
+ appName,
1600
+ verified: true,
1601
+ resolvedIp,
1602
+ expectedIp: serverIp
1603
+ });
1604
+ } else {
1605
+ logger$2.log(` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`);
1606
+ results.push({
1607
+ hostname,
1608
+ appName,
1609
+ verified: false,
1610
+ resolvedIp,
1611
+ expectedIp: serverIp
1612
+ });
1613
+ }
1614
+ } catch (error) {
1615
+ const message = error instanceof Error ? error.message : "Unknown error";
1616
+ logger$2.log(` ⚠ ${hostname} DNS not propagated (${message})`);
1617
+ results.push({
1618
+ hostname,
1619
+ appName,
1620
+ verified: false,
1621
+ expectedIp: serverIp,
1622
+ error: message
1619
1623
  });
1620
- log(` Claimed project ${found.name} for ${identity.key}`);
1621
1624
  }
1622
- return {
1623
- projectId: found.projectId,
1624
- name: found.name,
1625
- environments: found.environments,
1626
- via: found.via
1627
- };
1628
1625
  }
1629
- await hooks.beforeCreate?.();
1630
- const created = await api.createProject(require_store.projectName(identity), withMarker("Deployed by gkm.", marker));
1631
- return {
1632
- projectId: created.project.projectId,
1633
- name: created.project.name,
1634
- environments: [created.environment],
1635
- via: "created"
1636
- };
1626
+ const verified = results.filter((r) => r.verified).length;
1627
+ const skipped = results.filter((r) => r.skipped).length;
1628
+ const pending = results.filter((r) => !r.verified).length;
1629
+ if (pending > 0) {
1630
+ logger$2.log(`\n ${verified} verified, ${pending} pending propagation`);
1631
+ logger$2.log(" DNS changes may take 5-30 minutes to propagate");
1632
+ } else if (skipped > 0) logger$2.log(` ${verified} verified (${skipped} from cache)`);
1633
+ return results;
1637
1634
  }
1638
1635
  //#endregion
1639
- //#region src/deploy/registry.ts
1636
+ //#region src/target/dokploy/env-resolver.ts
1640
1637
  /**
1641
- * Which Dokploy registry a stage's images are pulled through.
1638
+ * Environment Variable Resolution for Dokploy Deployments
1642
1639
  *
1643
- * It used to be one id stored per machine (`~/.gkm/credentials.json`), and
1644
- * when that was empty, the first registry Dokploy listed — so an image could be
1645
- * deployed with credentials for a registry nobody chose, and two workspaces
1646
- * deployed from one machine shared whichever was stored last. The id is now
1647
- * kept in the stage's state, and found only from what the workspace
1648
- * configured.
1640
+ * Resolves sniffed environment variables to actual values during deployment.
1641
+ * Auto-supports common variables like DATABASE_URL, REDIS_URL, BETTER_AUTH_*,
1642
+ * and falls back to user-provided secrets.
1649
1643
  */
1650
- /** There is nowhere to push images to, so Dokploy has nothing to pull. */
1651
- var RegistryNotConfigured = class extends Error {
1652
- stage;
1653
- constructor(stage) {
1654
- super(`Deploying '${stage}' needs a container registry: Dokploy pulls each app's image from one. Set deploy.dokploy.registry in gkm.config.ts (e.g. 'ghcr.io/acme'), and deploy.dokploy.registryId if Dokploy already holds that registry's credentials.`);
1655
- this.stage = stage;
1656
- this.name = "RegistryNotConfigured";
1657
- }
1658
- };
1659
- /** `deploy.dokploy.registryId` names a registry Dokploy does not have. */
1660
- var RegistryNotFound = class extends Error {
1661
- registryId;
1662
- constructor(registryId) {
1663
- super(`deploy.dokploy.registryId '${registryId}' is not a registry on this Dokploy server. Find the right id under Settings → Docker Registry in Dokploy and set it in gkm.config.ts, or remove it to have deploy find the registry by its URL.`);
1664
- this.registryId = registryId;
1665
- this.name = "RegistryNotFound";
1666
- }
1667
- };
1668
- /** More than one Dokploy registry serves the configured registry. */
1669
- var RegistryAmbiguous = class extends Error {
1670
- registry;
1671
- registryIds;
1672
- constructor(registry, registryIds) {
1673
- super(`Dokploy has ${registryIds.length} registries for '${registry}' (${registryIds.join(", ")}). Set deploy.dokploy.registryId in gkm.config.ts to the one this workspace pushes with.`);
1674
- this.registry = registry;
1675
- this.registryIds = registryIds;
1676
- this.name = "RegistryAmbiguous";
1677
- }
1678
- };
1679
- /** `https://ghcr.io/acme/` → `{ host: 'ghcr.io', path: 'acme' }`. */
1680
- function parts(url) {
1681
- const [host = "", ...path] = url.replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase().split("/");
1682
- return {
1683
- host,
1684
- path: path.join("/")
1685
- };
1686
- }
1687
1644
  /**
1688
- * The Dokploy registries that serve `registry`: the same host, and — where
1689
- * Dokploy's own entry names one — the same path.
1645
+ * Generate a secure random secret (64 hex characters = 32 bytes)
1690
1646
  */
1691
- function serving(registries, registry) {
1692
- const wanted = parts(registry);
1693
- const sameHost = registries.filter((r) => parts(r.registryUrl ?? "").host === wanted.host);
1694
- if (sameHost.length <= 1) return sameHost;
1695
- return sameHost.filter((r) => {
1696
- return (parts(r.registryUrl ?? "").path || (r.imagePrefix ?? "").toLowerCase()) === wanted.path;
1697
- });
1647
+ function generateSecret() {
1648
+ return (0, node_crypto.randomBytes)(32).toString("hex");
1698
1649
  }
1699
- /** `registry.one`, or `null` when Dokploy says it has no such registry. */
1700
- async function registryById(api, registryId) {
1701
- try {
1702
- return await api.getRegistry(registryId) ?? null;
1703
- } catch (error) {
1704
- if (error instanceof require_sniffer.DokployApiError) return null;
1705
- throw error;
1706
- }
1650
+ /**
1651
+ * Get or generate a secret for an app.
1652
+ * If the secret already exists in state, returns it.
1653
+ * Otherwise generates a new one and stores it.
1654
+ */
1655
+ function getOrGenerateSecret(state, appName, secretName) {
1656
+ const existing = require_state.getGeneratedSecret(state, appName, secretName);
1657
+ if (existing) return existing;
1658
+ const generated = generateSecret();
1659
+ require_state.setGeneratedSecret(state, appName, secretName, generated);
1660
+ return generated;
1707
1661
  }
1708
1662
  /**
1709
- * The registry id for a stage, in order: the configured id, the one in the
1710
- * stage's state, the one Dokploy has for the configured registry URL, and
1711
- * otherwise a new one. Never a registry the workspace did not point at.
1663
+ * Resolve a single environment variable
1712
1664
  */
1713
- async function resolveRegistry(api, options) {
1714
- const { stage, registry, configuredId, stateId, log = () => {} } = options;
1715
- if (!registry) throw new RegistryNotConfigured(stage);
1716
- if (configuredId) {
1717
- const configured = await registryById(api, configuredId);
1718
- if (!configured) throw new RegistryNotFound(configuredId);
1719
- return configured;
1665
+ function resolveEnvVar(varName, context) {
1666
+ switch (varName) {
1667
+ case "PORT": return String(context.app.port);
1668
+ case "NODE_ENV": return "production";
1669
+ case "STAGE": return context.stage;
1670
+ case "BETTER_AUTH_URL": return `https://${context.appHostname}`;
1671
+ case "BETTER_AUTH_SECRET": return getOrGenerateSecret(context.state, context.appName, "BETTER_AUTH_SECRET");
1672
+ case "BETTER_AUTH_TRUSTED_ORIGINS":
1673
+ if (context.frontendUrls.length > 0) return context.frontendUrls.join(",");
1674
+ break;
1675
+ case "GKM_MASTER_KEY": if (context.masterKey) return context.masterKey;
1720
1676
  }
1721
- if (stateId) {
1722
- const known = await registryById(api, stateId);
1723
- if (known) return known;
1724
- log(` ⚠ The stage's registry ${stateId} no longer exists in Dokploy`);
1677
+ if (context.dependencyUrls && varName.endsWith("_URL")) {
1678
+ const depName = (require_workspace.stripPublicPrefix(varName) ?? varName).slice(0, -4).toLowerCase();
1679
+ if (context.dependencyUrls[depName]) return context.dependencyUrls[depName];
1680
+ }
1681
+ if (context.userSecrets) {
1682
+ if (context.userSecrets.custom[varName]) return context.userSecrets.custom[varName];
1683
+ if (varName in context.userSecrets.urls) return context.userSecrets.urls[varName];
1684
+ if (varName === "POSTGRES_PASSWORD" && context.userSecrets.services.postgres) return context.userSecrets.services.postgres.password;
1685
+ if (varName === "REDIS_PASSWORD" && context.userSecrets.services.redis) return context.userSecrets.services.redis.password;
1725
1686
  }
1726
- const matches = serving(await api.listRegistries(), registry);
1727
- if (matches.length > 1) throw new RegistryAmbiguous(registry, matches.map((r) => r.registryId));
1728
- if (matches[0]) return matches[0];
1729
- return options.create(registry);
1730
1687
  }
1731
- //#endregion
1732
- //#region src/deploy/secrets.ts
1733
1688
  /**
1734
- * Filter secrets to only include the env vars that an app requires.
1735
- *
1736
- * @param stageSecrets - All secrets for the stage
1737
- * @param sniffedEnv - The sniffed environment requirements for the app
1738
- * @returns Filtered secrets with found/missing tracking
1689
+ * Resolve all environment variables for an app
1739
1690
  */
1740
- function filterSecretsForApp(stageSecrets, sniffedEnv) {
1741
- const allSecrets = require_storage.toEmbeddableSecrets(stageSecrets);
1742
- const filtered = {};
1743
- const found = [];
1691
+ function resolveEnvVars(requiredVars, context) {
1692
+ const resolved = {};
1744
1693
  const missing = [];
1745
- for (const key of sniffedEnv.requiredEnvVars) if (key in allSecrets) {
1746
- filtered[key] = allSecrets[key];
1747
- found.push(key);
1748
- } else missing.push(key);
1694
+ for (const varName of requiredVars) {
1695
+ const value = resolveEnvVar(varName, context);
1696
+ if (value !== void 0) resolved[varName] = value;
1697
+ else missing.push(varName);
1698
+ }
1749
1699
  return {
1750
- appName: sniffedEnv.appName,
1751
- secrets: filtered,
1752
- found: found.sort(),
1753
- missing: missing.sort()
1700
+ resolved,
1701
+ missing
1754
1702
  };
1755
1703
  }
1756
1704
  /**
1757
- * Encrypt filtered secrets for an app.
1758
- * Generates an ephemeral master key that should be injected into Dokploy.
1759
- *
1760
- * @param filteredSecrets - The filtered secrets for the app
1761
- * @returns Encrypted payload with master key
1705
+ * Format missing variables error message
1762
1706
  */
1763
- function encryptSecretsForApp(filteredSecrets) {
1764
- const payload = require_sniffer.encryptSecrets(filteredSecrets.secrets);
1765
- return {
1766
- appName: filteredSecrets.appName,
1767
- payload,
1768
- masterKey: payload.masterKey,
1769
- secretCount: Object.keys(filteredSecrets.secrets).length,
1770
- missingSecrets: filteredSecrets.missing
1771
- };
1707
+ function formatMissingVarsError(appName, missing, stage) {
1708
+ return `Deployment failed: ${appName} is missing required environment variables:\n${missing.map((v) => ` - ${v}`).join("\n")}\n\nAdd them with:\n gkm secrets:set <VAR_NAME> <value> --stage ${stage}\n\nOr add them to the app's requiredEnv in gkm.config.ts to have them auto-resolved.`;
1772
1709
  }
1773
1710
  /**
1774
- * Filter and encrypt secrets for an app in one step.
1775
- *
1776
- * @param stageSecrets - All secrets for the stage
1777
- * @param sniffedEnv - The sniffed environment requirements for the app
1778
- * @returns Encrypted secrets with master key
1711
+ * Validate that all required environment variables can be resolved
1779
1712
  */
1780
- function prepareSecretsForApp(stageSecrets, sniffedEnv) {
1781
- return encryptSecretsForApp(filterSecretsForApp(stageSecrets, sniffedEnv));
1713
+ function validateEnvVars(requiredVars, context) {
1714
+ const { resolved, missing } = resolveEnvVars(requiredVars, context);
1715
+ return {
1716
+ valid: missing.length === 0,
1717
+ missing,
1718
+ resolved
1719
+ };
1782
1720
  }
1783
- /**
1784
- * Prepare secrets for multiple apps.
1785
- *
1786
- * @param stageSecrets - All secrets for the stage
1787
- * @param sniffedApps - Map of app name to sniffed environment
1788
- * @returns Map of app name to encrypted secrets
1789
- */
1790
- function prepareSecretsForAllApps(stageSecrets, sniffedApps) {
1791
- const results = /* @__PURE__ */ new Map();
1792
- for (const [appName, sniffedEnv] of sniffedApps) if (sniffedEnv.requiredEnvVars.length > 0) {
1793
- const encrypted = prepareSecretsForApp(stageSecrets, sniffedEnv);
1794
- results.set(appName, encrypted);
1721
+ //#endregion
1722
+ //#region src/target/dokploy/health.ts
1723
+ /** How long one request may take: a check that hangs is a failed check. */
1724
+ const REQUEST_TIMEOUT_MS = 1e4;
1725
+ /** An app never answered healthy enough times in a row. */
1726
+ var HealthCheckTimedOut = class extends Error {
1727
+ app;
1728
+ url;
1729
+ timeoutMs;
1730
+ lastStatus;
1731
+ constructor(app, url, timeoutMs, lastStatus) {
1732
+ super(`${app} did not become healthy within ${Math.round(timeoutMs / 1e3)}s: ${url} last ${lastStatus === void 0 ? "did not answer" : `answered ${lastStatus}`}. Check the app's logs in Dokploy. If it is only slow to start, raise deploy.dokploy.verify.healthTimeoutMs; if it serves its health route elsewhere, set deploy.dokploy.verify.healthCheckPath.`);
1733
+ this.app = app;
1734
+ this.url = url;
1735
+ this.timeoutMs = timeoutMs;
1736
+ this.lastStatus = lastStatus;
1737
+ this.name = "HealthCheckTimedOut";
1738
+ }
1739
+ };
1740
+ /** Wait `ms`, or reject with the signal's reason once it aborts. */
1741
+ function sleep(ms, signal) {
1742
+ return new Promise((resolve, reject) => {
1743
+ if (signal.aborted) return reject(signal.reason);
1744
+ const onAbort = () => {
1745
+ clearTimeout(timer);
1746
+ reject(signal.reason);
1747
+ };
1748
+ const timer = setTimeout(() => {
1749
+ signal.removeEventListener("abort", onAbort);
1750
+ resolve();
1751
+ }, ms);
1752
+ signal.addEventListener("abort", onAbort, { once: true });
1753
+ });
1754
+ }
1755
+ /** One request: its status, or undefined when it never answered. */
1756
+ async function probe(url, signal) {
1757
+ try {
1758
+ const response = await fetch(url, { signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) });
1759
+ response.body?.cancel().catch(() => {});
1760
+ return response.status;
1761
+ } catch {
1762
+ signal.throwIfAborted();
1763
+ return;
1795
1764
  }
1796
- return results;
1797
1765
  }
1798
1766
  /**
1799
- * Generate a report on secrets preparation.
1800
- */
1801
- function generateSecretsReport(encryptedApps, sniffedApps) {
1802
- const appsWithSecrets = [];
1803
- const appsWithoutSecrets = [];
1804
- const appsWithMissingSecrets = [];
1805
- for (const [appName, sniffedEnv] of sniffedApps) {
1806
- if (sniffedEnv.requiredEnvVars.length === 0) {
1807
- appsWithoutSecrets.push(appName);
1808
- continue;
1809
- }
1810
- const encrypted = encryptedApps.get(appName);
1811
- if (encrypted) {
1812
- appsWithSecrets.push(appName);
1813
- if (encrypted.missingSecrets.length > 0) appsWithMissingSecrets.push({
1814
- appName,
1815
- missing: encrypted.missingSecrets
1767
+ * Check `url` until it answers 2xx `healthyAfter` times in a row, emitting
1768
+ * `health.checked` for every check.
1769
+ *
1770
+ * @throws {HealthCheckTimedOut} when that has not happened within `timeoutMs`.
1771
+ */
1772
+ async function verifyHealth(check) {
1773
+ const { app, url, healthyAfter, intervalMs, timeoutMs, signal, emit } = check;
1774
+ const deadline = Date.now() + timeoutMs;
1775
+ let consecutive = 0;
1776
+ let attempt = 0;
1777
+ while (true) {
1778
+ attempt++;
1779
+ const status = await probe(url, signal);
1780
+ const healthy = status !== void 0 && status >= 200 && status < 300;
1781
+ emit({
1782
+ type: "health.checked",
1783
+ app,
1784
+ url,
1785
+ healthy,
1786
+ ...status !== void 0 ? { status } : {},
1787
+ attempt
1788
+ });
1789
+ consecutive = healthy ? consecutive + 1 : 0;
1790
+ if (consecutive >= healthyAfter) return;
1791
+ if (Date.now() + intervalMs > deadline) throw new HealthCheckTimedOut(app, url, timeoutMs, status);
1792
+ await sleep(intervalMs, signal);
1793
+ }
1794
+ }
1795
+ /** The migrations of a deployed stage failed; the release stopped before any app. */
1796
+ var DeployMigrationsFailed = class extends Error {
1797
+ stage;
1798
+ reason;
1799
+ constructor(stage, reason, message) {
1800
+ super(`Migrating "${stage}" failed, so no app was released: ${message}. Migrations applied before the failing one stay applied; fix it and deploy again.`);
1801
+ this.stage = stage;
1802
+ this.reason = reason;
1803
+ this.name = "DeployMigrationsFailed";
1804
+ }
1805
+ };
1806
+ /**
1807
+ * The URLs a stage's migrations connect with, keyed as the migrator reads
1808
+ * them, grouped by the database their cluster serves — for the targets that
1809
+ * have a migration to apply. Empty when none does, so a deploy publishes
1810
+ * nothing for a project that has not written one.
1811
+ */
1812
+ async function migrationUrls(root, manifest, env) {
1813
+ const byDatabase = /* @__PURE__ */ new Map();
1814
+ for (const target of (0, _geekmidas_manifest.migrationTargets)(manifest)) {
1815
+ if ((await require_provider.migrationFiles((0, node_path.join)(root, target.migrations))).size === 0) continue;
1816
+ for (const role of ["ownerUrl", "url"]) {
1817
+ const key = (0, _geekmidas_manifest.provideKey)(target.id, role);
1818
+ const url = env[key];
1819
+ if (!url) continue;
1820
+ const database = new URL(url).pathname.slice(1);
1821
+ byDatabase.set(database, {
1822
+ ...byDatabase.get(database),
1823
+ [key]: url
1816
1824
  });
1817
1825
  }
1818
1826
  }
1819
- return {
1820
- totalApps: sniffedApps.size,
1821
- appsWithSecrets: appsWithSecrets.sort(),
1822
- appsWithoutSecrets: appsWithoutSecrets.sort(),
1823
- appsWithMissingSecrets
1824
- };
1827
+ return byDatabase;
1828
+ }
1829
+ const answer = require_schemas.discriminatedUnion("reason", [require_schemas.object({
1830
+ reason: require_schemas.literal("migrated"),
1831
+ runs: require_schemas.array(require_schemas.object({
1832
+ migrations: require_schemas.string(),
1833
+ applied: require_schemas.array(require_schemas.string())
1834
+ }))
1835
+ }), require_schemas.object({
1836
+ reason: require_schemas.literal("failed"),
1837
+ error: require_schemas.looseObject({
1838
+ name: require_schemas.string(),
1839
+ message: require_schemas.string()
1840
+ })
1841
+ })]);
1842
+ /** Apply every pending migration, in the sandbox, as each construct's owner. */
1843
+ async function runMigrations(options) {
1844
+ const sandbox = options.sandbox ?? require_sandbox.activeSandbox() ?? new require_local.LocalSandbox({ root: options.root });
1845
+ const { value } = await require_discover.runWorker(sandbox, "migrations", {
1846
+ name: "migrate-worker",
1847
+ args: [JSON.stringify({
1848
+ root: options.root,
1849
+ manifest: options.manifest,
1850
+ patterns: options.patterns
1851
+ })],
1852
+ cwd: options.root,
1853
+ timeoutMs: options.timeoutMs ?? 9e5,
1854
+ secrets: options.urls,
1855
+ signal: options.signal,
1856
+ schema: answer
1857
+ });
1858
+ if (value.reason === "failed") throw new DeployMigrationsFailed(options.stage, value.error.name, value.error.message);
1859
+ return value.runs;
1825
1860
  }
1826
1861
  //#endregion
1827
- //#region src/deploy/index.ts
1862
+ //#region src/target/dokploy/postgres.ts
1828
1863
  /**
1829
- * Deploy Module
1830
- *
1831
- * Handles deployment of GKM workspaces to various providers (Docker, Dokploy).
1832
- *
1833
- * ## Per-App Database Credentials
1834
- *
1835
- * When deploying to Dokploy with Postgres, this module creates per-app database
1836
- * users with isolated schemas. This follows the same pattern as local dev mode
1837
- * (docker/postgres/init.sh).
1838
- *
1839
- * ### How It Works
1864
+ * Reaching a Dokploy Postgres from where the deploy runs.
1840
1865
  *
1841
- * 1. **Provisioning**: Creates Postgres service with master credentials
1842
- * 2. **User Creation**: For each backend app that needs DATABASE_URL:
1843
- * - Generates a unique password (stored in deploy state)
1844
- * - Creates a database user with that password
1845
- * - Assigns schema permissions based on app name
1846
- * 3. **Schema Assignment**:
1847
- * - `api` app: Uses `public` schema (shared tables)
1848
- * - Other apps (e.g., `auth`): Get their own schema with `search_path` set
1849
- * 4. **Environment Injection**: Each app receives its own DATABASE_URL
1850
- *
1851
- * ### Security
1852
- *
1853
- * - External Postgres port is enabled only during user creation, then disabled
1854
- * - Each app can only access its own schema
1855
- * - Credentials are stored in `.gkm/deploy-{stage}.json` (gitignored)
1856
- * - Subsequent deploys reuse existing credentials from state
1857
- *
1858
- * ### Example Flow
1859
- *
1860
- * ```
1861
- * gkm deploy --stage production
1862
- * ├─ Create Postgres (user: postgres, db: myproject)
1863
- * ├─ Enable external port temporarily
1864
- * ├─ Create user "api" → public schema
1865
- * ├─ Create user "auth" → auth schema (search_path=auth)
1866
- * ├─ Disable external port
1867
- * ├─ Deploy "api" with DATABASE_URL=postgresql://api:xxx@postgres:5432/myproject
1868
- * └─ Deploy "auth" with DATABASE_URL=postgresql://auth:yyy@postgres:5432/myproject
1869
- * ```
1870
- *
1871
- * @module deploy
1866
+ * A Dokploy Postgres answers on the server's Docker network and nowhere else:
1867
+ * its address is its service name, which only another container there
1868
+ * resolves. Two steps of a deploy need it from outside — the role DDL the
1869
+ * provisioners defer, and the migrations `release` applies — so the cluster
1870
+ * is published on an external port for exactly as long as they take, and
1871
+ * closed again whatever happens.
1872
1872
  */
1873
- const logger = require_discover.output;
1873
+ const logger$1 = require_discover.output;
1874
+ /** A Postgres published for the role DDL never accepted a connection. */
1875
+ var PostgresNotReady = class extends Error {
1876
+ host;
1877
+ port;
1878
+ attempts;
1879
+ constructor(host, port, attempts) {
1880
+ super(`Postgres not ready after ${attempts} retries (${host}:${port}). Check that the port is reachable from here — a firewall in front of the server drops it silently — and deploy again.`);
1881
+ this.host = host;
1882
+ this.port = port;
1883
+ this.attempts = attempts;
1884
+ this.name = "PostgresNotReady";
1885
+ }
1886
+ };
1887
+ /** No port could be published for a cluster, so its DDL cannot reach it. */
1888
+ var PostgresPortUnavailable = class extends Error {
1889
+ appName;
1890
+ taken;
1891
+ constructor(appName, taken) {
1892
+ super(`Could not publish a port for ${appName}. In use on this server: ${[...taken].sort((a, b) => a - b).join(", ") || "none reported"}. The role DDL needs to reach the cluster from here.`);
1893
+ this.appName = appName;
1894
+ this.taken = taken;
1895
+ this.name = "PostgresPortUnavailable";
1896
+ }
1897
+ };
1874
1898
  /**
1875
1899
  * Wait for Postgres to be ready to accept connections.
1876
1900
  *
@@ -1878,14 +1902,7 @@ const logger = require_discover.output;
1878
1902
  * Used after enabling the external port to ensure the database is accessible
1879
1903
  * before creating users.
1880
1904
  *
1881
- * @param host - The Postgres server hostname
1882
- * @param port - The external port (typically 5432)
1883
- * @param user - Master database user (postgres)
1884
- * @param password - Master database password
1885
- * @param database - Database name to connect to
1886
- * @param maxRetries - Maximum number of connection attempts (default: 30)
1887
- * @param retryIntervalMs - Milliseconds between retries (default: 2000)
1888
- * @throws Error if Postgres is not ready after maxRetries
1905
+ * @throws {PostgresNotReady} if Postgres is not ready after maxRetries
1889
1906
  */
1890
1907
  async function waitForPostgres(host, port, user, password, database, maxRetries = 30, retryIntervalMs = 2e3) {
1891
1908
  for (let i = 0; i < maxRetries; i++) try {
@@ -1903,49 +1920,12 @@ async function waitForPostgres(host, port, user, password, database, maxRetries
1903
1920
  return;
1904
1921
  } catch {
1905
1922
  if (i < maxRetries - 1) {
1906
- logger.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
1923
+ logger$1.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
1907
1924
  await new Promise((r) => setTimeout(r, retryIntervalMs));
1908
1925
  }
1909
1926
  }
1910
1927
  throw new PostgresNotReady(host, port, maxRetries);
1911
1928
  }
1912
- /** A Postgres published for the role DDL never accepted a connection. */
1913
- var PostgresNotReady = class extends Error {
1914
- host;
1915
- port;
1916
- attempts;
1917
- constructor(host, port, attempts) {
1918
- super(`Postgres not ready after ${attempts} retries (${host}:${port}). Check that the port is reachable from here — a firewall in front of the server drops it silently — and deploy again.`);
1919
- this.host = host;
1920
- this.port = port;
1921
- this.attempts = attempts;
1922
- this.name = "PostgresNotReady";
1923
- }
1924
- };
1925
- /** No port could be published for a cluster, so its DDL cannot reach it. */
1926
- var PostgresPortUnavailable = class extends Error {
1927
- appName;
1928
- taken;
1929
- constructor(appName, taken) {
1930
- super(`Could not publish a port for ${appName}. In use on this server: ${[...taken].sort((a, b) => a - b).join(", ") || "none reported"}. The role DDL needs to reach the cluster from here.`);
1931
- this.appName = appName;
1932
- this.taken = taken;
1933
- this.name = "PostgresPortUnavailable";
1934
- }
1935
- };
1936
- /**
1937
- * Run the manifest's DDL against a Dokploy Postgres.
1938
- *
1939
- * The cluster is only reachable from outside while an external port is
1940
- * published, so this opens one, applies, and leaves it as it found it. The
1941
- * alternative — running DDL from inside the network — needs a container to run
1942
- * it in, which is what `DatabaseBootstrap` is on AWS and what a Dokploy target
1943
- * has no equivalent for yet.
1944
- *
1945
- * The applier is the local target's, so every statement asks whether it is
1946
- * needed first: a redeploy is free, and a half-applied run recovers by being
1947
- * run again.
1948
- */
1949
1929
  /**
1950
1930
  * A high port for one service, the same one every time.
1951
1931
  *
@@ -1958,32 +1938,80 @@ function derivedPort(appName) {
1958
1938
  const digest = (0, node_crypto.createHash)("sha256").update(appName).digest();
1959
1939
  return 49152 + (digest[0] << 8 | digest[1]) % 16e3;
1960
1940
  }
1961
- async function applyDeclaredStatements(api, postgres, serverHostname, statements) {
1962
- let externalPort = await api.getPostgres(postgres.postgresId).then((current) => current.externalPort).catch(() => null) ?? void 0;
1941
+ /** The hostname of the Dokploy server, from its API endpoint. */
1942
+ function serverHostname(endpoint) {
1943
+ return new URL(endpoint).hostname;
1944
+ }
1945
+ /**
1946
+ * Publish `cluster` on an external port of the server at `host`, and wait
1947
+ * until it accepts a connection there.
1948
+ */
1949
+ async function publishPostgres(api, cluster, host) {
1950
+ let externalPort = await api.getPostgres(cluster.postgresId).then((current) => current.externalPort).catch(() => null) ?? void 0;
1963
1951
  const opened = externalPort === void 0;
1964
1952
  if (externalPort === void 0) {
1965
1953
  const taken = await api.publishedPorts().catch(() => /* @__PURE__ */ new Set());
1966
- const candidates = [5432, derivedPort(postgres.appName)].filter((port) => !taken.has(port));
1954
+ const candidates = [5432, derivedPort(cluster.appName)].filter((port) => !taken.has(port));
1967
1955
  for (const candidate of candidates) try {
1968
- await api.savePostgresExternalPort(postgres.postgresId, candidate);
1956
+ await api.savePostgresExternalPort(cluster.postgresId, candidate);
1969
1957
  externalPort = candidate;
1970
1958
  break;
1971
1959
  } catch (error) {
1972
1960
  if (!(error instanceof Error ? error.message : String(error)).includes("already in use")) throw error;
1973
- logger.log(` Port ${candidate} is taken; trying another...`);
1961
+ logger$1.log(` Port ${candidate} is taken; trying another...`);
1974
1962
  }
1975
- if (externalPort === void 0) throw new PostgresPortUnavailable(postgres.appName, [...taken]);
1976
- logger.log(` Publishing ${postgres.appName} on ${externalPort}...`);
1963
+ if (externalPort === void 0) throw new PostgresPortUnavailable(cluster.appName, [...taken]);
1964
+ logger$1.log(` Publishing ${cluster.appName} on ${externalPort}...`);
1965
+ }
1966
+ const port = externalPort;
1967
+ let closed = false;
1968
+ const close = async () => {
1969
+ if (!opened || closed) return;
1970
+ closed = true;
1971
+ await api.savePostgresExternalPort(cluster.postgresId, null).then(() => api.deployPostgres(cluster.postgresId)).catch(() => {
1972
+ logger$1.log(` ⚠ Could not close external port ${port} on ${cluster.appName} — close it in Dokploy.`);
1973
+ });
1974
+ };
1975
+ try {
1976
+ await api.deployPostgres(cluster.postgresId);
1977
+ await waitForPostgres(host, port, cluster.databaseUser, cluster.databasePassword, cluster.databaseName);
1978
+ } catch (error) {
1979
+ await close();
1980
+ throw error;
1977
1981
  }
1978
- await api.deployPostgres(postgres.postgresId);
1979
- await waitForPostgres(serverHostname, externalPort, postgres.databaseUser, postgres.databasePassword, postgres.databaseName);
1982
+ return {
1983
+ cluster,
1984
+ host,
1985
+ port,
1986
+ close
1987
+ };
1988
+ }
1989
+ /**
1990
+ * A URL the cluster's containers use — its service name, its internal port —
1991
+ * pointed at where `published` answers from here instead.
1992
+ */
1993
+ function publishedUrl(url, published) {
1994
+ const parsed = new URL(url);
1995
+ parsed.hostname = published.host;
1996
+ parsed.port = String(published.port);
1997
+ return parsed.toString();
1998
+ }
1999
+ /**
2000
+ * Run the manifest's DDL against a published Dokploy Postgres.
2001
+ *
2002
+ * The applier is the local target's, so every statement asks whether it is
2003
+ * needed first: a redeploy is free, and a half-applied run recovers by being
2004
+ * run again.
2005
+ */
2006
+ async function applyDeclaredStatements(published, statements) {
2007
+ const { cluster, host, port } = published;
1980
2008
  const client = { async query(database, sql, values) {
1981
2009
  const connection = new pg.Client({
1982
- host: serverHostname,
1983
- port: externalPort,
1984
- user: postgres.databaseUser,
1985
- password: postgres.databasePassword,
1986
- database: database ?? postgres.databaseName,
2010
+ host,
2011
+ port,
2012
+ user: cluster.databaseUser,
2013
+ password: cluster.databasePassword,
2014
+ database: database ?? cluster.databaseName,
1987
2015
  connectionTimeoutMillis: 15e3
1988
2016
  });
1989
2017
  connection.on("error", () => {});
@@ -1994,35 +2022,22 @@ async function applyDeclaredStatements(api, postgres, serverHostname, statements
1994
2022
  await connection.end();
1995
2023
  }
1996
2024
  } };
1997
- const unpublish = async () => {
1998
- if (!opened) return;
1999
- await api.savePostgresExternalPort(postgres.postgresId, null).then(() => api.deployPostgres(postgres.postgresId)).catch(() => {
2000
- logger.log(` ⚠ Could not close external port ${externalPort} on ${postgres.appName} — close it in Dokploy.`);
2001
- });
2002
- };
2003
2025
  let lastError;
2004
- try {
2005
- for (let attempt = 1; attempt <= 3; attempt++) try {
2006
- return await applyDeclared(client, statements);
2007
- } catch (error) {
2008
- lastError = error;
2009
- if (attempt === 3) break;
2010
- const message = error instanceof Error ? error.message : String(error);
2011
- logger.log(` ⏳ Cluster still settling (${message}); retrying (${attempt}/2)...`);
2012
- await new Promise((resolve) => setTimeout(resolve, 1e4));
2013
- await waitForPostgres(serverHostname, externalPort, postgres.databaseUser, postgres.databasePassword, postgres.databaseName).catch(() => {});
2014
- }
2015
- throw lastError;
2016
- } finally {
2017
- await unpublish();
2026
+ for (let attempt = 1; attempt <= 3; attempt++) try {
2027
+ return await applyDeclared(client, statements);
2028
+ } catch (error) {
2029
+ lastError = error;
2030
+ if (attempt === 3) break;
2031
+ const message = error instanceof Error ? error.message : String(error);
2032
+ logger$1.log(` ⏳ Cluster still settling (${message}); retrying (${attempt}/2)...`);
2033
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
2034
+ await waitForPostgres(host, port, cluster.databaseUser, cluster.databasePassword, cluster.databaseName).catch(() => {});
2018
2035
  }
2036
+ throw lastError;
2019
2037
  }
2020
- /**
2021
- * Get the server hostname from the Dokploy endpoint URL
2022
- */
2023
- function getServerHostname(endpoint) {
2024
- return new URL(endpoint).hostname;
2025
- }
2038
+ //#endregion
2039
+ //#region src/target/dokploy/engine.ts
2040
+ const logger = require_discover.output;
2026
2041
  /**
2027
2042
  * The variables an app cannot start without.
2028
2043
  *
@@ -2050,6 +2065,19 @@ var BackendDeployFailed = class extends Error {
2050
2065
  this.name = "BackendDeployFailed";
2051
2066
  }
2052
2067
  };
2068
+ /**
2069
+ * Sites failed to release or to answer. Every site is attempted before this
2070
+ * is thrown, so one broken site does not keep the others back — but the run
2071
+ * fails, and what failed is rolled back.
2072
+ */
2073
+ var FrontendDeployFailed = class extends Error {
2074
+ apps;
2075
+ constructor(apps, cause) {
2076
+ super(`Frontend deployment failed for ${apps.join(", ")}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
2077
+ this.apps = apps;
2078
+ this.name = "FrontendDeployFailed";
2079
+ }
2080
+ };
2053
2081
  /** An app's environment needs values the stage does not have. */
2054
2082
  var MissingEnvVars = class extends Error {
2055
2083
  app;
@@ -2078,6 +2106,16 @@ const CHILD_STDIO = {
2078
2106
  ],
2079
2107
  ignore: "ignore"
2080
2108
  };
2109
+ /** The defaults of `deploy.dokploy.verify`. */
2110
+ function verifySettings(config) {
2111
+ return {
2112
+ deploymentTimeoutMs: config?.deploymentTimeoutMs ?? 6e5,
2113
+ healthCheckPath: config?.healthCheckPath ?? "/health",
2114
+ healthyAfter: config?.healthyAfter ?? 3,
2115
+ intervalMs: config?.intervalMs ?? 2e3,
2116
+ healthTimeoutMs: config?.healthTimeoutMs ?? 3e5
2117
+ };
2118
+ }
2081
2119
  /**
2082
2120
  * Deploy every app in a workspace to Dokploy: the engine under the built-in
2083
2121
  * `dokploy` target.
@@ -2128,7 +2166,14 @@ async function validateDokploy(phase) {
2128
2166
  };
2129
2167
  return {
2130
2168
  ...run,
2131
- preflight: await preflight(run)
2169
+ preflight: await preflight(run),
2170
+ verify: verifySettings(workspace.deploy.dokploy?.verify),
2171
+ atomic: phase.atomic,
2172
+ releasing: {
2173
+ switched: /* @__PURE__ */ new Map(),
2174
+ failed: /* @__PURE__ */ new Set()
2175
+ },
2176
+ published: /* @__PURE__ */ new Map()
2132
2177
  };
2133
2178
  }
2134
2179
  /**
@@ -2139,7 +2184,7 @@ async function preflight({ workspace, manifest, stage, secrets: secretsStore, ct
2139
2184
  logger.log("\n🔐 Loading secrets and analyzing environment requirements...");
2140
2185
  const stored = await secretsStore.read();
2141
2186
  if (!stored) logger.log(` ⚠️ No secrets found for stage "${stage}"; starting them`);
2142
- const { secrets: stageSecrets, generated } = require_sniffer.withGeneratedSecrets(stored ?? require_storage.initStageSecrets(stage), manifest);
2187
+ const { secrets: stageSecrets, generated } = require_state.withGeneratedSecrets(stored ?? require_state.initStageSecrets(stage), manifest);
2143
2188
  if (generated.length > 0) {
2144
2189
  if (ctx.dryRun) logger.log(` 🔑 Would generate for "${stage}" (${secretsStore.store}): ${generated.join(", ")}`);
2145
2190
  else {
@@ -2166,6 +2211,19 @@ async function preflight({ workspace, manifest, stage, secrets: secretsStore, ct
2166
2211
  * before it is created, so a run stopped part way is finished by the next.
2167
2212
  */
2168
2213
  async function provisionDokploy(run) {
2214
+ try {
2215
+ await provision(run);
2216
+ } catch (error) {
2217
+ await closePublished(run);
2218
+ throw error;
2219
+ }
2220
+ }
2221
+ /** Unpublish every cluster the run still has published. */
2222
+ async function closePublished(run) {
2223
+ for (const published of run.published.values()) await published.close();
2224
+ run.published.clear();
2225
+ }
2226
+ async function provision(run) {
2169
2227
  const { workspace, manifest, stage, appsToDeployNames, identity, store, ctx } = run;
2170
2228
  const { stageSecrets } = run.preflight;
2171
2229
  const changes = [];
@@ -2179,7 +2237,7 @@ async function provisionDokploy(run) {
2179
2237
  };
2180
2238
  const { api, endpoint } = await dokployApi(workspace, ctx);
2181
2239
  logger.log("\n📋 Loading deploy state...");
2182
- const journal = await DeployJournal.open(store, stage, () => require_sniffer.createEmptyState(stage, "", ""));
2240
+ const journal = await require_state.DeployJournal.open(store, stage, () => require_state.createEmptyState(stage, "", ""));
2183
2241
  const state = journal.state;
2184
2242
  if (journal.existed) logger.log(` Found existing state for stage "${stage}"`);
2185
2243
  else logger.log(` Creating new state for stage "${stage}"`);
@@ -2191,7 +2249,7 @@ async function provisionDokploy(run) {
2191
2249
  key: "project",
2192
2250
  type: "project",
2193
2251
  data: {
2194
- name: require_store.projectName(identity),
2252
+ name: require_state.projectName(identity),
2195
2253
  identity: identity.key
2196
2254
  }
2197
2255
  };
@@ -2235,7 +2293,7 @@ async function provisionDokploy(run) {
2235
2293
  state.environmentId = environmentId;
2236
2294
  state.identity = identity.key;
2237
2295
  logger.log("\n🐳 Checking registry...");
2238
- const registry = workspace.deploy.dokploy?.registry;
2296
+ const registry = workspace.deploy.registry;
2239
2297
  let registryCreated = false;
2240
2298
  const dokployRegistry = await resolveRegistry(api, {
2241
2299
  stage,
@@ -2269,6 +2327,7 @@ async function provisionDokploy(run) {
2269
2327
  if (skippedMobileApps.length > 0) logger.log(`\n📱 Skipping ${skippedMobileApps.length} mobile app(s) — deploy via framework toolchain: ${skippedMobileApps.join(", ")}`);
2270
2328
  let declaredEnv = {};
2271
2329
  let declaredClusters = {};
2330
+ let migrations = /* @__PURE__ */ new Map();
2272
2331
  {
2273
2332
  logger.log("\n📦 Provisioning declared constructs...");
2274
2333
  const appUrls = {};
@@ -2291,30 +2350,35 @@ async function provisionDokploy(run) {
2291
2350
  declaredEnv = declared.env;
2292
2351
  declaredClusters = declared.clusters;
2293
2352
  if (Object.keys(declaredEnv).length > 0) logger.log(` 🔌 Resolved ${Object.keys(declaredEnv).length} declared URL(s)`);
2294
- if (declared.statements.length > 0) {
2295
- const serverHostname = getServerHostname(endpoint);
2296
- for (const [databaseName, cluster] of Object.entries(declared.clusters)) {
2297
- const statements = declared.statements.filter((statement) => statement.database === databaseName);
2298
- if (statements.length === 0) continue;
2299
- const created = await applyDeclaredStatements(api, cluster, serverHostname, statements);
2300
- logger.log(` 🗄️ ${databaseName}: applied ${statements.length} statement(s), ${created} new`);
2353
+ migrations = await migrationUrls(workspace.root, manifest, declaredEnv);
2354
+ const host = serverHostname(endpoint);
2355
+ for (const [databaseName, cluster] of Object.entries(declared.clusters)) {
2356
+ const statements = declared.statements.filter((statement) => statement.database === databaseName);
2357
+ if (statements.length === 0) continue;
2358
+ const published = await publishPostgres(api, cluster, host);
2359
+ run.published.set(databaseName, published);
2360
+ const created = await applyDeclaredStatements(published, statements);
2361
+ if (!migrations.has(databaseName)) {
2362
+ await published.close();
2363
+ run.published.delete(databaseName);
2301
2364
  }
2365
+ logger.log(` 🗄️ ${databaseName}: applied ${statements.length} statement(s), ${created} new`);
2302
2366
  }
2303
2367
  }
2304
2368
  const backupCluster = Object.values(declaredClusters)[0];
2305
2369
  if (workspace.deploy?.backups && backupCluster) {
2306
2370
  logger.log("\n💾 Provisioning backup destination...");
2307
- const { provisionBackupDestination } = await Promise.resolve().then(() => require("./backup-provisioner-BmhIO_Yr.cjs"));
2371
+ const { provisionBackupDestination } = await Promise.resolve().then(() => require("./backup-provisioner-oDEsPj02.cjs"));
2308
2372
  const backupState = await provisionBackupDestination({
2309
2373
  api,
2310
2374
  projectId: project.projectId,
2311
2375
  projectName: identity.scope,
2312
2376
  stage,
2313
2377
  config: workspace.deploy.backups,
2314
- existingState: require_sniffer.getBackupState(state),
2378
+ existingState: require_state.getBackupState(state),
2315
2379
  logger
2316
2380
  });
2317
- require_sniffer.setBackupState(state, backupState);
2381
+ require_state.setBackupState(state, backupState);
2318
2382
  if (!backupState.postgresBackupId) {
2319
2383
  const backupSchedule = workspace.deploy.backups.schedule ?? "0 2 * * *";
2320
2384
  const backupRetention = workspace.deploy.backups.retention ?? 30;
@@ -2328,7 +2392,7 @@ async function provisionDokploy(run) {
2328
2392
  enabled: true,
2329
2393
  keepLatestCount: backupRetention
2330
2394
  });
2331
- require_sniffer.setPostgresBackupId(state, backup.backupId);
2395
+ require_state.setPostgresBackupId(state, backup.backupId);
2332
2396
  logger.log(` ✓ Postgres backup schedule created (${backupSchedule})`);
2333
2397
  } else logger.log(" ✓ Using existing postgres backup schedule");
2334
2398
  }
@@ -2342,14 +2406,22 @@ async function provisionDokploy(run) {
2342
2406
  registry,
2343
2407
  registryId,
2344
2408
  declaredEnv,
2409
+ declaredClusters,
2410
+ migrations,
2345
2411
  changes,
2346
2412
  applied
2347
2413
  };
2348
2414
  }
2349
2415
  /**
2350
- * Each app's image built and pushed, its application configured and
2351
- * deployed, and its domain attached: backends first, each failure stopping
2352
- * the run; then sites, whose build args are the backends' public URLs.
2416
+ * The stage's migrations, then each app's image built and pushed, its
2417
+ * application configured and deployed, and its domain attached — counted as
2418
+ * released only once Dokploy's deployment has finished.
2419
+ *
2420
+ * Backends first, each failure stopping the run; then they are checked, and
2421
+ * a backend that does not answer stops the run too — a site built against an
2422
+ * API that is down is a site that is down. Then the sites, whose build args
2423
+ * are the backends' public URLs: every one is attempted, and any that failed
2424
+ * fails the run. `verify` checks the sites.
2353
2425
  *
2354
2426
  * Building is here rather than in a `build` phase of its own because an
2355
2427
  * image is built beside its application — a site's build args are resolved
@@ -2360,12 +2432,25 @@ async function releaseDokploy(run) {
2360
2432
  const { stageSecrets, sniffedApps, encryptedSecrets } = run.preflight;
2361
2433
  const { api, journal, state, environmentId, registry, registryId, declaredEnv, changes, applied } = run.provisioned;
2362
2434
  const project = { projectId: run.provisioned.projectId };
2435
+ try {
2436
+ await migrate(run);
2437
+ } finally {
2438
+ await closePublished(run);
2439
+ }
2363
2440
  const backendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "backend");
2364
2441
  const frontendApps = appsToDeployNames.filter((name) => workspace.apps[name].type === "web");
2365
2442
  const publicUrls = {};
2366
2443
  const results = [];
2367
2444
  const appHostnames = /* @__PURE__ */ new Map();
2368
2445
  const appDomainIds = /* @__PURE__ */ new Map();
2446
+ const released = {
2447
+ results,
2448
+ publicUrls,
2449
+ appHostnames,
2450
+ appDomainIds,
2451
+ applicationIds: /* @__PURE__ */ new Map()
2452
+ };
2453
+ run.released = released;
2369
2454
  /** An app is up: say so, and remember where. */
2370
2455
  const deployed = (appName, result, host) => {
2371
2456
  const url = `https://${host}`;
@@ -2382,29 +2467,8 @@ async function releaseDokploy(run) {
2382
2467
  url
2383
2468
  });
2384
2469
  };
2385
- /** An app failed: say so, and record it in the result. */
2386
- const failed = (appName, app, error) => {
2387
- const message = error instanceof Error ? error.message : "Unknown error";
2388
- logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
2389
- results.push({
2390
- appName,
2391
- type: app.type,
2392
- success: false,
2393
- error: message
2394
- });
2395
- ctx.emit({
2396
- type: "app.failed",
2397
- app: appName,
2398
- error: require_terminal.eventError(error)
2399
- });
2400
- };
2401
2470
  /** An image is pushed: worth keeping even if what follows fails. */
2402
- const built = async (appName, image, ref) => {
2403
- require_sniffer.setDeployedImage(state, appName, {
2404
- ref: image.imageRef ?? ref,
2405
- ...image.digest ? { digest: image.digest } : {}
2406
- });
2407
- await journal.save();
2471
+ const built = (appName, image, ref) => {
2408
2472
  ctx.emit({
2409
2473
  type: "artifact.built",
2410
2474
  app: appName,
@@ -2418,6 +2482,41 @@ async function releaseDokploy(run) {
2418
2482
  id: image.digest ?? image.imageRef ?? ref
2419
2483
  });
2420
2484
  };
2485
+ /**
2486
+ * Point an app's application at its image and environment, deploy it,
2487
+ * attach its domain, and wait for Dokploy to finish — then record the
2488
+ * release, with what ran before it as the one a rollback restores.
2489
+ */
2490
+ const release = async (appName, applicationId, image, envVars, host) => {
2491
+ const app = workspace.apps[appName];
2492
+ released.applicationIds.set(appName, applicationId);
2493
+ const since = await api.latestDeploymentAt(applicationId);
2494
+ const before = state.releases?.[appName]?.current;
2495
+ run.releasing.switched.set(appName, {
2496
+ applicationId,
2497
+ ...before ? { before } : {}
2498
+ });
2499
+ await api.saveDockerProvider(applicationId, image.ref, { registryId });
2500
+ await api.saveApplicationEnv(applicationId, envVars.join("\n"));
2501
+ logger.log(` Deploying to Dokploy...`);
2502
+ await api.deployApplication(applicationId);
2503
+ const domainId = await ensureDomain(api, journal, host, app.port, applicationId, applied);
2504
+ appHostnames.set(appName, host);
2505
+ if (domainId) appDomainIds.set(appName, domainId);
2506
+ logger.log(` Waiting for the deployment to finish...`);
2507
+ await api.waitForDeployment(applicationId, {
2508
+ since,
2509
+ timeoutMs: run.verify.deploymentTimeoutMs,
2510
+ intervalMs: run.verify.intervalMs,
2511
+ signal: ctx.signal
2512
+ });
2513
+ require_state.recordRelease(state, appName, {
2514
+ ...image,
2515
+ tag: imageTag
2516
+ });
2517
+ await journal.save();
2518
+ publicUrls[appName] = `https://${host}`;
2519
+ };
2421
2520
  const frontendUrls = [];
2422
2521
  for (const appName of frontendApps) frontendUrls.push(`https://${hostOf(workspace, appName, stage)}`);
2423
2522
  if (backendApps.length > 0) {
@@ -2427,14 +2526,14 @@ async function releaseDokploy(run) {
2427
2526
  const app = workspace.apps[appName];
2428
2527
  logger.log(`\n ⚙️ Deploying ${appName}...`);
2429
2528
  try {
2430
- const application = await ensureApplication(api, journal, appName, require_store.applicationName(identity, appName), project.projectId, environmentId, applied);
2529
+ const application = await ensureApplication(api, journal, appName, require_state.applicationName(identity, appName), project.projectId, environmentId, applied);
2431
2530
  const appSecrets = encryptedSecrets.get(appName);
2432
2531
  const credentials = appSecrets && appSecrets.secretCount > 0 ? {
2433
2532
  encrypted: appSecrets.payload.encrypted,
2434
2533
  iv: appSecrets.payload.iv
2435
2534
  } : void 0;
2436
2535
  if (appSecrets && credentials) logger.log(` Encrypted ${appSecrets.secretCount} secrets`);
2437
- const imageRef$1 = require_store.imageRef(identity, appName, registry, imageTag);
2536
+ const imageRef$1 = require_state.imageRef(identity, appName, registry, imageTag);
2438
2537
  logger.log(` Building Docker image: ${imageRef$1}`);
2439
2538
  const image = await deployDocker({
2440
2539
  stage,
@@ -2442,13 +2541,13 @@ async function releaseDokploy(run) {
2442
2541
  skipPush: false,
2443
2542
  config: {
2444
2543
  registry,
2445
- imageName: require_store.imageName(identity, appName),
2544
+ imageName: require_state.imageName(identity, appName),
2446
2545
  appName
2447
2546
  },
2448
2547
  credentials,
2449
2548
  ...dockerRun(workspace, app, ctx)
2450
2549
  });
2451
- await built(appName, image, imageRef$1);
2550
+ built(appName, image, imageRef$1);
2452
2551
  const backendHost = hostOf(workspace, appName, stage);
2453
2552
  const dependencyUrls = {};
2454
2553
  if (app.dependencies) {
@@ -2479,14 +2578,10 @@ async function releaseDokploy(run) {
2479
2578
  };
2480
2579
  const envVars = Object.entries(withDeclared).map(([key, value]) => `${key}=${value}`);
2481
2580
  if (Object.keys(withDeclared).length > 0) logger.log(` Resolved ${Object.keys(withDeclared).length} env vars: ${Object.keys(withDeclared).sort().join(", ")}`);
2482
- await api.saveDockerProvider(application.applicationId, imageRef$1, { registryId });
2483
- await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
2484
- logger.log(` Deploying to Dokploy...`);
2485
- await api.deployApplication(application.applicationId);
2486
- const domainId = await ensureDomain(api, journal, backendHost, app.port, application.applicationId, applied);
2487
- appHostnames.set(appName, backendHost);
2488
- if (domainId) appDomainIds.set(appName, domainId);
2489
- publicUrls[appName] = `https://${backendHost}`;
2581
+ await release(appName, application.applicationId, {
2582
+ ref: image.imageRef ?? imageRef$1,
2583
+ ...image.digest ? { digest: image.digest } : {}
2584
+ }, envVars, backendHost);
2490
2585
  deployed(appName, {
2491
2586
  appName,
2492
2587
  type: app.type,
@@ -2497,11 +2592,20 @@ async function releaseDokploy(run) {
2497
2592
  logger.log(` ✓ ${appName} deployed successfully`);
2498
2593
  } catch (error) {
2499
2594
  if (ctx.signal?.aborted) throw ctx.signal.reason;
2500
- failed(appName, app, error);
2595
+ failed(run, appName, error);
2501
2596
  throw new BackendDeployFailed(appName, error);
2502
2597
  }
2503
2598
  }
2599
+ await settleDns(run, backendApps);
2600
+ for (const appName of backendApps) try {
2601
+ await checkHealth(run, appName);
2602
+ } catch (error) {
2603
+ if (ctx.signal?.aborted) throw ctx.signal.reason;
2604
+ failed(run, appName, error);
2605
+ throw new BackendDeployFailed(appName, error);
2606
+ }
2504
2607
  }
2608
+ let frontendFailure;
2505
2609
  if (frontendApps.length > 0) {
2506
2610
  logger.log("\n🌐 PHASE 2: Deploying frontend applications...");
2507
2611
  for (const appName of frontendApps) {
@@ -2509,7 +2613,7 @@ async function releaseDokploy(run) {
2509
2613
  const app = workspace.apps[appName];
2510
2614
  logger.log(`\n 🌐 Deploying ${appName}...`);
2511
2615
  try {
2512
- const application = await ensureApplication(api, journal, appName, require_store.applicationName(identity, appName), project.projectId, environmentId, applied);
2616
+ const application = await ensureApplication(api, journal, appName, require_state.applicationName(identity, appName), project.projectId, environmentId, applied);
2513
2617
  const dependencyUrls = {};
2514
2618
  if (app.dependencies) {
2515
2619
  for (const dep of app.dependencies) if (publicUrls[dep]) dependencyUrls[dep] = publicUrls[dep];
@@ -2538,7 +2642,7 @@ async function releaseDokploy(run) {
2538
2642
  }
2539
2643
  }
2540
2644
  if (buildArgs.length > 0) logger.log(` Build args: ${publicUrlArgNames.join(", ")}`);
2541
- const imageRef$2 = require_store.imageRef(identity, appName, registry, imageTag);
2645
+ const imageRef$2 = require_state.imageRef(identity, appName, registry, imageTag);
2542
2646
  logger.log(` Building Docker image: ${imageRef$2}`);
2543
2647
  const image = await deployDocker({
2544
2648
  stage,
@@ -2546,28 +2650,24 @@ async function releaseDokploy(run) {
2546
2650
  skipPush: false,
2547
2651
  config: {
2548
2652
  registry,
2549
- imageName: require_store.imageName(identity, appName),
2653
+ imageName: require_state.imageName(identity, appName),
2550
2654
  appName
2551
2655
  },
2552
2656
  buildArgs,
2553
2657
  publicUrlArgs: publicUrlArgNames,
2554
2658
  ...dockerRun(workspace, app, ctx)
2555
2659
  });
2556
- await built(appName, image, imageRef$2);
2660
+ built(appName, image, imageRef$2);
2557
2661
  const envVars = [
2558
2662
  `NODE_ENV=production`,
2559
2663
  `PORT=${app.port}`,
2560
2664
  `STAGE=${stage}`
2561
2665
  ];
2562
2666
  for (const [key, value] of Object.entries(resolved)) envVars.push(`${key}=${value}`);
2563
- await api.saveDockerProvider(application.applicationId, imageRef$2, { registryId });
2564
- await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
2565
- logger.log(` Deploying to Dokploy...`);
2566
- await api.deployApplication(application.applicationId);
2567
- const domainId = await ensureDomain(api, journal, frontendHost, app.port, application.applicationId, applied);
2568
- appHostnames.set(appName, frontendHost);
2569
- if (domainId) appDomainIds.set(appName, domainId);
2570
- publicUrls[appName] = `https://${frontendHost}`;
2667
+ await release(appName, application.applicationId, {
2668
+ ref: image.imageRef ?? imageRef$2,
2669
+ ...image.digest ? { digest: image.digest } : {}
2670
+ }, envVars, frontendHost);
2571
2671
  deployed(appName, {
2572
2672
  appName,
2573
2673
  type: app.type,
@@ -2578,42 +2678,199 @@ async function releaseDokploy(run) {
2578
2678
  logger.log(` ✓ ${appName} deployed successfully`);
2579
2679
  } catch (error) {
2580
2680
  if (ctx.signal?.aborted) throw ctx.signal.reason;
2581
- failed(appName, app, error);
2681
+ failed(run, appName, error);
2682
+ frontendFailure ??= error;
2582
2683
  }
2583
2684
  }
2584
2685
  }
2585
2686
  logger.log("\n📋 Saving deploy state...");
2586
2687
  await journal.save();
2587
2688
  logger.log(" ✓ State saved");
2588
- run.released = {
2589
- results,
2590
- publicUrls,
2591
- appHostnames
2592
- };
2689
+ if (frontendFailure !== void 0) throw new FrontendDeployFailed(frontendApps.filter((app) => run.releasing.failed.has(app)), frontendFailure);
2593
2690
  }
2594
- /** DNS records for each released app, and Dokploy's validation of each domain. */
2691
+ /** Each released site answering, after its DNS. Backends were checked in `release`. */
2595
2692
  async function verifyDokploy(run) {
2693
+ const { workspace, ctx } = run;
2694
+ const sites = run.released.results.filter((result) => result.success).map((result) => result.appName).filter((app) => workspace.apps[app]?.type === "web");
2695
+ if (sites.length === 0) return;
2696
+ await settleDns(run, sites);
2697
+ let failure;
2698
+ for (const appName of sites) try {
2699
+ await checkHealth(run, appName);
2700
+ } catch (error) {
2701
+ if (ctx.signal.aborted) throw ctx.signal.reason;
2702
+ failed(run, appName, error);
2703
+ failure ??= error;
2704
+ }
2705
+ if (failure !== void 0) throw new FrontendDeployFailed(sites.filter((app) => run.releasing.failed.has(app)), failure);
2706
+ }
2707
+ /**
2708
+ * An app failed to release or to answer: say so, mark it for rollback, and
2709
+ * record it in the result — over the success it was recorded as, if its
2710
+ * health is what failed.
2711
+ */
2712
+ function failed(run, appName, error) {
2713
+ const app = run.workspace.apps[appName];
2714
+ const message = error instanceof Error ? error.message : "Unknown error";
2715
+ logger.log(` ✗ Failed to deploy ${appName}: ${message}`);
2716
+ const results = run.released.results;
2717
+ const recorded = results.find((result) => result.appName === appName);
2718
+ if (recorded) {
2719
+ recorded.success = false;
2720
+ recorded.error = message;
2721
+ } else results.push({
2722
+ appName,
2723
+ type: app.type,
2724
+ success: false,
2725
+ error: message
2726
+ });
2727
+ run.releasing.failed.add(appName);
2728
+ run.ctx.emit({
2729
+ type: "app.failed",
2730
+ app: appName,
2731
+ error: require_sniffer.eventError(error)
2732
+ });
2733
+ }
2734
+ /**
2735
+ * The stage's pending migrations, applied in the deploy's sandbox against
2736
+ * each cluster published for them. Nothing is published, and nothing runs,
2737
+ * for a project with no migration to apply.
2738
+ */
2739
+ async function migrate(run) {
2740
+ const { workspace, manifest, stage, ctx } = run;
2741
+ const { api, endpoint, migrations, declaredClusters } = run.provisioned;
2742
+ if (migrations.size === 0) return;
2743
+ logger.log("\n🗄️ Applying migrations...");
2744
+ const host = serverHostname(endpoint);
2745
+ const urls = {};
2746
+ for (const [database, keys] of migrations) {
2747
+ const cluster = declaredClusters[database];
2748
+ if (!cluster) continue;
2749
+ let published = run.published.get(database);
2750
+ if (!published) {
2751
+ published = await publishPostgres(api, cluster, host);
2752
+ run.published.set(database, published);
2753
+ }
2754
+ for (const [key, url] of Object.entries(keys)) urls[key] = publishedUrl(url, published);
2755
+ }
2756
+ const runs = await runMigrations({
2757
+ root: workspace.root,
2758
+ stage,
2759
+ manifest,
2760
+ patterns: require_state.constructGlobs(workspace),
2761
+ urls,
2762
+ signal: ctx.signal
2763
+ });
2764
+ for (const { migrations: folder, applied } of runs) logger.log(applied.length === 0 ? ` ✓ ${folder}: up to date` : ` ✓ ${folder}: applied ${applied.join(", ")}`);
2765
+ }
2766
+ /**
2767
+ * DNS records for `apps`' hosts, their propagation, and Dokploy's validation
2768
+ * of each domain — before anything is asked of them by name.
2769
+ */
2770
+ async function settleDns(run, apps) {
2596
2771
  const { workspace } = run;
2597
2772
  const { api, endpoint, journal, state } = run.provisioned;
2598
- const { appHostnames } = run.released;
2773
+ const appHostnames = new Map([...run.released.appHostnames].filter(([app]) => apps.includes(app)));
2599
2774
  const dnsConfig = workspace.deploy.dns;
2600
- if (dnsConfig && appHostnames.size > 0) {
2601
- const dnsResult = await orchestrateDns(appHostnames, dnsConfig, endpoint);
2602
- if (dnsResult?.serverIp && appHostnames.size > 0) {
2603
- await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
2604
- await journal.save();
2775
+ if (!dnsConfig || appHostnames.size === 0) return;
2776
+ const dnsResult = await orchestrateDns(appHostnames, dnsConfig, endpoint);
2777
+ if (dnsResult?.serverIp) {
2778
+ await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
2779
+ await journal.save();
2780
+ }
2781
+ if (dnsResult?.success) {
2782
+ logger.log("\n🔒 Validating domains for SSL certificates...");
2783
+ for (const [appName, hostname] of appHostnames) try {
2784
+ const result = await api.validateDomain(hostname);
2785
+ if (result.isValid) logger.log(` ✓ ${appName}: ${hostname} → ${result.resolvedIp}`);
2786
+ else logger.log(` ⚠ ${appName}: ${hostname} not valid`);
2787
+ } catch (validationError) {
2788
+ const message = validationError instanceof Error ? validationError.message : "Unknown error";
2789
+ logger.log(` ⚠ ${appName}: validation failed - ${message}`);
2605
2790
  }
2606
- if (dnsResult?.success && appHostnames.size > 0) {
2607
- logger.log("\n🔒 Validating domains for SSL certificates...");
2608
- for (const [appName, hostname] of appHostnames) try {
2609
- const result = await api.validateDomain(hostname);
2610
- if (result.isValid) logger.log(` ✓ ${appName}: ${hostname} → ${result.resolvedIp}`);
2611
- else logger.log(` ⚠ ${appName}: ${hostname} not valid`);
2612
- } catch (validationError) {
2613
- const message = validationError instanceof Error ? validationError.message : "Unknown error";
2614
- logger.log(` ⚠ ${appName}: validation failed - ${message}`);
2615
- }
2791
+ }
2792
+ }
2793
+ /**
2794
+ * Whether a released app answers: `healthyAfter` 2xx in a row from its
2795
+ * health route under its domain. An app without a domain has nothing to ask
2796
+ * by name, so Dokploy's finished deployment is its check.
2797
+ */
2798
+ async function checkHealth(run, appName) {
2799
+ const { workspace, verify, ctx } = run;
2800
+ const { appHostnames, appDomainIds, applicationIds } = run.released;
2801
+ const host = appHostnames.get(appName);
2802
+ if (!host || !appDomainIds.has(appName)) {
2803
+ ctx.emit({
2804
+ type: "health.checked",
2805
+ app: appName,
2806
+ url: `dokploy:application.status/${applicationIds.get(appName)}`,
2807
+ healthy: true,
2808
+ attempt: 1
2809
+ });
2810
+ logger.log(` ✓ ${appName}: no domain, so its finished deployment is its check`);
2811
+ return;
2812
+ }
2813
+ const url = `https://${host}${workspace.apps[appName].type === "web" ? "/" : verify.healthCheckPath}`;
2814
+ logger.log(`\n🩺 Checking ${appName} at ${url}...`);
2815
+ await verifyHealth({
2816
+ app: appName,
2817
+ url,
2818
+ healthyAfter: verify.healthyAfter,
2819
+ intervalMs: verify.intervalMs,
2820
+ timeoutMs: verify.healthTimeoutMs,
2821
+ signal: ctx.signal,
2822
+ emit: ctx.emit
2823
+ });
2824
+ logger.log(` ✓ ${appName} healthy (${verify.healthyAfter} in a row)`);
2825
+ }
2826
+ /**
2827
+ * Point an application back at `image`, deploy it, and wait for Dokploy to
2828
+ * finish. The environment stays the newer release's: it is resolved from the
2829
+ * stage, which a rollback does not change, and holds no image of its own.
2830
+ */
2831
+ async function restoreImage(api, options) {
2832
+ const { applicationId, image, registryId, verify, signal } = options;
2833
+ const since = await api.latestDeploymentAt(applicationId);
2834
+ await api.saveDockerProvider(applicationId, image.ref, registryId ? { registryId } : void 0);
2835
+ await api.deployApplication(applicationId);
2836
+ await api.waitForDeployment(applicationId, {
2837
+ since,
2838
+ timeoutMs: verify.deploymentTimeoutMs,
2839
+ intervalMs: verify.intervalMs,
2840
+ signal
2841
+ });
2842
+ }
2843
+ /**
2844
+ * Put back what ran before this run, after its `release` or `verify` failed:
2845
+ * the apps that failed — or, `atomic`, every app the run released — each
2846
+ * pointed at its previous image and redeployed. An app released for the
2847
+ * first time has nothing to go back to and is left as it is.
2848
+ */
2849
+ async function rollbackDokploy(run, failure) {
2850
+ const { switched, failed: failedApps } = run.releasing;
2851
+ const apps = [...switched.keys()].filter((app) => run.atomic || failedApps.has(app));
2852
+ if (!run.provisioned || apps.length === 0) {
2853
+ logger.log(`\n⏪ Nothing to roll back: the ${failure.phase} failed before any app was switched.`);
2854
+ return;
2855
+ }
2856
+ const { api, journal, state, registryId } = run.provisioned;
2857
+ logger.log(`\n⏪ Rolling back ${run.atomic ? "every app this run released" : "what failed"}: ${apps.join(", ")}`);
2858
+ for (const appName of apps) {
2859
+ const { applicationId, before } = switched.get(appName);
2860
+ if (!before) {
2861
+ logger.log(` ⚠ ${appName}: this was its first release, so there is nothing to restore`);
2862
+ continue;
2616
2863
  }
2864
+ await restoreImage(api, {
2865
+ applicationId,
2866
+ image: before,
2867
+ registryId,
2868
+ verify: run.verify,
2869
+ signal: run.ctx.signal
2870
+ });
2871
+ require_state.recordRollback(state, appName, before);
2872
+ await journal.save();
2873
+ logger.log(` ✓ ${appName} rolled back to ${before.ref}`);
2617
2874
  }
2618
2875
  }
2619
2876
  /** What the run did, printed as the summary it always ended with. */
@@ -2680,7 +2937,7 @@ async function ensureApplication(api, journal, appName, dokployAppName, projectI
2680
2937
  action: via === "created" ? "create" : "reuse",
2681
2938
  id: resource.applicationId
2682
2939
  }, via);
2683
- require_sniffer.setApplicationId(journal.state, appName, resource.applicationId);
2940
+ require_state.setApplicationId(journal.state, appName, resource.applicationId);
2684
2941
  return resource;
2685
2942
  }
2686
2943
  /**
@@ -2730,10 +2987,14 @@ async function ensureDomain(api, journal, host, port, applicationId, applied) {
2730
2987
  function isAbort(error) {
2731
2988
  return error instanceof Error && error.name === "AbortError";
2732
2989
  }
2733
- /** Where docker runs for one app: in the app's own directory. */
2990
+ /**
2991
+ * Where docker runs for one app: the workspace root, building from the
2992
+ * Dockerfile `gkm docker` writes for the app there.
2993
+ */
2734
2994
  function dockerRun(workspace, app, ctx) {
2735
2995
  return {
2736
- cwd: (0, node_path.resolve)(workspace.root, app.path),
2996
+ cwd: workspace.root,
2997
+ appPath: app.path,
2737
2998
  ...ctx.signal ? { signal: ctx.signal } : {},
2738
2999
  ...ctx.stdio ? { stdio: ctx.stdio } : {}
2739
3000
  };
@@ -2758,7 +3019,7 @@ async function dokployApi(workspace, ctx) {
2758
3019
  /** Each app's public host, the same way the deploy resolves it. */
2759
3020
  function hostOf(workspace, appName, stage) {
2760
3021
  const app = workspace.apps[appName];
2761
- return require_sniffer.resolveHost(appName, app, stage, workspace.deploy?.domains, app.type === "web" && require_sniffer.isMainFrontendApp(appName, app, workspace.apps));
3022
+ return require_state.resolveHost(appName, app, stage, workspace.deploy?.domains, app.type === "web" && require_state.isMainFrontendApp(appName, app, workspace.apps));
2762
3023
  }
2763
3024
  /**
2764
3025
  * What a deploy would do, from the same lookups it starts with — and nothing
@@ -2792,7 +3053,7 @@ async function planDokploy(run) {
2792
3053
  key: "project",
2793
3054
  resourceType: "project",
2794
3055
  action: "create"
2795
- }, require_store.projectName(identity));
3056
+ }, require_state.projectName(identity));
2796
3057
  const environment = project?.environments.find((e) => e.name.toLowerCase() === stage.toLowerCase());
2797
3058
  plan(environment ? {
2798
3059
  key: "environment",
@@ -2807,7 +3068,7 @@ async function planDokploy(run) {
2807
3068
  let registryPlanned;
2808
3069
  const registry = await resolveRegistry(api, {
2809
3070
  stage,
2810
- registry: workspace.deploy.dokploy?.registry,
3071
+ registry: workspace.deploy.registry,
2811
3072
  configuredId: workspace.deploy.dokploy?.registryId,
2812
3073
  stateId: state?.registryId,
2813
3074
  log: (message) => logger.log(message),
@@ -2839,7 +3100,7 @@ async function planDokploy(run) {
2839
3100
  for (const appName of appsToDeployNames) {
2840
3101
  const app = workspace.apps[appName];
2841
3102
  if (app.type === "mobile") continue;
2842
- const dokployAppName = require_store.applicationName(identity, appName);
3103
+ const dokployAppName = require_state.applicationName(identity, appName);
2843
3104
  const recordedId = state?.applications?.[appName];
2844
3105
  const existing = project && environment ? (recordedId ? await api.getApplication(recordedId).catch(() => null) : null) ?? await api.findApplicationByName(project.projectId, dokployAppName, environment.environmentId) : null;
2845
3106
  plan(existing ? {
@@ -2852,7 +3113,7 @@ async function planDokploy(run) {
2852
3113
  resourceType: "application",
2853
3114
  action: "create"
2854
3115
  }, dokployAppName);
2855
- const ref = require_store.imageRef(identity, appName, workspace.deploy.dokploy?.registry, imageTag);
3116
+ const ref = require_state.imageRef(identity, appName, workspace.deploy.registry, imageTag);
2856
3117
  plan({
2857
3118
  key: `image:${appName}`,
2858
3119
  resourceType: "image",
@@ -2906,6 +3167,24 @@ Object.defineProperty(exports, "BackendDeployFailed", {
2906
3167
  return BackendDeployFailed;
2907
3168
  }
2908
3169
  });
3170
+ Object.defineProperty(exports, "DeployMigrationsFailed", {
3171
+ enumerable: true,
3172
+ get: function() {
3173
+ return DeployMigrationsFailed;
3174
+ }
3175
+ });
3176
+ Object.defineProperty(exports, "FrontendDeployFailed", {
3177
+ enumerable: true,
3178
+ get: function() {
3179
+ return FrontendDeployFailed;
3180
+ }
3181
+ });
3182
+ Object.defineProperty(exports, "HealthCheckTimedOut", {
3183
+ enumerable: true,
3184
+ get: function() {
3185
+ return HealthCheckTimedOut;
3186
+ }
3187
+ });
2909
3188
  Object.defineProperty(exports, "MissingEnvVars", {
2910
3189
  enumerable: true,
2911
3190
  get: function() {
@@ -2936,6 +3215,12 @@ Object.defineProperty(exports, "RegistryNotFound", {
2936
3215
  return RegistryNotFound;
2937
3216
  }
2938
3217
  });
3218
+ Object.defineProperty(exports, "dokployApi", {
3219
+ enumerable: true,
3220
+ get: function() {
3221
+ return dokployApi;
3222
+ }
3223
+ });
2939
3224
  Object.defineProperty(exports, "dokployResult", {
2940
3225
  enumerable: true,
2941
3226
  get: function() {
@@ -2960,6 +3245,18 @@ Object.defineProperty(exports, "releaseDokploy", {
2960
3245
  return releaseDokploy;
2961
3246
  }
2962
3247
  });
3248
+ Object.defineProperty(exports, "restoreImage", {
3249
+ enumerable: true,
3250
+ get: function() {
3251
+ return restoreImage;
3252
+ }
3253
+ });
3254
+ Object.defineProperty(exports, "rollbackDokploy", {
3255
+ enumerable: true,
3256
+ get: function() {
3257
+ return rollbackDokploy;
3258
+ }
3259
+ });
2963
3260
  Object.defineProperty(exports, "validateDokploy", {
2964
3261
  enumerable: true,
2965
3262
  get: function() {
@@ -2972,5 +3269,11 @@ Object.defineProperty(exports, "verifyDokploy", {
2972
3269
  return verifyDokploy;
2973
3270
  }
2974
3271
  });
3272
+ Object.defineProperty(exports, "verifySettings", {
3273
+ enumerable: true,
3274
+ get: function() {
3275
+ return verifySettings;
3276
+ }
3277
+ });
2975
3278
 
2976
- //# sourceMappingURL=deploy-55rJkdKz.cjs.map
3279
+ //# sourceMappingURL=engine-DR6KurWX.cjs.map