@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,27 +1,21 @@
1
1
  import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
- import { _ as storeHostingerToken, g as storeDokployCredentials, h as removeDokployCredentials, l as getCredentialsPath, s as run$1, t as LocalSandbox, u as getDokployCredentials } from "./local-Cd8ZYQG3.mjs";
3
- import { d as output, r as discover, s as nodeWithTsx } from "./discover-LMrqsWSU.mjs";
4
- import { i as activeSandbox } from "./sandbox-Dm247NGx.mjs";
2
+ import { s as run$1, u as gkmHome } from "./local-CWR4Y_kW.mjs";
3
+ import { d as output, r as discover } from "./discover-BFoJWKGD.mjs";
5
4
  import { t as appKey } from "./derive-B4vDpzgP.mjs";
6
- import { E as TEST_STAGE, p as getPublicEnvPrefix, t as allConstructGlobs } from "./workspace-Bwit4q9O.mjs";
5
+ import { O as TEST_STAGE, h as getPublicEnvPrefix, n as allConstructGlobs } from "./workspace-Be6uBhlF.mjs";
7
6
  import { ConfigNotFound, NotInAnApp, loadConfig, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
8
- import { i as toEmbeddableSecrets } from "./storage-BzaUxmqe.mjs";
9
- import { i as FileSecretsStore, n as secretsStoreFor } from "./store-BaaonhkJ.mjs";
10
- import { r as runtimeOf } from "./runtime-Cmn-FeYO.mjs";
7
+ import { c as runtimeOf } from "./resolve-Db0tNlrW.mjs";
11
8
  import { i as DEFAULT_STORAGE, n as DEFAULT_CACHE, r as DEFAULT_EVENTS$1, t as readFakes } from "./fakes-C9RiT6XG.mjs";
12
9
  import { basename, dirname, join, parse, resolve } from "node:path";
13
- import { stdin, stdout } from "node:process";
14
- import * as readline from "node:readline/promises";
15
- import { copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
16
- import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
17
- import { hostname, networkInterfaces, userInfo } from "node:os";
10
+ import { constants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
11
+ import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
12
+ import { homedir, networkInterfaces } from "node:os";
18
13
  import { promisify } from "node:util";
19
- import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase } from "@geekmidas/manifest";
20
- import { fileURLToPath } from "node:url";
14
+ import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
21
15
  import { execFile, spawn } from "node:child_process";
22
16
  import { createServer } from "node:net";
23
17
  import { config } from "dotenv";
24
- import { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
18
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
25
19
  import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
26
20
  import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
27
21
  import * as snsUrl from "@geekmidas/events/sns/url";
@@ -32,189 +26,528 @@ import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/clie
32
26
  import { Client } from "pg";
33
27
  import { stringify } from "yaml";
34
28
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
35
- //#region src/prompt.ts
36
- /**
37
- * Asking the person at the terminal.
38
- *
39
- * CLI layer only: a command's action may prompt, nothing it calls may. A
40
- * library call (`deploy()`) that needs something it was not given raises a
41
- * named error instead, and the command decides whether to ask.
42
- */
43
- /** A prompt was needed with no terminal to ask at. */
44
- var PromptNeedsTerminal = class extends Error {
45
- question;
46
- instead;
47
- constructor(question, instead) {
48
- super(`Interactive input required. ${instead}`);
49
- this.question = question;
50
- this.instead = instead;
51
- this.name = "PromptNeedsTerminal";
29
+ //#region src/secrets/storage.ts
30
+ var storage_exports = /* @__PURE__ */ __exportAll({
31
+ initStageSecrets: () => initStageSecrets,
32
+ maskPassword: () => maskPassword,
33
+ toEmbeddableSecrets: () => toEmbeddableSecrets,
34
+ validateEnvironmentVariables: () => validateEnvironmentVariables,
35
+ withCustomSecret: () => withCustomSecret
36
+ });
37
+ /**
38
+ * Initialize an empty StageSecrets object for a stage.
39
+ */
40
+ function initStageSecrets(stage) {
41
+ const now = (/* @__PURE__ */ new Date()).toISOString();
42
+ return {
43
+ stage,
44
+ createdAt: now,
45
+ updatedAt: now,
46
+ services: {},
47
+ urls: {},
48
+ custom: {}
49
+ };
50
+ }
51
+ /**
52
+ * Convert StageSecrets to embeddable format (flat key-value pairs).
53
+ * This is what gets encrypted and embedded in the bundle.
54
+ */
55
+ function toEmbeddableSecrets(secrets) {
56
+ return {
57
+ ...secrets.urls,
58
+ ...secrets.custom,
59
+ ...secrets.services.postgres && {
60
+ POSTGRES_USER: secrets.services.postgres.username,
61
+ POSTGRES_PASSWORD: secrets.services.postgres.password,
62
+ POSTGRES_DB: secrets.services.postgres.database ?? "app",
63
+ POSTGRES_HOST: secrets.services.postgres.host,
64
+ POSTGRES_PORT: String(secrets.services.postgres.port)
65
+ },
66
+ ...secrets.services.redis && {
67
+ REDIS_PASSWORD: secrets.services.redis.password,
68
+ REDIS_HOST: secrets.services.redis.host,
69
+ REDIS_PORT: String(secrets.services.redis.port)
70
+ },
71
+ ...secrets.services.minio && {
72
+ STORAGE_ACCESS_KEY_ID: secrets.services.minio.username,
73
+ STORAGE_SECRET_ACCESS_KEY: secrets.services.minio.password,
74
+ STORAGE_BUCKET: secrets.services.minio.bucket ?? "app",
75
+ STORAGE_REGION: "eu-west-1",
76
+ STORAGE_FORCE_PATH_STYLE: "true"
77
+ },
78
+ ...secrets.services.mailpit && {
79
+ SMTP_HOST: secrets.services.mailpit.host,
80
+ SMTP_PORT: String(secrets.services.mailpit.port),
81
+ SMTP_USER: secrets.services.mailpit.username,
82
+ SMTP_PASS: secrets.services.mailpit.password,
83
+ SMTP_SECURE: "false",
84
+ MAIL_FROM: "noreply@localhost"
85
+ },
86
+ ...secrets.services.localstack && {
87
+ AWS_ACCESS_KEY_ID: secrets.services.localstack.accessKeyId ?? secrets.services.localstack.username,
88
+ AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
89
+ AWS_REGION: secrets.services.localstack.region ?? "us-east-1",
90
+ AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`
91
+ },
92
+ ...secrets.services.pgboss && {
93
+ PGBOSS_DB_USER: secrets.services.pgboss.username,
94
+ PGBOSS_DB_PASSWORD: secrets.services.pgboss.password
95
+ }
96
+ };
97
+ }
98
+ /** The same secrets with one custom value set, and `updatedAt` moved on. */
99
+ function withCustomSecret(secrets, key, value) {
100
+ return {
101
+ ...secrets,
102
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
103
+ custom: {
104
+ ...secrets.custom,
105
+ [key]: value
106
+ }
107
+ };
108
+ }
109
+ /**
110
+ * Mask a password for display (show first 4 and last 2 chars).
111
+ */
112
+ function maskPassword(password) {
113
+ if (password.length <= 8) return "********";
114
+ return `${password.slice(0, 4)}${"*".repeat(password.length - 6)}${password.slice(-2)}`;
115
+ }
116
+ /**
117
+ * Validate that all required environment variables are present in secrets.
118
+ *
119
+ * @param requiredVars - Array of environment variable names required by the application
120
+ * @param secrets - Stage secrets to validate against
121
+ * @returns Validation result with missing and provided variables
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
126
+ * const secrets = await secretsStoreFor(workspace, 'production').then((s) => s.read('production'));
127
+ * const result = validateEnvironmentVariables(required, secrets);
128
+ *
129
+ * if (!result.valid) {
130
+ * console.error(`Missing environment variables: ${result.missing.join(', ')}`);
131
+ * }
132
+ * ```
133
+ */
134
+ function validateEnvironmentVariables(requiredVars, secrets) {
135
+ const embeddable = toEmbeddableSecrets(secrets);
136
+ const availableVars = new Set(Object.keys(embeddable));
137
+ const missing = [];
138
+ const provided = [];
139
+ for (const varName of requiredVars) if (availableVars.has(varName)) provided.push(varName);
140
+ else missing.push(varName);
141
+ return {
142
+ valid: missing.length === 0,
143
+ missing: missing.sort(),
144
+ provided: provided.sort(),
145
+ required: [...requiredVars].sort()
146
+ };
147
+ }
148
+ //#endregion
149
+ //#region src/deploy/identity.ts
150
+ /**
151
+ * Who a deploy is, on a target it shares with other workspaces.
152
+ *
153
+ * A Dokploy server is one flat namespace of projects, images and Docker
154
+ * service names. Deploys used to identify everything they made by the
155
+ * workspace name alone, so two workspaces called `shop` — or `Shop` and
156
+ * `shop` — deploying to one server landed in the same project, pushed over
157
+ * each other's images and redeployed each other's applications. A
158
+ * `DeployIdentity` is what a deploy names and claims its resources by instead.
159
+ */
160
+ /** A namespace that cannot be part of a Dokploy name or an image path. */
161
+ var DeployNamespaceInvalid = class extends Error {
162
+ namespace;
163
+ constructor(namespace) {
164
+ super(`deploy.namespace '${namespace}' is not usable as a name: use lowercase letters, digits and single '-' between them (e.g. 'acme' or 'acme-platform') in gkm.config.ts.`);
165
+ this.namespace = namespace;
166
+ this.name = "DeployNamespaceInvalid";
52
167
  }
53
168
  };
54
- /** Whether there is a person at a terminal to ask. */
55
- function canPrompt() {
56
- return process.stdin.isTTY === true;
57
- }
58
- /**
59
- * Ask `message` and resolve with the line typed — without echoing it when
60
- * `hidden`. Ctrl+C at a hidden prompt exits, as it would anywhere else in a
61
- * terminal program: raw mode has taken the signal away from the shell.
62
- */
63
- async function prompt(message, options) {
64
- if (!canPrompt()) throw new PromptNeedsTerminal(message, options.instead);
65
- if (options.hidden) {
66
- process.stdout.write(message);
67
- return new Promise((resolve, reject) => {
68
- let value = "";
69
- const cleanup = () => {
70
- process.stdin.setRawMode(false);
71
- process.stdin.pause();
72
- process.stdin.removeListener("data", onData);
73
- process.stdin.removeListener("error", onError);
74
- };
75
- const onError = (err) => {
76
- cleanup();
77
- reject(err);
78
- };
79
- const onData = (char) => {
80
- const c = char.toString();
81
- if (c === "\n" || c === "\r") {
82
- cleanup();
83
- process.stdout.write("\n");
84
- resolve(value);
85
- } else if (c === "") {
86
- cleanup();
87
- process.stdout.write("\n");
88
- process.exit(1);
89
- } else if (c === "" || c === "\b") {
90
- if (value.length > 0) value = value.slice(0, -1);
91
- } else value += c;
92
- };
93
- process.stdin.setRawMode(true);
94
- process.stdin.resume();
95
- process.stdin.on("data", onData);
96
- process.stdin.on("error", onError);
97
- });
98
- }
99
- const rl = readline.createInterface({
100
- input: stdin,
101
- output: stdout
102
- });
103
- try {
104
- return await rl.question(message);
105
- } finally {
106
- rl.close();
169
+ /** A workspace name with nothing a target can name a resource by. */
170
+ var DeployProjectNameInvalid = class extends Error {
171
+ workspaceName;
172
+ constructor(workspaceName) {
173
+ super(`The workspace name '${workspaceName}' has no letters or digits to name deployed resources by. Set "name" in gkm.config.ts.`);
174
+ this.workspaceName = workspaceName;
175
+ this.name = "DeployProjectNameInvalid";
107
176
  }
177
+ };
178
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
179
+ /**
180
+ * Lowercase `[a-z0-9-]`, runs of anything else collapsed to one `-`: valid as
181
+ * a Docker image path component, a Docker service name and a Dokploy name.
182
+ */
183
+ function slug(value) {
184
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
185
+ }
186
+ /** The namespace a workspace is in when it does not name one. */
187
+ function defaultNamespace(workspaceName) {
188
+ return slug(kebabCase(workspaceName));
189
+ }
190
+ function deployIdentity(workspace, stage) {
191
+ const project = slug(workspace.name);
192
+ if (!project) throw new DeployProjectNameInvalid(workspace.name);
193
+ const fallback = defaultNamespace(workspace.name);
194
+ const namespace = workspace.deploy?.namespace ?? fallback;
195
+ if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
196
+ return {
197
+ namespace,
198
+ project,
199
+ stage,
200
+ key: `${namespace}/${project}`,
201
+ scope: namespace === fallback ? project : `${namespace}-${project}`
202
+ };
203
+ }
204
+ /**
205
+ * The token a Dokploy project's description carries to say which identity
206
+ * created it. A project with the right name and no marker belongs to someone
207
+ * else, and a deploy never adopts it.
208
+ */
209
+ function ownershipMarker(identity) {
210
+ return `gkm:${identity.key}`;
211
+ }
212
+ /** Whether a project description carries `identity`'s marker. */
213
+ function isOwnedBy(description, identity) {
214
+ const marker = ownershipMarker(identity);
215
+ return (description ?? "").split(/\s+/).includes(marker);
216
+ }
217
+ /** Any `gkm:` marker a description carries — whoever's it is. */
218
+ function markerOf(description) {
219
+ return (description ?? "").split(/\s+/).find((t) => t.startsWith("gkm:"));
220
+ }
221
+ /** What the Dokploy project is called. */
222
+ function projectName(identity) {
223
+ return identity.scope;
224
+ }
225
+ /**
226
+ * The repository an app's image is pushed to, below the registry:
227
+ * `<namespace>/<project>-<app>`.
228
+ *
229
+ * The namespace is a path segment of its own so that a registry that maps
230
+ * paths to owners (an organisation's packages, a Harbor project) can grant it
231
+ * as one; the app is joined to the project so the two read as one name.
232
+ */
233
+ function imageName(identity, app) {
234
+ return `${identity.namespace}/${identity.project}-${slug(kebabCase(app))}`;
235
+ }
236
+ /** The full image ref an app is built, pushed and deployed as. */
237
+ function imageRef(identity, app, registry, tag) {
238
+ const name = imageName(identity, app);
239
+ return registry ? `${registry.replace(/\/+$/, "")}/${name}:${tag}` : `${name}:${tag}`;
240
+ }
241
+ /**
242
+ * What one application is called on the target.
243
+ *
244
+ * The same rule the constructs use, through the same `scopedName`, scoped by
245
+ * the stage and the identity: the application beside
246
+ * `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
247
+ * Dokploy application's name is also its Docker service name, which is unique
248
+ * on the whole server, so the namespace has to be in it too — `acme`'s and
249
+ * `globex`'s `shop` would otherwise both run `production-shop-api`.
250
+ *
251
+ * The app id is dropped when it repeats the project, so a project named for its
252
+ * one application is `production-kitchen-sink` rather than
253
+ * `production-kitchen-sink-kitchen-sink`.
254
+ */
255
+ function applicationName(identity, app) {
256
+ return slug(app) === identity.project ? `${identity.stage}-${identity.scope}` : scopedName([identity.stage, identity.scope], app);
108
257
  }
109
258
  //#endregion
110
- //#region src/auth/index.ts
111
- const logger$2 = console;
112
- /**
113
- * Validate Dokploy token by making a test API call
114
- */
115
- async function validateDokployToken(endpoint, token) {
116
- const { DokployApi } = await Promise.resolve().then(() => dokploy_api_exports);
117
- return new DokployApi({
118
- baseUrl: endpoint,
119
- token
120
- }).validateToken();
121
- }
122
- /** What answers a login prompt without a terminal. */
123
- const LOGIN_INSTEAD = "Please provide --token option.";
124
- /**
125
- * Login to a service
126
- */
127
- async function loginCommand(options) {
128
- const { provider, token: providedToken, endpoint: providedEndpoint } = options;
129
- if (provider === "dokploy") {
130
- logger$2.log("\n🔐 Logging in to Dokploy...\n");
131
- let endpoint = providedEndpoint;
132
- if (!endpoint) endpoint = await prompt("Dokploy URL (e.g., https://dokploy.example.com): ", { instead: LOGIN_INSTEAD });
133
- endpoint = endpoint.replace(/\/$/, "");
134
- try {
135
- new URL(endpoint);
136
- } catch {
137
- logger$2.error("Invalid URL format");
138
- process.exit(1);
139
- }
140
- let token = providedToken;
141
- if (!token) {
142
- logger$2.log(`\nGenerate a token at: ${endpoint}/settings/profile\n`);
143
- token = await prompt("API Token: ", {
144
- hidden: true,
145
- instead: LOGIN_INSTEAD
146
- });
147
- }
148
- if (!token) {
149
- logger$2.error("Token is required");
150
- process.exit(1);
151
- }
152
- logger$2.log("\nValidating credentials...");
153
- if (!await validateDokployToken(endpoint, token)) {
154
- logger$2.error("\n✗ Invalid credentials. Please check your token and try again.");
155
- process.exit(1);
156
- }
157
- await storeDokployCredentials(token, endpoint);
158
- logger$2.log("\n✓ Successfully logged in to Dokploy!");
159
- logger$2.log(` Endpoint: ${endpoint}`);
160
- logger$2.log(` Credentials stored in: ${getCredentialsPath()}`);
161
- logger$2.log("\nYou can now use deploy commands without setting DOKPLOY_API_TOKEN.");
259
+ //#region src/secrets/keystore.ts
260
+ /**
261
+ * The keys that open each stage's encrypted secrets file, kept outside the
262
+ * project in the CLI's home.
263
+ *
264
+ * Keys were kept at `~/.gkm/<folder>/<stage>.key`, named after the project
265
+ * directory — so two checkouts in folders with the same name (`~/work/api`
266
+ * and `~/oss/api`) shared one key, and the second `gkm secrets:init`
267
+ * overwrote the first project's. They are now kept under the project's
268
+ * identity, `<home>/keys/<namespace>/<project>/<stage>.key`, the same
269
+ * `<namespace>/<project>` a deploy claims its Dokploy project by. A key still
270
+ * at the old place is copied to the new one the first time it is read; the
271
+ * old file stays, so an older CLI on the same machine keeps working.
272
+ */
273
+ /** Key length for AES-256 encryption */
274
+ const KEY_LENGTH = 32;
275
+ /** A keystore project key that is not `<namespace>/<project>`. */
276
+ var KeystoreProjectInvalid = class extends Error {
277
+ key;
278
+ constructor(key) {
279
+ super(`'${key}' is not a keystore project: expected '<namespace>/<project>' in lowercase letters, digits and '-', as deployIdentity() produces. Pass the workspace's identity key.`);
280
+ this.key = key;
281
+ this.name = "KeystoreProjectInvalid";
162
282
  }
163
- if (provider === "hostinger") {
164
- logger$2.log("\n🔐 Logging in to Hostinger...\n");
165
- let token = providedToken;
166
- if (!token) {
167
- logger$2.log("\nGenerate a token at: https://hpanel.hostinger.com/profile/api\n");
168
- token = await prompt("API Token: ", {
169
- hidden: true,
170
- instead: LOGIN_INSTEAD
171
- });
172
- }
173
- if (!token) {
174
- logger$2.error("Token is required");
175
- process.exit(1);
176
- }
177
- await storeHostingerToken(token);
178
- logger$2.log("\n✓ Hostinger token stored.");
179
- logger$2.log(` Credentials stored in: ${getCredentialsPath()}`);
180
- logger$2.log(" Not validated here: every DNS endpoint is scoped to a domain, so the\n first deploy that touches DNS is where a bad token reports itself.");
181
- logger$2.log("\nYou can now use it as a DNS provider without setting HOSTINGER_API_TOKEN.");
283
+ };
284
+ /** A stage has no key, here or at the place keys used to be kept. */
285
+ var KeyNotFound = class extends Error {
286
+ stage;
287
+ project;
288
+ path;
289
+ constructor(stage, project, path) {
290
+ super(`Encryption key not found for stage "${stage}" in project "${project}". Expected key at: ${path}. Run \`gkm secrets:init --stage ${stage}\` to create one, or copy the stage's key there.`);
291
+ this.stage = stage;
292
+ this.project = project;
293
+ this.path = path;
294
+ this.name = "KeyNotFound";
182
295
  }
296
+ };
297
+ const PROJECT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
298
+ /**
299
+ * The keystore key of a workspace: its deploy identity without the stage, so
300
+ * a project's keys and its Dokploy project are named by the same thing.
301
+ */
302
+ function projectKey(workspace) {
303
+ return deployIdentity(workspace, "").key;
304
+ }
305
+ /** The keystore project of a loaded workspace. */
306
+ function keystoreProject(workspace, home) {
307
+ const key = projectKey(workspace);
308
+ const unscoped = projectKey({ name: workspace.name });
309
+ return {
310
+ key,
311
+ legacy: [.../* @__PURE__ */ new Set([folderName(workspace.root), workspace.name])].filter((name) => !!name),
312
+ ...unscoped !== key ? { aliases: [unscoped] } : {},
313
+ ...home ? { home } : {}
314
+ };
183
315
  }
184
316
  /**
185
- * Logout from a service
317
+ * Whether `name` stays inside the home it is joined to. A workspace name was
318
+ * joined as is — `@acme/shop` is `~/.gkm/@acme/shop` — but never upwards.
186
319
  */
187
- async function logoutCommand(options) {
188
- const { provider = "dokploy" } = options;
189
- if (provider === "all") {
190
- if (await removeDokployCredentials()) logger$2.log("\n✓ Logged out from all services");
191
- else logger$2.log("\nNo stored credentials found");
192
- return;
320
+ function isLegacyName(name) {
321
+ return name.split(/[\\/]/).every((part) => part !== ".." && part !== "");
322
+ }
323
+ /** The last segment of a path, as the old keystore named projects. */
324
+ function folderName(path) {
325
+ const name = path.split(/[\\/]/).filter(Boolean).at(-1);
326
+ return name && name !== "." && name !== ".." ? name : void 0;
327
+ }
328
+ /**
329
+ * Get the keystore directory for a project:
330
+ * `<home>/keys/<namespace>/<project>`.
331
+ */
332
+ function getKeystoreDir(project) {
333
+ if (!PROJECT_KEY.test(project.key)) throw new KeystoreProjectInvalid(project.key);
334
+ const [namespace, name] = project.key.split("/");
335
+ return join(project.home ?? gkmHome(), "keys", namespace, name);
336
+ }
337
+ /** Get the path to a stage's encryption key. */
338
+ function getKeyPath(stage, project) {
339
+ return join(getKeystoreDir(project), `${stage}.key`);
340
+ }
341
+ /**
342
+ * Where else a stage's key may be, most likely first: under an alias of the
343
+ * project, then where keys were kept before they were keyed by identity —
344
+ * under the configured home, then under `~/.gkm`, which is where every CLI
345
+ * before `GKM_HOME` wrote.
346
+ */
347
+ function legacyKeyPaths(stage, project) {
348
+ const aliases = (project.aliases ?? []).map((key) => getKeyPath(stage, {
349
+ key,
350
+ home: project.home
351
+ }));
352
+ const homes = [.../* @__PURE__ */ new Set([project.home ?? gkmHome(), join(homedir(), ".gkm")])];
353
+ const names = (project.legacy ?? []).filter(isLegacyName);
354
+ return [...aliases, ...names.flatMap((name) => homes.map((home) => join(home, name, `${stage}.key`)))];
355
+ }
356
+ /**
357
+ * Generate a new encryption key for a stage, stored owner-only at
358
+ * `<home>/keys/<namespace>/<project>/<stage>.key`.
359
+ */
360
+ async function generateKey(stage, project, flag = "w") {
361
+ const keyDir = getKeystoreDir(project);
362
+ const keyPath = getKeyPath(stage, project);
363
+ await mkdir(keyDir, {
364
+ recursive: true,
365
+ mode: 448
366
+ });
367
+ const key = randomBytes(KEY_LENGTH).toString("hex");
368
+ await writeFile(keyPath, key, {
369
+ mode: 384,
370
+ encoding: "utf-8",
371
+ flag
372
+ });
373
+ await chmod(keyPath, 384);
374
+ return key;
375
+ }
376
+ /**
377
+ * Copy a key from where it used to be kept to where it is kept now.
378
+ *
379
+ * Copied, not moved: an older CLI on the same machine still reads the old
380
+ * place. Exclusive, so two processes migrating at once cannot overwrite a key
381
+ * one of them already put there.
382
+ */
383
+ async function migrateLegacyKey(stage, project) {
384
+ const legacy = legacyKeyPaths(stage, project).find((path) => existsSync(path));
385
+ if (!legacy) return false;
386
+ const keyPath = getKeyPath(stage, project);
387
+ await mkdir(getKeystoreDir(project), {
388
+ recursive: true,
389
+ mode: 448
390
+ });
391
+ try {
392
+ await copyFile(legacy, keyPath, constants.COPYFILE_EXCL);
393
+ } catch (error) {
394
+ if (error.code !== "EEXIST") throw error;
193
395
  }
194
- if (provider === "dokploy") {
195
- if (await removeDokployCredentials()) logger$2.log("\n✓ Logged out from Dokploy");
196
- else logger$2.log("\nNo Dokploy credentials found");
396
+ await chmod(keyPath, 384);
397
+ return true;
398
+ }
399
+ /**
400
+ * Read an encryption key for a stage, or `null` when there is none — after
401
+ * copying it over from where keys used to be kept, if it is still there.
402
+ */
403
+ async function readKey(stage, project) {
404
+ const keyPath = getKeyPath(stage, project);
405
+ if (!existsSync(keyPath) && !await migrateLegacyKey(stage, project)) return null;
406
+ return (await readFile(keyPath, "utf-8")).trim();
407
+ }
408
+ /**
409
+ * Read an encryption key for a stage, throwing {@link KeyNotFound} if there
410
+ * is none.
411
+ */
412
+ async function requireKey(stage, project) {
413
+ const key = await readKey(stage, project);
414
+ if (!key) throw new KeyNotFound(stage, project.key, getKeyPath(stage, project));
415
+ return key;
416
+ }
417
+ /**
418
+ * Get or create a key for a stage.
419
+ * If the key already exists, it is returned. Otherwise, a new key is generated.
420
+ */
421
+ async function getOrCreateKey(stage, project) {
422
+ const existingKey = await readKey(stage, project);
423
+ if (existingKey) return existingKey;
424
+ try {
425
+ return await generateKey(stage, project, "wx");
426
+ } catch (error) {
427
+ if (error.code !== "EEXIST") throw error;
428
+ return requireKey(stage, project);
197
429
  }
198
430
  }
431
+ //#endregion
432
+ //#region src/secrets/file.ts
433
+ /** Where the files live, relative to the project root. */
434
+ const SECRETS_DIR = ".gkm/secrets";
435
+ /** AES-256-GCM configuration */
436
+ const ALGORITHM = "aes-256-gcm";
437
+ const IV_LENGTH = 12;
199
438
  /**
200
- * Show current login status
439
+ * A stage's secrets as an encrypted file on this machine:
440
+ * `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
441
+ * `keys/<namespace>/<project>/<stage>.key`.
442
+ *
443
+ * The local stage's store, always — its secrets belong to the machine running
444
+ * `gkm dev` — and a deployed stage's when `secrets.store` is `'file'`.
201
445
  */
202
- async function whoamiCommand() {
203
- logger$2.log("\n📋 Current credentials:\n");
204
- const dokploy = await getDokployCredentials();
205
- if (dokploy) {
206
- logger$2.log(" Dokploy:");
207
- logger$2.log(` Endpoint: ${dokploy.endpoint}`);
208
- logger$2.log(` Token: ${maskToken(dokploy.token)}`);
209
- } else logger$2.log(" Dokploy: Not logged in");
210
- logger$2.log(`\n Credentials file: ${getCredentialsPath()}`);
446
+ var FileSecretsStore = class {
447
+ root;
448
+ name = "file";
449
+ project;
450
+ /**
451
+ * @param project - Whose keys: a workspace's `keystoreProject()`. Outside a
452
+ * workspace there is no config to name the project, so it is named the
453
+ * way a workspace without `name` is — its package name, else its folder.
454
+ */
455
+ constructor(root, project) {
456
+ this.root = root;
457
+ this.project = project ?? {
458
+ key: projectKey({ name: packageName(root) ?? basename(root) }),
459
+ legacy: [basename(root)]
460
+ };
461
+ }
462
+ /** The file a stage's secrets are kept in. */
463
+ path(stage) {
464
+ return join(this.root, SECRETS_DIR, `${stage}.json`);
465
+ }
466
+ async read(stage) {
467
+ const path = this.path(stage);
468
+ if (!existsSync(path)) return null;
469
+ const data = JSON.parse(await readFile(path, "utf-8"));
470
+ if (!(data.version === 1 && data.encrypted && data.iv)) return data;
471
+ const key = await readKey(stage, this.project);
472
+ if (!key) throw new MissingSecretsKey(stage, this.project.key, getKeyPath(stage, this.project));
473
+ return decrypt(data, key);
474
+ }
475
+ async write(stage, secrets) {
476
+ await mkdir(join(this.root, SECRETS_DIR), { recursive: true });
477
+ const key = await getOrCreateKey(stage, this.project);
478
+ await writeFile(this.path(stage), JSON.stringify(encrypt(secrets, key), null, 2), "utf-8");
479
+ }
480
+ };
481
+ /** A stage's secrets file exists, and the key that opens it does not. */
482
+ var MissingSecretsKey = class extends Error {
483
+ stage;
484
+ project;
485
+ path;
486
+ constructor(stage, project, path) {
487
+ super(`Decryption key not found for stage "${stage}". Expected key at: ${path}. Copy the stage's key there from whoever created it, or set secrets.store to a store this machine can reach.`);
488
+ this.stage = stage;
489
+ this.project = project;
490
+ this.path = path;
491
+ this.name = "MissingSecretsKey";
492
+ }
493
+ };
494
+ /** A project's package name without its scope, as a workspace name reads it. */
495
+ function packageName(root) {
496
+ try {
497
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
498
+ return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
499
+ } catch {
500
+ return;
501
+ }
502
+ }
503
+ function encrypt(secrets, keyHex) {
504
+ const key = Buffer.from(keyHex, "hex");
505
+ const iv = randomBytes(IV_LENGTH);
506
+ const cipher = createCipheriv(ALGORITHM, key, iv);
507
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(secrets), "utf-8"), cipher.final()]);
508
+ return {
509
+ version: 1,
510
+ encrypted: Buffer.concat([ciphertext, cipher.getAuthTag()]).toString("base64"),
511
+ iv: iv.toString("hex")
512
+ };
513
+ }
514
+ function decrypt(data, keyHex) {
515
+ const key = Buffer.from(keyHex, "hex");
516
+ const combined = Buffer.from(data.encrypted, "base64");
517
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(data.iv, "hex"));
518
+ decipher.setAuthTag(combined.subarray(-16));
519
+ const plaintext = Buffer.concat([decipher.update(combined.subarray(0, -16)), decipher.final()]);
520
+ return JSON.parse(plaintext.toString("utf-8"));
211
521
  }
522
+ //#endregion
523
+ //#region src/secrets/store.ts
524
+ var store_exports = /* @__PURE__ */ __exportAll({
525
+ FileSecretsStore: () => FileSecretsStore,
526
+ isRemoteStore: () => isRemoteStore,
527
+ secretsStoreFor: () => secretsStoreFor
528
+ });
212
529
  /**
213
- * Mask a token for display
530
+ * The store a stage's secrets are kept in — what every command reads and
531
+ * writes, for the stage it acts on.
532
+ *
533
+ * The local stage is always the file: its secrets belong to the machine
534
+ * running `gkm dev`. A deployed stage uses `secrets.store`.
214
535
  */
215
- function maskToken(token) {
216
- if (token.length <= 8) return "****";
217
- return `${token.slice(0, 4)}...${token.slice(-4)}`;
536
+ async function secretsStoreFor(workspace, stage, options = {}) {
537
+ const configured = workspace.secrets.store ?? "file";
538
+ if (stage === workspace.stages.local || configured === "file") return new FileSecretsStore(workspace.root, keystoreProject(workspace, options.home));
539
+ if (configured.provider !== "ssm") return configured.provider;
540
+ const { AwsSecretsStore } = await import("./aws-DojzQnLd.mjs");
541
+ return new AwsSecretsStore({
542
+ project: workspace.name,
543
+ region: configured.region,
544
+ ...options.profile ? { profile: options.profile } : {}
545
+ });
546
+ }
547
+ /** Whether a stage's secrets live somewhere other than this machine. */
548
+ function isRemoteStore(workspace, stage) {
549
+ const configured = workspace.secrets.store ?? "file";
550
+ return stage !== workspace.stages.local && configured !== "file";
218
551
  }
219
552
  //#endregion
220
553
  //#region src/credentials/index.ts
@@ -453,17 +786,6 @@ async function prepareEntryCredentials(options) {
453
786
  };
454
787
  }
455
788
  //#endregion
456
- //#region src/workspace/client-generator.ts
457
- /**
458
- * Normalize routes to an array of patterns.
459
- * Handles a glob or a list of them.
460
- * @internal Exported for use in dev command
461
- */
462
- function normalizeRoutes(routes) {
463
- if (!routes) return [];
464
- return Array.isArray(routes) ? routes : [routes];
465
- }
466
- //#endregion
467
789
  //#region src/reconcile/containers.ts
468
790
  /**
469
791
  * What gkm knows about each container it can derive.
@@ -3358,822 +3680,693 @@ function localAddress(port) {
3358
3680
  return `http://localhost:${port}`;
3359
3681
  }
3360
3682
  //#endregion
3361
- //#region src/secrets/encryption.ts
3362
- var encryption_exports = /* @__PURE__ */ __exportAll({
3363
- encryptSecrets: () => encryptSecrets,
3364
- generateDefineOptions: () => generateDefineOptions,
3365
- keyFingerprint: () => keyFingerprint
3366
- });
3367
- /** AES-256-GCM configuration */
3368
- const ALGORITHM = "aes-256-gcm";
3369
- const KEY_LENGTH = 32;
3370
- const IV_LENGTH = 12;
3683
+ //#region src/target/dokploy/domain.ts
3684
+ /** A stage was deployed that `deploy.domains` gives no domain. */
3685
+ var NoDomainForStage = class extends Error {
3686
+ stage;
3687
+ constructor(stage) {
3688
+ super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
3689
+ this.stage = stage;
3690
+ this.name = "NoDomainForStage";
3691
+ }
3692
+ };
3371
3693
  /**
3372
- * Encrypt secrets for embedding in a bundle.
3373
- * Uses AES-256-GCM with a randomly generated ephemeral key.
3694
+ * The hostname an app answers on for a stage.
3695
+ *
3696
+ * 1. An explicit `app.domain` — a string, or one per stage — wins.
3697
+ * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
3698
+ * answers on the domain itself, and every other app on
3699
+ * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
3374
3700
  *
3375
- * @param secrets - Key-value pairs to encrypt
3376
- * @returns Encrypted payload with ephemeral master key
3701
+ * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
3377
3702
  */
3378
- function encryptSecrets(secrets) {
3379
- const masterKey = randomBytes(KEY_LENGTH);
3380
- const iv = randomBytes(IV_LENGTH);
3381
- const plaintext = JSON.stringify(secrets);
3382
- const cipher = createCipheriv(ALGORITHM, masterKey, iv);
3383
- const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
3384
- const authTag = cipher.getAuthTag();
3385
- return {
3386
- encrypted: Buffer.concat([ciphertext, authTag]).toString("base64"),
3387
- iv: iv.toString("hex"),
3388
- masterKey: masterKey.toString("hex")
3389
- };
3703
+ function resolveHost(appName, app, stage, domains, isMainFrontend) {
3704
+ if (app.domain) {
3705
+ if (typeof app.domain === "string") return app.domain;
3706
+ if (app.domain[stage]) return app.domain[stage];
3707
+ }
3708
+ const baseDomain = domains?.[stage];
3709
+ if (!baseDomain) throw new NoDomainForStage(stage);
3710
+ if (isMainFrontend) return baseDomain;
3711
+ return `${app.subdomain ?? appName}.${baseDomain}`;
3390
3712
  }
3391
3713
  /**
3392
- * Generate the define options for tsdown/esbuild.
3393
- * These will be injected at build time.
3714
+ * Whether this app is the site the base domain points at — see `rootSite`,
3715
+ * which the local edge asks too.
3716
+ *
3717
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
3394
3718
  */
3395
- function generateDefineOptions(payload) {
3396
- return {
3397
- __GKM_ENCRYPTED_CREDENTIALS__: JSON.stringify(payload.encrypted),
3398
- __GKM_CREDENTIALS_IV__: JSON.stringify(payload.iv)
3399
- };
3719
+ function isMainFrontendApp(appName, app, allApps) {
3720
+ if (app.type !== "web") return false;
3721
+ return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
3722
+ name,
3723
+ root: a.root
3724
+ }))) === appName;
3400
3725
  }
3401
3726
  /**
3402
- * A short, one-way name for a master key: the first 8 hex characters of its
3403
- * SHA-256.
3727
+ * Get public URL arg names from app dependencies.
3404
3728
  *
3405
- * Printed where the key itself used to be, so a person can tell which key a
3406
- * build used — and match it against the one they hold — without the output
3407
- * carrying a value that decrypts every secret of the stage.
3729
+ * @param app - The web/mobile app configuration
3730
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
3731
+ * without a public prefix (e.g. Remix).
3408
3732
  */
3409
- function keyFingerprint(masterKey) {
3410
- return createHash("sha256").update(masterKey).digest("hex").slice(0, 8);
3733
+ function getPublicUrlArgNames(app) {
3734
+ const prefix = getPublicEnvPrefix(app.framework);
3735
+ if (!prefix) return [];
3736
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
3411
3737
  }
3412
3738
  //#endregion
3413
- //#region src/deploy/generated.ts
3739
+ //#region src/docker/imageRef.ts
3414
3740
  /**
3415
- * The values a deployed stage generates once and keeps: what nothing can
3416
- * provision and nobody else issues.
3417
- *
3418
- * - **A seed**, which salts every password a deploy derives — a database's
3419
- * master and roles, a bucket's root user. Derived from repo facts alone
3420
- * (project, stage, construct id), those passwords were computable by anyone
3421
- * who could read the repo.
3422
- * - **Each `secret` construct's value** — an auth server's signing secret —
3423
- * random, rather than derived.
3424
- * - **Each `encryption` construct's keyring** — one random key and an index
3425
- * key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
3741
+ * An image reference, checked before it reaches `docker`.
3426
3742
  *
3427
- * Both are generated on the first deploy that needs them and written to the
3428
- * stage's store, so every later deploy reads the same values: sessions survive
3429
- * a redeploy, and a role's password does not move. A value set by hand with
3430
- * `gkm secrets:set` is kept, never replaced.
3431
- */
3432
- /**
3433
- * The stage's secrets with its seed and every declared `secret`'s value
3434
- * present, generating what is missing. Pure: the caller writes the result
3435
- * back to the stage's store when `generated` is not empty.
3743
+ * The parts come from the workspace — a registry in `gkm.config.ts`, an image
3744
+ * name from the project, a tag from `--tag` — and an argument array already
3745
+ * keeps them out of a shell. What it cannot do is stop `docker` reading a value
3746
+ * that starts with `-` as one of its own flags, so a ref is held to Docker's
3747
+ * own grammar (distribution/reference) and anything else is refused by name
3748
+ * before a build starts.
3436
3749
  */
3437
- function withGeneratedSecrets(secrets, manifest) {
3438
- const generated = [];
3439
- const custom = { ...secrets.custom };
3440
- for (const declaration of Object.values(manifest)) {
3441
- if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
3442
- const key = declaration.provides?.[0];
3443
- if (!key || custom[key]) continue;
3444
- custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
3445
- generated.push(key);
3750
+ const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
3751
+ const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
3752
+ const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
3753
+ const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
3754
+ /** Docker refuses a repository name longer than this. */
3755
+ const MAX_NAME_LENGTH = 255;
3756
+ /** A value that is not an image reference was about to be built or pushed. */
3757
+ var ImageRefInvalid = class extends Error {
3758
+ ref;
3759
+ constructor(ref) {
3760
+ super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
3761
+ this.ref = ref;
3762
+ this.name = "ImageRefInvalid";
3446
3763
  }
3447
- const seed = secrets.seed ?? random();
3448
- if (!secrets.seed) generated.push("seed");
3449
- return {
3450
- secrets: generated.length ? {
3451
- ...secrets,
3452
- seed,
3453
- custom,
3454
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3455
- } : secrets,
3456
- generated
3457
- };
3458
- }
3459
- /** 256 bits, URL-safe. */
3460
- function random() {
3461
- return randomBytes(32).toString("base64url");
3462
- }
3463
- //#endregion
3464
- //#region src/deploy/StateProvider.ts
3465
- /**
3466
- * Check if value is a StateProvider implementation.
3467
- */
3468
- function isStateProvider(value) {
3469
- return typeof value === "object" && value !== null && typeof value.read === "function" && typeof value.write === "function";
3764
+ };
3765
+ /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
3766
+ function validateImageRef(ref) {
3767
+ const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
3768
+ if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
3769
+ return ref;
3470
3770
  }
3471
3771
  //#endregion
3472
- //#region src/deploy/StateStore.ts
3772
+ //#region src/docker/templates.ts
3773
+ const LOCKFILES = [
3774
+ ["pnpm-lock.yaml", "pnpm"],
3775
+ ["bun.lockb", "bun"],
3776
+ ["yarn.lock", "yarn"],
3777
+ ["package-lock.json", "npm"]
3778
+ ];
3473
3779
  /**
3474
- * Deploy state store.
3475
- *
3476
- * `StateProvider` reads and writes a whole stage at once, with nothing to stop
3477
- * two runs of the same stage from overwriting each other and nowhere to record
3478
- * a resource the moment it exists. A `StateStore` adds the three things a
3479
- * deploy needs to be safe against crashes and concurrent runs:
3480
- *
3481
- * - `lock(stage)`: one run per stage. A second gets `StateLocked`, naming the
3482
- * holder, instead of racing the first.
3483
- * - Versioned `read` and conditional `write`: a write names the version it was
3484
- * based on, and raises `StateVersionConflict` when the stored state moved on
3485
- * underneath it, so nothing is lost silently even without the lock.
3486
- * - Per-resource records: `putResource` journals one resource at a time, so a
3487
- * run that dies half way leaves behind the ids of everything it created.
3488
- *
3489
- * Built-in stores: `LocalStateStore`, `SSMStateStore`, `S3StateStore`. A
3490
- * custom `StateProvider` is wrapped by `LegacyStateStore`, which keeps it
3491
- * working but cannot lock.
3780
+ * Detect package manager from lockfiles
3781
+ * Walks up the directory tree to find lockfile (for monorepos)
3492
3782
  */
3493
- var StateLocked = class extends Error {
3494
- stage;
3495
- holder;
3496
- location;
3497
- constructor(stage, holder, location) {
3498
- const by = holder ? `${holder.owner}@${holder.host} (pid ${holder.pid}) since ${holder.acquiredAt}${holder.operation ? ` for '${holder.operation}'` : ""}` : "a run that did not record who it was";
3499
- super(`Deploy state for stage '${stage}' is locked by ${by} (${location}). Wait for that run to finish; if it crashed, release the lock with \`gkm state:unlock --stage ${stage}\`.`);
3500
- this.stage = stage;
3501
- this.holder = holder;
3502
- this.location = location;
3503
- this.name = "StateLocked";
3504
- }
3505
- };
3506
- var StateVersionConflict = class extends Error {
3507
- stage;
3508
- expectedVersion;
3509
- actualVersion;
3510
- constructor(stage, expectedVersion, actualVersion) {
3511
- super(`Deploy state for stage '${stage}' changed since it was read (expected ${expectedVersion ? `version ${expectedVersion}` : "no state"}, found ${actualVersion ? `version ${actualVersion}` : "no state"}). Another run wrote it: read the state again and re-apply the change, and hold the stage's lock so runs take turns.`);
3512
- this.stage = stage;
3513
- this.expectedVersion = expectedVersion;
3514
- this.actualVersion = actualVersion;
3515
- this.name = "StateVersionConflict";
3516
- }
3517
- };
3518
- var StageStateMissing = class extends Error {
3519
- stage;
3520
- constructor(stage) {
3521
- super(`There is no deploy state for stage '${stage}' to record a resource in. Write the stage's state first (write with expectedVersion: null).`);
3522
- this.stage = stage;
3523
- this.name = "StageStateMissing";
3524
- }
3525
- };
3526
- var StateUnreadable = class extends Error {
3527
- stage;
3528
- location;
3529
- constructor(stage, location, cause) {
3530
- super(`Deploy state for stage '${stage}' at ${location} is not valid JSON. Restore it from a backup (or the provider's history) rather than deploying without it — a deploy without state recreates every resource.`, { cause });
3531
- this.stage = stage;
3532
- this.location = location;
3533
- this.name = "StateUnreadable";
3534
- }
3535
- };
3536
- var StateSchemaTooNew = class extends Error {
3537
- stage;
3538
- schemaVersion;
3539
- constructor(stage, schemaVersion) {
3540
- super(`Deploy state for stage '${stage}' has schema version ${String(schemaVersion)}, newer than this @geekmidas/cli understands (2). Upgrade @geekmidas/cli to the version that wrote it.`);
3541
- this.stage = stage;
3542
- this.schemaVersion = schemaVersion;
3543
- this.name = "StateSchemaTooNew";
3783
+ function detectPackageManager(cwd = process.cwd()) {
3784
+ let dir = cwd;
3785
+ const root = parse(dir).root;
3786
+ while (dir !== root) {
3787
+ for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
3788
+ dir = dirname(dir);
3544
3789
  }
3545
- };
3790
+ for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
3791
+ return "pnpm";
3792
+ }
3546
3793
  /**
3547
- * Not thrown: emitted (once per store) when a custom `StateProvider` stands
3548
- * in for a store, so a deploy that cannot be protected from a concurrent one
3549
- * says so.
3794
+ * Find the lockfile path by walking up the directory tree
3795
+ * Returns the full path to the lockfile, or null if not found
3550
3796
  */
3551
- var StateStoreWithoutLocking = class extends Error {
3552
- provider;
3553
- constructor(provider) {
3554
- super(`The custom state provider '${provider}' has no lock and no versions, so two deploys of the same stage can still overwrite each other. Implement StateStore (lock, versioned read, conditional write) or use the 'local', 'ssm' or 's3' state provider.`);
3555
- this.provider = provider;
3556
- this.name = "StateStoreWithoutLocking";
3557
- }
3558
- };
3559
- var StateStoreNeedsWorkspaceName = class extends Error {
3560
- provider;
3561
- constructor(provider) {
3562
- super(`The '${provider}' state provider keys state by workspace name. Set "name" in gkm.config.ts.`);
3563
- this.provider = provider;
3564
- this.name = "StateStoreNeedsWorkspaceName";
3565
- }
3566
- };
3567
- var UnknownStateProvider = class extends Error {
3568
- config;
3569
- constructor(config) {
3570
- super(`Unknown state provider ${JSON.stringify(config)}. Use 'local', 'ssm', 's3', or an object implementing StateStore or StateProvider.`);
3571
- this.config = config;
3572
- this.name = "UnknownStateProvider";
3573
- }
3574
- };
3575
- /** A lock or write that could not get at the state backend in time. */
3576
- var StateStoreBusy = class extends Error {
3577
- stage;
3578
- location;
3579
- constructor(stage, location) {
3580
- super(`Timed out waiting to write deploy state for stage '${stage}' (${location}). If no other gkm process is running, remove ${location}.`);
3581
- this.stage = stage;
3582
- this.location = location;
3583
- this.name = "StateStoreBusy";
3797
+ function findLockfilePath(cwd = process.cwd()) {
3798
+ let dir = cwd;
3799
+ const root = parse(dir).root;
3800
+ while (dir !== root) {
3801
+ for (const [lockfile] of LOCKFILES) {
3802
+ const lockfilePath = join(dir, lockfile);
3803
+ if (existsSync(lockfilePath)) return lockfilePath;
3804
+ }
3805
+ dir = dirname(dir);
3584
3806
  }
3585
- };
3586
- function decodeDocument(stage, body, location) {
3587
- let parsed;
3588
- try {
3589
- parsed = JSON.parse(body);
3590
- } catch (error) {
3591
- throw new StateUnreadable(stage, location, error);
3807
+ for (const [lockfile] of LOCKFILES) {
3808
+ const lockfilePath = join(root, lockfile);
3809
+ if (existsSync(lockfilePath)) return lockfilePath;
3592
3810
  }
3593
- if (typeof parsed !== "object" || parsed === null) throw new StateUnreadable(stage, location, parsed);
3594
- const schemaVersion = parsed.schemaVersion;
3595
- if (schemaVersion === void 0) return {
3596
- schemaVersion: 1,
3597
- state: parsed
3598
- };
3599
- if (schemaVersion === 2) return {
3600
- schemaVersion: 2,
3601
- document: parsed
3602
- };
3603
- throw new StateSchemaTooNew(stage, schemaVersion);
3604
- }
3605
- function encodeDocument(document) {
3606
- return JSON.stringify(document, null, 2);
3811
+ return null;
3607
3812
  }
3608
3813
  /**
3609
- * v1 state as a v2 document. The ids v1 kept are seeded as `ready` records,
3610
- * so the first journaled deploy after an upgrade adopts them rather than
3611
- * treating them as unknown.
3814
+ * Check if we're in a monorepo (lockfile is in a parent directory)
3612
3815
  */
3613
- function migrateV1(stage, state) {
3614
- const now = (/* @__PURE__ */ new Date()).toISOString();
3615
- const resources = {};
3616
- const seed = (type, name, id) => {
3617
- if (!id) return;
3618
- const key = name ? `${type}:${name}` : type;
3619
- resources[key] = {
3620
- key,
3621
- type,
3622
- id,
3623
- status: "ready",
3624
- updatedAt: now
3625
- };
3626
- };
3627
- seed("project", null, state.projectId);
3628
- seed("environment", null, state.environmentId);
3629
- for (const [app, id] of Object.entries(state.applications ?? {})) seed("application", app, id);
3630
- seed("postgres", null, state.services?.postgresId);
3631
- seed("redis", null, state.services?.redisId);
3632
- seed("backup-destination", null, state.backups?.destinationId);
3633
- return {
3634
- schemaVersion: 2,
3635
- stage,
3636
- serial: 1,
3637
- state,
3638
- resources,
3639
- updatedAt: now
3640
- };
3641
- }
3642
- function newLockHolder(options) {
3643
- let owner = "unknown";
3644
- try {
3645
- owner = userInfo().username;
3646
- } catch {}
3647
- return {
3648
- id: randomUUID(),
3649
- owner,
3650
- host: hostname(),
3651
- pid: process.pid,
3652
- acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
3653
- operation: options?.operation
3654
- };
3655
- }
3656
- function contentVersion(body) {
3657
- return createHash("sha256").update(body).digest("hex").slice(0, 16);
3816
+ function isMonorepo(cwd = process.cwd()) {
3817
+ const lockfilePath = findLockfilePath(cwd);
3818
+ if (!lockfilePath) return false;
3819
+ return dirname(lockfilePath) !== cwd;
3658
3820
  }
3659
3821
  /**
3660
- * A store over a backend that can read and conditionally write one blob per
3661
- * stage, and create a lock object only if it does not exist. Migration and
3662
- * resource records are the same for every such backend, so they live here.
3822
+ * Check if turbo.json exists (walks up directory tree)
3663
3823
  */
3664
- var DocumentStateStore = class {
3665
- async lock(stage, options) {
3666
- const holder = newLockHolder(options);
3667
- await this.createLock(stage, holder);
3668
- let released = false;
3669
- return {
3670
- stage,
3671
- holder,
3672
- release: async () => {
3673
- if (released) return;
3674
- released = true;
3675
- await this.removeLock(stage, holder);
3676
- }
3677
- };
3678
- }
3679
- async forceUnlock(stage) {
3680
- const holder = await this.readLock(stage);
3681
- await this.removeLock(stage, null);
3682
- return holder;
3683
- }
3684
- async read(stage) {
3685
- const current = await this.readDocument(stage);
3686
- if (!current) return null;
3687
- return {
3688
- state: current.document.state,
3689
- resources: current.document.resources,
3690
- version: current.version
3691
- };
3692
- }
3693
- async write(stage, state, options) {
3694
- if (options.expectedVersion === null) {
3695
- const document = {
3696
- schemaVersion: 2,
3697
- stage,
3698
- serial: 1,
3699
- state,
3700
- resources: {},
3701
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3702
- };
3703
- return this.writeRaw(stage, encodeDocument(document), null);
3704
- }
3705
- return this.mutate(stage, options.expectedVersion, (document) => ({
3706
- ...document,
3707
- state
3708
- }));
3709
- }
3710
- async putResource(stage, record, options) {
3711
- return this.mutate(stage, options?.expectedVersion, (document) => ({
3712
- ...document,
3713
- resources: {
3714
- ...document.resources,
3715
- [record.key]: {
3716
- ...record,
3717
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3718
- }
3719
- }
3720
- }));
3721
- }
3722
- async deleteResource(stage, key, options) {
3723
- return this.mutate(stage, options?.expectedVersion, (document) => {
3724
- const { [key]: _removed, ...resources } = document.resources;
3725
- return {
3726
- ...document,
3727
- resources
3728
- };
3729
- });
3730
- }
3731
- async mutate(stage, expectedVersion, change) {
3732
- const current = await this.readDocument(stage);
3733
- if (!current) {
3734
- if (expectedVersion !== void 0) throw new StateVersionConflict(stage, expectedVersion, null);
3735
- throw new StageStateMissing(stage);
3736
- }
3737
- if (expectedVersion !== void 0 && current.version !== expectedVersion) throw new StateVersionConflict(stage, expectedVersion, current.version);
3738
- const next = change(current.document);
3739
- next.serial = current.document.serial + 1;
3740
- next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3741
- return this.writeRaw(stage, encodeDocument(next), current.version);
3742
- }
3743
- /**
3744
- * Reads the stage's document, migrating v1 in place on the way. The
3745
- * migration is itself a conditional write: if another run migrated (or
3746
- * wrote) first, its result is read instead.
3747
- */
3748
- async readDocument(stage, attempt = 0) {
3749
- const raw = await this.readRaw(stage);
3750
- if (!raw) return null;
3751
- const decoded = decodeDocument(stage, raw.body, this.location(stage));
3752
- if (decoded.schemaVersion === 2) return {
3753
- document: decoded.document,
3754
- version: raw.version
3755
- };
3756
- await this.writeV1Backup(stage, raw.body);
3757
- const document = migrateV1(stage, decoded.state);
3758
- try {
3759
- return {
3760
- document,
3761
- version: await this.writeRaw(stage, encodeDocument(document), raw.version)
3762
- };
3763
- } catch (error) {
3764
- if (error instanceof StateVersionConflict && attempt < 2) return this.readDocument(stage, attempt + 1);
3765
- throw error;
3766
- }
3824
+ function hasTurboConfig(cwd = process.cwd()) {
3825
+ let dir = cwd;
3826
+ const root = parse(dir).root;
3827
+ while (dir !== root) {
3828
+ if (existsSync(join(dir, "turbo.json"))) return true;
3829
+ dir = dirname(dir);
3767
3830
  }
3768
- };
3769
- function isStateStore(value) {
3770
- return typeof value === "object" && value !== null && typeof value.lock === "function" && typeof value.read === "function" && typeof value.write === "function" && typeof value.putResource === "function";
3831
+ return existsSync(join(root, "turbo.json"));
3771
3832
  }
3772
3833
  /**
3773
- * The store for a workspace's `state` config:
3774
- *
3775
- * - none / `'local'`: `LocalStateStore` under `.gkm/`
3776
- * - `'ssm'`: `SSMStateStore`, with no local cache — SSM is read directly, so
3777
- * a stale local copy can never win over it
3778
- * - `'s3'`: `S3StateStore`
3779
- * - a `StateStore` object: used as is
3780
- * - a `StateProvider` object: wrapped by `LegacyStateStore`
3834
+ * Get install command for turbo builds (without frozen lockfile)
3835
+ * Turbo prune creates a subset that may not perfectly match the lockfile
3781
3836
  */
3782
- async function createStateStore(options) {
3783
- const { config, workspaceRoot, workspaceName } = options;
3784
- if (!config || config.provider === "local") {
3785
- const { LocalStateStore } = await import("./LocalStateStore-Cy1ZBW7U.mjs").then((n) => n.n);
3786
- return new LocalStateStore(workspaceRoot);
3787
- }
3788
- if (isStateStore(config.provider)) return config.provider;
3789
- if (isStateProvider(config.provider)) {
3790
- const { LegacyStateStore } = await import("./LegacyStateStore-DIgkGaUP.mjs");
3791
- return new LegacyStateStore(config.provider, { warn: options.warn });
3792
- }
3793
- if (config.provider === "ssm") {
3794
- if (!workspaceName) throw new StateStoreNeedsWorkspaceName("ssm");
3795
- const { SSMStateStore } = await import("./SSMStateStore-DfF174xQ.mjs");
3796
- const ssm = config;
3797
- return SSMStateStore.create({
3798
- workspaceName,
3799
- region: ssm.region,
3800
- profile: ssm.profile
3801
- });
3802
- }
3803
- if (config.provider === "s3") {
3804
- if (!workspaceName) throw new StateStoreNeedsWorkspaceName("s3");
3805
- const { S3StateStore } = await import("./S3StateStore-D3x6ivBL.mjs");
3806
- const s3 = config;
3807
- return S3StateStore.create({
3808
- workspaceName,
3809
- bucket: s3.bucket,
3810
- prefix: s3.prefix,
3811
- region: s3.region,
3812
- profile: s3.profile
3813
- });
3814
- }
3815
- throw new UnknownStateProvider(config);
3837
+ function getTurboInstallCmd(pm) {
3838
+ return {
3839
+ pnpm: "pnpm install",
3840
+ npm: "npm install",
3841
+ yarn: "yarn install",
3842
+ bun: "bun install"
3843
+ }[pm];
3816
3844
  }
3817
- //#endregion
3818
- //#region src/deploy/state.ts
3819
3845
  /**
3820
- * Create a new empty state for a stage
3846
+ * Get package manager specific commands and paths
3821
3847
  */
3822
- function createEmptyState(stage, projectId, environmentId) {
3848
+ function getPmConfig(pm) {
3823
3849
  return {
3824
- provider: "dokploy",
3825
- stage,
3826
- projectId,
3827
- environmentId,
3828
- applications: {},
3829
- services: {},
3830
- lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
3831
- };
3850
+ pnpm: {
3851
+ install: "corepack enable && corepack prepare pnpm@latest --activate",
3852
+ lockfile: "pnpm-lock.yaml",
3853
+ fetch: "pnpm fetch",
3854
+ installCmd: "pnpm install --frozen-lockfile --offline",
3855
+ cacheTarget: "/root/.local/share/pnpm/store",
3856
+ cacheId: "pnpm",
3857
+ run: "pnpm",
3858
+ exec: "pnpm exec",
3859
+ dlx: "pnpm dlx",
3860
+ addGlobal: "pnpm add -g"
3861
+ },
3862
+ npm: {
3863
+ install: "",
3864
+ lockfile: "package-lock.json",
3865
+ fetch: "",
3866
+ installCmd: "npm ci",
3867
+ cacheTarget: "/root/.npm",
3868
+ cacheId: "npm",
3869
+ run: "npm run",
3870
+ exec: "npx",
3871
+ dlx: "npx",
3872
+ addGlobal: "npm install -g"
3873
+ },
3874
+ yarn: {
3875
+ install: "corepack enable && corepack prepare yarn@stable --activate",
3876
+ lockfile: "yarn.lock",
3877
+ fetch: "",
3878
+ installCmd: "yarn install --frozen-lockfile",
3879
+ cacheTarget: "/root/.yarn/cache",
3880
+ cacheId: "yarn",
3881
+ run: "yarn",
3882
+ exec: "yarn exec",
3883
+ dlx: "yarn dlx",
3884
+ addGlobal: "yarn global add"
3885
+ },
3886
+ bun: {
3887
+ install: "npm install -g bun",
3888
+ lockfile: "bun.lockb",
3889
+ fetch: "",
3890
+ installCmd: "bun install --frozen-lockfile",
3891
+ cacheTarget: "/root/.bun/install/cache",
3892
+ cacheId: "bun",
3893
+ run: "bun run",
3894
+ exec: "bunx",
3895
+ dlx: "bunx",
3896
+ addGlobal: "bun add -g"
3897
+ }
3898
+ }[pm];
3832
3899
  }
3833
3900
  /**
3834
- * Set application ID in state (mutates state)
3835
- */
3836
- function setApplicationId(state, appName, applicationId) {
3837
- state.applications[appName] = applicationId;
3838
- }
3839
- /**
3840
- * Record the image an app was deployed with (mutates state)
3841
- */
3842
- function setDeployedImage(state, appName, image) {
3843
- state.images = {
3844
- ...state.images,
3845
- [appName]: image
3846
- };
3847
- }
3848
- /**
3849
- * Get a generated secret for an app
3850
- */
3851
- function getGeneratedSecret(state, appName, secretName) {
3852
- return state?.generatedSecrets?.[appName]?.[secretName];
3853
- }
3854
- /**
3855
- * Set a generated secret for an app (mutates state)
3856
- */
3857
- function setGeneratedSecret(state, appName, secretName, value) {
3858
- if (!state.generatedSecrets) state.generatedSecrets = {};
3859
- if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
3860
- state.generatedSecrets[appName][secretName] = value;
3861
- }
3862
- /**
3863
- * Set DNS verification record for a hostname (mutates state)
3864
- */
3865
- function setDnsVerification(state, hostname, serverIp) {
3866
- if (!state.dnsVerified) state.dnsVerified = {};
3867
- state.dnsVerified[hostname] = {
3868
- serverIp,
3869
- verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
3870
- };
3871
- }
3872
- /**
3873
- * Check if a hostname is already verified with the given IP
3874
- */
3875
- function isDnsVerified(state, hostname, serverIp) {
3876
- return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
3877
- }
3878
- /**
3879
- * Get the key for a DNS record in state
3901
+ * Generate a multi-stage Dockerfile for building from source
3902
+ * Optimized for build speed with:
3903
+ * - BuildKit cache mounts for package manager store
3904
+ * - pnpm fetch for better layer caching (when using pnpm)
3905
+ * - Optional turbo prune for monorepos
3880
3906
  */
3881
- function getDnsRecordKey(name, type) {
3882
- return `${name}:${type}`;
3907
+ function generateMultiStageDockerfile(options) {
3908
+ const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
3909
+ if (turbo) return generateTurboDockerfile({
3910
+ ...options,
3911
+ turboPackage: turboPackage ?? "api"
3912
+ });
3913
+ const pm = getPmConfig(packageManager);
3914
+ return `# syntax=docker/dockerfile:1
3915
+ # Stage 1: Dependencies
3916
+ FROM ${baseImage} AS deps
3917
+
3918
+ WORKDIR /app
3919
+ ${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
3920
+ ${packageManager === "pnpm" ? `# Copy lockfile first for better caching
3921
+ COPY ${pm.lockfile} ./
3922
+
3923
+ # Fetch dependencies (downloads to virtual store, cached separately)
3924
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
3925
+ ${pm.fetch}
3926
+
3927
+ # Copy package.json after fetch
3928
+ COPY package.json ./
3929
+
3930
+ # Install from cache (fast - no network needed)
3931
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
3932
+ ${pm.installCmd}` : `# Copy package files
3933
+ COPY package.json ${pm.lockfile} ./
3934
+
3935
+ # Install dependencies with cache
3936
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
3937
+ ${pm.installCmd}`}
3938
+
3939
+ # Stage 2: Build
3940
+ FROM deps AS builder
3941
+
3942
+ WORKDIR /app
3943
+
3944
+ # Copy source (deps already installed)
3945
+ COPY . .
3946
+
3947
+ # Build any workspace packages the app depends on.
3948
+ #
3949
+ # A no-op for a project whose dependencies come from a registry, and required
3950
+ # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
3951
+ # workspace package's \`dist\` never reaches the image and its bin has nothing to
3952
+ # import. \`--if-present\` is what makes the same Dockerfile correct for both.
3953
+ RUN ${pm.run} --if-present build
3954
+
3955
+ # Build production server using gkm
3956
+ RUN ${pm.exec} gkm build --provider server --production
3957
+
3958
+ # Stage 3: Production
3959
+ FROM ${baseImage} AS runner
3960
+
3961
+ WORKDIR /app
3962
+
3963
+ # Install tini for proper signal handling as PID 1
3964
+ RUN apk add --no-cache tini
3965
+
3966
+ # Create non-root user
3967
+ RUN addgroup --system --gid 1001 nodejs && \\
3968
+ adduser --system --uid 1001 hono
3969
+
3970
+ # Copy bundled server
3971
+ COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
3972
+
3973
+ # Environment
3974
+ ENV NODE_ENV=production
3975
+ ENV PORT=${port}
3976
+
3977
+ # Health check
3978
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
3979
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
3980
+
3981
+ # Switch to non-root user
3982
+ USER hono
3983
+
3984
+ EXPOSE ${port}
3985
+
3986
+ # Use tini as entrypoint to handle PID 1 responsibilities
3987
+ ENTRYPOINT ["/sbin/tini", "--"]
3988
+ CMD ["node", "server.mjs"]
3989
+ `;
3883
3990
  }
3884
3991
  /**
3885
- * Set a created DNS record in state (mutates state)
3992
+ * Generate a Dockerfile optimized for Turbo monorepos
3993
+ * Uses turbo prune to create minimal Docker context
3886
3994
  */
3887
- function setDnsRecord(state, record) {
3888
- if (!state.dnsRecords) state.dnsRecords = {};
3889
- const key = getDnsRecordKey(record.name, record.type);
3890
- state.dnsRecords[key] = {
3891
- ...record,
3892
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
3893
- };
3995
+ function generateTurboDockerfile(options) {
3996
+ const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
3997
+ const pm = getPmConfig(packageManager);
3998
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
3999
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
4000
+ return `# syntax=docker/dockerfile:1
4001
+ # Stage 1: Prune monorepo
4002
+ FROM ${baseImage} AS pruner
4003
+
4004
+ WORKDIR /app
4005
+
4006
+ ${installPm}
4007
+
4008
+ COPY . .
4009
+
4010
+ # Prune to only include necessary packages
4011
+ RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4012
+
4013
+ # Stage 2: Install dependencies
4014
+ FROM ${baseImage} AS deps
4015
+
4016
+ WORKDIR /app
4017
+
4018
+ ${installPm}
4019
+
4020
+ # Copy pruned lockfile and package.jsons
4021
+ COPY --from=pruner /app/out/${pm.lockfile} ./
4022
+ COPY --from=pruner /app/out/json/ ./
4023
+
4024
+ # Install dependencies (no frozen-lockfile since turbo prune creates a subset)
4025
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4026
+ ${turboInstallCmd}
4027
+
4028
+ # Stage 3: Build
4029
+ FROM deps AS builder
4030
+
4031
+ WORKDIR /app
4032
+
4033
+ # Copy pruned source
4034
+ COPY --from=pruner /app/out/full/ ./
4035
+
4036
+ # Build any workspace packages the app depends on.
4037
+ #
4038
+ # A no-op for a project whose dependencies come from a registry, and required
4039
+ # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
4040
+ # workspace package's \`dist\` never reaches the image and its bin has nothing to
4041
+ # import. \`--if-present\` is what makes the same Dockerfile correct for both.
4042
+ RUN ${pm.run} --if-present build
4043
+
4044
+ # Build production server using gkm
4045
+ RUN ${pm.exec} gkm build --provider server --production
4046
+
4047
+ # Stage 4: Production
4048
+ FROM ${baseImage} AS runner
4049
+
4050
+ WORKDIR /app
4051
+
4052
+ RUN apk add --no-cache tini
4053
+
4054
+ RUN addgroup --system --gid 1001 nodejs && \\
4055
+ adduser --system --uid 1001 hono
4056
+
4057
+ COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
4058
+
4059
+ ENV NODE_ENV=production
4060
+ ENV PORT=${port}
4061
+
4062
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4063
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4064
+
4065
+ USER hono
4066
+
4067
+ EXPOSE ${port}
4068
+
4069
+ ENTRYPOINT ["/sbin/tini", "--"]
4070
+ CMD ["node", "server.mjs"]
4071
+ `;
3894
4072
  }
3895
4073
  /**
3896
- * Get backup state from state
4074
+ * Generate a slim Dockerfile for pre-built bundles
3897
4075
  */
3898
- function getBackupState(state) {
3899
- return state?.backups;
4076
+ function generateSlimDockerfile(options) {
4077
+ const { baseImage, port, healthCheckPath, bundle = ".gkm/server/dist/server.mjs" } = options;
4078
+ return `# Slim Dockerfile for pre-built production bundle
4079
+ FROM ${baseImage}
4080
+
4081
+ WORKDIR /app
4082
+
4083
+ # Install tini for proper signal handling as PID 1
4084
+ # Handles SIGTERM propagation and zombie process reaping
4085
+ RUN apk add --no-cache tini
4086
+
4087
+ # Create non-root user
4088
+ RUN addgroup --system --gid 1001 nodejs && \\
4089
+ adduser --system --uid 1001 hono
4090
+
4091
+ # Copy pre-built bundle
4092
+ COPY ${bundle} ./
4093
+
4094
+ # Environment
4095
+ ENV NODE_ENV=production
4096
+ ENV PORT=${port}
4097
+
4098
+ # Health check
4099
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4100
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4101
+
4102
+ # Switch to non-root user
4103
+ USER hono
4104
+
4105
+ EXPOSE ${port}
4106
+
4107
+ # Use tini as entrypoint to handle PID 1 responsibilities
4108
+ ENTRYPOINT ["/sbin/tini", "--"]
4109
+ CMD ["node", "server.mjs"]
4110
+ `;
3900
4111
  }
3901
4112
  /**
3902
- * Set backup state (mutates state)
4113
+ * Generate .dockerignore file
3903
4114
  */
3904
- function setBackupState(state, backupState) {
3905
- state.backups = backupState;
3906
- }
3907
- /**
3908
- * Set postgres backup ID in state (mutates state)
3909
- */
3910
- function setPostgresBackupId(state, backupId) {
3911
- if (state.backups) state.backups.postgresBackupId = backupId;
4115
+ function generateDockerignore() {
4116
+ return `# Dependencies
4117
+ node_modules
4118
+ .pnpm-store
4119
+
4120
+ # Build output (except what we need)
4121
+ .gkm/aws*
4122
+ .gkm/server/*.ts
4123
+ !.gkm/server/dist
4124
+
4125
+ # The key gkm build --stage encrypted secrets with: runtime env only, never
4126
+ # the build context
4127
+ .gkm/server/master.key
4128
+
4129
+ # gkm compose's stacks: each app's env file holds its stage's secrets
4130
+ .gkm/compose
4131
+
4132
+ # IDE and editor
4133
+ .idea
4134
+ .vscode
4135
+ *.swp
4136
+ *.swo
4137
+
4138
+ # Git
4139
+ .git
4140
+ .gitignore
4141
+
4142
+ # Logs
4143
+ *.log
4144
+ npm-debug.log*
4145
+ pnpm-debug.log*
4146
+
4147
+ # Test files
4148
+ **/*.test.ts
4149
+ **/*.spec.ts
4150
+ **/__tests__
4151
+ coverage
4152
+
4153
+ # Documentation
4154
+ docs
4155
+ *.md
4156
+ !README.md
4157
+
4158
+ # Environment files (handle secrets separately)
4159
+ .env
4160
+ .env.*
4161
+ !.env.example
4162
+
4163
+ # Docker files (don't copy recursively)
4164
+ Dockerfile*
4165
+ docker-compose*
4166
+ .dockerignore
4167
+ `;
3912
4168
  }
3913
- //#endregion
3914
- //#region src/deploy/domain.ts
3915
- /** A stage was deployed that `deploy.domains` gives no domain. */
3916
- var NoDomainForStage = class extends Error {
3917
- stage;
3918
- constructor(stage) {
3919
- super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
3920
- this.stage = stage;
3921
- this.name = "NoDomainForStage";
3922
- }
3923
- };
3924
4169
  /**
3925
- * The hostname an app answers on for a stage.
3926
- *
3927
- * 1. An explicit `app.domain` — a string, or one per stage — wins.
3928
- * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
3929
- * answers on the domain itself, and every other app on
3930
- * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
3931
- *
3932
- * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
4170
+ * Generate docker-entrypoint.sh for custom startup logic
3933
4171
  */
3934
- function resolveHost(appName, app, stage, domains, isMainFrontend) {
3935
- if (app.domain) {
3936
- if (typeof app.domain === "string") return app.domain;
3937
- if (app.domain[stage]) return app.domain[stage];
3938
- }
3939
- const baseDomain = domains?.[stage];
3940
- if (!baseDomain) throw new NoDomainForStage(stage);
3941
- if (isMainFrontend) return baseDomain;
3942
- return `${app.subdomain ?? appName}.${baseDomain}`;
4172
+ function generateDockerEntrypoint() {
4173
+ return `#!/bin/sh
4174
+ set -e
4175
+
4176
+ # Run any custom startup scripts here
4177
+ # Example: wait for database
4178
+ # until nc -z $DB_HOST $DB_PORT; do
4179
+ # echo "Waiting for database..."
4180
+ # sleep 1
4181
+ # done
4182
+
4183
+ # Execute the main command
4184
+ exec "$@"
4185
+ `;
3943
4186
  }
3944
4187
  /**
3945
- * Whether this app is the site the base domain points at — see `rootSite`,
3946
- * which the local edge asks too.
3947
- *
3948
- * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
4188
+ * Resolve Docker configuration from GkmConfig with defaults
3949
4189
  */
3950
- function isMainFrontendApp(appName, app, allApps) {
3951
- if (app.type !== "web") return false;
3952
- return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
3953
- name,
3954
- root: a.root
3955
- }))) === appName;
4190
+ function resolveDockerConfig(config) {
4191
+ const docker = config.docker ?? {};
4192
+ return {
4193
+ registry: docker.registry ?? "",
4194
+ imageName: docker.imageName ?? config.name ?? "api",
4195
+ baseImage: docker.baseImage ?? "node:22-alpine",
4196
+ port: docker.port ?? 3e3
4197
+ };
3956
4198
  }
3957
4199
  /**
3958
- * Get public URL arg names from app dependencies.
3959
- *
3960
- * @param app - The web/mobile app configuration
3961
- * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
3962
- * without a public prefix (e.g. Remix).
4200
+ * Generate a Dockerfile for Next.js frontend apps using standalone output.
4201
+ * Uses turbo prune for monorepo optimization.
4202
+ * @internal Exported for testing
3963
4203
  */
3964
- function getPublicUrlArgNames(app) {
3965
- const prefix = getPublicEnvPrefix(app.framework);
3966
- if (!prefix) return [];
3967
- return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
4204
+ function generateNextjsDockerfile(options) {
4205
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
4206
+ const pm = getPmConfig(packageManager);
4207
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
4208
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
4209
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4210
+ const publicUrlArgDeclarations = publicUrlArgs.map((arg) => `ARG ${arg}=""`).join("\n");
4211
+ const publicUrlEnvDeclarations = publicUrlArgs.map((arg) => `ENV ${arg}=$${arg}`).join("\n");
4212
+ return `# syntax=docker/dockerfile:1
4213
+ # Next.js standalone Dockerfile with turbo prune optimization
4214
+
4215
+ # Stage 1: Prune monorepo
4216
+ FROM ${baseImage} AS pruner
4217
+
4218
+ WORKDIR /app
4219
+
4220
+ ${installPm}
4221
+
4222
+ COPY . .
4223
+
4224
+ # Prune to only include necessary packages
4225
+ RUN ${turboCmd} prune ${turboPackage} --docker
4226
+
4227
+ # Stage 2: Install dependencies
4228
+ FROM ${baseImage} AS deps
4229
+
4230
+ WORKDIR /app
4231
+
4232
+ ${installPm}
4233
+
4234
+ # Copy pruned lockfile and package.jsons
4235
+ COPY --from=pruner /app/out/${pm.lockfile} ./
4236
+ COPY --from=pruner /app/out/json/ ./
4237
+
4238
+ # Install dependencies
4239
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4240
+ ${turboInstallCmd}
4241
+
4242
+ # Stage 3: Build
4243
+ FROM deps AS builder
4244
+
4245
+ WORKDIR /app
4246
+
4247
+ # Build-time args for public API URLs (populated by gkm deploy)
4248
+ # These get baked into the Next.js build as public environment variables
4249
+ ${publicUrlArgDeclarations}
4250
+
4251
+ # Convert ARGs to ENVs for Next.js build
4252
+ ${publicUrlEnvDeclarations}
4253
+
4254
+ # Copy pruned source
4255
+ COPY --from=pruner /app/out/full/ ./
4256
+
4257
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4258
+ # Using wildcard to make it optional for single-app projects
4259
+ COPY --from=pruner /app/tsconfig.* ./
4260
+
4261
+ # Ensure public directory exists (may be empty for scaffolded projects)
4262
+ RUN mkdir -p ${appPath}/public
4263
+
4264
+ # Set Next.js to produce standalone output
4265
+ ENV NEXT_TELEMETRY_DISABLED=1
4266
+
4267
+ # Build the application
4268
+ RUN ${turboCmd} run build --filter=${turboPackage}
4269
+
4270
+ # Stage 4: Production
4271
+ FROM ${baseImage} AS runner
4272
+
4273
+ WORKDIR /app
4274
+
4275
+ # Install tini for proper signal handling
4276
+ RUN apk add --no-cache tini
4277
+
4278
+ # Create non-root user
4279
+ RUN addgroup --system --gid 1001 nodejs && \\
4280
+ adduser --system --uid 1001 nextjs
4281
+
4282
+ # Set environment
4283
+ ENV NODE_ENV=production
4284
+ ENV NEXT_TELEMETRY_DISABLED=1
4285
+ ENV PORT=${port}
4286
+ ENV HOSTNAME="0.0.0.0"
4287
+
4288
+ # Copy static files and standalone output
4289
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
4290
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
4291
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
4292
+
4293
+ USER nextjs
4294
+
4295
+ EXPOSE ${port}
4296
+
4297
+ # A site answers at its root; Docker, and Dokploy, read the container's
4298
+ # health from it rather than from the process merely running. 127.0.0.1, not
4299
+ # localhost: the server listens on IPv4, and localhost can resolve to ::1.
4300
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4301
+ CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
4302
+
4303
+ ENTRYPOINT ["/sbin/tini", "--"]
4304
+ CMD ["node", "${appPath}/server.js"]
4305
+ `;
3968
4306
  }
3969
- //#endregion
3970
- //#region src/docker/imageRef.ts
3971
4307
  /**
3972
- * An image reference, checked before it reaches `docker`.
3973
- *
3974
- * The parts come from the workspace — a registry in `gkm.config.ts`, an image
3975
- * name from the project, a tag from `--tag` — and an argument array already
3976
- * keeps them out of a shell. What it cannot do is stop `docker` reading a value
3977
- * that starts with `-` as one of its own flags, so a ref is held to Docker's
3978
- * own grammar (distribution/reference) and anything else is refused by name
3979
- * before a build starts.
4308
+ * Generate a Dockerfile for backend apps in a workspace.
4309
+ * Uses turbo prune for monorepo optimization.
4310
+ * @internal Exported for testing
3980
4311
  */
3981
- const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
3982
- const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
3983
- const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
3984
- const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
3985
- /** Docker refuses a repository name longer than this. */
3986
- const MAX_NAME_LENGTH = 255;
3987
- /** A value that is not an image reference was about to be built or pushed. */
3988
- var ImageRefInvalid = class extends Error {
3989
- ref;
3990
- constructor(ref) {
3991
- super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
3992
- this.ref = ref;
3993
- this.name = "ImageRefInvalid";
3994
- }
3995
- };
3996
- /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
3997
- function validateImageRef(ref) {
3998
- const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
3999
- if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
4000
- return ref;
4001
- }
4002
- //#endregion
4003
- //#region src/docker/templates.ts
4004
- const LOCKFILES = [
4005
- ["pnpm-lock.yaml", "pnpm"],
4006
- ["bun.lockb", "bun"],
4007
- ["yarn.lock", "yarn"],
4008
- ["package-lock.json", "npm"]
4009
- ];
4010
- /**
4011
- * Detect package manager from lockfiles
4012
- * Walks up the directory tree to find lockfile (for monorepos)
4013
- */
4014
- function detectPackageManager(cwd = process.cwd()) {
4015
- let dir = cwd;
4016
- const root = parse(dir).root;
4017
- while (dir !== root) {
4018
- for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
4019
- dir = dirname(dir);
4020
- }
4021
- for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
4022
- return "pnpm";
4023
- }
4024
- /**
4025
- * Find the lockfile path by walking up the directory tree
4026
- * Returns the full path to the lockfile, or null if not found
4027
- */
4028
- function findLockfilePath(cwd = process.cwd()) {
4029
- let dir = cwd;
4030
- const root = parse(dir).root;
4031
- while (dir !== root) {
4032
- for (const [lockfile] of LOCKFILES) {
4033
- const lockfilePath = join(dir, lockfile);
4034
- if (existsSync(lockfilePath)) return lockfilePath;
4035
- }
4036
- dir = dirname(dir);
4037
- }
4038
- for (const [lockfile] of LOCKFILES) {
4039
- const lockfilePath = join(root, lockfile);
4040
- if (existsSync(lockfilePath)) return lockfilePath;
4041
- }
4042
- return null;
4043
- }
4044
- /**
4045
- * Check if we're in a monorepo (lockfile is in a parent directory)
4046
- */
4047
- function isMonorepo(cwd = process.cwd()) {
4048
- const lockfilePath = findLockfilePath(cwd);
4049
- if (!lockfilePath) return false;
4050
- return dirname(lockfilePath) !== cwd;
4051
- }
4052
- /**
4053
- * Check if turbo.json exists (walks up directory tree)
4054
- */
4055
- function hasTurboConfig(cwd = process.cwd()) {
4056
- let dir = cwd;
4057
- const root = parse(dir).root;
4058
- while (dir !== root) {
4059
- if (existsSync(join(dir, "turbo.json"))) return true;
4060
- dir = dirname(dir);
4061
- }
4062
- return existsSync(join(root, "turbo.json"));
4063
- }
4064
- /**
4065
- * Get install command for turbo builds (without frozen lockfile)
4066
- * Turbo prune creates a subset that may not perfectly match the lockfile
4067
- */
4068
- function getTurboInstallCmd(pm) {
4069
- return {
4070
- pnpm: "pnpm install",
4071
- npm: "npm install",
4072
- yarn: "yarn install",
4073
- bun: "bun install"
4074
- }[pm];
4075
- }
4076
- /**
4077
- * Get package manager specific commands and paths
4078
- */
4079
- function getPmConfig(pm) {
4080
- return {
4081
- pnpm: {
4082
- install: "corepack enable && corepack prepare pnpm@latest --activate",
4083
- lockfile: "pnpm-lock.yaml",
4084
- fetch: "pnpm fetch",
4085
- installCmd: "pnpm install --frozen-lockfile --offline",
4086
- cacheTarget: "/root/.local/share/pnpm/store",
4087
- cacheId: "pnpm",
4088
- run: "pnpm",
4089
- exec: "pnpm exec",
4090
- dlx: "pnpm dlx",
4091
- addGlobal: "pnpm add -g"
4092
- },
4093
- npm: {
4094
- install: "",
4095
- lockfile: "package-lock.json",
4096
- fetch: "",
4097
- installCmd: "npm ci",
4098
- cacheTarget: "/root/.npm",
4099
- cacheId: "npm",
4100
- run: "npm run",
4101
- exec: "npx",
4102
- dlx: "npx",
4103
- addGlobal: "npm install -g"
4104
- },
4105
- yarn: {
4106
- install: "corepack enable && corepack prepare yarn@stable --activate",
4107
- lockfile: "yarn.lock",
4108
- fetch: "",
4109
- installCmd: "yarn install --frozen-lockfile",
4110
- cacheTarget: "/root/.yarn/cache",
4111
- cacheId: "yarn",
4112
- run: "yarn",
4113
- exec: "yarn exec",
4114
- dlx: "yarn dlx",
4115
- addGlobal: "yarn global add"
4116
- },
4117
- bun: {
4118
- install: "npm install -g bun",
4119
- lockfile: "bun.lockb",
4120
- fetch: "",
4121
- installCmd: "bun install --frozen-lockfile",
4122
- cacheTarget: "/root/.bun/install/cache",
4123
- cacheId: "bun",
4124
- run: "bun run",
4125
- exec: "bunx",
4126
- dlx: "bunx",
4127
- addGlobal: "bun add -g"
4128
- }
4129
- }[pm];
4130
- }
4131
- /**
4132
- * Generate a multi-stage Dockerfile for building from source
4133
- * Optimized for build speed with:
4134
- * - BuildKit cache mounts for package manager store
4135
- * - pnpm fetch for better layer caching (when using pnpm)
4136
- * - Optional turbo prune for monorepos
4137
- */
4138
- function generateMultiStageDockerfile(options) {
4139
- const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
4140
- if (turbo) return generateTurboDockerfile({
4141
- ...options,
4142
- turboPackage: turboPackage ?? "api"
4143
- });
4312
+ function generateBackendDockerfile(options) {
4313
+ const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
4144
4314
  const pm = getPmConfig(packageManager);
4315
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
4316
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
4145
4317
  return `# syntax=docker/dockerfile:1
4146
- # Stage 1: Dependencies
4147
- FROM ${baseImage} AS deps
4318
+ # Backend Dockerfile with turbo prune optimization
4319
+
4320
+ # Stage 1: Prune monorepo
4321
+ FROM ${baseImage} AS pruner
4148
4322
 
4149
4323
  WORKDIR /app
4150
- ${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
4151
- ${packageManager === "pnpm" ? `# Copy lockfile first for better caching
4152
- COPY ${pm.lockfile} ./
4153
4324
 
4154
- # Fetch dependencies (downloads to virtual store, cached separately)
4155
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4156
- ${pm.fetch}
4325
+ ${installPm}
4157
4326
 
4158
- # Copy package.json after fetch
4159
- COPY package.json ./
4327
+ COPY . .
4160
4328
 
4161
- # Install from cache (fast - no network needed)
4162
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4163
- ${pm.installCmd}` : `# Copy package files
4164
- COPY package.json ${pm.lockfile} ./
4329
+ # Prune to only include necessary packages
4330
+ RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4165
4331
 
4166
- # Install dependencies with cache
4332
+ # Stage 2: Install dependencies
4333
+ FROM ${baseImage} AS deps
4334
+
4335
+ WORKDIR /app
4336
+
4337
+ ${installPm}
4338
+
4339
+ # Copy pruned lockfile and package.jsons
4340
+ COPY --from=pruner /app/out/${pm.lockfile} ./
4341
+ COPY --from=pruner /app/out/json/ ./
4342
+
4343
+ # Install dependencies
4167
4344
  RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4168
- ${pm.installCmd}`}
4345
+ ${turboInstallCmd}
4169
4346
 
4170
- # Stage 2: Build
4347
+ # Stage 3: Build
4171
4348
  FROM deps AS builder
4172
4349
 
4173
4350
  WORKDIR /app
4174
4351
 
4175
- # Copy source (deps already installed)
4176
- COPY . .
4352
+ # Copy pruned source
4353
+ COPY --from=pruner /app/out/full/ ./
4354
+
4355
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4356
+ # Using wildcard to make it optional for single-app projects
4357
+ COPY --from=pruner /app/gkm.config.* ./
4358
+ COPY --from=pruner /app/tsconfig.* ./
4359
+
4360
+ # Write encrypted credentials for gkm build to embed.
4361
+ # A build secret (\`docker build --secret id=gkm_credentials,src=…\`), not an
4362
+ # ARG: an ARG is recorded in the image history. Two lines, ciphertext then IV;
4363
+ # absent when the app has no secrets.
4364
+ RUN --mount=type=secret,id=gkm_credentials,required=false \
4365
+ if [ -s /run/secrets/gkm_credentials ]; then \
4366
+ mkdir -p ${appPath}/.gkm && \
4367
+ sed -n 1p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.enc && \
4368
+ sed -n 2p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.iv; \
4369
+ fi
4177
4370
 
4178
4371
  # Build any workspace packages the app depends on.
4179
4372
  #
@@ -4184,51 +4377,49 @@ COPY . .
4184
4377
  RUN ${pm.run} --if-present build
4185
4378
 
4186
4379
  # Build production server using gkm
4187
- RUN ${pm.exec} gkm build --provider server --production
4380
+ RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
4188
4381
 
4189
- # Stage 3: Production
4382
+ # Stage 4: Production
4190
4383
  FROM ${baseImage} AS runner
4191
4384
 
4192
4385
  WORKDIR /app
4193
4386
 
4194
- # Install tini for proper signal handling as PID 1
4195
4387
  RUN apk add --no-cache tini
4196
4388
 
4197
- # Create non-root user
4198
4389
  RUN addgroup --system --gid 1001 nodejs && \\
4199
4390
  adduser --system --uid 1001 hono
4200
4391
 
4201
4392
  # Copy bundled server
4202
- COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
4393
+ COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
4203
4394
 
4204
- # Environment
4205
4395
  ENV NODE_ENV=production
4206
4396
  ENV PORT=${port}
4207
4397
 
4208
- # Health check
4209
4398
  HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4210
4399
  CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4211
4400
 
4212
- # Switch to non-root user
4213
4401
  USER hono
4214
4402
 
4215
4403
  EXPOSE ${port}
4216
4404
 
4217
- # Use tini as entrypoint to handle PID 1 responsibilities
4218
4405
  ENTRYPOINT ["/sbin/tini", "--"]
4219
4406
  CMD ["node", "server.mjs"]
4220
4407
  `;
4221
4408
  }
4222
4409
  /**
4223
- * Generate a Dockerfile optimized for Turbo monorepos
4224
- * Uses turbo prune to create minimal Docker context
4410
+ * Generate a Dockerfile for apps with a custom entry point.
4411
+ * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
4412
+ * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
4413
+ * @internal Exported for testing
4225
4414
  */
4226
- function generateTurboDockerfile(options) {
4227
- const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
4415
+ function generateEntryDockerfile(options) {
4416
+ const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
4228
4417
  const pm = getPmConfig(packageManager);
4229
4418
  const installPm = pm.install ? `RUN ${pm.install}` : "";
4230
4419
  const turboInstallCmd = getTurboInstallCmd(packageManager);
4231
4420
  return `# syntax=docker/dockerfile:1
4421
+ # Entry-based Dockerfile with turbo prune + tsdown bundling
4422
+
4232
4423
  # Stage 1: Prune monorepo
4233
4424
  FROM ${baseImage} AS pruner
4234
4425
 
@@ -4252,11 +4443,11 @@ ${installPm}
4252
4443
  COPY --from=pruner /app/out/${pm.lockfile} ./
4253
4444
  COPY --from=pruner /app/out/json/ ./
4254
4445
 
4255
- # Install dependencies (no frozen-lockfile since turbo prune creates a subset)
4446
+ # Install dependencies
4256
4447
  RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4257
4448
  ${turboInstallCmd}
4258
4449
 
4259
- # Stage 3: Build
4450
+ # Stage 3: Build with tsdown
4260
4451
  FROM deps AS builder
4261
4452
 
4262
4453
  WORKDIR /app
@@ -4264,501 +4455,149 @@ WORKDIR /app
4264
4455
  # Copy pruned source
4265
4456
  COPY --from=pruner /app/out/full/ ./
4266
4457
 
4267
- # Build any workspace packages the app depends on.
4268
- #
4269
- # A no-op for a project whose dependencies come from a registry, and required
4270
- # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
4271
- # workspace package's \`dist\` never reaches the image and its bin has nothing to
4272
- # import. \`--if-present\` is what makes the same Dockerfile correct for both.
4273
- RUN ${pm.run} --if-present build
4274
-
4275
- # Build production server using gkm
4276
- RUN ${pm.exec} gkm build --provider server --production
4458
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4459
+ # Using wildcard to make it optional for single-app projects
4460
+ COPY --from=pruner /app/tsconfig.* ./
4277
4461
 
4278
- # Stage 4: Production
4279
- FROM ${baseImage} AS runner
4280
-
4281
- WORKDIR /app
4282
-
4283
- RUN apk add --no-cache tini
4284
-
4285
- RUN addgroup --system --gid 1001 nodejs && \\
4286
- adduser --system --uid 1001 hono
4287
-
4288
- COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
4289
-
4290
- ENV NODE_ENV=production
4291
- ENV PORT=${port}
4292
-
4293
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4294
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4295
-
4296
- USER hono
4297
-
4298
- EXPOSE ${port}
4462
+ # Bundle entry point with esbuild (outputs to dist/index.mjs)
4463
+ # Creates a fully standalone bundle with all dependencies included
4464
+ # Use define to embed credentials if present.
4465
+ # They arrive as a build secret (\`docker build --secret id=gkm_credentials,src=…\`),
4466
+ # not an ARG: an ARG is recorded in the image history. Two lines, ciphertext
4467
+ # then IV; absent when the app has no secrets.
4468
+ RUN --mount=type=secret,id=gkm_credentials,required=false \
4469
+ cd ${appPath} && \
4470
+ if [ -s /run/secrets/gkm_credentials ]; then \
4471
+ CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \
4472
+ IV=$(sed -n 2p /run/secrets/gkm_credentials) && \
4473
+ npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
4474
+ --outfile=dist/index.mjs --packages=bundle \
4475
+ --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
4476
+ --define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
4477
+ --define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
4478
+ else \
4479
+ npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
4480
+ --outfile=dist/index.mjs --packages=bundle \
4481
+ --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
4482
+ fi
4299
4483
 
4300
- ENTRYPOINT ["/sbin/tini", "--"]
4301
- CMD ["node", "server.mjs"]
4302
- `;
4303
- }
4304
- /**
4305
- * Generate a slim Dockerfile for pre-built bundles
4306
- */
4307
- function generateSlimDockerfile(options) {
4308
- const { baseImage, port, healthCheckPath, bundle = ".gkm/server/dist/server.mjs" } = options;
4309
- return `# Slim Dockerfile for pre-built production bundle
4310
- FROM ${baseImage}
4484
+ # Stage 4: Production
4485
+ FROM ${baseImage} AS runner
4311
4486
 
4312
4487
  WORKDIR /app
4313
4488
 
4314
- # Install tini for proper signal handling as PID 1
4315
- # Handles SIGTERM propagation and zombie process reaping
4316
4489
  RUN apk add --no-cache tini
4317
4490
 
4318
- # Create non-root user
4319
4491
  RUN addgroup --system --gid 1001 nodejs && \\
4320
- adduser --system --uid 1001 hono
4492
+ adduser --system --uid 1001 app
4321
4493
 
4322
- # Copy pre-built bundle
4323
- COPY ${bundle} ./
4494
+ # Copy bundled output only (no node_modules needed - fully bundled)
4495
+ COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
4324
4496
 
4325
- # Environment
4326
4497
  ENV NODE_ENV=production
4327
4498
  ENV PORT=${port}
4328
4499
 
4329
- # Health check
4330
4500
  HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4331
4501
  CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4332
4502
 
4333
- # Switch to non-root user
4334
- USER hono
4503
+ USER app
4335
4504
 
4336
4505
  EXPOSE ${port}
4337
4506
 
4338
- # Use tini as entrypoint to handle PID 1 responsibilities
4339
4507
  ENTRYPOINT ["/sbin/tini", "--"]
4340
- CMD ["node", "server.mjs"]
4341
- `;
4342
- }
4343
- /**
4344
- * Generate .dockerignore file
4345
- */
4346
- function generateDockerignore() {
4347
- return `# Dependencies
4348
- node_modules
4349
- .pnpm-store
4350
-
4351
- # Build output (except what we need)
4352
- .gkm/aws*
4353
- .gkm/server/*.ts
4354
- !.gkm/server/dist
4355
-
4356
- # The key gkm build --stage encrypted secrets with: runtime env only, never
4357
- # the build context
4358
- .gkm/server/master.key
4359
-
4360
- # gkm compose's stacks: each app's env file holds its stage's secrets
4361
- .gkm/compose
4362
-
4363
- # IDE and editor
4364
- .idea
4365
- .vscode
4366
- *.swp
4367
- *.swo
4368
-
4369
- # Git
4370
- .git
4371
- .gitignore
4372
-
4373
- # Logs
4374
- *.log
4375
- npm-debug.log*
4376
- pnpm-debug.log*
4377
-
4378
- # Test files
4379
- **/*.test.ts
4380
- **/*.spec.ts
4381
- **/__tests__
4382
- coverage
4383
-
4384
- # Documentation
4385
- docs
4386
- *.md
4387
- !README.md
4388
-
4389
- # Environment files (handle secrets separately)
4390
- .env
4391
- .env.*
4392
- !.env.example
4393
-
4394
- # Docker files (don't copy recursively)
4395
- Dockerfile*
4396
- docker-compose*
4397
- .dockerignore
4398
- `;
4399
- }
4400
- /**
4401
- * Generate docker-entrypoint.sh for custom startup logic
4402
- */
4403
- function generateDockerEntrypoint() {
4404
- return `#!/bin/sh
4405
- set -e
4406
-
4407
- # Run any custom startup scripts here
4408
- # Example: wait for database
4409
- # until nc -z $DB_HOST $DB_PORT; do
4410
- # echo "Waiting for database..."
4411
- # sleep 1
4412
- # done
4413
-
4414
- # Execute the main command
4415
- exec "$@"
4508
+ CMD ["node", "index.mjs"]
4416
4509
  `;
4417
4510
  }
4418
4511
  /**
4419
- * Resolve Docker configuration from GkmConfig with defaults
4420
- */
4421
- function resolveDockerConfig(config) {
4422
- const docker = config.docker ?? {};
4423
- return {
4424
- registry: docker.registry ?? "",
4425
- imageName: docker.imageName ?? config.name ?? "api",
4426
- baseImage: docker.baseImage ?? "node:22-alpine",
4427
- port: docker.port ?? 3e3
4428
- };
4429
- }
4430
- /**
4431
- * Generate a Dockerfile for Next.js frontend apps using standalone output.
4432
- * Uses turbo prune for monorepo optimization.
4512
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
4513
+ * Remix). Builds with turbo, then runs the framework's production start
4514
+ * script via `npm start`.
4515
+ *
4516
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
4517
+ * can inline them at build time. Per-framework prefix is the caller's job.
4433
4518
  * @internal Exported for testing
4434
4519
  */
4435
- function generateNextjsDockerfile(options) {
4436
- const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
4520
+ function generateNodeWebDockerfile(options) {
4521
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4437
4522
  const pm = getPmConfig(packageManager);
4438
4523
  const installPm = pm.install ? `RUN ${pm.install}` : "";
4439
4524
  const turboInstallCmd = getTurboInstallCmd(packageManager);
4440
4525
  const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4441
- const publicUrlArgDeclarations = publicUrlArgs.map((arg) => `ARG ${arg}=""`).join("\n");
4442
- const publicUrlEnvDeclarations = publicUrlArgs.map((arg) => `ENV ${arg}=$${arg}`).join("\n");
4526
+ const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
4527
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4528
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4443
4529
  return `# syntax=docker/dockerfile:1
4444
- # Next.js standalone Dockerfile with turbo prune optimization
4530
+ # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
4445
4531
 
4446
- # Stage 1: Prune monorepo
4447
4532
  FROM ${baseImage} AS pruner
4448
-
4449
4533
  WORKDIR /app
4450
-
4451
4534
  ${installPm}
4452
-
4453
4535
  COPY . .
4454
-
4455
- # Prune to only include necessary packages
4456
4536
  RUN ${turboCmd} prune ${turboPackage} --docker
4457
4537
 
4458
- # Stage 2: Install dependencies
4459
4538
  FROM ${baseImage} AS deps
4460
-
4461
4539
  WORKDIR /app
4462
-
4463
4540
  ${installPm}
4464
-
4465
- # Copy pruned lockfile and package.jsons
4466
4541
  COPY --from=pruner /app/out/${pm.lockfile} ./
4467
4542
  COPY --from=pruner /app/out/json/ ./
4468
-
4469
- # Install dependencies
4470
4543
  RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4471
4544
  ${turboInstallCmd}
4472
4545
 
4473
- # Stage 3: Build
4474
4546
  FROM deps AS builder
4475
-
4476
4547
  WORKDIR /app
4477
-
4478
- # Build-time args for public API URLs (populated by gkm deploy)
4479
- # These get baked into the Next.js build as public environment variables
4480
- ${publicUrlArgDeclarations}
4481
-
4482
- # Convert ARGs to ENVs for Next.js build
4483
- ${publicUrlEnvDeclarations}
4484
-
4485
- # Copy pruned source
4548
+ ${argDecls}
4549
+ ${envDecls}
4486
4550
  COPY --from=pruner /app/out/full/ ./
4487
-
4488
- # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4489
- # Using wildcard to make it optional for single-app projects
4490
4551
  COPY --from=pruner /app/tsconfig.* ./
4491
-
4492
- # Ensure public directory exists (may be empty for scaffolded projects)
4493
- RUN mkdir -p ${appPath}/public
4494
-
4495
- # Set Next.js to produce standalone output
4496
- ENV NEXT_TELEMETRY_DISABLED=1
4497
-
4498
- # Build the application
4499
4552
  RUN ${turboCmd} run build --filter=${turboPackage}
4500
4553
 
4501
- # Stage 4: Production
4502
4554
  FROM ${baseImage} AS runner
4503
-
4504
4555
  WORKDIR /app
4505
-
4506
- # Install tini for proper signal handling
4507
4556
  RUN apk add --no-cache tini
4508
-
4509
- # Create non-root user
4510
4557
  RUN addgroup --system --gid 1001 nodejs && \\
4511
- adduser --system --uid 1001 nextjs
4558
+ adduser --system --uid 1001 app
4559
+
4560
+ # Copy the whole built workspace — frameworks differ on output paths
4561
+ COPY --from=builder --chown=app:nodejs /app/ ./
4512
4562
 
4513
- # Set environment
4514
4563
  ENV NODE_ENV=production
4515
- ENV NEXT_TELEMETRY_DISABLED=1
4516
4564
  ENV PORT=${port}
4517
4565
  ENV HOSTNAME="0.0.0.0"
4518
4566
 
4519
- # Copy static files and standalone output
4520
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
4521
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
4522
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
4523
-
4524
- USER nextjs
4525
-
4567
+ USER app
4526
4568
  EXPOSE ${port}
4527
4569
 
4570
+ # A site answers at its root; Docker, and Dokploy, read the container's
4571
+ # health from it rather than from the process merely running. 127.0.0.1, not
4572
+ # localhost: the server listens on IPv4, and localhost can resolve to ::1.
4573
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4574
+ CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
4575
+
4528
4576
  ENTRYPOINT ["/sbin/tini", "--"]
4529
- CMD ["node", "${appPath}/server.js"]
4577
+ CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
4530
4578
  `;
4531
4579
  }
4532
4580
  /**
4533
- * Generate a Dockerfile for backend apps in a workspace.
4534
- * Uses turbo prune for monorepo optimization.
4581
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
4582
+ * turbo, then serves it via nginx.
4535
4583
  * @internal Exported for testing
4536
4584
  */
4537
- function generateBackendDockerfile(options) {
4538
- const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
4585
+ function generateViteStaticDockerfile(options) {
4586
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4539
4587
  const pm = getPmConfig(packageManager);
4540
4588
  const installPm = pm.install ? `RUN ${pm.install}` : "";
4541
4589
  const turboInstallCmd = getTurboInstallCmd(packageManager);
4590
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4591
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4592
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4542
4593
  return `# syntax=docker/dockerfile:1
4543
- # Backend Dockerfile with turbo prune optimization
4594
+ # Vite SPA Dockerfile — builds static assets, serves via nginx
4544
4595
 
4545
- # Stage 1: Prune monorepo
4546
4596
  FROM ${baseImage} AS pruner
4547
-
4548
4597
  WORKDIR /app
4549
-
4550
4598
  ${installPm}
4551
-
4552
4599
  COPY . .
4553
-
4554
- # Prune to only include necessary packages
4555
- RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4556
-
4557
- # Stage 2: Install dependencies
4558
- FROM ${baseImage} AS deps
4559
-
4560
- WORKDIR /app
4561
-
4562
- ${installPm}
4563
-
4564
- # Copy pruned lockfile and package.jsons
4565
- COPY --from=pruner /app/out/${pm.lockfile} ./
4566
- COPY --from=pruner /app/out/json/ ./
4567
-
4568
- # Install dependencies
4569
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4570
- ${turboInstallCmd}
4571
-
4572
- # Stage 3: Build
4573
- FROM deps AS builder
4574
-
4575
- WORKDIR /app
4576
-
4577
- # Copy pruned source
4578
- COPY --from=pruner /app/out/full/ ./
4579
-
4580
- # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4581
- # Using wildcard to make it optional for single-app projects
4582
- COPY --from=pruner /app/gkm.config.* ./
4583
- COPY --from=pruner /app/tsconfig.* ./
4584
-
4585
- # Write encrypted credentials for gkm build to embed.
4586
- # A build secret (\`docker build --secret id=gkm_credentials,src=…\`), not an
4587
- # ARG: an ARG is recorded in the image history. Two lines, ciphertext then IV;
4588
- # absent when the app has no secrets.
4589
- RUN --mount=type=secret,id=gkm_credentials,required=false \
4590
- if [ -s /run/secrets/gkm_credentials ]; then \
4591
- mkdir -p ${appPath}/.gkm && \
4592
- sed -n 1p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.enc && \
4593
- sed -n 2p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.iv; \
4594
- fi
4595
-
4596
- # Build any workspace packages the app depends on.
4597
- #
4598
- # A no-op for a project whose dependencies come from a registry, and required
4599
- # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
4600
- # workspace package's \`dist\` never reaches the image and its bin has nothing to
4601
- # import. \`--if-present\` is what makes the same Dockerfile correct for both.
4602
- RUN ${pm.run} --if-present build
4603
-
4604
- # Build production server using gkm
4605
- RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
4606
-
4607
- # Stage 4: Production
4608
- FROM ${baseImage} AS runner
4609
-
4610
- WORKDIR /app
4611
-
4612
- RUN apk add --no-cache tini
4613
-
4614
- RUN addgroup --system --gid 1001 nodejs && \\
4615
- adduser --system --uid 1001 hono
4616
-
4617
- # Copy bundled server
4618
- COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
4619
-
4620
- ENV NODE_ENV=production
4621
- ENV PORT=${port}
4622
-
4623
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4624
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4625
-
4626
- USER hono
4627
-
4628
- EXPOSE ${port}
4629
-
4630
- ENTRYPOINT ["/sbin/tini", "--"]
4631
- CMD ["node", "server.mjs"]
4632
- `;
4633
- }
4634
- /**
4635
- * Generate a Dockerfile for apps with a custom entry point.
4636
- * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
4637
- * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
4638
- * @internal Exported for testing
4639
- */
4640
- function generateEntryDockerfile(options) {
4641
- const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
4642
- const pm = getPmConfig(packageManager);
4643
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4644
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4645
- return `# syntax=docker/dockerfile:1
4646
- # Entry-based Dockerfile with turbo prune + tsdown bundling
4647
-
4648
- # Stage 1: Prune monorepo
4649
- FROM ${baseImage} AS pruner
4650
-
4651
- WORKDIR /app
4652
-
4653
- ${installPm}
4654
-
4655
- COPY . .
4656
-
4657
- # Prune to only include necessary packages
4658
- RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4659
-
4660
- # Stage 2: Install dependencies
4661
- FROM ${baseImage} AS deps
4662
-
4663
- WORKDIR /app
4664
-
4665
- ${installPm}
4666
-
4667
- # Copy pruned lockfile and package.jsons
4668
- COPY --from=pruner /app/out/${pm.lockfile} ./
4669
- COPY --from=pruner /app/out/json/ ./
4670
-
4671
- # Install dependencies
4672
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4673
- ${turboInstallCmd}
4674
-
4675
- # Stage 3: Build with tsdown
4676
- FROM deps AS builder
4677
-
4678
- WORKDIR /app
4679
-
4680
- # Copy pruned source
4681
- COPY --from=pruner /app/out/full/ ./
4682
-
4683
- # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4684
- # Using wildcard to make it optional for single-app projects
4685
- COPY --from=pruner /app/tsconfig.* ./
4686
-
4687
- # Bundle entry point with esbuild (outputs to dist/index.mjs)
4688
- # Creates a fully standalone bundle with all dependencies included
4689
- # Use define to embed credentials if present.
4690
- # They arrive as a build secret (\`docker build --secret id=gkm_credentials,src=…\`),
4691
- # not an ARG: an ARG is recorded in the image history. Two lines, ciphertext
4692
- # then IV; absent when the app has no secrets.
4693
- RUN --mount=type=secret,id=gkm_credentials,required=false \
4694
- cd ${appPath} && \
4695
- if [ -s /run/secrets/gkm_credentials ]; then \
4696
- CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \
4697
- IV=$(sed -n 2p /run/secrets/gkm_credentials) && \
4698
- npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
4699
- --outfile=dist/index.mjs --packages=bundle \
4700
- --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
4701
- --define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
4702
- --define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
4703
- else \
4704
- npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
4705
- --outfile=dist/index.mjs --packages=bundle \
4706
- --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
4707
- fi
4708
-
4709
- # Stage 4: Production
4710
- FROM ${baseImage} AS runner
4711
-
4712
- WORKDIR /app
4713
-
4714
- RUN apk add --no-cache tini
4715
-
4716
- RUN addgroup --system --gid 1001 nodejs && \\
4717
- adduser --system --uid 1001 app
4718
-
4719
- # Copy bundled output only (no node_modules needed - fully bundled)
4720
- COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
4721
-
4722
- ENV NODE_ENV=production
4723
- ENV PORT=${port}
4724
-
4725
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4726
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4727
-
4728
- USER app
4729
-
4730
- EXPOSE ${port}
4731
-
4732
- ENTRYPOINT ["/sbin/tini", "--"]
4733
- CMD ["node", "index.mjs"]
4734
- `;
4735
- }
4736
- /**
4737
- * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
4738
- * Remix). Builds with turbo, then runs the framework's production start
4739
- * script via `npm start`.
4740
- *
4741
- * Public URL build args are declared so framework bundlers (Vite, Vinxi)
4742
- * can inline them at build time. Per-framework prefix is the caller's job.
4743
- * @internal Exported for testing
4744
- */
4745
- function generateNodeWebDockerfile(options) {
4746
- const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4747
- const pm = getPmConfig(packageManager);
4748
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4749
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4750
- const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4751
- const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
4752
- const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4753
- const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4754
- return `# syntax=docker/dockerfile:1
4755
- # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
4756
-
4757
- FROM ${baseImage} AS pruner
4758
- WORKDIR /app
4759
- ${installPm}
4760
- COPY . .
4761
- RUN ${turboCmd} prune ${turboPackage} --docker
4600
+ RUN ${turboCmd} prune ${turboPackage} --docker
4762
4601
 
4763
4602
  FROM ${baseImage} AS deps
4764
4603
  WORKDIR /app
@@ -4776,70 +4615,17 @@ COPY --from=pruner /app/out/full/ ./
4776
4615
  COPY --from=pruner /app/tsconfig.* ./
4777
4616
  RUN ${turboCmd} run build --filter=${turboPackage}
4778
4617
 
4779
- FROM ${baseImage} AS runner
4780
- WORKDIR /app
4781
- RUN apk add --no-cache tini
4782
- RUN addgroup --system --gid 1001 nodejs && \\
4783
- adduser --system --uid 1001 app
4784
-
4785
- # Copy the whole built workspace — frameworks differ on output paths
4786
- COPY --from=builder --chown=app:nodejs /app/ ./
4787
-
4788
- ENV NODE_ENV=production
4789
- ENV PORT=${port}
4790
- ENV HOSTNAME="0.0.0.0"
4791
-
4792
- USER app
4618
+ FROM nginx:alpine AS runner
4619
+ COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
4620
+ RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
4621
+ > /etc/nginx/conf.d/default.conf
4793
4622
  EXPOSE ${port}
4794
-
4795
- ENTRYPOINT ["/sbin/tini", "--"]
4796
- CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
4797
- `;
4798
- }
4799
- /**
4800
- * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
4801
- * turbo, then serves it via nginx.
4802
- * @internal Exported for testing
4803
- */
4804
- function generateViteStaticDockerfile(options) {
4805
- const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4806
- const pm = getPmConfig(packageManager);
4807
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4808
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4809
- const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4810
- const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4811
- const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4812
- return `# syntax=docker/dockerfile:1
4813
- # Vite SPA Dockerfile — builds static assets, serves via nginx
4814
-
4815
- FROM ${baseImage} AS pruner
4816
- WORKDIR /app
4817
- ${installPm}
4818
- COPY . .
4819
- RUN ${turboCmd} prune ${turboPackage} --docker
4820
-
4821
- FROM ${baseImage} AS deps
4822
- WORKDIR /app
4823
- ${installPm}
4824
- COPY --from=pruner /app/out/${pm.lockfile} ./
4825
- COPY --from=pruner /app/out/json/ ./
4826
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4827
- ${turboInstallCmd}
4828
-
4829
- FROM deps AS builder
4830
- WORKDIR /app
4831
- ${argDecls}
4832
- ${envDecls}
4833
- COPY --from=pruner /app/out/full/ ./
4834
- COPY --from=pruner /app/tsconfig.* ./
4835
- RUN ${turboCmd} run build --filter=${turboPackage}
4836
-
4837
- FROM nginx:alpine AS runner
4838
- COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
4839
- RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
4840
- > /etc/nginx/conf.d/default.conf
4841
- EXPOSE ${port}
4842
- CMD ["nginx", "-g", "daemon off;"]
4623
+ # A site answers at its root; Docker, and Dokploy, read the container's
4624
+ # health from it rather than from the process merely running. 127.0.0.1, not
4625
+ # localhost: the server listens on IPv4, and localhost can resolve to ::1.
4626
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4627
+ CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
4628
+ CMD ["nginx", "-g", "daemon off;"]
4843
4629
  `;
4844
4630
  }
4845
4631
  //#endregion
@@ -4849,1403 +4635,616 @@ const logger = output;
4849
4635
  var DockerBuildFailed = class extends Error {
4850
4636
  imageRef;
4851
4637
  constructor(imageRef, cause) {
4852
- super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
4853
- this.imageRef = imageRef;
4854
- this.name = "DockerBuildFailed";
4855
- }
4856
- };
4857
- /** `docker push` failed — usually the registry login or its permissions. */
4858
- var DockerPushFailed = class extends Error {
4859
- imageRef;
4860
- constructor(imageRef, cause) {
4861
- super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
4862
- this.imageRef = imageRef;
4863
- this.name = "DockerPushFailed";
4864
- }
4865
- };
4866
- /** `gkm docker --push` was asked for with nowhere to push to. */
4867
- var PushNeedsRegistry = class extends Error {
4868
- imageName;
4869
- constructor(imageName) {
4870
- super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
4871
- this.imageName = imageName;
4872
- this.name = "PushNeedsRegistry";
4873
- }
4874
- };
4875
- /**
4876
- * Docker command implementation
4877
- * Generates Dockerfile, docker-compose.yml, and related files
4878
- *
4879
- * Default: Multi-stage Dockerfile that builds from source inside Docker
4880
- * --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
4881
- */
4882
- async function dockerCommand(options) {
4883
- const cwd = options.cwd ?? process.cwd();
4884
- const loadedConfig = await loadWorkspaceConfig(cwd);
4885
- if (loadedConfig.type === "workspace") {
4886
- logger.log("📦 Detected workspace configuration");
4887
- return workspaceDockerCommand(loadedConfig.workspace, loadedConfig.manifest);
4888
- }
4889
- const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
4890
- const healthCheckPath = "/health";
4891
- const useSlim = options.slim === true;
4892
- if (useSlim) {
4893
- const distDir = join(cwd, ".gkm", "server", "dist");
4894
- if (!existsSync(join(distDir, "server.mjs"))) throw new Error("Slim Dockerfile requires a pre-built bundle. Run `gkm build --provider server --production` first, or omit --slim to use multi-stage build.");
4895
- }
4896
- const dockerDir = join(cwd, ".gkm", "docker");
4897
- await mkdir(dockerDir, { recursive: true });
4898
- const packageManager = detectPackageManager(cwd);
4899
- const inMonorepo = isMonorepo(cwd);
4900
- const hasTurbo = hasTurboConfig(cwd);
4901
- let useTurbo = options.turbo ?? false;
4902
- if (inMonorepo && !useSlim) {
4903
- if (hasTurbo) {
4904
- useTurbo = true;
4905
- logger.log(" Detected monorepo with turbo.json - using turbo prune");
4906
- } else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
4907
- }
4908
- let turboPackage = options.turboPackage ?? dockerConfig.imageName;
4909
- if (useTurbo && !options.turboPackage) try {
4910
- const pkg = __require(`${cwd}/package.json`);
4911
- if (pkg.name) {
4912
- turboPackage = pkg.name;
4913
- logger.log(` Turbo package: ${turboPackage}`);
4914
- }
4915
- } catch {}
4916
- const templateOptions = {
4917
- imageName: dockerConfig.imageName,
4918
- baseImage: dockerConfig.baseImage,
4919
- port: dockerConfig.port,
4920
- healthCheckPath,
4921
- prebuilt: useSlim,
4922
- turbo: useTurbo,
4923
- turboPackage,
4924
- packageManager
4925
- };
4926
- const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
4927
- const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
4928
- const dockerfilePath = join(dockerDir, "Dockerfile");
4929
- await writeFile(dockerfilePath, dockerfile);
4930
- logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
4931
- const composePath = await writeConstructsCompose(loadedConfig.workspace);
4932
- const dockerignore = generateDockerignore();
4933
- const dockerignorePath = join(cwd, ".dockerignore");
4934
- await writeFile(dockerignorePath, dockerignore);
4935
- logger.log("Generated: .dockerignore (project root)");
4936
- const entrypoint = generateDockerEntrypoint();
4937
- const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
4938
- await writeFile(entrypointPath, entrypoint);
4939
- logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
4940
- const result = {
4941
- dockerfile: dockerfilePath,
4942
- dockerCompose: composePath,
4943
- dockerignore: dockerignorePath,
4944
- entrypoint: entrypointPath
4945
- };
4946
- if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
4947
- if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
4948
- return result;
4949
- }
4950
- /**
4951
- * Ensure lockfile exists in the build context
4952
- * For monorepos, copies from workspace root if needed
4953
- * Returns cleanup function if file was copied
4954
- */
4955
- function ensureLockfile(cwd) {
4956
- const lockfilePath = findLockfilePath(cwd);
4957
- if (!lockfilePath) {
4958
- logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
4959
- return null;
4960
- }
4961
- const lockfileName = basename(lockfilePath);
4962
- const localLockfile = join(cwd, lockfileName);
4963
- if (lockfilePath === localLockfile) return null;
4964
- logger.log(` Copying ${lockfileName} from monorepo root...`);
4965
- copyFileSync(lockfilePath, localLockfile);
4966
- return () => {
4967
- try {
4968
- unlinkSync(localLockfile);
4969
- } catch {}
4970
- };
4971
- }
4972
- /**
4973
- * Build Docker image
4974
- * Uses BuildKit for cache mount support
4975
- */
4976
- async function buildDockerImage(imageName, options, cwd) {
4977
- const tag = options.tag ?? "latest";
4978
- const registry = options.registry;
4979
- const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
4980
- logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
4981
- const cleanup = ensureLockfile(cwd);
4982
- try {
4983
- await run$1("docker", [
4984
- "build",
4985
- "--file=.gkm/docker/Dockerfile",
4986
- `--tag=${fullImageName}`,
4987
- "."
4988
- ], {
4989
- cwd,
4990
- env: {
4991
- ...process.env,
4992
- DOCKER_BUILDKIT: "1"
4993
- }
4994
- });
4995
- logger.log(`✅ Docker image built: ${fullImageName}`);
4996
- } catch (error) {
4997
- throw new DockerBuildFailed(fullImageName, error);
4998
- } finally {
4999
- cleanup?.();
5000
- }
5001
- }
5002
- /**
5003
- * Push Docker image to registry
5004
- */
5005
- async function pushDockerImage(imageName, options, cwd) {
5006
- const tag = options.tag ?? "latest";
5007
- const registry = options.registry;
5008
- if (!registry) throw new PushNeedsRegistry(imageName);
5009
- const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
5010
- logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
5011
- try {
5012
- await run$1("docker", ["push", fullImageName], { cwd });
5013
- logger.log(`✅ Docker image pushed: ${fullImageName}`);
5014
- } catch (error) {
5015
- throw new DockerPushFailed(fullImageName, error);
5016
- }
5017
- }
5018
- /**
5019
- * Get the package name from package.json in an app directory.
5020
- */
5021
- function getAppPackageName(appPath) {
5022
- try {
5023
- const pkgPath = join(appPath, "package.json");
5024
- if (!existsSync(pkgPath)) return;
5025
- const content = readFileSync(pkgPath, "utf-8");
5026
- return JSON.parse(content).name;
5027
- } catch {
5028
- return;
5029
- }
5030
- }
5031
- /**
5032
- * Generate Dockerfiles for all apps in a workspace.
5033
- * @internal Exported for testing
5034
- */
5035
- async function workspaceDockerCommand(workspace, manifest) {
5036
- const results = [];
5037
- const apps = Object.entries(workspace.apps);
5038
- logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
5039
- const dockerDir = join(workspace.root, ".gkm", "docker");
5040
- await mkdir(dockerDir, { recursive: true });
5041
- const packageManager = detectPackageManager(workspace.root);
5042
- logger.log(` Package manager: ${packageManager}`);
5043
- for (const [appName, app] of apps) {
5044
- if (app.type === "mobile") {
5045
- logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
5046
- continue;
5047
- }
5048
- const appPath = app.path;
5049
- const turboPackage = getAppPackageName(join(workspace.root, appPath)) ?? appName;
5050
- const imageName = appName;
5051
- const buildType = !!app.entry ? "entry" : app.type;
5052
- logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
5053
- let dockerfile;
5054
- if (app.type === "web") dockerfile = siteDockerfile(appName, app, {
5055
- turboPackage,
5056
- packageManager,
5057
- ...manifest ? { manifest } : {}
5058
- });
5059
- else if (app.entry) dockerfile = generateEntryDockerfile({
5060
- imageName,
5061
- baseImage: "node:22-alpine",
5062
- port: app.port,
5063
- appPath,
5064
- entry: app.entry,
5065
- turboPackage,
5066
- packageManager,
5067
- healthCheckPath: "/health"
5068
- });
5069
- else dockerfile = generateBackendDockerfile({
5070
- imageName,
5071
- baseImage: "node:22-alpine",
5072
- port: app.port,
5073
- appPath,
5074
- turboPackage,
5075
- packageManager,
5076
- healthCheckPath: "/health"
5077
- });
5078
- const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
5079
- await writeFile(dockerfilePath, dockerfile);
5080
- logger.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
5081
- results.push({
5082
- appName,
5083
- type: app.type,
5084
- dockerfile: dockerfilePath,
5085
- imageName
5086
- });
5087
- }
5088
- const dockerignore = generateDockerignore();
5089
- const dockerignorePath = join(workspace.root, ".dockerignore");
5090
- await writeFile(dockerignorePath, dockerignore);
5091
- logger.log(`\n Generated: .dockerignore (workspace root)`);
5092
- const composePath = await writeConstructsCompose(workspace);
5093
- logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
5094
- logger.log("\n📋 Build commands:");
5095
- for (const result of results) {
5096
- const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
5097
- logger.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
5098
- }
5099
- printRunInstructions(workspace);
5100
- return {
5101
- apps: results,
5102
- dockerCompose: composePath,
5103
- dockerignore: dockerignorePath
5104
- };
5105
- }
5106
- /**
5107
- * A site's Dockerfile, with an `ARG` for every key its bundler inlines.
5108
- *
5109
- * The keys come from the site's declaration where there is one — every
5110
- * public key its edges give it, `VITE_UPLOADS_SERVER_URL` included — and an
5111
- * `ARG` the Dockerfile does not declare is a build argument the build drops,
5112
- * leaving the bundle with an empty URL.
5113
- */
5114
- function siteDockerfile(appName, app, options) {
5115
- const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
5116
- const publicUrlArgs = declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app);
5117
- const webOpts = {
5118
- imageName: appName,
5119
- baseImage: "node:22-alpine",
5120
- port: app.port,
5121
- appPath: app.path,
5122
- turboPackage: options.turboPackage,
5123
- packageManager: options.packageManager,
5124
- publicUrlArgs
5125
- };
5126
- switch (app.framework) {
5127
- case "vite": return generateViteStaticDockerfile(webOpts);
5128
- case "tanstack-start":
5129
- case "remix": return generateNodeWebDockerfile(webOpts);
5130
- default: return generateNextjsDockerfile(webOpts);
5131
- }
5132
- }
5133
- /**
5134
- * The package name turbo prunes an app by: its package.json's, or its name.
5135
- */
5136
- function appPackageName(root, appName, path) {
5137
- return getAppPackageName(join(root, path)) ?? appName;
5138
- }
5139
- /**
5140
- * Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
5141
- * the apps beside the containers their constructs derive.
5142
- *
5143
- * Through reconcile rather than a second generator, so what `gkm docker`
5144
- * describes and what `gkm dev` runs cannot disagree. Nothing is started.
5145
- */
5146
- async function writeConstructsCompose(workspace) {
5147
- await reconcileWorkspace(workspace, {
5148
- stage: workspace.stages.local,
5149
- start: false
5150
- });
5151
- logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
5152
- return join(workspace.root, COMPOSE_PATH);
5153
- }
5154
- function printRunInstructions(workspace) {
5155
- const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
5156
- logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
5157
- logger.log(" gkm setup # starts the containers and creates what they hold");
5158
- logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
5159
- }
5160
- //#endregion
5161
- //#region src/deploy/dokploy-api.ts
5162
- var dokploy_api_exports = /* @__PURE__ */ __exportAll({
5163
- DEFAULT_DOKPLOY_TIMEOUT_MS: () => DEFAULT_DOKPLOY_TIMEOUT_MS,
5164
- DokployApi: () => DokployApi,
5165
- DokployApiError: () => DokployApiError,
5166
- DokployRequestTimedOut: () => DokployRequestTimedOut
5167
- });
5168
- /** Per request, so a stalled server fails the deploy, not the CI job. */
5169
- const DEFAULT_DOKPLOY_TIMEOUT_MS = 3e4;
5170
- /**
5171
- * Dokploy accepted the connection but did not answer in time.
5172
- *
5173
- * Not retried: a request that arrived and stalled may still be acted on, and
5174
- * sending it again could repeat a deploy or a create the server is already
5175
- * doing.
5176
- */
5177
- var DokployRequestTimedOut = class extends Error {
5178
- baseUrl;
5179
- endpoint;
5180
- timeoutMs;
5181
- constructor(baseUrl, endpoint, timeoutMs) {
5182
- super(`Dokploy at ${baseUrl} did not answer ${endpoint} within ${timeoutMs}ms. Check that the Dokploy server is healthy, or raise \`timeoutMs\` if this call is known to be slow.`);
5183
- this.baseUrl = baseUrl;
5184
- this.endpoint = endpoint;
5185
- this.timeoutMs = timeoutMs;
5186
- this.name = "DokployRequestTimedOut";
5187
- }
5188
- };
5189
- /**
5190
- * Waits `ms` between retries, or rejects with the signal's reason the moment
5191
- * it aborts — a caller who gave up should not sit through the backoff.
5192
- */
5193
- function pause(ms, signal) {
5194
- return new Promise((resolve, reject) => {
5195
- if (signal?.aborted) {
5196
- reject(signal.reason);
5197
- return;
5198
- }
5199
- const onAbort = () => {
5200
- clearTimeout(timer);
5201
- reject(signal?.reason);
5202
- };
5203
- const timer = setTimeout(() => {
5204
- signal?.removeEventListener("abort", onAbort);
5205
- resolve();
5206
- }, ms);
5207
- signal?.addEventListener("abort", onAbort, { once: true });
5208
- });
5209
- }
5210
- var DokployApiError = class extends Error {
5211
- status;
5212
- statusText;
5213
- issues;
5214
- constructor(message, status, statusText, issues) {
5215
- super(message);
5216
- this.status = status;
5217
- this.statusText = statusText;
5218
- this.issues = issues;
5219
- this.name = "DokployApiError";
5220
- }
5221
- };
5222
- /**
5223
- * Dokploy API client
5224
- */
5225
- var DokployApi = class {
5226
- baseUrl;
5227
- token;
5228
- timeoutMs;
5229
- signal;
5230
- constructor(options) {
5231
- this.baseUrl = options.baseUrl.replace(/\/$/, "");
5232
- this.token = options.token;
5233
- this.timeoutMs = options.timeoutMs ?? 3e4;
5234
- this.signal = options.signal;
5235
- }
5236
- /**
5237
- * Make a GET request to the Dokploy API
5238
- */
5239
- async get(endpoint) {
5240
- return this.request("GET", endpoint);
5241
- }
5242
- /**
5243
- * Make a POST request to the Dokploy API
5244
- */
5245
- async post(endpoint, body) {
5246
- return this.request("POST", endpoint, body);
5247
- }
5248
- /**
5249
- * Make a request to the Dokploy API
5250
- */
5251
- async request(method, endpoint, body) {
5252
- const url = `${this.baseUrl}/api/${endpoint}`;
5253
- let response;
5254
- let timeout;
5255
- let lastError;
5256
- for (let attempt = 1; attempt <= 3; attempt++) {
5257
- this.signal?.throwIfAborted();
5258
- timeout = AbortSignal.timeout(this.timeoutMs);
5259
- try {
5260
- response = await fetch(url, {
5261
- method,
5262
- headers: {
5263
- "Content-Type": "application/json",
5264
- "x-api-key": this.token
5265
- },
5266
- body: body ? JSON.stringify(body) : void 0,
5267
- signal: this.signal ? AbortSignal.any([this.signal, timeout]) : timeout
5268
- });
5269
- break;
5270
- } catch (error) {
5271
- const stopped = this.stopped(timeout, endpoint);
5272
- if (stopped) throw stopped;
5273
- lastError = error;
5274
- if (attempt === 3) break;
5275
- await pause(attempt * 750, this.signal);
5276
- }
5277
- }
5278
- if (!response || !timeout) {
5279
- const detail = lastError instanceof Error ? lastError.message : String(lastError);
5280
- throw new DokployApiError(`Could not reach Dokploy at ${this.baseUrl} (${endpoint}): ${detail}`, 0, "Network error");
5281
- }
5282
- try {
5283
- return await this.read(response);
5284
- } catch (error) {
5285
- throw this.stopped(timeout, endpoint) ?? error;
5286
- }
5287
- }
5288
- /**
5289
- * What a request cut short should reject with: the caller's own reason
5290
- * when they aborted, `DokployRequestTimedOut` when the deadline passed,
5291
- * nothing when it was neither.
5292
- */
5293
- stopped(timeout, endpoint) {
5294
- if (this.signal?.aborted) return this.signal.reason;
5295
- if (timeout.aborted) return new DokployRequestTimedOut(this.baseUrl, endpoint, this.timeoutMs);
5296
- }
5297
- /** Turns a response into its JSON body, or a `DokployApiError`. */
5298
- async read(response) {
5299
- if (!response.ok) {
5300
- let errorMessage = `Dokploy API error: ${response.status} ${response.statusText}`;
5301
- let issues;
5302
- try {
5303
- const errorBody = await response.json();
5304
- if (errorBody.message) errorMessage = `Dokploy API error: ${errorBody.message}`;
5305
- if (errorBody.issues?.length) {
5306
- issues = errorBody.issues;
5307
- errorMessage += `\n Issues: ${errorBody.issues.map((i) => i.message).join(", ")}`;
5308
- }
5309
- } catch {}
5310
- throw new DokployApiError(errorMessage, response.status, response.statusText, issues);
5311
- }
5312
- const text = await response.text();
5313
- if (!text || text.trim() === "") return;
5314
- return JSON.parse(text);
5315
- }
5316
- /**
5317
- * Every external port published on this server, across every project.
5318
- *
5319
- * Dokploy knows what it has bound; guessing does not. A port that looks free
5320
- * from here can be held by a service in a project this deploy has never
5321
- * heard of — which surfaces as `Port 5432 is already in use by container
5322
- * "…"`, naming something the caller cannot see.
5323
- *
5324
- * `project.all` is a summary, so each project is fetched: the per-service
5325
- * `externalPort` only appears on `project.one`.
5326
- */
5327
- async publishedPorts() {
5328
- const ports = /* @__PURE__ */ new Set();
5329
- const projects = await this.listProjects();
5330
- for (const project of projects) {
5331
- const details = await this.getProject(project.projectId);
5332
- for (const environment of details.environments ?? []) {
5333
- const resources = [
5334
- ...environment.postgres ?? [],
5335
- ...environment.redis ?? [],
5336
- ...environment.applications ?? []
5337
- ];
5338
- for (const resource of resources) if (typeof resource.externalPort === "number") ports.add(resource.externalPort);
5339
- }
5340
- }
5341
- return ports;
5342
- }
5343
- /**
5344
- * Validate the API token by making a test request
5345
- */
5346
- async validateToken() {
5347
- try {
5348
- await this.get("project.all");
5349
- return true;
5350
- } catch {
5351
- return false;
5352
- }
5353
- }
5354
- /**
5355
- * List all projects
5356
- */
5357
- async listProjects() {
5358
- return this.get("project.all");
5359
- }
5360
- /**
5361
- * Get a single project by ID
5362
- */
5363
- async getProject(projectId) {
5364
- return this.get(`project.one?projectId=${projectId}`);
5365
- }
5366
- /**
5367
- * Create a new project
5368
- */
5369
- async createProject(name, description) {
5370
- return this.post("project.create", {
5371
- name,
5372
- description: description ?? `Created by gkm CLI`
5373
- });
5374
- }
5375
- /**
5376
- * Change a project's name or description.
5377
- *
5378
- * The name is sent with the description because `project.update` validates
5379
- * the whole project on some Dokploy versions, and a missing name fails it.
5380
- */
5381
- async updateProject(projectId, updates) {
5382
- await this.post("project.update", {
5383
- projectId,
5384
- ...updates
5385
- });
5386
- }
5387
- /**
5388
- * Delete a project and all its resources
5389
- */
5390
- async deleteProject(projectId) {
5391
- await this.post("project.remove", { projectId });
5392
- }
5393
- /**
5394
- * Create an environment in a project
5395
- */
5396
- async createEnvironment(projectId, name, description) {
5397
- return this.post("environment.create", {
5398
- projectId,
5399
- name,
5400
- description: description ?? `${name} environment`
5401
- });
5402
- }
5403
- /**
5404
- * List all applications in a project
5405
- */
5406
- async listApplications(projectId) {
5407
- return this.listInProject(projectId, (env) => env.applications);
5408
- }
5409
- /**
5410
- * Everything of one kind in a project, across its environments.
5411
- *
5412
- * From `project.one`, because the per-kind `*.all?projectId=` endpoints
5413
- * return **404** on current Dokploy: resources live under environments now.
5414
- * Those calls used to be wrapped in `catch { return [] }`, which turned "I
5415
- * could not ask" into "there are none" — and for a find-or-create that is
5416
- * the worst available answer. Every deploy created a second application, a
5417
- * second Postgres and a second Redis, silently, for as long as the project
5418
- * existed.
5419
- *
5420
- * A failure here is not swallowed: creating a duplicate is worse than
5421
- * stopping.
5422
- */
5423
- async listInProject(projectId, pick) {
5424
- return ((await this.getProject(projectId)).environments ?? []).flatMap((environment) => (pick(environment) ?? []).map((resource) => ({
5425
- ...resource,
5426
- environmentId: environment.environmentId
5427
- })));
5428
- }
5429
- /**
5430
- * Find an application by name in a project
5431
- */
5432
- async findApplicationByName(projectId, name, environmentId) {
5433
- const applications = await this.listApplications(projectId);
5434
- const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
5435
- return applications.find((app) => (!environmentId || app.environmentId === environmentId) && (app.name === name || app.appName === normalizedName));
5436
- }
5437
- /**
5438
- * Create a new application
5439
- */
5440
- async createApplication(name, projectId, environmentId) {
5441
- return this.post("application.create", {
5442
- name,
5443
- projectId,
5444
- environmentId,
5445
- appName: name.toLowerCase().replace(/[^a-z0-9-]/g, "-")
5446
- });
5447
- }
5448
- /**
5449
- * Find or create an application by name
5450
- */
5451
- async findOrCreateApplication(name, projectId, environmentId) {
5452
- const existing = await this.findApplicationByName(projectId, name, environmentId);
5453
- if (existing) return {
5454
- application: existing,
5455
- created: false
5456
- };
5457
- return {
5458
- application: await this.createApplication(name, projectId, environmentId),
5459
- created: true
5460
- };
5461
- }
5462
- /**
5463
- * Get an application by ID
5464
- */
5465
- async getApplication(applicationId) {
5466
- try {
5467
- return await this.get(`application.one?applicationId=${applicationId}`);
5468
- } catch {
5469
- return null;
5470
- }
5471
- }
5472
- /**
5473
- * Update an application
5474
- */
5475
- async updateApplication(applicationId, updates) {
5476
- await this.post("application.update", {
5477
- applicationId,
5478
- ...updates
5479
- });
5480
- }
5481
- /**
5482
- * Save environment variables for an application
5483
- */
5484
- async saveApplicationEnv(applicationId, env, options) {
5485
- await this.post("application.saveEnvironment", {
5486
- applicationId,
5487
- env,
5488
- buildArgs: options?.buildArgs ?? null,
5489
- buildSecrets: options?.buildSecrets ?? null,
5490
- createEnvFile: false
5491
- });
5492
- }
5493
- /**
5494
- * Configure application to use Docker provider (pull from registry)
5495
- *
5496
- * For private registries, either:
5497
- * - Use `registryId` if the registry is configured in Dokploy
5498
- * - Or provide `username`, `password`, and `registryUrl` directly
5499
- */
5500
- async saveDockerProvider(applicationId, dockerImage, options) {
5501
- await this.post("application.saveDockerProvider", {
5502
- applicationId,
5503
- dockerImage,
5504
- ...options,
5505
- username: options?.username ?? null,
5506
- password: options?.password ?? null,
5507
- registryUrl: options?.registryUrl ?? null
5508
- });
5509
- }
5510
- /**
5511
- * Deploy an application
5512
- */
5513
- async deployApplication(applicationId) {
5514
- await this.post("application.deploy", { applicationId });
5515
- }
5516
- /**
5517
- * Delete an application
5518
- */
5519
- async deleteApplication(applicationId) {
5520
- await this.post("application.remove", { applicationId });
5521
- }
5522
- /**
5523
- * List all registries
5524
- */
5525
- async listRegistries() {
5526
- return this.get("registry.all");
5527
- }
5528
- /**
5529
- * Create a new registry
5530
- */
5531
- async createRegistry(registryName, registryUrl, username, password, options) {
5532
- return this.post("registry.create", {
5533
- registryName,
5534
- registryUrl,
5535
- username,
5536
- password,
5537
- imagePrefix: options?.imagePrefix
5538
- });
5539
- }
5540
- /**
5541
- * Get a registry by ID
5542
- */
5543
- async getRegistry(registryId) {
5544
- return this.get(`registry.one?registryId=${registryId}`);
5545
- }
5546
- /**
5547
- * Update a registry
5548
- */
5549
- async updateRegistry(registryId, updates) {
5550
- await this.post("registry.update", {
5551
- registryId,
5552
- ...updates
5553
- });
5554
- }
5555
- /**
5556
- * Delete a registry
5557
- */
5558
- async deleteRegistry(registryId) {
5559
- await this.post("registry.remove", { registryId });
5560
- }
5561
- /**
5562
- * List all Postgres databases in a project
5563
- */
5564
- async listPostgres(projectId) {
5565
- return this.listInProject(projectId, (env) => env.postgres);
5566
- }
5567
- /**
5568
- * Find a Postgres database by name in a project
5569
- */
5570
- async findPostgresByName(projectId, name) {
5571
- const databases = await this.listPostgres(projectId);
5572
- const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
5573
- const match = databases.find((db) => db.name === name || db.appName === normalizedName);
5574
- if (!match) return void 0;
5575
- return this.getPostgres(match.postgresId);
5576
- }
5577
- /**
5578
- * Create a new Postgres database
5579
- */
5580
- async createPostgres(name, projectId, environmentId, options) {
5581
- return this.post("postgres.create", {
5582
- name,
5583
- projectId,
5584
- environmentId,
5585
- appName: options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
5586
- databaseName: options?.databaseName,
5587
- databaseUser: options?.databaseUser ?? "postgres",
5588
- databasePassword: options?.databasePassword,
5589
- dockerImage: options?.dockerImage ?? "postgres:18",
5590
- description: options?.description ?? `Postgres database for ${name}`
5591
- });
5592
- }
5593
- /**
5594
- * Find or create a Postgres database by name
5595
- */
5596
- async findOrCreatePostgres(name, projectId, environmentId, options) {
5597
- const existing = await this.findPostgresByName(projectId, name);
5598
- if (existing) return {
5599
- postgres: existing,
5600
- created: false
5601
- };
5602
- return {
5603
- postgres: await this.createPostgres(name, projectId, environmentId, options),
5604
- created: true
5605
- };
5606
- }
5607
- /**
5608
- * Get a Postgres database by ID
5609
- */
5610
- async getPostgres(postgresId) {
5611
- return this.get(`postgres.one?postgresId=${postgresId}`);
5612
- }
5613
- /**
5614
- * Deploy a Postgres database
5615
- */
5616
- async deployPostgres(postgresId) {
5617
- await this.post("postgres.deploy", { postgresId });
5618
- }
5619
- /**
5620
- * Save environment variables for Postgres
5621
- */
5622
- async savePostgresEnv(postgresId, env) {
5623
- await this.post("postgres.saveEnvironment", {
5624
- postgresId,
5625
- env
5626
- });
5627
- }
5628
- /**
5629
- * Set external port for Postgres (for external access)
5630
- */
5631
- async savePostgresExternalPort(postgresId, externalPort) {
5632
- await this.post("postgres.saveExternalPort", {
5633
- postgresId,
5634
- externalPort
5635
- });
5636
- }
5637
- /**
5638
- * Update Postgres configuration
5639
- */
5640
- async updatePostgres(postgresId, updates) {
5641
- await this.post("postgres.update", {
5642
- postgresId,
5643
- ...updates
5644
- });
5645
- }
5646
- /**
5647
- * Delete a Postgres database
5648
- */
5649
- async deletePostgres(postgresId) {
5650
- await this.post("postgres.remove", { postgresId });
5651
- }
5652
- /**
5653
- * The compose stacks in a project.
5654
- *
5655
- * Dokploy has first-class primitives for Postgres and Redis and none for
5656
- * MinIO, so an object store is a compose stack — the one kind whose contents
5657
- * this target writes rather than configures.
5658
- */
5659
- async listCompose(projectId) {
5660
- return this.listInProject(projectId, (env) => env.compose);
5661
- }
5662
- /** A compose stack by name, hydrated — `project.one` returns summaries. */
5663
- async findComposeByName(projectId, name) {
5664
- const summary = (await this.listCompose(projectId)).find((compose) => compose.name === name);
5665
- return summary ? this.getCompose(summary.composeId) : void 0;
5666
- }
5667
- async getCompose(composeId) {
5668
- return this.get(`compose.one?composeId=${composeId}`);
5669
- }
5670
- /**
5671
- * A compose stack carrying the file this target wrote.
5672
- *
5673
- * Created empty and then updated, because `compose.create` takes no file:
5674
- * it defaults to `sourceType: 'github'` and expects a repository. `raw` is
5675
- * what makes the file ours.
5676
- */
5677
- async findOrCreateCompose(name, projectId, environmentId, composeFile) {
5678
- const existing = await this.findComposeByName(projectId, name);
5679
- const compose = existing ?? await this.post("compose.create", {
5680
- name,
5681
- environmentId,
5682
- composeType: "docker-compose"
5683
- });
5684
- await this.post("compose.update", {
5685
- composeId: compose.composeId,
5686
- sourceType: "raw",
5687
- composeFile
5688
- });
5689
- return {
5690
- compose,
5691
- created: !existing
5692
- };
5693
- }
5694
- async deployCompose(composeId) {
5695
- await this.post("compose.deploy", { composeId });
5696
- }
5697
- async deleteCompose(composeId) {
5698
- await this.post("compose.delete", {
5699
- composeId,
5700
- deleteVolumes: false
5701
- });
5702
- }
5703
- /**
5704
- * List all Redis instances in a project
5705
- */
5706
- async listRedis(projectId) {
5707
- return this.listInProject(projectId, (env) => env.redis);
5708
- }
5709
- /**
5710
- * Find a Redis instance by name in a project
5711
- */
5712
- async findRedisByName(projectId, name) {
5713
- const instances = await this.listRedis(projectId);
5714
- const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
5715
- const match = instances.find((redis) => redis.name === name || redis.appName === normalizedName);
5716
- return match ? this.getRedis(match.redisId) : void 0;
5717
- }
5718
- /**
5719
- * Create a new Redis instance
5720
- */
5721
- async createRedis(name, projectId, environmentId, options) {
5722
- return this.post("redis.create", {
5723
- name,
5724
- projectId,
5725
- environmentId,
5726
- appName: options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
5727
- databasePassword: options?.databasePassword,
5728
- dockerImage: options?.dockerImage ?? "redis:8",
5729
- description: options?.description ?? `Redis instance for ${name}`
5730
- });
5731
- }
5732
- /**
5733
- * Find or create a Redis instance by name
5734
- */
5735
- async findOrCreateRedis(name, projectId, environmentId, options) {
5736
- const existing = await this.findRedisByName(projectId, name);
5737
- if (existing) return {
5738
- redis: existing,
5739
- created: false
5740
- };
5741
- return {
5742
- redis: await this.createRedis(name, projectId, environmentId, options),
5743
- created: true
5744
- };
5745
- }
5746
- /**
5747
- * Get a Redis instance by ID
5748
- */
5749
- async getRedis(redisId) {
5750
- return this.get(`redis.one?redisId=${redisId}`);
5751
- }
5752
- /**
5753
- * Deploy a Redis instance
5754
- */
5755
- async deployRedis(redisId) {
5756
- await this.post("redis.deploy", { redisId });
5757
- }
5758
- /**
5759
- * Save environment variables for Redis
5760
- */
5761
- async saveRedisEnv(redisId, env) {
5762
- await this.post("redis.saveEnvironment", {
5763
- redisId,
5764
- env
5765
- });
5766
- }
5767
- /**
5768
- * Set external port for Redis (for external access)
5769
- */
5770
- async saveRedisExternalPort(redisId, externalPort) {
5771
- await this.post("redis.saveExternalPort", {
5772
- redisId,
5773
- externalPort
5774
- });
5775
- }
5776
- /**
5777
- * Update Redis configuration
5778
- */
5779
- async updateRedis(redisId, updates) {
5780
- await this.post("redis.update", {
5781
- redisId,
5782
- ...updates
5783
- });
5784
- }
5785
- /**
5786
- * Delete a Redis instance
5787
- */
5788
- async deleteRedis(redisId) {
5789
- await this.post("redis.remove", { redisId });
5790
- }
5791
- /**
5792
- * Create a new domain for an application
5793
- */
5794
- async createDomain(options) {
5795
- return this.post("domain.create", options);
5796
- }
5797
- /**
5798
- * Update an existing domain
5799
- */
5800
- async updateDomain(domainId, updates) {
5801
- await this.post("domain.update", {
5802
- domainId,
5803
- ...updates
5804
- });
5805
- }
5806
- /**
5807
- * Delete a domain
5808
- */
5809
- async deleteDomain(domainId) {
5810
- await this.post("domain.delete", { domainId });
5811
- }
5812
- /**
5813
- * Get a domain by ID
5814
- */
5815
- async getDomain(domainId) {
5816
- return this.get(`domain.one?domainId=${domainId}`);
5817
- }
5818
- /**
5819
- * Get all domains for an application
5820
- */
5821
- async getDomainsByApplicationId(applicationId) {
5822
- return this.get(`domain.byApplicationId?applicationId=${applicationId}`);
5823
- }
5824
- /**
5825
- * Validate a domain and trigger SSL certificate generation
5826
- *
5827
- * This should be called after DNS records are created and propagated.
5828
- * It triggers Let's Encrypt certificate generation for HTTPS domains.
5829
- *
5830
- * @param domain - The domain hostname to validate (e.g., 'api.example.com')
5831
- */
5832
- async validateDomain(domain) {
5833
- return this.post("domain.validateDomain", { domain });
4638
+ super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
4639
+ this.imageRef = imageRef;
4640
+ this.name = "DockerBuildFailed";
5834
4641
  }
5835
- /**
5836
- * Auto-generate a domain name for an application
5837
- */
5838
- async generateDomain(appName, serverId) {
5839
- return this.post("domain.generateDomain", {
5840
- appName,
5841
- serverId
5842
- });
4642
+ };
4643
+ /** `docker push` failed — usually the registry login or its permissions. */
4644
+ var DockerPushFailed = class extends Error {
4645
+ imageRef;
4646
+ constructor(imageRef, cause) {
4647
+ super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
4648
+ this.imageRef = imageRef;
4649
+ this.name = "DockerPushFailed";
5843
4650
  }
5844
- /**
5845
- * List all backup destinations
5846
- */
5847
- async listDestinations() {
5848
- return this.get("destination.all");
4651
+ };
4652
+ /** `gkm docker --push` was asked for with nowhere to push to. */
4653
+ var PushNeedsRegistry = class extends Error {
4654
+ imageName;
4655
+ constructor(imageName) {
4656
+ super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
4657
+ this.imageName = imageName;
4658
+ this.name = "PushNeedsRegistry";
5849
4659
  }
5850
- /**
5851
- * Get a destination by ID
5852
- */
5853
- async getDestination(destinationId) {
5854
- return this.get(`destination.one?destinationId=${destinationId}`);
4660
+ };
4661
+ /**
4662
+ * Docker command implementation
4663
+ * Generates Dockerfile, docker-compose.yml, and related files
4664
+ *
4665
+ * Default: Multi-stage Dockerfile that builds from source inside Docker
4666
+ * --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
4667
+ */
4668
+ async function dockerCommand(options) {
4669
+ const cwd = options.cwd ?? process.cwd();
4670
+ const loadedConfig = await loadWorkspaceConfig(cwd);
4671
+ if (loadedConfig.type === "workspace") {
4672
+ logger.log("📦 Detected workspace configuration");
4673
+ return workspaceDockerCommand(loadedConfig.workspace, loadedConfig.manifest);
5855
4674
  }
5856
- /**
5857
- * Create a new S3 backup destination
5858
- */
5859
- async createDestination(options) {
5860
- return this.post("destination.create", { ...options });
4675
+ const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
4676
+ const healthCheckPath = "/health";
4677
+ const useSlim = options.slim === true;
4678
+ if (useSlim) {
4679
+ const distDir = join(cwd, ".gkm", "server", "dist");
4680
+ if (!existsSync(join(distDir, "server.mjs"))) throw new Error("Slim Dockerfile requires a pre-built bundle. Run `gkm build --provider server --production` first, or omit --slim to use multi-stage build.");
5861
4681
  }
5862
- /**
5863
- * Find a destination by name
5864
- */
5865
- async findDestinationByName(name) {
5866
- return (await this.listDestinations()).find((d) => d.name === name);
4682
+ const dockerDir = join(cwd, ".gkm", "docker");
4683
+ await mkdir(dockerDir, { recursive: true });
4684
+ const packageManager = detectPackageManager(cwd);
4685
+ const inMonorepo = isMonorepo(cwd);
4686
+ const hasTurbo = hasTurboConfig(cwd);
4687
+ let useTurbo = options.turbo ?? false;
4688
+ if (inMonorepo && !useSlim) {
4689
+ if (hasTurbo) {
4690
+ useTurbo = true;
4691
+ logger.log(" Detected monorepo with turbo.json - using turbo prune");
4692
+ } else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
5867
4693
  }
5868
- /**
5869
- * Find or create a destination by name
5870
- */
5871
- async findOrCreateDestination(name, options) {
5872
- const existing = await this.findDestinationByName(name);
5873
- if (existing) return {
5874
- destination: existing,
5875
- created: false
5876
- };
5877
- return {
5878
- destination: await this.createDestination({
5879
- name,
5880
- ...options
5881
- }),
5882
- created: true
5883
- };
4694
+ let turboPackage = options.turboPackage ?? dockerConfig.imageName;
4695
+ if (useTurbo && !options.turboPackage) try {
4696
+ const pkg = __require(`${cwd}/package.json`);
4697
+ if (pkg.name) {
4698
+ turboPackage = pkg.name;
4699
+ logger.log(` Turbo package: ${turboPackage}`);
4700
+ }
4701
+ } catch {}
4702
+ const templateOptions = {
4703
+ imageName: dockerConfig.imageName,
4704
+ baseImage: dockerConfig.baseImage,
4705
+ port: dockerConfig.port,
4706
+ healthCheckPath,
4707
+ prebuilt: useSlim,
4708
+ turbo: useTurbo,
4709
+ turboPackage,
4710
+ packageManager
4711
+ };
4712
+ const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
4713
+ const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
4714
+ const dockerfilePath = join(dockerDir, "Dockerfile");
4715
+ await writeFile(dockerfilePath, dockerfile);
4716
+ logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
4717
+ const composePath = await writeConstructsCompose(loadedConfig.workspace);
4718
+ const dockerignore = generateDockerignore();
4719
+ const dockerignorePath = join(cwd, ".dockerignore");
4720
+ await writeFile(dockerignorePath, dockerignore);
4721
+ logger.log("Generated: .dockerignore (project root)");
4722
+ const entrypoint = generateDockerEntrypoint();
4723
+ const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
4724
+ await writeFile(entrypointPath, entrypoint);
4725
+ logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
4726
+ const result = {
4727
+ dockerfile: dockerfilePath,
4728
+ dockerCompose: composePath,
4729
+ dockerignore: dockerignorePath,
4730
+ entrypoint: entrypointPath
4731
+ };
4732
+ if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
4733
+ if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
4734
+ return result;
4735
+ }
4736
+ /**
4737
+ * Ensure lockfile exists in the build context
4738
+ * For monorepos, copies from workspace root if needed
4739
+ * Returns cleanup function if file was copied
4740
+ */
4741
+ function ensureLockfile(cwd) {
4742
+ const lockfilePath = findLockfilePath(cwd);
4743
+ if (!lockfilePath) {
4744
+ logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
4745
+ return null;
5884
4746
  }
5885
- /**
5886
- * Update a destination
5887
- */
5888
- async updateDestination(destinationId, updates) {
5889
- await this.post("destination.update", {
5890
- destinationId,
5891
- ...updates
4747
+ const lockfileName = basename(lockfilePath);
4748
+ const localLockfile = join(cwd, lockfileName);
4749
+ if (lockfilePath === localLockfile) return null;
4750
+ logger.log(` Copying ${lockfileName} from monorepo root...`);
4751
+ copyFileSync(lockfilePath, localLockfile);
4752
+ return () => {
4753
+ try {
4754
+ unlinkSync(localLockfile);
4755
+ } catch {}
4756
+ };
4757
+ }
4758
+ /**
4759
+ * Build Docker image
4760
+ * Uses BuildKit for cache mount support
4761
+ */
4762
+ async function buildDockerImage(imageName, options, cwd) {
4763
+ const tag = options.tag ?? "latest";
4764
+ const registry = options.registry;
4765
+ const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
4766
+ logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
4767
+ const cleanup = ensureLockfile(cwd);
4768
+ try {
4769
+ await run$1("docker", [
4770
+ "build",
4771
+ "--file=.gkm/docker/Dockerfile",
4772
+ `--tag=${fullImageName}`,
4773
+ "."
4774
+ ], {
4775
+ cwd,
4776
+ env: {
4777
+ ...process.env,
4778
+ DOCKER_BUILDKIT: "1"
4779
+ }
5892
4780
  });
4781
+ logger.log(`✅ Docker image built: ${fullImageName}`);
4782
+ } catch (error) {
4783
+ throw new DockerBuildFailed(fullImageName, error);
4784
+ } finally {
4785
+ cleanup?.();
5893
4786
  }
5894
- /**
5895
- * Delete a destination
5896
- */
5897
- async deleteDestination(destinationId) {
5898
- await this.post("destination.remove", { destinationId });
4787
+ }
4788
+ /**
4789
+ * Push Docker image to registry
4790
+ */
4791
+ async function pushDockerImage(imageName, options, cwd) {
4792
+ const tag = options.tag ?? "latest";
4793
+ const registry = options.registry;
4794
+ if (!registry) throw new PushNeedsRegistry(imageName);
4795
+ const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
4796
+ logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
4797
+ try {
4798
+ await run$1("docker", ["push", fullImageName], { cwd });
4799
+ logger.log(`✅ Docker image pushed: ${fullImageName}`);
4800
+ } catch (error) {
4801
+ throw new DockerPushFailed(fullImageName, error);
5899
4802
  }
5900
- /**
5901
- * Test connection to a destination
5902
- */
5903
- async testDestinationConnection(destinationId) {
5904
- return this.post("destination.testConnection", { destinationId });
4803
+ }
4804
+ /**
4805
+ * Get the package name from package.json in an app directory.
4806
+ */
4807
+ function getAppPackageName(appPath) {
4808
+ try {
4809
+ const pkgPath = join(appPath, "package.json");
4810
+ if (!existsSync(pkgPath)) return;
4811
+ const content = readFileSync(pkgPath, "utf-8");
4812
+ return JSON.parse(content).name;
4813
+ } catch {
4814
+ return;
5905
4815
  }
5906
- /**
5907
- * Create a backup schedule for postgres
5908
- */
5909
- async createPostgresBackup(options) {
5910
- return this.post("backup.create", {
5911
- ...options,
5912
- databaseType: "postgres"
4816
+ }
4817
+ /**
4818
+ * Generate Dockerfiles for all apps in a workspace.
4819
+ * @internal Exported for testing
4820
+ */
4821
+ async function workspaceDockerCommand(workspace, manifest) {
4822
+ const results = [];
4823
+ const apps = Object.entries(workspace.apps);
4824
+ logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
4825
+ const dockerDir = join(workspace.root, ".gkm", "docker");
4826
+ await mkdir(dockerDir, { recursive: true });
4827
+ const packageManager = detectPackageManager(workspace.root);
4828
+ logger.log(` Package manager: ${packageManager}`);
4829
+ for (const [appName, app] of apps) {
4830
+ if (app.type === "mobile") {
4831
+ logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
4832
+ continue;
4833
+ }
4834
+ const appPath = app.path;
4835
+ const turboPackage = getAppPackageName(join(workspace.root, appPath)) ?? appName;
4836
+ const imageName = appName;
4837
+ const buildType = !!app.entry ? "entry" : app.type;
4838
+ logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
4839
+ let dockerfile;
4840
+ if (app.type === "web") dockerfile = siteDockerfile(appName, app, {
4841
+ turboPackage,
4842
+ packageManager,
4843
+ ...manifest ? { manifest } : {}
5913
4844
  });
5914
- }
5915
- /**
5916
- * List backups for a postgres database
5917
- */
5918
- async listPostgresBackups(postgresId) {
5919
- return this.get(`backup.all?postgresId=${postgresId}&databaseType=postgres`);
5920
- }
5921
- /**
5922
- * Get a backup by ID
5923
- */
5924
- async getBackup(backupId) {
5925
- return this.get(`backup.one?backupId=${backupId}`);
5926
- }
5927
- /**
5928
- * Update a backup schedule
5929
- */
5930
- async updateBackup(backupId, updates) {
5931
- await this.post("backup.update", {
5932
- backupId,
5933
- ...updates
4845
+ else if (app.entry) dockerfile = generateEntryDockerfile({
4846
+ imageName,
4847
+ baseImage: "node:22-alpine",
4848
+ port: app.port,
4849
+ appPath,
4850
+ entry: app.entry,
4851
+ turboPackage,
4852
+ packageManager,
4853
+ healthCheckPath: "/health"
4854
+ });
4855
+ else dockerfile = generateBackendDockerfile({
4856
+ imageName,
4857
+ baseImage: "node:22-alpine",
4858
+ port: app.port,
4859
+ appPath,
4860
+ turboPackage,
4861
+ packageManager,
4862
+ healthCheckPath: "/health"
4863
+ });
4864
+ const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
4865
+ await writeFile(dockerfilePath, dockerfile);
4866
+ logger.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
4867
+ results.push({
4868
+ appName,
4869
+ type: app.type,
4870
+ dockerfile: dockerfilePath,
4871
+ imageName
5934
4872
  });
5935
4873
  }
5936
- /**
5937
- * Delete a backup schedule
5938
- */
5939
- async deleteBackup(backupId) {
5940
- await this.post("backup.remove", { backupId });
5941
- }
5942
- /**
5943
- * Trigger a manual backup run
5944
- */
5945
- async runBackupManually(backupId) {
5946
- await this.post("backup.manualBackup", { backupId });
4874
+ const dockerignore = generateDockerignore();
4875
+ const dockerignorePath = join(workspace.root, ".dockerignore");
4876
+ await writeFile(dockerignorePath, dockerignore);
4877
+ logger.log(`\n Generated: .dockerignore (workspace root)`);
4878
+ const composePath = await writeConstructsCompose(workspace);
4879
+ logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
4880
+ logger.log("\n📋 Build commands:");
4881
+ for (const result of results) {
4882
+ const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
4883
+ logger.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
5947
4884
  }
5948
- };
5949
- //#endregion
5950
- //#region src/deploy/sniffer.ts
5951
- const __filename = fileURLToPath(import.meta.url);
5952
- const __dirname = dirname(__filename);
4885
+ printRunInstructions(workspace);
4886
+ return {
4887
+ apps: results,
4888
+ dockerCompose: composePath,
4889
+ dockerignore: dockerignorePath
4890
+ };
4891
+ }
5953
4892
  /**
5954
- * Resolve the path to a sniffer helper file.
5955
- * Handles both dev (.ts with tsx) and production (.mjs from dist).
5956
- *
5957
- * In production: sniffer.ts is bundled into dist/index.mjs, but sniffer helper
5958
- * files are output to dist/deploy/ as standalone modules for subprocess loading.
4893
+ * A site's Dockerfile, with an `ARG` for every key its bundler inlines.
5959
4894
  *
5960
- * In development: All files are in src/deploy/ and loaded via tsx.
4895
+ * The keys come from the site's declaration where there is one — every
4896
+ * public key its edges give it, `VITE_UPLOADS_SERVER_URL` included — and an
4897
+ * `ARG` the Dockerfile does not declare is a build argument the build drops,
4898
+ * leaving the bundle with an empty URL.
5961
4899
  */
5962
- function resolveSnifferFile(baseName) {
5963
- const deployMjsPath = resolve(__dirname, "deploy", `${baseName}.mjs`);
5964
- if (existsSync(deployMjsPath)) return deployMjsPath;
5965
- const mjsPath = resolve(__dirname, `${baseName}.mjs`);
5966
- if (existsSync(mjsPath)) return mjsPath;
5967
- const tsPath = resolve(__dirname, `${baseName}.ts`);
5968
- if (existsSync(tsPath)) return tsPath;
5969
- return tsPath;
4900
+ function siteDockerfile(appName, app, options) {
4901
+ const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
4902
+ const publicUrlArgs = declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app);
4903
+ const webOpts = {
4904
+ imageName: appName,
4905
+ baseImage: "node:22-alpine",
4906
+ port: app.port,
4907
+ appPath: app.path,
4908
+ turboPackage: options.turboPackage,
4909
+ packageManager: options.packageManager,
4910
+ publicUrlArgs
4911
+ };
4912
+ switch (app.framework) {
4913
+ case "vite": return generateViteStaticDockerfile(webOpts);
4914
+ case "tanstack-start":
4915
+ case "remix": return generateNodeWebDockerfile(webOpts);
4916
+ default: return generateNextjsDockerfile(webOpts);
4917
+ }
5970
4918
  }
5971
4919
  /**
5972
- * Run a sniffer worker in the sandbox, with the sandbox's environment and
5973
- * nothing of the deploy's: the deploy's Dokploy token, registry login and AWS
5974
- * keys stay where they are. A worker that outlives the timeout is killed, and
5975
- * comes back as the error the caller reports.
4920
+ * The package name turbo prunes an app by: its package.json's, or its name.
5976
4921
  */
5977
- async function runSniffer(run, workspacePath, cwd, nodeArgs) {
5978
- const sandbox = run.sandbox ?? activeSandbox() ?? new LocalSandbox({ root: resolve(workspacePath) });
5979
- try {
5980
- const result = await sandbox.exec("node", nodeArgs, {
5981
- cwd,
5982
- env: { ...sandbox.env },
5983
- timeoutMs: run.timeoutMs ?? 3e4,
5984
- output: "capture"
5985
- });
5986
- return {
5987
- stdout: result.stdout,
5988
- stderr: result.stderr,
5989
- code: result.exitCode
5990
- };
5991
- } catch (error) {
5992
- return { error: error instanceof Error ? error : new Error(String(error)) };
5993
- }
5994
- }
5995
- /** The worker's JSON answer: the last object on stdout naming `envVars`. */
5996
- function snifferAnswer(stdout) {
5997
- try {
5998
- const jsonMatch = stdout.match(/\{[^{}]*"envVars"[^{}]*\}[^{]*$/);
5999
- return jsonMatch ? JSON.parse(jsonMatch[0]) : void 0;
6000
- } catch {
6001
- return;
6002
- }
4922
+ function appPackageName(root, appName, path) {
4923
+ return getAppPackageName(join(root, path)) ?? appName;
6003
4924
  }
6004
4925
  /**
6005
- * Get required environment variables for an app.
4926
+ * Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
4927
+ * the apps beside the containers their constructs derive.
6006
4928
  *
6007
- * Detection strategy (in order):
6008
- * 1. Frontend apps: Returns empty (no server secrets)
6009
- * 2. Apps with `requiredEnv`: Uses explicit list from config
6010
- * 3. Entry apps: Imports entry file in subprocess to capture config.parse() calls
6011
- * 4. Route-based apps: Loads route files and calls getEnvironment() on each construct
6012
- * 5. Apps with `envParser` (no routes): Runs SnifferEnvironmentParser to detect usage
6013
- * 6. Apps with neither: Returns empty
4929
+ * Through reconcile rather than a second generator, so what `gkm docker`
4930
+ * describes and what `gkm dev` runs cannot disagree. Nothing is started.
4931
+ */
4932
+ async function writeConstructsCompose(workspace) {
4933
+ await reconcileWorkspace(workspace, {
4934
+ stage: workspace.stages.local,
4935
+ start: false
4936
+ });
4937
+ logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
4938
+ return join(workspace.root, COMPOSE_PATH);
4939
+ }
4940
+ function printRunInstructions(workspace) {
4941
+ const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
4942
+ logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
4943
+ logger.log(" gkm setup # starts the containers and creates what they hold");
4944
+ logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
4945
+ }
4946
+ //#endregion
4947
+ //#region src/deploy/generated.ts
4948
+ /**
4949
+ * The values a deployed stage generates once and keeps: what nothing can
4950
+ * provision and nobody else issues.
6014
4951
  *
6015
- * This function handles "fire and forget" async operations gracefully,
6016
- * capturing errors and unhandled rejections without failing the build.
4952
+ * - **A seed**, which salts every password a deploy derives — a database's
4953
+ * master and roles, a bucket's root user. Derived from repo facts alone
4954
+ * (project, stage, construct id), those passwords were computable by anyone
4955
+ * who could read the repo.
4956
+ * - **Each `secret` construct's value** — an auth server's signing secret —
4957
+ * random, rather than derived.
4958
+ * - **Each `encryption` construct's keyring** — one random key and an index
4959
+ * key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
6017
4960
  *
6018
- * @param app - The normalized app configuration
6019
- * @param appName - The name of the app
6020
- * @param workspacePath - Absolute path to the workspace root
6021
- * @param options - Optional configuration for sniffing behavior
6022
- * @returns The sniffed environment with required variables
4961
+ * Both are generated on the first deploy that needs them and written to the
4962
+ * stage's store, so every later deploy reads the same values: sessions survive
4963
+ * a redeploy, and a role's password does not move. A value set by hand with
4964
+ * `gkm secrets:set` is kept, never replaced.
4965
+ */
4966
+ /**
4967
+ * The stage's secrets with its seed and every declared `secret`'s value
4968
+ * present, generating what is missing. Pure: the caller writes the result
4969
+ * back to the stage's store when `generated` is not empty.
6023
4970
  */
6024
- async function sniffAppEnvironment(app, appName, workspacePath, options = {}) {
6025
- const { logWarnings = true, markOptional = false } = options;
6026
- const run = {
6027
- ...options.sandbox ? { sandbox: options.sandbox } : {},
6028
- ...options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}
4971
+ function withGeneratedSecrets(secrets, manifest) {
4972
+ const generated = [];
4973
+ const custom = { ...secrets.custom };
4974
+ for (const declaration of Object.values(manifest)) {
4975
+ if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
4976
+ const key = declaration.provides?.[0];
4977
+ if (!key || custom[key]) continue;
4978
+ custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
4979
+ generated.push(key);
4980
+ }
4981
+ const seed = secrets.seed ?? random();
4982
+ if (!secrets.seed) generated.push("seed");
4983
+ return {
4984
+ secrets: generated.length ? {
4985
+ ...secrets,
4986
+ seed,
4987
+ custom,
4988
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4989
+ } : secrets,
4990
+ generated
6029
4991
  };
6030
- if (app.type === "web" || app.type === "mobile") {
6031
- const publicPrefix = getPublicEnvPrefix(app.framework);
6032
- const depVars = publicPrefix ? (app.dependencies ?? []).map((dep) => `${publicPrefix}${dep.toUpperCase()}_URL`) : [];
6033
- if (app.config) {
6034
- const sniffedVars = [];
6035
- const sniffedOptional = [];
6036
- const configPaths = [];
6037
- if (app.config.client) configPaths.push(app.config.client);
6038
- if (app.config.server) configPaths.push(app.config.server);
6039
- for (const configPath of configPaths) {
6040
- const result = await sniffEntryFile(configPath, app.path, workspacePath, run);
6041
- if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Config file "${configPath}" threw error during sniffing (env vars still captured): ${result.error.message}`);
6042
- sniffedVars.push(...result.envVars);
6043
- sniffedOptional.push(...result.optionalEnvVars);
4992
+ }
4993
+ /** 256 bits, URL-safe. */
4994
+ function random() {
4995
+ return randomBytes(32).toString("base64url");
4996
+ }
4997
+ //#endregion
4998
+ //#region src/deploy/journal.ts
4999
+ var DeployJournal = class DeployJournal {
5000
+ store;
5001
+ stage;
5002
+ state;
5003
+ resources;
5004
+ version;
5005
+ existed;
5006
+ constructor(store, stage, state, resources, version, existed) {
5007
+ this.store = store;
5008
+ this.stage = stage;
5009
+ this.state = state;
5010
+ this.resources = resources;
5011
+ this.version = version;
5012
+ this.existed = existed;
5013
+ }
5014
+ /**
5015
+ * The stage's journal — its stored state, or `initial` written as the
5016
+ * stage's first state, so there is somewhere to record the very first
5017
+ * resource before it is created.
5018
+ */
5019
+ static async open(store, stage, initial) {
5020
+ const stored = await store.read(stage);
5021
+ if (stored) return new DeployJournal(store, stage, stored.state, stored.resources, stored.version, true);
5022
+ const state = initial();
5023
+ const version = await store.write(stage, state, { expectedVersion: null });
5024
+ return new DeployJournal(store, stage, state, {}, version, false);
5025
+ }
5026
+ record(key) {
5027
+ return this.resources[key];
5028
+ }
5029
+ /** The keys a previous run marked `pending` and never saw created. */
5030
+ unfinished() {
5031
+ return Object.values(this.resources).filter((record) => record.status === "pending").map((record) => record.key);
5032
+ }
5033
+ /** Records that `entry` is about to be created. */
5034
+ async pending(entry) {
5035
+ await this.put({
5036
+ ...entry,
5037
+ status: "pending"
5038
+ });
5039
+ }
5040
+ /** Records `entry` as existing with `id`; a no-op if it already is. */
5041
+ async ready(entry, id) {
5042
+ const current = this.resources[entry.key];
5043
+ if (current?.status === "ready" && current.id === id) return;
5044
+ await this.put({
5045
+ ...entry,
5046
+ status: "ready",
5047
+ id
5048
+ });
5049
+ }
5050
+ /**
5051
+ * The resource `entry` names: by its recorded id, else by looking it up,
5052
+ * else created — with `pending` written before the create and `ready`
5053
+ * after, so a run that dies in between leaves a record the next run
5054
+ * resolves by looking the resource up rather than creating another.
5055
+ */
5056
+ async ensure(entry, steps) {
5057
+ const record = this.resources[entry.key];
5058
+ let staleId;
5059
+ if (record?.status === "ready" && record.id && steps.get) {
5060
+ const known = await steps.get(record.id);
5061
+ if (known) {
5062
+ await this.ready(entry, steps.id(known));
5063
+ return {
5064
+ resource: known,
5065
+ via: "recorded"
5066
+ };
6044
5067
  }
6045
- const optionalEnvVars = [...new Set(sniffedOptional)];
5068
+ staleId = record.id;
5069
+ }
5070
+ const existing = await steps.find();
5071
+ if (existing) {
5072
+ await this.ready(entry, steps.id(existing));
6046
5073
  return {
6047
- appName,
6048
- requiredEnvVars: applyMarkOptional([.../* @__PURE__ */ new Set([...depVars, ...sniffedVars])], optionalEnvVars, markOptional),
6049
- optionalEnvVars
5074
+ resource: existing,
5075
+ via: record?.status === "pending" ? "resumed" : "found",
5076
+ ...staleId ? { staleId } : {}
6050
5077
  };
6051
5078
  }
5079
+ await this.pending(entry);
5080
+ const created = await steps.create();
5081
+ await this.ready(entry, steps.id(created));
6052
5082
  return {
6053
- appName,
6054
- requiredEnvVars: depVars,
6055
- optionalEnvVars: []
6056
- };
6057
- }
6058
- if (app.entry) {
6059
- const result = await sniffEntryFile(app.entry, app.path, workspacePath, run);
6060
- if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Entry file threw error during sniffing (env vars still captured): ${result.error.message}`);
6061
- return {
6062
- appName,
6063
- requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
6064
- optionalEnvVars: result.optionalEnvVars
6065
- };
6066
- }
6067
- if (app.routes) {
6068
- const result = await sniffRouteFiles(normalizeRoutes(app.routes), app.path, workspacePath, run);
6069
- if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Route sniffing threw error (env vars still captured): ${result.error.message}`);
6070
- return {
6071
- appName,
6072
- requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
6073
- optionalEnvVars: result.optionalEnvVars
5083
+ resource: created,
5084
+ via: "created",
5085
+ ...staleId ? { staleId } : {}
6074
5086
  };
6075
5087
  }
6076
- if (app.envParser) {
6077
- const result = await sniffEnvParser(app.envParser, app.path, workspacePath, run);
6078
- if (logWarnings) {
6079
- if (result.error) output.warn(`[sniffer] ${appName}: envParser threw error during sniffing (env vars still captured): ${result.error.message}`);
6080
- if (result.unhandledRejections.length > 0) output.warn(`[sniffer] ${appName}: Fire-and-forget rejections during sniffing (suppressed): ${result.unhandledRejections.map((e) => e.message).join(", ")}`);
6081
- }
6082
- return {
6083
- appName,
6084
- requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
6085
- optionalEnvVars: result.optionalEnvVars
5088
+ /** Writes the stage's state, keeping its resource records. */
5089
+ async save() {
5090
+ this.state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
5091
+ this.version = await this.store.write(this.stage, this.state, { expectedVersion: this.version });
5092
+ }
5093
+ async put(record) {
5094
+ this.version = await this.store.putResource(this.stage, record, { expectedVersion: this.version });
5095
+ this.resources = {
5096
+ ...this.resources,
5097
+ [record.key]: {
5098
+ ...record,
5099
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5100
+ }
6086
5101
  };
6087
5102
  }
5103
+ };
5104
+ //#endregion
5105
+ //#region src/deploy/state.ts
5106
+ /**
5107
+ * Create a new empty state for a stage
5108
+ */
5109
+ function createEmptyState(stage, projectId, environmentId) {
6088
5110
  return {
6089
- appName,
6090
- requiredEnvVars: [],
6091
- optionalEnvVars: []
5111
+ provider: "dokploy",
5112
+ stage,
5113
+ projectId,
5114
+ environmentId,
5115
+ applications: {},
5116
+ services: {},
5117
+ lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
6092
5118
  };
6093
5119
  }
6094
5120
  /**
6095
- * Apply the `?` suffix to optional variables in an env var list.
6096
- * Only modifies the list when `markOptional` is true and there are optional vars.
5121
+ * Set application ID in state (mutates state)
6097
5122
  */
6098
- function applyMarkOptional(envVars, optionalEnvVars, markOptional) {
6099
- if (!markOptional || optionalEnvVars.length === 0) return envVars;
6100
- const optionalSet = new Set(optionalEnvVars);
6101
- return envVars.map((v) => optionalSet.has(v) ? `${v}?` : v);
5123
+ function setApplicationId(state, appName, applicationId) {
5124
+ state.applications[appName] = applicationId;
5125
+ }
5126
+ /** Two records naming the same image: by digest when both have one. */
5127
+ function sameImage(a, b) {
5128
+ return a.digest && b.digest ? a.digest === b.digest : a.ref === b.ref;
5129
+ }
5130
+ /** The newest release in `history` after `index` that a rollback may restore. */
5131
+ function restorable(history, index, current) {
5132
+ return history.slice(index + 1).find((entry) => !entry.rolledBack && !sameImage(entry, current));
6102
5133
  }
6103
5134
  /**
6104
- * Sniff an entry file by importing it in a subprocess.
6105
- *
6106
- * Entry apps call `config.parse()` at module load time. To capture which
6107
- * env vars are accessed, we:
6108
- * 1. Spawn a subprocess with a module loader hook
6109
- * 2. The loader intercepts `@geekmidas/envkit` and replaces EnvironmentParser
6110
- * with SnifferEnvironmentParser
6111
- * 3. Import the entry file (triggers config.parse())
6112
- * 4. Capture and return the accessed env var names
6113
- *
6114
- * This approach provides process isolation - each app is sniffed in its own
6115
- * subprocess, preventing module cache pollution.
6116
- *
6117
- * @param entryPath - Relative path to the entry file (e.g., './src/index.ts')
6118
- * @param appPath - The app's path relative to workspace (e.g., 'apps/auth')
6119
- * @param workspacePath - Absolute path to workspace root
6120
- * @returns EntrySniffResult with env vars and optional error
5135
+ * Record that `image` went live for `appName` (mutates state): it becomes
5136
+ * `current`, and what was current becomes `previous`. Releasing what already
5137
+ * runs — a redeploy of the same digest — changes nothing.
6121
5138
  */
6122
- async function sniffEntryFile(entryPath, appPath, workspacePath, run = {}) {
6123
- const fullEntryPath = resolve(workspacePath, appPath, entryPath);
6124
- const loaderPath = resolveSnifferFile("sniffer-loader");
6125
- const workerPath = resolveSnifferFile("sniffer-worker");
6126
- const ran = await runSniffer(run, workspacePath, resolve(workspacePath, appPath), nodeWithTsx(workerPath, [fullEntryPath], [loaderPath]));
6127
- if ("error" in ran) return {
6128
- envVars: [],
6129
- optionalEnvVars: [],
6130
- error: ran.error
5139
+ function recordRelease(state, appName, image, releasedAt = /* @__PURE__ */ new Date()) {
5140
+ const releases = state.releases?.[appName];
5141
+ if (releases && sameImage(releases.current, image)) return;
5142
+ const entry = {
5143
+ ...image,
5144
+ releasedAt: releasedAt.toISOString()
6131
5145
  };
6132
- const answer = snifferAnswer(ran.stdout);
6133
- if (answer) return {
6134
- envVars: answer.envVars || [],
6135
- optionalEnvVars: answer.optionalEnvVars || [],
6136
- error: answer.error ? new Error(answer.error) : void 0
5146
+ const history = [entry, ...releases?.history ?? []].slice(0, 10);
5147
+ const previous = releases?.current;
5148
+ state.releases = {
5149
+ ...state.releases,
5150
+ [appName]: {
5151
+ current: entry,
5152
+ ...previous ? { previous } : {},
5153
+ history
5154
+ }
6137
5155
  };
6138
- return {
6139
- envVars: [],
6140
- optionalEnvVars: [],
6141
- error: /* @__PURE__ */ new Error(`Failed to sniff entry file (exit code ${ran.code}): ${ran.stderr || ran.stdout || "No output"}`)
5156
+ }
5157
+ /**
5158
+ * Record that `appName` was rolled back to `to` (mutates state): every
5159
+ * release newer than it is marked rolled back, so neither this rollback nor
5160
+ * a later one restores it, and `previous` moves to the release before `to`.
5161
+ */
5162
+ function recordRollback(state, appName, to) {
5163
+ const releases = state.releases?.[appName];
5164
+ if (!releases) return;
5165
+ const index = releases.history.findIndex((entry) => !entry.rolledBack && sameImage(entry, to));
5166
+ if (index === -1) return;
5167
+ const history = releases.history.map((entry, i) => i < index ? {
5168
+ ...entry,
5169
+ rolledBack: true
5170
+ } : entry);
5171
+ const current = history[index];
5172
+ const previous = restorable(history, index, current);
5173
+ state.releases = {
5174
+ ...state.releases,
5175
+ [appName]: {
5176
+ current,
5177
+ ...previous ? { previous } : {},
5178
+ history
5179
+ }
6142
5180
  };
6143
5181
  }
6144
5182
  /**
6145
- * Sniff route files by loading constructs and calling getEnvironment().
6146
- *
6147
- * Route-based apps have endpoints, functions, crons, and subscribers that
6148
- * use services. Each service's register() method accesses environment variables.
6149
- *
6150
- * This runs in a subprocess with tsx loader to properly handle TypeScript
6151
- * compilation and path alias resolution (e.g., `src/...` imports).
6152
- *
6153
- * @param routes - Glob pattern(s) for route files
6154
- * @param appPath - The app's path relative to workspace (e.g., 'apps/api')
6155
- * @param workspacePath - Absolute path to workspace root
6156
- * @returns EntrySniffResult with env vars and optional error
5183
+ * Get a generated secret for an app
6157
5184
  */
6158
- async function sniffRouteFiles(routes, appPath, workspacePath, run = {}) {
6159
- const fullAppPath = resolve(workspacePath, appPath);
6160
- const workerPath = resolveSnifferFile("sniffer-routes-worker");
6161
- const pattern = (Array.isArray(routes) ? routes : [routes])[0];
6162
- if (!pattern) return {
6163
- envVars: [],
6164
- optionalEnvVars: [],
6165
- error: /* @__PURE__ */ new Error("No route patterns provided")
6166
- };
6167
- const ran = await runSniffer(run, workspacePath, fullAppPath, nodeWithTsx(workerPath, [fullAppPath, pattern]));
6168
- if ("error" in ran) return {
6169
- envVars: [],
6170
- optionalEnvVars: [],
6171
- error: ran.error
6172
- };
6173
- if (ran.stderr) ran.stderr.split("\n").filter((line) => line.trim()).forEach((line) => output.warn(line));
6174
- const answer = snifferAnswer(ran.stdout);
6175
- if (answer) return {
6176
- envVars: answer.envVars || [],
6177
- optionalEnvVars: answer.optionalEnvVars || [],
6178
- error: answer.error ? new Error(answer.error) : void 0
6179
- };
6180
- return {
6181
- envVars: [],
6182
- optionalEnvVars: [],
6183
- error: /* @__PURE__ */ new Error(`Failed to sniff route files (exit code ${ran.code}): ${ran.stderr || ran.stdout || "No output"}`)
6184
- };
5185
+ function getGeneratedSecret(state, appName, secretName) {
5186
+ return state?.generatedSecrets?.[appName]?.[secretName];
6185
5187
  }
6186
5188
  /**
6187
- * Run the SnifferEnvironmentParser on an envParser module to detect
6188
- * which environment variables it accesses.
6189
- *
6190
- * In a subprocess, like the entry and route sniffers: the module is the
6191
- * project's code, and it was imported into the deploy itself before — with
6192
- * every credential the deploy held in reach. Fire-and-forget rejections are
6193
- * collected there with the shared `sniffWithFireAndForget`.
6194
- *
6195
- * @param envParserPath - The envParser config (e.g., './src/config/env#envParser')
6196
- * @param appPath - The app's path relative to workspace
6197
- * @param workspacePath - Absolute path to workspace root
6198
- * @returns SniffResult with env vars and any errors encountered
5189
+ * Set a generated secret for an app (mutates state)
6199
5190
  */
6200
- async function sniffEnvParser(envParserPath, appPath, workspacePath, run = {}) {
6201
- const [modulePath, exportName = "default"] = envParserPath.split("#");
6202
- if (!modulePath) return {
6203
- envVars: [],
6204
- optionalEnvVars: [],
6205
- unhandledRejections: []
6206
- };
6207
- const appRoot = resolve(workspacePath, appPath);
6208
- const fullPath = resolve(appRoot, modulePath);
6209
- const workerPath = resolveSnifferFile("sniffer-envparser-worker");
6210
- const ran = await runSniffer(run, workspacePath, appRoot, nodeWithTsx(workerPath, [fullPath, exportName]));
6211
- if ("error" in ran) return {
6212
- envVars: [],
6213
- optionalEnvVars: [],
6214
- unhandledRejections: [],
6215
- error: ran.error
6216
- };
6217
- const answer = snifferAnswer(ran.stdout);
6218
- if (!answer) return {
6219
- envVars: [],
6220
- optionalEnvVars: [],
6221
- unhandledRejections: [],
6222
- error: /* @__PURE__ */ new Error(`Failed to sniff envParser (exit code ${ran.code}): ${ran.stderr || ran.stdout || "No output"}`)
5191
+ function setGeneratedSecret(state, appName, secretName, value) {
5192
+ if (!state.generatedSecrets) state.generatedSecrets = {};
5193
+ if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
5194
+ state.generatedSecrets[appName][secretName] = value;
5195
+ }
5196
+ /**
5197
+ * Set DNS verification record for a hostname (mutates state)
5198
+ */
5199
+ function setDnsVerification(state, hostname, serverIp) {
5200
+ if (!state.dnsVerified) state.dnsVerified = {};
5201
+ state.dnsVerified[hostname] = {
5202
+ serverIp,
5203
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
6223
5204
  };
6224
- for (const warning of answer.warnings ?? []) output.warn(warning);
6225
- return {
6226
- envVars: answer.envVars ?? [],
6227
- optionalEnvVars: answer.optionalEnvVars ?? [],
6228
- unhandledRejections: (answer.unhandledRejections ?? []).map((message) => new Error(message)),
6229
- ...answer.error ? { error: new Error(answer.error) } : {}
5205
+ }
5206
+ /**
5207
+ * Check if a hostname is already verified with the given IP
5208
+ */
5209
+ function isDnsVerified(state, hostname, serverIp) {
5210
+ return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
5211
+ }
5212
+ /**
5213
+ * Get the key for a DNS record in state
5214
+ */
5215
+ function getDnsRecordKey(name, type) {
5216
+ return `${name}:${type}`;
5217
+ }
5218
+ /**
5219
+ * Set a created DNS record in state (mutates state)
5220
+ */
5221
+ function setDnsRecord(state, record) {
5222
+ if (!state.dnsRecords) state.dnsRecords = {};
5223
+ const key = getDnsRecordKey(record.name, record.type);
5224
+ state.dnsRecords[key] = {
5225
+ ...record,
5226
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6230
5227
  };
6231
5228
  }
6232
5229
  /**
6233
- * Sniff environment requirements for multiple apps.
6234
- *
6235
- * @param apps - Map of app name to app config
6236
- * @param workspacePath - Absolute path to workspace root
6237
- * @param options - Optional configuration for sniffing behavior
6238
- * @returns Map of app name to sniffed environment
5230
+ * Get backup state from state
6239
5231
  */
6240
- async function sniffAllApps(apps, workspacePath, options = {}) {
6241
- const results = /* @__PURE__ */ new Map();
6242
- for (const [appName, app] of Object.entries(apps)) {
6243
- const sniffed = await sniffAppEnvironment(app, appName, workspacePath, options);
6244
- results.set(appName, sniffed);
6245
- }
6246
- return results;
5232
+ function getBackupState(state) {
5233
+ return state?.backups;
5234
+ }
5235
+ /**
5236
+ * Set backup state (mutates state)
5237
+ */
5238
+ function setBackupState(state, backupState) {
5239
+ state.backups = backupState;
5240
+ }
5241
+ /**
5242
+ * Set postgres backup ID in state (mutates state)
5243
+ */
5244
+ function setPostgresBackupId(state, backupId) {
5245
+ if (state.backups) state.backups.postgresBackupId = backupId;
6247
5246
  }
6248
5247
  //#endregion
6249
- export { pgClient as $, StateStoreBusy as A, FAKE_ENV as B, setDnsRecord as C, loginCommand as Ct, DocumentStateStore as D, canPrompt as Dt, setPostgresBackupId as E, whoamiCommand as Et, newLockHolder as F, workspace_exports as G, constructGlobs as H, withGeneratedSecrets as I, applyPostgres as J, LOCAL_CA_PATH as K, encryptSecrets as L, StateVersionConflict as M, contentVersion as N, StageStateMissing as O, prompt as Ot, createStateStore as P, composeFor as Q, encryption_exports as R, setDeployedImage as S, prepareEntryCredentials as St, setGeneratedSecret as T, validateDokployToken as Tt, derivedContainers as U, backendsOf as V, reconcileWorkspace as W, postgresStatements as X, postgresDatabaseNames as Y, quoteIdentifier as Z, getBackupState as _, createEntryWrapper as _t, DockerBuildFailed as a, resourceName as at, setApplicationId as b, loadEnvFiles as bt, dockerCommand as c, eventsBackendFor as ct, generateDockerignore as d, DEFAULT_IMAGES as dt, appEnvKeys as et, generateSlimDockerfile as f, describeServices as ft, createEmptyState as g, createCredentialsPreload as gt, resolveHost as h, primaryPortKey as ht, DokployApiError as i, planFor as it, StateStoreWithoutLocking as j, StateLocked as k, siteDockerfile as l, providerOf as lt, isMainFrontendApp as m, portKeys as mt, sniffAppEnvironment as n, envFor as nt, DockerPushFailed as o, hostFor as ot, validateImageRef as p, fakeKey as pt, forgetState as q, DokployApi as r, localRolePassword as rt, appPackageName as s, cacheBackendFor as st, sniffAllApps as t, networkEnv as tt, detectPackageManager as u, storageBackendFor as ut, getGeneratedSecret as v, findAvailablePort as vt, setDnsVerification as w, logoutCommand as wt, setBackupState as x, loadSecretsForApp as xt, isDnsVerified as y, isPortAvailable as yt, keyFingerprint as z };
5248
+ export { describeServices as $, reconcileWorkspace as A, withCustomSecret as At, appEnvKeys as B, validateImageRef as C, markerOf as Ct, backendsOf as D, maskPassword as Dt, FAKE_ENV as E, initStageSecrets as Et, postgresDatabaseNames as F, planFor as G, networkEnv as H, postgresStatements as I, cacheBackendFor as J, resourceName as K, quoteIdentifier as L, LOCAL_CA_PATH as M, forgetState as N, constructGlobs as O, storage_exports as Ot, applyPostgres as P, DEFAULT_IMAGES as Q, composeFor as R, generateSlimDockerfile as S, isOwnedBy as St, resolveHost as T, projectName as Tt, envFor as U, dockerfileOf as V, localRolePassword as W, providerOf as X, eventsBackendFor as Y, storageBackendFor as Z, appPackageName as _, readKey as _t, recordRelease as a, findAvailablePort as at, detectPackageManager as b, imageName as bt, setBackupState as c, loadSecretsForApp as ct, setGeneratedSecret as d, secretsStoreFor as dt, fakeKey as et, setPostgresBackupId as f, store_exports as ft, DockerPushFailed as g, projectKey as gt, DockerBuildFailed as h, keystoreProject as ht, isDnsVerified as i, createEntryWrapper as it, workspace_exports as j, derivedContainers as k, toEmbeddableSecrets as kt, setDnsRecord as l, prepareEntryCredentials as lt, withGeneratedSecrets as m, getKeyPath as mt, getBackupState as n, primaryPortKey as nt, recordRollback as o, isPortAvailable as ot, DeployJournal as p, FileSecretsStore as pt, hostFor as q, getGeneratedSecret as r, createCredentialsPreload as rt, setApplicationId as s, loadEnvFiles as st, createEmptyState as t, portKeys as tt, setDnsVerification as u, isRemoteStore as ut, dockerCommand as v, applicationName as vt, isMainFrontendApp as w, ownershipMarker as wt, generateDockerignore as x, imageRef as xt, siteDockerfile as y, deployIdentity as yt, pgClient as z };
6250
5249
 
6251
- //# sourceMappingURL=sniffer-49KPbDgq.mjs.map
5250
+ //# sourceMappingURL=state-uuYqnRVO.mjs.map