@geekmidas/cli 10.0.0-alpha.92 → 10.0.0-alpha.94

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 (206) hide show
  1. package/dist/{GoDaddyProvider-BqYGk9XT.mjs → GoDaddyProvider-BRZUWD8z.mjs} +3 -3
  2. package/dist/{GoDaddyProvider-BqYGk9XT.mjs.map → GoDaddyProvider-BRZUWD8z.mjs.map} +1 -1
  3. package/dist/{GoDaddyProvider-CJwg2r_9.cjs → GoDaddyProvider-DzguxU_a.cjs} +3 -3
  4. package/dist/{GoDaddyProvider-CJwg2r_9.cjs.map → GoDaddyProvider-DzguxU_a.cjs.map} +1 -1
  5. package/dist/{HostingerProvider-1mqD6aG8.mjs → HostingerProvider-Cjh_2Ml6.mjs} +9 -4
  6. package/dist/{HostingerProvider-1mqD6aG8.mjs.map → HostingerProvider-Cjh_2Ml6.mjs.map} +1 -1
  7. package/dist/{HostingerProvider-C6t-PSNe.cjs → HostingerProvider-OdQmKXE3.cjs} +9 -4
  8. package/dist/HostingerProvider-OdQmKXE3.cjs.map +1 -0
  9. package/dist/LegacyStateStore-BQZb6-j2.mjs +124 -0
  10. package/dist/LegacyStateStore-BQZb6-j2.mjs.map +1 -0
  11. package/dist/LegacyStateStore-D50dgUPc.cjs +124 -0
  12. package/dist/LegacyStateStore-D50dgUPc.cjs.map +1 -0
  13. package/dist/{LocalStateStore-Y8XHdoCR.mjs → LocalStateStore-BSaS2535.mjs} +2 -2
  14. package/dist/{LocalStateStore-Y8XHdoCR.mjs.map → LocalStateStore-BSaS2535.mjs.map} +1 -1
  15. package/dist/{LocalStateStore-BMfseje2.cjs → LocalStateStore-EFBUjuMX.cjs} +8 -8
  16. package/dist/{LocalStateStore-BMfseje2.cjs.map → LocalStateStore-EFBUjuMX.cjs.map} +1 -1
  17. package/dist/{S3StateStore-BmMt2HhW.cjs → S3StateStore-C5mv8To3.cjs} +5 -5
  18. package/dist/{S3StateStore-BmMt2HhW.cjs.map → S3StateStore-C5mv8To3.cjs.map} +1 -1
  19. package/dist/{S3StateStore-BfoY4MvD.mjs → S3StateStore-CrFq4UZU.mjs} +2 -2
  20. package/dist/{S3StateStore-BfoY4MvD.mjs.map → S3StateStore-CrFq4UZU.mjs.map} +1 -1
  21. package/dist/{SSMStateStore-Bdyw_0Jj.mjs → SSMStateStore-D3Z6mZnA.mjs} +2 -2
  22. package/dist/{SSMStateStore-Bdyw_0Jj.mjs.map → SSMStateStore-D3Z6mZnA.mjs.map} +1 -1
  23. package/dist/{SSMStateStore-D9K62wM4.cjs → SSMStateStore-DuZCH7_v.cjs} +9 -9
  24. package/dist/{SSMStateStore-D9K62wM4.cjs.map → SSMStateStore-DuZCH7_v.cjs.map} +1 -1
  25. package/dist/{add-2Opn2VVE.mjs → add-C85isXFl.mjs} +4 -5
  26. package/dist/add-C85isXFl.mjs.map +1 -0
  27. package/dist/{add-oN0b18HN.cjs → add-DsVDdvon.cjs} +5 -6
  28. package/dist/add-DsVDdvon.cjs.map +1 -0
  29. package/dist/{bundler-CXQDrhzN.cjs → bundler-BEgUKc4j.cjs} +5 -5
  30. package/dist/{bundler-CXQDrhzN.cjs.map → bundler-BEgUKc4j.cjs.map} +1 -1
  31. package/dist/{bundler-C-AdesaG.mjs → bundler-BSHog8CH.mjs} +5 -5
  32. package/dist/{bundler-C-AdesaG.mjs.map → bundler-BSHog8CH.mjs.map} +1 -1
  33. package/dist/{cli-BjhaVObS.mjs → cli-BsFenyaf.mjs} +10 -6
  34. package/dist/cli-BsFenyaf.mjs.map +1 -0
  35. package/dist/{cli-BRZFYvdb.cjs → cli-BvBdv660.cjs} +9 -5
  36. package/dist/cli-BvBdv660.cjs.map +1 -0
  37. package/dist/compose-Cd09gW-n.cjs +1562 -0
  38. package/dist/compose-Cd09gW-n.cjs.map +1 -0
  39. package/dist/compose-D2RFYd_f.mjs +1515 -0
  40. package/dist/compose-D2RFYd_f.mjs.map +1 -0
  41. package/dist/{config-C9zx7KsX.d.cts → config-DvHNMoOp.d.cts} +4 -4
  42. package/dist/{config-BJo8Wggp.d.mts.map → config-DvHNMoOp.d.cts.map} +1 -1
  43. package/dist/{config-BJo8Wggp.d.mts → config-Ngesg4O8.d.mts} +4 -4
  44. package/dist/{config-C9zx7KsX.d.cts.map → config-Ngesg4O8.d.mts.map} +1 -1
  45. package/dist/config.cjs +1 -1
  46. package/dist/config.d.cts +3 -3
  47. package/dist/config.d.mts +3 -3
  48. package/dist/config.mjs +1 -1
  49. package/dist/{credentials-QBu7VgaQ.cjs → credentials-D7XJypNd.cjs} +9 -3
  50. package/dist/{credentials-QBu7VgaQ.cjs.map → credentials-D7XJypNd.cjs.map} +1 -1
  51. package/dist/{credentials-C4KII3PD.mjs → credentials-DpfnUvIR.mjs} +9 -3
  52. package/dist/{credentials-C4KII3PD.mjs.map → credentials-DpfnUvIR.mjs.map} +1 -1
  53. package/dist/deploy/public.cjs +13 -12
  54. package/dist/deploy/public.d.cts +25 -10
  55. package/dist/deploy/public.d.cts.map +1 -1
  56. package/dist/deploy/public.d.mts +25 -10
  57. package/dist/deploy/public.d.mts.map +1 -1
  58. package/dist/deploy/public.mjs +11 -11
  59. package/dist/{deploy-6ToUHJmM.cjs → deploy-C911Akkn.cjs} +125 -10
  60. package/dist/deploy-C911Akkn.cjs.map +1 -0
  61. package/dist/{deploy-yuJHlkY0.mjs → deploy-CeJ5D0xn.mjs} +124 -9
  62. package/dist/deploy-CeJ5D0xn.mjs.map +1 -0
  63. package/dist/{discover-G76EQ4vY.d.mts → discover-CTzOSGUF.d.cts} +2 -2
  64. package/dist/{discover-CRMhvHG9.d.cts.map → discover-CTzOSGUF.d.cts.map} +1 -1
  65. package/dist/{discover-CRMhvHG9.d.cts → discover-CihAtaoj.d.mts} +2 -2
  66. package/dist/{discover-G76EQ4vY.d.mts.map → discover-CihAtaoj.d.mts.map} +1 -1
  67. package/dist/{dokploy-Bvvfj2qe.cjs → dokploy-CV7GcobZ.cjs} +2 -2
  68. package/dist/{dokploy-Bvvfj2qe.cjs.map → dokploy-CV7GcobZ.cjs.map} +1 -1
  69. package/dist/{dokploy-BvbJ_A4Q.mjs → dokploy-D_aat2Xj.mjs} +2 -2
  70. package/dist/{dokploy-BvbJ_A4Q.mjs.map → dokploy-D_aat2Xj.mjs.map} +1 -1
  71. package/dist/{encryption-CgW167LO.cjs → encryption-DFcivJaO.cjs} +3 -3
  72. package/dist/{encryption-CgW167LO.cjs.map → encryption-DFcivJaO.cjs.map} +1 -1
  73. package/dist/{encryption-C9Jm9dfn.mjs → encryption-_4qKqtTx.mjs} +3 -3
  74. package/dist/{encryption-C9Jm9dfn.mjs.map → encryption-_4qKqtTx.mjs.map} +1 -1
  75. package/dist/{engine-Dj7ZKO8H.cjs → engine-Bv-LZpmj.cjs} +13 -11
  76. package/dist/engine-Bv-LZpmj.cjs.map +1 -0
  77. package/dist/{engine-C8Ye9MUm.mjs → engine-DPMHThkg.mjs} +11 -9
  78. package/dist/engine-DPMHThkg.mjs.map +1 -0
  79. package/dist/{github-BD051KZZ.cjs → github-Br42NMrW.cjs} +94 -6
  80. package/dist/github-Br42NMrW.cjs.map +1 -0
  81. package/dist/{github-B1k42hIt.mjs → github-xsDT19im.mjs} +94 -6
  82. package/dist/github-xsDT19im.mjs.map +1 -0
  83. package/dist/{index-D1pHwqkK.d.mts → index-BlpcClwM.d.mts} +2 -2
  84. package/dist/{index-B5uuwx-Y.d.cts.map → index-BlpcClwM.d.mts.map} +1 -1
  85. package/dist/{index-B5uuwx-Y.d.cts → index-CwLvFqtH.d.cts} +2 -2
  86. package/dist/{index-D1pHwqkK.d.mts.map → index-CwLvFqtH.d.cts.map} +1 -1
  87. package/dist/index.cjs +240 -250
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.mjs +234 -243
  90. package/dist/index.mjs.map +1 -1
  91. package/dist/{local-Otp74ak0.mjs → local-B7Zv1NoA.mjs} +2 -2
  92. package/dist/{local-Otp74ak0.mjs.map → local-B7Zv1NoA.mjs.map} +1 -1
  93. package/dist/{local-B61kUh0w.cjs → local-OPC9BCZt.cjs} +2 -2
  94. package/dist/{local-B61kUh0w.cjs.map → local-OPC9BCZt.cjs.map} +1 -1
  95. package/dist/{migrate-IZsYyZ5F.mjs → migrate-B7evHv7O.mjs} +3 -3
  96. package/dist/{migrate-IZsYyZ5F.mjs.map → migrate-B7evHv7O.mjs.map} +1 -1
  97. package/dist/{migrate-D3VVsiHp.cjs → migrate-D5jUz20_.cjs} +3 -3
  98. package/dist/{migrate-D3VVsiHp.cjs.map → migrate-D5jUz20_.cjs.map} +1 -1
  99. package/dist/{migrate-BSbZ7-9n.mjs → migrate-DhvFPGLi.mjs} +4 -4
  100. package/dist/{migrate-BSbZ7-9n.mjs.map → migrate-DhvFPGLi.mjs.map} +1 -1
  101. package/dist/{migrate-f6JDjyCp.cjs → migrate-SkWJotn5.cjs} +3 -3
  102. package/dist/{migrate-f6JDjyCp.cjs.map → migrate-SkWJotn5.cjs.map} +1 -1
  103. package/dist/openapi.cjs +1 -1
  104. package/dist/openapi.d.cts +1 -1
  105. package/dist/openapi.d.mts +1 -1
  106. package/dist/openapi.mjs +1 -1
  107. package/dist/providers-BFeP_mYw.cjs +1098 -0
  108. package/dist/providers-BFeP_mYw.cjs.map +1 -0
  109. package/dist/providers-sTj-BG0S.mjs +1079 -0
  110. package/dist/providers-sTj-BG0S.mjs.map +1 -0
  111. package/dist/reconcile/public.d.cts +2 -2
  112. package/dist/reconcile/public.d.mts +2 -2
  113. package/dist/{reconcile-Dh1Jk7WE.mjs → reconcile--I7V4Klj.mjs} +2 -2
  114. package/dist/{reconcile-Dh1Jk7WE.mjs.map → reconcile--I7V4Klj.mjs.map} +1 -1
  115. package/dist/{resolve-DUp65SnJ.cjs → resolve-CFEOtRsl.cjs} +4 -4
  116. package/dist/{resolve-DUp65SnJ.cjs.map → resolve-CFEOtRsl.cjs.map} +1 -1
  117. package/dist/{resolve-Be4bF1w4.mjs → resolve-Dyd1S2wn.mjs} +4 -4
  118. package/dist/{resolve-Be4bF1w4.mjs.map → resolve-Dyd1S2wn.mjs.map} +1 -1
  119. package/dist/{rollback-DDHtd0GJ.cjs → rollback-D720SFhV.cjs} +11 -8
  120. package/dist/rollback-D720SFhV.cjs.map +1 -0
  121. package/dist/{rollback-D1d-fW0r.mjs → rollback-NroNbGC0.mjs} +10 -7
  122. package/dist/rollback-NroNbGC0.mjs.map +1 -0
  123. package/dist/{runtime-Es1V4e9M.d.mts → runtime-BRCq6dQ7.d.mts} +2 -2
  124. package/dist/{runtime-DDrHlYe5.d.cts.map → runtime-BRCq6dQ7.d.mts.map} +1 -1
  125. package/dist/{runtime-DDrHlYe5.d.cts → runtime-VktAo_8B.d.cts} +2 -2
  126. package/dist/{runtime-Es1V4e9M.d.mts.map → runtime-VktAo_8B.d.cts.map} +1 -1
  127. package/dist/{sandbox-MaxSeU92.d.cts → sandbox-D1gd0iDz.d.cts} +2 -2
  128. package/dist/{sandbox-C3WsTa5m.d.mts.map → sandbox-D1gd0iDz.d.cts.map} +1 -1
  129. package/dist/{sandbox-C3WsTa5m.d.mts → sandbox-zHnRLCj0.d.mts} +2 -2
  130. package/dist/{sandbox-MaxSeU92.d.cts.map → sandbox-zHnRLCj0.d.mts.map} +1 -1
  131. package/dist/secrets-CwLauUB_.mjs +36 -0
  132. package/dist/secrets-CwLauUB_.mjs.map +1 -0
  133. package/dist/secrets-DAvi_O9g.cjs +47 -0
  134. package/dist/secrets-DAvi_O9g.cjs.map +1 -0
  135. package/dist/{seeds-BKoJYsE7.cjs → seeds-BCg8X36S.cjs} +1732 -2300
  136. package/dist/seeds-BCg8X36S.cjs.map +1 -0
  137. package/dist/{seeds-CvGQJnPO.mjs → seeds-CTxTZa-R.mjs} +1637 -2270
  138. package/dist/seeds-CTxTZa-R.mjs.map +1 -0
  139. package/dist/{sniffer-Dq5BS98f.cjs → sniffer-C0z3NB2A.cjs} +572 -3
  140. package/dist/sniffer-C0z3NB2A.cjs.map +1 -0
  141. package/dist/{sniffer-BTtGAtm3.mjs → sniffer-DCcuzHz_.mjs} +496 -5
  142. package/dist/sniffer-DCcuzHz_.mjs.map +1 -0
  143. package/dist/{sst-CnKY-CSh.cjs → sst-DKNH243z.cjs} +3 -3
  144. package/dist/{sst-CnKY-CSh.cjs.map → sst-DKNH243z.cjs.map} +1 -1
  145. package/dist/{sst-BaRXp2Dd.mjs → sst-DRDSDwOk.mjs} +3 -3
  146. package/dist/{sst-BaRXp2Dd.mjs.map → sst-DRDSDwOk.mjs.map} +1 -1
  147. package/dist/stack-B3fPDTBg.cjs +1346 -0
  148. package/dist/stack-B3fPDTBg.cjs.map +1 -0
  149. package/dist/stack-NrdH5ngX.mjs +1239 -0
  150. package/dist/stack-NrdH5ngX.mjs.map +1 -0
  151. package/dist/{stages-BI8JvPzI.cjs → stages-CkqP1N4S.cjs} +16 -3
  152. package/dist/stages-CkqP1N4S.cjs.map +1 -0
  153. package/dist/{stages-DY-fQKmo.mjs → stages-veC4TcQe.mjs} +16 -3
  154. package/dist/stages-veC4TcQe.mjs.map +1 -0
  155. package/dist/target/public.cjs +1 -1
  156. package/dist/target/public.d.cts +2 -2
  157. package/dist/target/public.d.mts +2 -2
  158. package/dist/target/public.mjs +1 -1
  159. package/dist/{terminal-8XngdSTo.cjs → terminal-CkUYuAPd.cjs} +2 -2
  160. package/dist/{terminal-8XngdSTo.cjs.map → terminal-CkUYuAPd.cjs.map} +1 -1
  161. package/dist/{terminal-Dtd6BZX9.mjs → terminal-DAHs2Zko.mjs} +3 -3
  162. package/dist/{terminal-Dtd6BZX9.mjs.map → terminal-DAHs2Zko.mjs.map} +1 -1
  163. package/dist/{types-Bk9st2cd.d.cts → types-DMoaFrjg.d.mts} +108 -33
  164. package/dist/types-DMoaFrjg.d.mts.map +1 -0
  165. package/dist/{types-B3Gqs175.d.mts → types-DXyaSmqu.d.cts} +109 -32
  166. package/dist/types-DXyaSmqu.d.cts.map +1 -0
  167. package/dist/workspace/index.cjs +1 -1
  168. package/dist/workspace/index.d.cts +2 -2
  169. package/dist/workspace/index.d.mts +2 -2
  170. package/dist/workspace/index.mjs +1 -1
  171. package/dist/{workspace-Bzw7Zs82.mjs → workspace-DWK1PTXP.mjs} +4 -4
  172. package/dist/workspace-DWK1PTXP.mjs.map +1 -0
  173. package/dist/{workspace-CHOB2Xg3.cjs → workspace-cSyi5nb1.cjs} +4 -4
  174. package/dist/workspace-cSyi5nb1.cjs.map +1 -0
  175. package/package.json +15 -15
  176. package/dist/HostingerProvider-C6t-PSNe.cjs.map +0 -1
  177. package/dist/LegacyStateStore-CeEwayMB.cjs +0 -89
  178. package/dist/LegacyStateStore-CeEwayMB.cjs.map +0 -1
  179. package/dist/LegacyStateStore-DdJ1bCRV.mjs +0 -89
  180. package/dist/LegacyStateStore-DdJ1bCRV.mjs.map +0 -1
  181. package/dist/add-2Opn2VVE.mjs.map +0 -1
  182. package/dist/add-oN0b18HN.cjs.map +0 -1
  183. package/dist/cli-BRZFYvdb.cjs.map +0 -1
  184. package/dist/cli-BjhaVObS.mjs.map +0 -1
  185. package/dist/compose-CTdVeeVk.mjs +0 -3151
  186. package/dist/compose-CTdVeeVk.mjs.map +0 -1
  187. package/dist/compose-fnYmPk_c.cjs +0 -3276
  188. package/dist/compose-fnYmPk_c.cjs.map +0 -1
  189. package/dist/deploy-6ToUHJmM.cjs.map +0 -1
  190. package/dist/deploy-yuJHlkY0.mjs.map +0 -1
  191. package/dist/engine-C8Ye9MUm.mjs.map +0 -1
  192. package/dist/engine-Dj7ZKO8H.cjs.map +0 -1
  193. package/dist/github-B1k42hIt.mjs.map +0 -1
  194. package/dist/github-BD051KZZ.cjs.map +0 -1
  195. package/dist/rollback-D1d-fW0r.mjs.map +0 -1
  196. package/dist/rollback-DDHtd0GJ.cjs.map +0 -1
  197. package/dist/seeds-BKoJYsE7.cjs.map +0 -1
  198. package/dist/seeds-CvGQJnPO.mjs.map +0 -1
  199. package/dist/sniffer-BTtGAtm3.mjs.map +0 -1
  200. package/dist/sniffer-Dq5BS98f.cjs.map +0 -1
  201. package/dist/stages-BI8JvPzI.cjs.map +0 -1
  202. package/dist/stages-DY-fQKmo.mjs.map +0 -1
  203. package/dist/types-B3Gqs175.d.mts.map +0 -1
  204. package/dist/types-Bk9st2cd.d.cts.map +0 -1
  205. package/dist/workspace-Bzw7Zs82.mjs.map +0 -1
  206. package/dist/workspace-CHOB2Xg3.cjs.map +0 -1
@@ -1,13 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
2
  import { t as GkmError } from "./errors-D8Gwp5kU.mjs";
3
- import { r as storedCredentials, s as run$1, x as gkmHome } from "./credentials-C4KII3PD.mjs";
3
+ import { s as run$1, x as gkmHome } from "./credentials-DpfnUvIR.mjs";
4
4
  import { d as output, r as discover, u as ConstructGenerator } from "./discover-qjFapetL.mjs";
5
5
  import { t as appKey } from "./derive-B4vDpzgP.mjs";
6
- import { $ as RotationInProgress, B as LOGS_PORT, G as checkS3Config, H as provisioningOf, J as BucketNameUnavailable, K as AccessKeyLimit, Q as RecordedBucketUnreachable, R as stageSampleRate, U as stageProvider, W as S3_PROVISIONING, X as IamUserNotOwned, Y as BucketOwnedByAnotherProject, Z as ProvisionedBucketUnreachable, _ as PUBLIC_ENV_PREFIXES, a as allConstructGlobs, at as SERVER_IPV6_KEY, et as S3RegionRequired, it as SERVER_IPV4_KEY, j as TEST_STAGE, lt as UnknownSecretsStoreProvider, q as BucketKeySetElsewhere, st as isReservedStageKey, v as getPublicEnvPrefix, z as telemetryEnv } from "./workspace-Bzw7Zs82.mjs";
6
+ import { B as LOGS_PORT, H as provisioningOf, R as stageSampleRate, U as stageProvider, _ as PUBLIC_ENV_PREFIXES, a as allConstructGlobs, at as SERVER_IPV6_KEY, it as SERVER_IPV4_KEY, j as TEST_STAGE, lt as UnknownSecretsStoreProvider, ot as cnameTarget, st as isReservedStageKey, v as getPublicEnvPrefix, z as telemetryEnv } from "./workspace-DWK1PTXP.mjs";
7
7
  import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
8
8
  import { a as telemetryArgs, c as startTelemetryCode, n as runtimeFor, u as writeTelemetryModule } from "./EndpointGenerator-Bor2tZCm.mjs";
9
9
  import { a as DEFAULT_STORAGE, i as DEFAULT_EVENTS$1, n as readFakes, o as holdsEveryCache, r as DEFAULT_CACHE, t as SubscriberGenerator } from "./SubscriberGenerator-CyN2VF5X.mjs";
10
- import { c as runtimeOf } from "./resolve-Be4bF1w4.mjs";
10
+ import { c as runtimeOf } from "./resolve-Dyd1S2wn.mjs";
11
11
  import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
12
12
  import { constants, existsSync, readFileSync } from "node:fs";
13
13
  import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
@@ -16,9 +16,9 @@ import { promisify } from "node:util";
16
16
  import { Cron } from "@geekmidas/constructs/crons";
17
17
  import { Queue } from "@geekmidas/constructs/queue";
18
18
  import { CLIENT_IP_HEADER, DEFAULT_POSTGRES_VERSION, TELEMETRY_KEYS, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
19
- import { createServer } from "node:net";
19
+ import { createServer, isIPv4, isIPv6 } from "node:net";
20
20
  import { execFile, spawn } from "node:child_process";
21
- import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
21
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt } from "node:crypto";
22
22
  import { config } from "dotenv";
23
23
  import { stringify } from "yaml";
24
24
  import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
@@ -31,7 +31,6 @@ import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/clie
31
31
  import { Client } from "pg";
32
32
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
33
33
  import { lookup } from "node:dns/promises";
34
- import * as s3Url from "@geekmidas/storage/s3-url";
35
34
  //#region src/workspace/rootSite.ts
36
35
  /**
37
36
  * Which site the base domain points at — deployed and locally alike.
@@ -4924,7 +4923,7 @@ function bucketPolicies(plan) {
4924
4923
  * client, can be admitted by the policy where the client would have said no.
4925
4924
  * Prefer `**` where crossing segments is what you meant.
4926
4925
  */
4927
- function bucketPolicy$1(bucket, open) {
4926
+ function bucketPolicy(bucket, open) {
4928
4927
  return JSON.stringify({
4929
4928
  Version: "2012-10-17",
4930
4929
  Statement: [{
@@ -5006,7 +5005,7 @@ async function databasesExist(client, names) {
5006
5005
  async function applyPolicies(client, policies) {
5007
5006
  const applied = [];
5008
5007
  for (const { bucket, open } of policies) {
5009
- const wanted = bucketPolicy$1(bucket, open);
5008
+ const wanted = bucketPolicy(bucket, open);
5010
5009
  const describe = `open paths on ${bucket}`;
5011
5010
  if (equivalent(await client.policy(bucket), wanted)) {
5012
5011
  applied.push({
@@ -6646,999 +6645,370 @@ function isUnder(file, dir) {
6646
6645
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel) ? !rel.startsWith(`..${sep}`) : false;
6647
6646
  }
6648
6647
  //#endregion
6649
- //#region src/deploy/devServices.ts
6648
+ //#region src/target/dokploy/dns/DnsProvider.ts
6650
6649
  /**
6651
- * Mail and object storage on a deployed stage: real ones, or — only when
6652
- * someone asked — the dev services the local stage runs.
6653
- *
6654
- * Locally a declared `Email` is Mailpit and a declared bucket is MinIO, and
6655
- * nobody has to configure either. Deployed, the same two containers would be
6656
- * a stage that silently delivers no mail and keeps its files on one
6657
- * container's disk. So a deployed stage on a server target takes both from
6658
- * its secrets — the mail server's URL and sending address, and each bucket's
6659
- * URL — and a stage missing any of them fails before anything is built,
6660
- * provisioned or written, naming every key at once.
6661
- *
6662
- * A bucket's credentials are not checked: they are optional, and come from
6663
- * either of two places. The bucket's URL may carry a key of its own
6664
- * (`s3://KEY:SECRET@uploads`), which wins; otherwise the S3 client's default
6665
- * chain applies — the stage's shared `AWS_ACCESS_KEY_ID`/
6666
- * `AWS_SECRET_ACCESS_KEY` when it set them, or a role.
6667
- *
6668
- * `--allow-dev-services` is the way out for a stage that is not production —
6669
- * a preview, a demo: every construct the stage does not account for gets the
6670
- * dev service the local stage runs for it — a bucket MinIO, mail Mailpit —
6671
- * and the target derives the keys from it. What the stage does account for
6672
- * still wins: a key set in its secrets, or a provider (`deploy.objects`)
6673
- * that backs the kind. Every run that uses one says so, loudly.
6674
- *
6675
- * Pure: what each target runs is decided here from declarations and the
6676
- * stage's values, and the targets do the running.
6650
+ * Check if value is a DnsProvider implementation.
6677
6651
  */
6652
+ function isDnsProvider(value) {
6653
+ return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
6654
+ }
6678
6655
  /**
6679
- * The dev services a deployed stage can run — one per kind that has one on
6680
- * every server target: a bucket's MinIO and mail's Mailpit. A cache or a
6681
- * database is not here because a server stage runs its own anyway.
6656
+ * Create a DNS provider based on configuration.
6657
+ *
6658
+ * - 'hostinger': HostingerProvider
6659
+ * - 'route53': Route53Provider
6660
+ * - 'godaddy': GoDaddyProvider
6661
+ * - 'manual': Returns null (user handles DNS)
6662
+ * - Custom: Use provided DnsProvider implementation
6682
6663
  */
6683
- const DEV_SERVICES = ["minio", "mailpit"];
6664
+ async function createDnsProvider(options) {
6665
+ const { config } = options;
6666
+ if (config.provider === "manual") return null;
6667
+ if (isDnsProvider(config.provider)) return config.provider;
6668
+ const provider = config.provider;
6669
+ if (provider === "hostinger") {
6670
+ const { HostingerProvider } = await import("./HostingerProvider-Cjh_2Ml6.mjs");
6671
+ return new HostingerProvider(options.hostinger);
6672
+ }
6673
+ if (provider === "route53") {
6674
+ const { Route53Provider } = await import("./Route53Provider-QXxjkZO1.mjs");
6675
+ const route53Config = config;
6676
+ return new Route53Provider({
6677
+ region: route53Config.region,
6678
+ profile: route53Config.profile,
6679
+ hostedZoneId: route53Config.hostedZoneId
6680
+ });
6681
+ }
6682
+ if (provider === "godaddy") {
6683
+ const { GoDaddyProvider } = await import("./GoDaddyProvider-BRZUWD8z.mjs");
6684
+ return new GoDaddyProvider(options.godaddy ?? {});
6685
+ }
6686
+ if (provider === "cloudflare") throw new DnsProviderNotImplemented("cloudflare");
6687
+ throw new DnsProviderUnknown(config);
6688
+ }
6684
6689
  /**
6685
- * The S3 client's shared key pair. Optional: a bucket whose URL carries its own
6686
- * key (`s3://KEY:SECRET@bucket`) signs with that, and one without signs with
6687
- * this pair when the stage set it, or with whatever else the SDK's default
6688
- * chain finds — a role.
6690
+ * The provider's credentials can write records and not read them — a key
6691
+ * scoped to updates only. What reads them falls back to writing every record,
6692
+ * which is idempotent, without a diff.
6689
6693
  */
6690
- const STORAGE_KEY_PAIR = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
6691
- /** Dev services asked of a target that runs no containers. */
6692
- var DevServicesNeedServerTarget = class extends GkmError {
6693
- target;
6694
- constructor(target) {
6695
- super(`--allow-dev-services applies to server targets only, and '${target}' deploys to AWS, where a bucket is S3 and mail is the stage's own. Drop --allow-dev-services.`);
6696
- this.target = target;
6697
- this.name = "DevServicesNeedServerTarget";
6694
+ var DnsRecordsUnreadable = class extends GkmError {
6695
+ provider;
6696
+ domain;
6697
+ reason;
6698
+ constructor(provider, domain, reason) {
6699
+ super(`The ${provider} key cannot read ${domain}'s DNS records (${reason}), so changes cannot be compared with what is there: every record is written, which leaves one that already has its value as it was.`);
6700
+ this.provider = provider;
6701
+ this.domain = domain;
6702
+ this.reason = reason;
6703
+ this.name = "DnsRecordsUnreadable";
6698
6704
  }
6699
6705
  };
6700
- /**
6701
- * `--allow-dev-services` given a value — the list it used to take. It is a
6702
- * switch now: every construct the stage does not account for gets its dev
6703
- * service.
6704
- */
6705
- var AllowDevServicesTakesNoValue = class extends GkmError {
6706
- value;
6707
- constructor(value) {
6708
- super(`--allow-dev-services takes no value (it was given '${value}'). It runs the dev service for every bucket and mail the stage does not account for — no key in its secrets, no provider in deploy.<kind>.<stage> — so there is nothing to list. Pass --allow-dev-services on its own.`);
6709
- this.value = value;
6710
- this.name = "AllowDevServicesTakesNoValue";
6706
+ /** A DNS provider gkm names and does not have yet. */
6707
+ var DnsProviderNotImplemented = class extends Error {
6708
+ provider;
6709
+ constructor(provider) {
6710
+ super(`The ${provider} DNS provider is not implemented yet. Use 'route53', 'godaddy', 'hostinger' or 'manual' for this domain in dns, or pass your own DnsProvider object.`);
6711
+ this.provider = provider;
6712
+ this.name = "DnsProviderNotImplemented";
6713
+ }
6714
+ };
6715
+ /** A `provider:` that is none of the built-ins, nor a DnsProvider object. */
6716
+ var DnsProviderUnknown = class extends GkmError {
6717
+ config;
6718
+ constructor(config) {
6719
+ super(`Unknown DNS provider: ${JSON.stringify(config)}. dns takes 'route53', 'godaddy', 'hostinger', 'manual', or an object implementing DnsProvider.`);
6720
+ this.config = config;
6721
+ this.name = "DnsProviderUnknown";
6711
6722
  }
6712
6723
  };
6724
+ //#endregion
6725
+ //#region src/deploy/state.ts
6713
6726
  /**
6714
- * Refuses `--allow-dev-services <value>` and `--allow-dev-services=<value>` on
6715
- * a command line, with the reason, before the parser calls the value an
6716
- * excess argument. `gkm deploy` and `gkm compose` take no positional
6717
- * argument, so whatever follows the flag and is not a flag was meant for it.
6718
- *
6719
- * @throws {AllowDevServicesTakesNoValue}
6727
+ * Create a new empty state for a stage
6720
6728
  */
6721
- function assertDevServicesFlag(argv) {
6722
- for (let i = 0; i < argv.length; i++) {
6723
- const arg = argv[i];
6724
- if (arg.startsWith("--allow-dev-services=")) throw new AllowDevServicesTakesNoValue(arg.slice(arg.indexOf("=") + 1));
6725
- const next = argv[i + 1];
6726
- if (arg === "--allow-dev-services" && next && !next.startsWith("-")) throw new AllowDevServicesTakesNoValue(next);
6727
- }
6729
+ function createEmptyState(stage, projectId, environmentId) {
6730
+ return {
6731
+ provider: "dokploy",
6732
+ stage,
6733
+ projectId,
6734
+ environmentId,
6735
+ applications: {},
6736
+ services: {},
6737
+ lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
6738
+ };
6739
+ }
6740
+ /** A new compose stage's state. */
6741
+ function createComposeState(stage) {
6742
+ return {
6743
+ provider: "compose",
6744
+ stage,
6745
+ lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
6746
+ };
6728
6747
  }
6729
6748
  /**
6730
- * A deployed stage with mail or object storage that its secrets do not
6731
- * configure — every missing key, across every app, at once.
6749
+ * `state` as the Dokploy target keeps it. A stage whose state another target
6750
+ * wrote — one moved from compose to Dokploy — keeps its core (releases,
6751
+ * identity) and starts with no Dokploy ids, as a first deploy would.
6732
6752
  */
6733
- var ExternalServicesNotConfigured = class extends GkmError {
6734
- stage;
6735
- missing;
6736
- constructor(stage, missing) {
6737
- const standIns = missing.some((m) => m.service !== void 0);
6738
- const lines = missing.map((m) => {
6739
- const by = m.apps?.length ? `, read by ${m.apps.join(", ")}` : "";
6740
- return ` gkm secrets:set ${m.key} '${m.example}' --stage ${stage}\n ${m.what}${by}` + (m.note ? `\n ${m.note}` : "") + (m.hint ? `\n ${m.hint}` : "");
6741
- });
6742
- super(`The stage '${stage}' is deployed, and a deployed stage's mail and object storage are real services: gkm runs no Mailpit or MinIO for it. Set ${missing.length === 1 ? "this key" : `these ${missing.length} keys`} in the stage's secrets:\n\n${lines.join("\n")}\n\n` + (standIns ? "For a stage that is not production — a preview, a demo — the dev services can run instead, with --allow-dev-services. Mailpit delivers no mail, and MinIO keeps every object on one container's disk.\n\n" : "") + `Or run: gkm secrets:add --stage ${stage}`);
6743
- this.stage = stage;
6744
- this.missing = missing;
6745
- this.name = "ExternalServicesNotConfigured";
6746
- }
6747
- };
6753
+ function asDokployState(state) {
6754
+ if (state.provider === "dokploy") return state;
6755
+ const { provider: _provider, ...core } = state;
6756
+ return {
6757
+ ...core,
6758
+ provider: "dokploy",
6759
+ projectId: "",
6760
+ environmentId: "",
6761
+ applications: {},
6762
+ services: {}
6763
+ };
6764
+ }
6748
6765
  /**
6749
- * Every mail and storage construct a manifest declares, with the apps that
6750
- * depend on each — what a target that provisions the whole manifest checks.
6766
+ * Set application ID in state (mutates state)
6751
6767
  */
6752
- function manifestServiceDeclarations(manifest) {
6753
- return Object.entries(manifest).flatMap(([id, declaration]) => {
6754
- const { kind } = declaration;
6755
- if (kind !== "email" && kind !== "objects" && kind !== "file-server") return [];
6756
- const apps = dependentsOf(manifest, id).filter((caller) => {
6757
- const callerKind = manifest[caller]?.kind;
6758
- return callerKind === "rest-api" || callerKind === "site";
6759
- }).map(appKey);
6760
- return [{
6761
- id,
6762
- kind,
6763
- ...declaration.kind === "file-server" ? { of: declaration.of } : {},
6764
- ...apps.length ? { apps } : {}
6765
- }];
6766
- });
6768
+ function setApplicationId(state, appName, applicationId) {
6769
+ state.applications[appName] = applicationId;
6770
+ }
6771
+ /** Two records naming the same image: by digest when both have one. */
6772
+ function sameImage(a, b) {
6773
+ return a.digest && b.digest ? a.digest === b.digest : a.ref === b.ref;
6774
+ }
6775
+ /** The newest release in `history` after `index` that a rollback may restore. */
6776
+ function restorable(history, index, current) {
6777
+ return history.slice(index + 1).find((entry) => !entry.rolledBack && !sameImage(entry, current));
6767
6778
  }
6768
6779
  /**
6769
- * Every key a stage must be given rather than derive — the one list both a
6770
- * deploy's checks and `gkm secrets:add` read, so what a deploy refuses and
6771
- * what the builder offers cannot drift.
6772
- *
6773
- * - Deployed, mail is its URL and its sending address, a bucket its URL, and
6774
- * a file server its public URL. Locally all of it is Mailpit and MinIO.
6775
- * - On every stage, a third party's credentials: nobody but the third party
6776
- * can issue them.
6777
- *
6778
- * Never a derived value: a database URL, a generated secret, the seed.
6780
+ * Record that `image` went live for `appName` (mutates state): it becomes
6781
+ * `current`, and what was current becomes `previous`. Releasing what already
6782
+ * runs — a redeploy of the same digest — changes nothing.
6779
6783
  */
6780
- function requiredStageKeys(input) {
6781
- const domain = input.domain ?? "example.com";
6782
- const keys = [];
6783
- const add = (entry) => {
6784
- if (!keys.some((k) => k.key === entry.key)) keys.push(entry);
6784
+ function recordRelease(state, appName, image, by, releasedAt = /* @__PURE__ */ new Date()) {
6785
+ const releases = state.releases?.[appName];
6786
+ if (releases && sameImage(releases.current, image)) return;
6787
+ const entry = {
6788
+ ...image,
6789
+ releasedAt: releasedAt.toISOString(),
6790
+ releasedBy: by
6785
6791
  };
6786
- const byId = (list) => [...list ?? []].sort((a, b) => a.id.localeCompare(b.id));
6787
- const apps = (d) => d.apps?.length ? { apps: [...d.apps].sort() } : {};
6788
- if (!input.local) {
6789
- const services = byId(input.services);
6790
- const objects = input.providers?.objects;
6791
- const storage = objects?.accounted ? objects.hint ? { hint: objects.hint } : {} : { service: "minio" };
6792
- for (const d of services.filter((d) => d.kind === "email")) {
6793
- add({
6794
- key: provideKey(d.id, "url"),
6795
- id: d.id,
6796
- kind: "email",
6797
- what: `where '${d.id}' sends mail — any SMTP server`,
6798
- example: `smtp://user:password@smtp.${domain}:587`,
6799
- service: "mailpit",
6800
- ...apps(d)
6801
- });
6802
- add({
6803
- key: provideKey(d.id, "from"),
6804
- id: d.id,
6805
- kind: "email",
6806
- what: `the address '${d.id}' sends from, on a domain the mail server has verified`,
6807
- example: `noreply@${domain}`,
6808
- service: "mailpit",
6809
- ...apps(d)
6810
- });
6792
+ const history = [entry, ...releases?.history ?? []].slice(0, 10);
6793
+ const previous = releases?.current;
6794
+ state.releases = {
6795
+ ...state.releases,
6796
+ [appName]: {
6797
+ current: entry,
6798
+ ...previous ? { previous } : {},
6799
+ history
6811
6800
  }
6812
- for (const d of services.filter((d) => d.kind === "objects")) add({
6813
- key: provideKey(d.id, "url"),
6814
- id: d.id,
6815
- kind: "bucket",
6816
- what: `the bucket '${d.id}' — S3, R2, or any S3-compatible store (add &endpoint=… for one that is not S3)`,
6817
- note: `credentials are optional: a key for this bucket alone in the URL (s3://KEY:SECRET@${appKey(d.id)}?…) wins; without one, the shared AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or a role signs`,
6818
- example: `s3://${appKey(d.id)}?region=eu-west-1`,
6819
- ...storage,
6820
- ...apps(d)
6821
- });
6822
- for (const d of services.filter((d) => d.kind === "file-server")) add({
6823
- key: provideKey(d.id, "url"),
6824
- id: d.id,
6825
- kind: "file-server",
6826
- what: `the public address '${d.id}' serves its bucket on — a CDN or the bucket's own domain`,
6827
- example: `https://${appKey(d.id)}.${domain}`,
6828
- ...storage,
6829
- ...d.of ? { of: d.of } : {},
6830
- ...apps(d)
6831
- });
6832
- }
6833
- if (!input.local && input.server) add({
6834
- key: SERVER_IPV4_KEY,
6835
- id: "server",
6836
- kind: "server",
6837
- what: "the public IPv4 address of the server the stage's stack runs on — its hosts' DNS records point at it, and every deploy checks they do",
6838
- note: `${SERVER_IPV6_KEY} adds its IPv6 address (AAAA records); neither is ever handed to an app`,
6839
- example: "203.0.113.10"
6840
- });
6841
- for (const d of byId(input.credentials)) add({
6842
- key: provideKey(d.id, "credentials"),
6843
- id: d.id,
6844
- kind: d.kind,
6845
- what: d.kind === "external-api" ? `the credentials '${d.id}' was issued for this stage, as its schema describes them` : `the credential '${d.id}', as its schema describes it`,
6846
- example: "{\"apiKey\":\"…\"}",
6847
- ...apps(d)
6848
- });
6849
- return keys;
6801
+ };
6850
6802
  }
6851
6803
  /**
6852
- * Whether only the stage's secrets can supply `key` to a construct of `kind`
6853
- * — what nothing on the stage derives.
6854
- *
6855
- * A third party's credentials, always. Deployed, also each generated secret
6856
- * and keyring: the stage generated each one once, and every run reads the
6857
- * same value back.
6804
+ * Record that `appName` was rolled back to `to` (mutates state): every
6805
+ * release newer than it is marked rolled back, so neither this rollback nor
6806
+ * a later one restores it, and `previous` moves to the release before `to`.
6858
6807
  */
6859
- function suppliedOnly(kind, id, key, local) {
6860
- if (kind === "credential") return true;
6861
- if (kind === "external-api") return key === provideKey(id, "credentials");
6862
- if (local) return false;
6863
- return kind === "secret" || kind === "encryption";
6808
+ function recordRollback(state, appName, to) {
6809
+ const releases = state.releases?.[appName];
6810
+ if (!releases) return;
6811
+ const index = releases.history.findIndex((entry) => !entry.rolledBack && sameImage(entry, to));
6812
+ if (index === -1) return;
6813
+ const history = releases.history.map((entry, i) => i < index ? {
6814
+ ...entry,
6815
+ rolledBack: true
6816
+ } : entry);
6817
+ const current = history[index];
6818
+ const previous = restorable(history, index, current);
6819
+ state.releases = {
6820
+ ...state.releases,
6821
+ [appName]: {
6822
+ current,
6823
+ ...previous ? { previous } : {},
6824
+ history
6825
+ }
6826
+ };
6864
6827
  }
6865
6828
  /**
6866
- * Where a deployed stage's mail and storage come from, and what it lacks.
6867
- *
6868
- * - Mail is external once its URL is set, and then needs its sending
6869
- * address too. Unset, it is Mailpit where allowed — the address derived
6870
- * unless set — and missing otherwise.
6871
- * - A bucket is external once its URL is set; its credentials, in the URL or
6872
- * the shared pair, are optional. Unset, it is MinIO where allowed, and
6873
- * missing otherwise.
6874
- * - A file server's URL is derived where its bucket is MinIO, unless set;
6875
- * over an external bucket the stage must set it.
6876
- *
6877
- * The keys themselves are {@link requiredStageKeys}'s.
6829
+ * Get a generated secret for an app
6878
6830
  */
6879
- function externalServices(input) {
6880
- const { supplied, allow } = input;
6881
- const has = (key) => supplied[key] !== void 0;
6882
- const required = requiredStageKeys({
6883
- local: false,
6884
- services: input.declarations,
6885
- ...input.domain ? { domain: input.domain } : {},
6886
- ...input.providers ? { providers: input.providers } : {}
6887
- });
6888
- const standIn = (k) => allow && k.service !== void 0 && !has(k.key);
6889
- const mailpit = required.filter((k) => k.kind === "email" && k.key === provideKey(k.id, "url")).filter(standIn).map((k) => k.id);
6890
- const minio = required.filter((k) => k.kind === "bucket").filter(standIn).map((k) => k.id);
6891
- const covered = (k) => k.kind === "email" && mailpit.includes(k.id) || k.kind === "bucket" && minio.includes(k.id) || k.kind === "file-server" && k.of !== void 0 && minio.includes(k.of);
6892
- return {
6893
- missing: required.filter((k) => !has(k.key) && !covered(k)).map(({ key, id, what, note, example, service, apps, hint }) => ({
6894
- key,
6895
- id,
6896
- what,
6897
- ...note ? { note } : {},
6898
- example,
6899
- ...service ? { service } : {},
6900
- ...apps ? { apps } : {},
6901
- ...hint ? { hint } : {}
6902
- })),
6903
- minio,
6904
- mailpit
6905
- };
6831
+ function getGeneratedSecret(state, appName, secretName) {
6832
+ return state?.generatedSecrets?.[appName]?.[secretName];
6906
6833
  }
6907
6834
  /**
6908
- * `externalServices`, refusing a stage that lacks anything.
6909
- *
6910
- * @throws {ExternalServicesNotConfigured} naming every missing key
6835
+ * Set a generated secret for an app (mutates state)
6911
6836
  */
6912
- function assertExternalServices(input) {
6913
- const services = externalServices(input);
6914
- if (services.missing.length > 0) throw new ExternalServicesNotConfigured(input.stage, services.missing);
6915
- return services;
6916
- }
6917
- /** The dev services a run uses, one entry per service it runs. */
6918
- function devServicesUsed(services) {
6919
- const used = [];
6920
- if (services.mailpit.length > 0) used.push({
6921
- service: "mailpit",
6922
- ids: services.mailpit
6923
- });
6924
- if (services.minio.length > 0) used.push({
6925
- service: "minio",
6926
- ids: services.minio
6927
- });
6928
- return used;
6929
- }
6930
- /** The line a run prints for each dev service a deployed stage uses. */
6931
- function devServiceWarning(stage, use) {
6932
- return `⚠️ DEV SERVICE ON A DEPLOYED STAGE (${stage}): ${use.service === "mailpit" ? `Mailpit is catching mail for ${use.ids.join(", ")}. It delivers NO mail: nobody receives a sign-in link, a receipt or a reset` : `MinIO is storing ${use.ids.join(", ")}. Every object is on one container's disk, with no replication and no backup`}. It is not production-grade — set the stage's own keys in its secrets for that.`;
6837
+ function setGeneratedSecret(state, appName, secretName, value) {
6838
+ if (!state.generatedSecrets) state.generatedSecrets = {};
6839
+ if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
6840
+ state.generatedSecrets[appName][secretName] = value;
6933
6841
  }
6934
6842
  /**
6935
- * Report each dev service a run uses: a loud line, and a `dev-service.used`
6936
- * event a host can act on.
6843
+ * Set DNS verification record for a hostname (mutates state)
6937
6844
  */
6938
- function reportDevServices(ctx, uses) {
6939
- for (const use of uses) {
6940
- ctx.logger.warn(`\n${devServiceWarning(ctx.stage, use)}`);
6941
- ctx.emit({
6942
- type: "dev-service.used",
6943
- service: use.service,
6944
- stage: ctx.stage,
6945
- constructs: use.ids
6946
- });
6947
- }
6845
+ function setDnsVerification(state, hostname, serverIp) {
6846
+ if (!state.dnsVerified) state.dnsVerified = {};
6847
+ state.dnsVerified[hostname] = {
6848
+ serverIp,
6849
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
6850
+ };
6948
6851
  }
6949
- //#endregion
6950
- //#region src/deploy/StateProvider.ts
6951
6852
  /**
6952
- * Check if value is a StateProvider implementation.
6853
+ * Check if a hostname is already verified with the given IP
6953
6854
  */
6954
- function isStateProvider(value) {
6955
- return typeof value === "object" && value !== null && typeof value.read === "function" && typeof value.write === "function";
6855
+ function isDnsVerified(state, hostname, serverIp) {
6856
+ return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
6956
6857
  }
6957
- //#endregion
6958
- //#region src/deploy/StateStore.ts
6959
6858
  /**
6960
- * Deploy state store.
6961
- *
6962
- * `StateProvider` reads and writes a whole stage at once, with nothing to stop
6963
- * two runs of the same stage from overwriting each other and nowhere to record
6964
- * a resource the moment it exists. A `StateStore` adds the three things a
6965
- * deploy needs to be safe against crashes and concurrent runs:
6966
- *
6967
- * - `lock(stage)`: one run per stage. A second gets `StateLocked`, naming the
6968
- * holder, instead of racing the first.
6969
- * - Versioned `read` and conditional `write`: a write names the version it was
6970
- * based on, and raises `StateVersionConflict` when the stored state moved on
6971
- * underneath it, so nothing is lost silently even without the lock.
6972
- * - Per-resource records: `putResource` journals one resource at a time, so a
6973
- * run that dies half way leaves behind the ids of everything it created.
6974
- *
6975
- * Built-in stores: `LocalStateStore`, `SSMStateStore`, `S3StateStore`. A
6976
- * custom `StateProvider` is wrapped by `LegacyStateStore`, which keeps it
6977
- * working but cannot lock.
6859
+ * Get the key for a DNS record in state
6978
6860
  */
6979
- var StateLocked = class extends GkmError {
6980
- stage;
6981
- holder;
6982
- location;
6983
- constructor(stage, holder, location) {
6984
- 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";
6985
- 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}\`.`);
6986
- this.stage = stage;
6987
- this.holder = holder;
6988
- this.location = location;
6989
- this.name = "StateLocked";
6990
- }
6991
- };
6992
- var StateVersionConflict = class extends GkmError {
6993
- stage;
6994
- expectedVersion;
6995
- actualVersion;
6996
- constructor(stage, expectedVersion, actualVersion) {
6997
- 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.`);
6998
- this.stage = stage;
6999
- this.expectedVersion = expectedVersion;
7000
- this.actualVersion = actualVersion;
7001
- this.name = "StateVersionConflict";
7002
- }
7003
- };
7004
- var StageStateMissing = class extends GkmError {
7005
- stage;
7006
- constructor(stage) {
7007
- super(`There is no deploy state for stage '${stage}' to record a resource in. Write the stage's state first (write with expectedVersion: null).`);
7008
- this.stage = stage;
7009
- this.name = "StageStateMissing";
7010
- }
7011
- };
7012
- var StateUnreadable = class extends GkmError {
7013
- stage;
7014
- location;
7015
- constructor(stage, location, cause) {
7016
- 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 });
7017
- this.stage = stage;
7018
- this.location = location;
7019
- this.name = "StateUnreadable";
7020
- }
7021
- };
7022
- var StateSchemaTooNew = class extends GkmError {
7023
- stage;
7024
- schemaVersion;
7025
- constructor(stage, schemaVersion) {
7026
- 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.`);
7027
- this.stage = stage;
7028
- this.schemaVersion = schemaVersion;
7029
- this.name = "StateSchemaTooNew";
7030
- }
7031
- };
6861
+ function getDnsRecordKey(name, type) {
6862
+ return `${name}:${type}`;
6863
+ }
7032
6864
  /**
7033
- * Not thrown: emitted (once per store) when a custom `StateProvider` stands
7034
- * in for a store, so a deploy that cannot be protected from a concurrent one
7035
- * says so.
6865
+ * Set a created DNS record in state (mutates state)
7036
6866
  */
7037
- var StateStoreWithoutLocking = class extends GkmError {
7038
- provider;
7039
- constructor(provider) {
7040
- 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.`);
7041
- this.provider = provider;
7042
- this.name = "StateStoreWithoutLocking";
7043
- }
7044
- };
7045
- var StateStoreNeedsWorkspaceName = class extends GkmError {
7046
- provider;
7047
- constructor(provider) {
7048
- super(`The '${provider}' state provider keys state by workspace name. Set "name" in gkm.config.ts.`);
7049
- this.provider = provider;
7050
- this.name = "StateStoreNeedsWorkspaceName";
7051
- }
7052
- };
7053
- var UnknownStateProvider = class extends GkmError {
7054
- config;
7055
- constructor(config) {
7056
- super(`Unknown state provider ${JSON.stringify(config)}. Use 'local', 'ssm', 's3', or an object implementing StateStore or StateProvider.`);
7057
- this.config = config;
7058
- this.name = "UnknownStateProvider";
7059
- }
7060
- };
7061
- /** A lock or write that could not get at the state backend in time. */
7062
- var StateStoreBusy = class extends GkmError {
7063
- stage;
7064
- location;
7065
- constructor(stage, location) {
7066
- super(`Timed out waiting to write deploy state for stage '${stage}' (${location}). If no other gkm process is running, remove ${location}.`);
7067
- this.stage = stage;
7068
- this.location = location;
7069
- this.name = "StateStoreBusy";
7070
- }
7071
- };
7072
- function decodeDocument(stage, body, location) {
7073
- let parsed;
7074
- try {
7075
- parsed = JSON.parse(body);
7076
- } catch (error) {
7077
- throw new StateUnreadable(stage, location, error);
7078
- }
7079
- if (typeof parsed !== "object" || parsed === null) throw new StateUnreadable(stage, location, parsed);
7080
- const schemaVersion = parsed.schemaVersion;
7081
- if (schemaVersion === void 0) return {
7082
- schemaVersion: 1,
7083
- state: parsed
7084
- };
7085
- if (schemaVersion === 2) return {
7086
- schemaVersion: 2,
7087
- document: parsed
6867
+ function setDnsRecord(state, record) {
6868
+ if (!state.dnsRecords) state.dnsRecords = {};
6869
+ const key = getDnsRecordKey(record.name, record.type);
6870
+ state.dnsRecords[key] = {
6871
+ ...record,
6872
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
7088
6873
  };
7089
- throw new StateSchemaTooNew(stage, schemaVersion);
7090
- }
7091
- function encodeDocument(document) {
7092
- return JSON.stringify(document, null, 2);
7093
6874
  }
7094
6875
  /**
7095
- * v1 state as a v2 document. The ids v1 kept are seeded as `ready` records,
7096
- * so the first journaled deploy after an upgrade adopts them rather than
7097
- * treating them as unknown.
6876
+ * Get backup state from state
7098
6877
  */
7099
- function migrateV1(stage, state) {
7100
- const now = (/* @__PURE__ */ new Date()).toISOString();
7101
- const resources = {};
7102
- const seed = (type, name, id) => {
7103
- if (!id) return;
7104
- const key = name ? `${type}:${name}` : type;
7105
- resources[key] = {
7106
- key,
7107
- type,
7108
- id,
7109
- status: "ready",
7110
- updatedAt: now
7111
- };
7112
- };
7113
- seed("project", null, state.projectId);
7114
- seed("environment", null, state.environmentId);
7115
- for (const [app, id] of Object.entries(state.applications ?? {})) seed("application", app, id);
7116
- seed("postgres", null, state.services?.postgresId);
7117
- seed("redis", null, state.services?.redisId);
7118
- seed("backup-destination", null, state.backups?.destinationId);
7119
- return {
7120
- schemaVersion: 2,
7121
- stage,
7122
- serial: 1,
7123
- state,
7124
- resources,
7125
- updatedAt: now
7126
- };
6878
+ function getBackupState(state) {
6879
+ return state?.backups;
7127
6880
  }
7128
- function newLockHolder(options) {
7129
- let owner = "unknown";
7130
- try {
7131
- owner = userInfo().username;
7132
- } catch {}
7133
- return {
7134
- id: randomUUID(),
7135
- owner,
7136
- host: hostname(),
7137
- pid: process.pid,
7138
- acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
7139
- operation: options?.operation
7140
- };
6881
+ /**
6882
+ * Set backup state (mutates state)
6883
+ */
6884
+ function setBackupState(state, backupState) {
6885
+ state.backups = backupState;
7141
6886
  }
7142
- function contentVersion(body) {
7143
- return createHash("sha256").update(body).digest("hex").slice(0, 16);
6887
+ /**
6888
+ * Set postgres backup ID in state (mutates state)
6889
+ */
6890
+ function setPostgresBackupId(state, backupId) {
6891
+ if (state.backups) state.backups.postgresBackupId = backupId;
7144
6892
  }
6893
+ //#endregion
6894
+ //#region src/target/dokploy/dns/index.ts
7145
6895
  /**
7146
- * A store over a backend that can read and conditionally write one blob per
7147
- * stage, and create a lock object only if it does not exist. Migration and
7148
- * resource records are the same for every such backend, so they live here.
6896
+ * DNS orchestration for deployments
6897
+ *
6898
+ * Handles automatic DNS record creation for deployed applications.
7149
6899
  */
7150
- var DocumentStateStore = class {
7151
- async lock(stage, options) {
7152
- const holder = newLockHolder(options);
7153
- await this.createLock(stage, holder);
7154
- let released = false;
7155
- return {
7156
- stage,
7157
- holder,
7158
- release: async () => {
7159
- if (released) return;
7160
- released = true;
7161
- await this.removeLock(stage, holder);
7162
- }
7163
- };
7164
- }
7165
- async forceUnlock(stage) {
7166
- const holder = await this.readLock(stage);
7167
- await this.removeLock(stage, null);
7168
- return holder;
7169
- }
7170
- async read(stage) {
7171
- const current = await this.readDocument(stage);
7172
- if (!current) return null;
7173
- return {
7174
- state: current.document.state,
7175
- resources: current.document.resources,
7176
- version: current.version
7177
- };
7178
- }
7179
- async write(stage, state, options) {
7180
- if (options.expectedVersion === null) {
7181
- const document = {
7182
- schemaVersion: 2,
7183
- stage,
7184
- serial: 1,
7185
- state,
7186
- resources: {},
7187
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7188
- };
7189
- return this.writeRaw(stage, encodeDocument(document), null);
7190
- }
7191
- return this.mutate(stage, options.expectedVersion, (document) => ({
7192
- ...document,
7193
- state
7194
- }));
7195
- }
7196
- async putResource(stage, record, options) {
7197
- return this.mutate(stage, options?.expectedVersion, (document) => ({
7198
- ...document,
7199
- resources: {
7200
- ...document.resources,
7201
- [record.key]: {
7202
- ...record,
7203
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7204
- }
7205
- }
7206
- }));
7207
- }
7208
- async deleteResource(stage, key, options) {
7209
- return this.mutate(stage, options?.expectedVersion, (document) => {
7210
- const { [key]: _removed, ...resources } = document.resources;
7211
- return {
7212
- ...document,
7213
- resources
7214
- };
7215
- });
7216
- }
7217
- async mutate(stage, expectedVersion, change) {
7218
- const current = await this.readDocument(stage);
7219
- if (!current) {
7220
- if (expectedVersion !== void 0) throw new StateVersionConflict(stage, expectedVersion, null);
7221
- throw new StageStateMissing(stage);
7222
- }
7223
- if (expectedVersion !== void 0 && current.version !== expectedVersion) throw new StateVersionConflict(stage, expectedVersion, current.version);
7224
- const next = change(current.document);
7225
- next.serial = current.document.serial + 1;
7226
- next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7227
- return this.writeRaw(stage, encodeDocument(next), current.version);
7228
- }
7229
- /**
7230
- * Reads the stage's document, migrating v1 in place on the way. The
7231
- * migration is itself a conditional write: if another run migrated (or
7232
- * wrote) first, its result is read instead.
7233
- */
7234
- async readDocument(stage, attempt = 0) {
7235
- const raw = await this.readRaw(stage);
7236
- if (!raw) return null;
7237
- const decoded = decodeDocument(stage, raw.body, this.location(stage));
7238
- if (decoded.schemaVersion === 2) return {
7239
- document: decoded.document,
7240
- version: raw.version
7241
- };
7242
- await this.writeV1Backup(stage, raw.body);
7243
- const document = migrateV1(stage, decoded.state);
7244
- try {
7245
- return {
7246
- document,
7247
- version: await this.writeRaw(stage, encodeDocument(document), raw.version)
7248
- };
7249
- } catch (error) {
7250
- if (error instanceof StateVersionConflict && attempt < 2) return this.readDocument(stage, attempt + 1);
7251
- throw error;
7252
- }
7253
- }
7254
- };
7255
- function isStateStore(value) {
7256
- return typeof value === "object" && value !== null && typeof value.lock === "function" && typeof value.read === "function" && typeof value.write === "function" && typeof value.putResource === "function";
7257
- }
7258
- /**
7259
- * The store for a workspace's `state` config:
7260
- *
7261
- * - none / `'local'`: `LocalStateStore` under `.gkm/`
7262
- * - `'ssm'`: `SSMStateStore`, with no local cache — SSM is read directly, so
7263
- * a stale local copy can never win over it
7264
- * - `'s3'`: `S3StateStore`
7265
- * - a `StateStore` object: used as is
7266
- * - a `StateProvider` object: wrapped by `LegacyStateStore`
7267
- */
7268
- async function createStateStore(options) {
7269
- const { config, workspaceRoot, workspaceName } = options;
7270
- if (!config || config.provider === "local") {
7271
- const { LocalStateStore } = await import("./LocalStateStore-Y8XHdoCR.mjs").then((n) => n.n);
7272
- return new LocalStateStore(workspaceRoot);
7273
- }
7274
- if (isStateStore(config.provider)) return config.provider;
7275
- if (isStateProvider(config.provider)) {
7276
- const { LegacyStateStore } = await import("./LegacyStateStore-DdJ1bCRV.mjs");
7277
- return new LegacyStateStore(config.provider, { warn: options.warn });
7278
- }
7279
- if (config.provider === "ssm") {
7280
- if (!workspaceName) throw new StateStoreNeedsWorkspaceName("ssm");
7281
- const { SSMStateStore } = await import("./SSMStateStore-Bdyw_0Jj.mjs");
7282
- const ssm = config;
7283
- return SSMStateStore.create({
7284
- workspaceName,
7285
- region: ssm.region,
7286
- profile: ssm.profile
7287
- });
7288
- }
7289
- if (config.provider === "s3") {
7290
- if (!workspaceName) throw new StateStoreNeedsWorkspaceName("s3");
7291
- const { S3StateStore } = await import("./S3StateStore-BfoY4MvD.mjs");
7292
- const s3 = config;
7293
- return S3StateStore.create({
7294
- workspaceName,
7295
- bucket: s3.bucket,
7296
- prefix: s3.prefix,
7297
- region: s3.region,
7298
- profile: s3.profile
7299
- });
7300
- }
7301
- throw new UnknownStateProvider(config);
6900
+ const logger$1 = output;
6901
+ /**
6902
+ * Check if DNS config is legacy format (single domain with `domain` property)
6903
+ */
6904
+ function isLegacyDnsConfig(config) {
6905
+ return typeof config === "object" && config !== null && "provider" in config && "domain" in config;
7302
6906
  }
7303
- //#endregion
7304
- //#region src/target/dokploy/dns/DnsProvider.ts
7305
6907
  /**
7306
- * Check if value is a DnsProvider implementation.
6908
+ * Normalize DNS config to new multi-domain format
7307
6909
  */
7308
- function isDnsProvider(value) {
7309
- return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.getRecords === "function" && typeof value.upsertRecords === "function" && typeof value.deleteRecords === "function";
6910
+ function normalizeDnsConfig(config) {
6911
+ if (isLegacyDnsConfig(config)) {
6912
+ const { domain, ...providerConfig } = config;
6913
+ return { [domain]: providerConfig };
6914
+ }
6915
+ return config;
7310
6916
  }
7311
6917
  /**
7312
- * Create a DNS provider based on configuration.
6918
+ * Find the root domain for a hostname from available DNS configs
7313
6919
  *
7314
- * - 'hostinger': HostingerProvider
7315
- * - 'route53': Route53Provider
7316
- * - 'godaddy': GoDaddyProvider
7317
- * - 'manual': Returns null (user handles DNS)
7318
- * - Custom: Use provided DnsProvider implementation
6920
+ * @example
6921
+ * findRootDomain('api.geekmidas.com', { 'geekmidas.com': {...}, 'geekmidas.dev': {...} })
6922
+ * // Returns 'geekmidas.com'
7319
6923
  */
7320
- async function createDnsProvider(options) {
7321
- const { config } = options;
7322
- if (config.provider === "manual") return null;
7323
- if (isDnsProvider(config.provider)) return config.provider;
7324
- const provider = config.provider;
7325
- if (provider === "hostinger") {
7326
- const { HostingerProvider } = await import("./HostingerProvider-1mqD6aG8.mjs");
7327
- return new HostingerProvider();
7328
- }
7329
- if (provider === "route53") {
7330
- const { Route53Provider } = await import("./Route53Provider-QXxjkZO1.mjs");
7331
- const route53Config = config;
7332
- return new Route53Provider({
7333
- region: route53Config.region,
7334
- profile: route53Config.profile,
7335
- hostedZoneId: route53Config.hostedZoneId
7336
- });
7337
- }
7338
- if (provider === "godaddy") {
7339
- const { GoDaddyProvider } = await import("./GoDaddyProvider-BqYGk9XT.mjs");
7340
- return new GoDaddyProvider(options.godaddy ?? {});
7341
- }
7342
- if (provider === "cloudflare") throw new DnsProviderNotImplemented("cloudflare");
7343
- throw new DnsProviderUnknown(config);
6924
+ function findRootDomain(hostname, dnsConfig) {
6925
+ const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
6926
+ for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
6927
+ return null;
7344
6928
  }
7345
6929
  /**
7346
- * The provider's credentials can write records and not read them — a key
7347
- * scoped to updates only. What reads them falls back to writing every record,
7348
- * which is idempotent, without a diff.
6930
+ * Group hostnames by their root domain
7349
6931
  */
7350
- var DnsRecordsUnreadable = class extends GkmError {
7351
- provider;
7352
- domain;
7353
- reason;
7354
- constructor(provider, domain, reason) {
7355
- super(`The ${provider} key cannot read ${domain}'s DNS records (${reason}), so changes cannot be compared with what is there: every record is written, which leaves one that already has its value as it was.`);
7356
- this.provider = provider;
7357
- this.domain = domain;
7358
- this.reason = reason;
7359
- this.name = "DnsRecordsUnreadable";
7360
- }
7361
- };
7362
- /** A DNS provider gkm names and does not have yet. */
7363
- var DnsProviderNotImplemented = class extends Error {
7364
- provider;
7365
- constructor(provider) {
7366
- super(`The ${provider} DNS provider is not implemented yet. Use 'route53', 'godaddy', 'hostinger' or 'manual' for this domain in dns, or pass your own DnsProvider object.`);
7367
- this.provider = provider;
7368
- this.name = "DnsProviderNotImplemented";
7369
- }
7370
- };
7371
- /** A `provider:` that is none of the built-ins, nor a DnsProvider object. */
7372
- var DnsProviderUnknown = class extends GkmError {
7373
- config;
7374
- constructor(config) {
7375
- super(`Unknown DNS provider: ${JSON.stringify(config)}. dns takes 'route53', 'godaddy', 'hostinger', 'manual', or an object implementing DnsProvider.`);
7376
- this.config = config;
7377
- this.name = "DnsProviderUnknown";
6932
+ function groupHostnamesByDomain(appHostnames, dnsConfig) {
6933
+ const grouped = /* @__PURE__ */ new Map();
6934
+ for (const [appName, hostname] of appHostnames) {
6935
+ const rootDomain = findRootDomain(hostname, dnsConfig);
6936
+ if (!rootDomain) {
6937
+ logger$1.log(` ⚠ No DNS config found for hostname: ${hostname}`);
6938
+ continue;
6939
+ }
6940
+ if (!grouped.has(rootDomain)) grouped.set(rootDomain, /* @__PURE__ */ new Map());
6941
+ grouped.get(rootDomain).set(appName, hostname);
7378
6942
  }
7379
- };
7380
- //#endregion
7381
- //#region src/deploy/state.ts
6943
+ return grouped;
6944
+ }
7382
6945
  /**
7383
- * Create a new empty state for a stage
6946
+ * Resolve IP address from a hostname
7384
6947
  */
7385
- function createEmptyState(stage, projectId, environmentId) {
7386
- return {
7387
- provider: "dokploy",
7388
- stage,
7389
- projectId,
7390
- environmentId,
7391
- applications: {},
7392
- services: {},
7393
- lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
7394
- };
6948
+ async function resolveHostnameToIp(hostname) {
6949
+ try {
6950
+ return (await lookup(hostname, { family: 4 })).address;
6951
+ } catch (error) {
6952
+ throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
6953
+ }
7395
6954
  }
7396
6955
  /**
7397
- * Set application ID in state (mutates state)
6956
+ * Extract subdomain from full hostname relative to root domain
6957
+ *
6958
+ * @example
6959
+ * extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
6960
+ * extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
7398
6961
  */
7399
- function setApplicationId(state, appName, applicationId) {
7400
- state.applications[appName] = applicationId;
7401
- }
7402
- /** Two records naming the same image: by digest when both have one. */
7403
- function sameImage(a, b) {
7404
- return a.digest && b.digest ? a.digest === b.digest : a.ref === b.ref;
7405
- }
7406
- /** The newest release in `history` after `index` that a rollback may restore. */
7407
- function restorable(history, index, current) {
7408
- return history.slice(index + 1).find((entry) => !entry.rolledBack && !sameImage(entry, current));
6962
+ function extractSubdomain(hostname, rootDomain) {
6963
+ if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
6964
+ return hostname.slice(0, -(rootDomain.length + 1)) || "@";
7409
6965
  }
7410
6966
  /**
7411
- * Record that `image` went live for `appName` (mutates state): it becomes
7412
- * `current`, and what was current becomes `previous`. Releasing what already
7413
- * runs — a redeploy of the same digest — changes nothing.
6967
+ * Generate required DNS records for a deployment
7414
6968
  */
7415
- function recordRelease(state, appName, image, releasedAt = /* @__PURE__ */ new Date()) {
7416
- const releases = state.releases?.[appName];
7417
- if (releases && sameImage(releases.current, image)) return;
7418
- const entry = {
7419
- ...image,
7420
- releasedAt: releasedAt.toISOString()
7421
- };
7422
- const history = [entry, ...releases?.history ?? []].slice(0, 10);
7423
- const previous = releases?.current;
7424
- state.releases = {
7425
- ...state.releases,
7426
- [appName]: {
7427
- current: entry,
7428
- ...previous ? { previous } : {},
7429
- history
7430
- }
7431
- };
6969
+ function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
6970
+ const records = [];
6971
+ for (const [appName, hostname] of appHostnames) {
6972
+ const subdomain = extractSubdomain(hostname, rootDomain);
6973
+ records.push({
6974
+ hostname,
6975
+ subdomain,
6976
+ type: "A",
6977
+ value: serverIp,
6978
+ appName
6979
+ });
6980
+ }
6981
+ return records;
7432
6982
  }
7433
6983
  /**
7434
- * Record that `appName` was rolled back to `to` (mutates state): every
7435
- * release newer than it is marked rolled back, so neither this rollback nor
7436
- * a later one restores it, and `previous` moves to the release before `to`.
6984
+ * Print DNS records table
7437
6985
  */
7438
- function recordRollback(state, appName, to) {
7439
- const releases = state.releases?.[appName];
7440
- if (!releases) return;
7441
- const index = releases.history.findIndex((entry) => !entry.rolledBack && sameImage(entry, to));
7442
- if (index === -1) return;
7443
- const history = releases.history.map((entry, i) => i < index ? {
7444
- ...entry,
7445
- rolledBack: true
7446
- } : entry);
7447
- const current = history[index];
7448
- const previous = restorable(history, index, current);
7449
- state.releases = {
7450
- ...state.releases,
7451
- [appName]: {
7452
- current,
7453
- ...previous ? { previous } : {},
7454
- history
7455
- }
7456
- };
6986
+ function printDnsRecordsTable(records, rootDomain) {
6987
+ logger$1.log(`\n 📋 DNS Records for ${rootDomain}:`);
6988
+ logger$1.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
6989
+ logger$1.log(" │ Subdomain │ Type │ Value │ Status │");
6990
+ logger$1.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
6991
+ for (const record of records) {
6992
+ const subdomain = record.subdomain.padEnd(35);
6993
+ const type = record.type.padEnd(4);
6994
+ const value = record.value.padEnd(15);
6995
+ let status;
6996
+ if (record.error) status = "✗";
6997
+ else if (record.created) status = "✓ new";
6998
+ else if (record.existed) status = "✓";
6999
+ else status = "?";
7000
+ logger$1.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
7001
+ }
7002
+ logger$1.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
7457
7003
  }
7458
7004
  /**
7459
- * Get a generated secret for an app
7005
+ * Print DNS records in a simple format for manual setup
7460
7006
  */
7461
- function getGeneratedSecret(state, appName, secretName) {
7462
- return state?.generatedSecrets?.[appName]?.[secretName];
7463
- }
7464
- /**
7465
- * Set a generated secret for an app (mutates state)
7466
- */
7467
- function setGeneratedSecret(state, appName, secretName, value) {
7468
- if (!state.generatedSecrets) state.generatedSecrets = {};
7469
- if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
7470
- state.generatedSecrets[appName][secretName] = value;
7471
- }
7472
- /**
7473
- * Set DNS verification record for a hostname (mutates state)
7474
- */
7475
- function setDnsVerification(state, hostname, serverIp) {
7476
- if (!state.dnsVerified) state.dnsVerified = {};
7477
- state.dnsVerified[hostname] = {
7478
- serverIp,
7479
- verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
7480
- };
7481
- }
7482
- /**
7483
- * Check if a hostname is already verified with the given IP
7484
- */
7485
- function isDnsVerified(state, hostname, serverIp) {
7486
- return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
7487
- }
7488
- /**
7489
- * Get the key for a DNS record in state
7490
- */
7491
- function getDnsRecordKey(name, type) {
7492
- return `${name}:${type}`;
7493
- }
7494
- /**
7495
- * Set a created DNS record in state (mutates state)
7496
- */
7497
- function setDnsRecord(state, record) {
7498
- if (!state.dnsRecords) state.dnsRecords = {};
7499
- const key = getDnsRecordKey(record.name, record.type);
7500
- state.dnsRecords[key] = {
7501
- ...record,
7502
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
7503
- };
7504
- }
7505
- /**
7506
- * Get backup state from state
7507
- */
7508
- function getBackupState(state) {
7509
- return state?.backups;
7510
- }
7511
- /**
7512
- * Set backup state (mutates state)
7513
- */
7514
- function setBackupState(state, backupState) {
7515
- state.backups = backupState;
7516
- }
7517
- /**
7518
- * Set postgres backup ID in state (mutates state)
7519
- */
7520
- function setPostgresBackupId(state, backupId) {
7521
- if (state.backups) state.backups.postgresBackupId = backupId;
7522
- }
7523
- //#endregion
7524
- //#region src/target/dokploy/dns/index.ts
7525
- /**
7526
- * DNS orchestration for deployments
7527
- *
7528
- * Handles automatic DNS record creation for deployed applications.
7529
- */
7530
- const logger$1 = output;
7531
- /**
7532
- * Check if DNS config is legacy format (single domain with `domain` property)
7533
- */
7534
- function isLegacyDnsConfig(config) {
7535
- return typeof config === "object" && config !== null && "provider" in config && "domain" in config;
7536
- }
7537
- /**
7538
- * Normalize DNS config to new multi-domain format
7539
- */
7540
- function normalizeDnsConfig(config) {
7541
- if (isLegacyDnsConfig(config)) {
7542
- const { domain, ...providerConfig } = config;
7543
- return { [domain]: providerConfig };
7544
- }
7545
- return config;
7546
- }
7547
- /**
7548
- * Find the root domain for a hostname from available DNS configs
7549
- *
7550
- * @example
7551
- * findRootDomain('api.geekmidas.com', { 'geekmidas.com': {...}, 'geekmidas.dev': {...} })
7552
- * // Returns 'geekmidas.com'
7553
- */
7554
- function findRootDomain(hostname, dnsConfig) {
7555
- const domains = Object.keys(dnsConfig).sort((a, b) => b.length - a.length);
7556
- for (const domain of domains) if (hostname === domain || hostname.endsWith(`.${domain}`)) return domain;
7557
- return null;
7558
- }
7559
- /**
7560
- * Group hostnames by their root domain
7561
- */
7562
- function groupHostnamesByDomain(appHostnames, dnsConfig) {
7563
- const grouped = /* @__PURE__ */ new Map();
7564
- for (const [appName, hostname] of appHostnames) {
7565
- const rootDomain = findRootDomain(hostname, dnsConfig);
7566
- if (!rootDomain) {
7567
- logger$1.log(` ⚠ No DNS config found for hostname: ${hostname}`);
7568
- continue;
7569
- }
7570
- if (!grouped.has(rootDomain)) grouped.set(rootDomain, /* @__PURE__ */ new Map());
7571
- grouped.get(rootDomain).set(appName, hostname);
7572
- }
7573
- return grouped;
7574
- }
7575
- /**
7576
- * Resolve IP address from a hostname
7577
- */
7578
- async function resolveHostnameToIp(hostname) {
7579
- try {
7580
- return (await lookup(hostname, { family: 4 })).address;
7581
- } catch (error) {
7582
- throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
7583
- }
7584
- }
7585
- /**
7586
- * Extract subdomain from full hostname relative to root domain
7587
- *
7588
- * @example
7589
- * extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
7590
- * extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
7591
- */
7592
- function extractSubdomain(hostname, rootDomain) {
7593
- if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
7594
- return hostname.slice(0, -(rootDomain.length + 1)) || "@";
7595
- }
7596
- /**
7597
- * Generate required DNS records for a deployment
7598
- */
7599
- function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
7600
- const records = [];
7601
- for (const [appName, hostname] of appHostnames) {
7602
- const subdomain = extractSubdomain(hostname, rootDomain);
7603
- records.push({
7604
- hostname,
7605
- subdomain,
7606
- type: "A",
7607
- value: serverIp,
7608
- appName
7609
- });
7610
- }
7611
- return records;
7612
- }
7613
- /**
7614
- * Print DNS records table
7615
- */
7616
- function printDnsRecordsTable(records, rootDomain) {
7617
- logger$1.log(`\n 📋 DNS Records for ${rootDomain}:`);
7618
- logger$1.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
7619
- logger$1.log(" │ Subdomain │ Type │ Value │ Status │");
7620
- logger$1.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
7621
- for (const record of records) {
7622
- const subdomain = record.subdomain.padEnd(35);
7623
- const type = record.type.padEnd(4);
7624
- const value = record.value.padEnd(15);
7625
- let status;
7626
- if (record.error) status = "✗";
7627
- else if (record.created) status = "✓ new";
7628
- else if (record.existed) status = "✓";
7629
- else status = "?";
7630
- logger$1.log(` │ ${subdomain} │ ${type} │ ${value} │ ${status.padEnd(6)} │`);
7631
- }
7632
- logger$1.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
7633
- }
7634
- /**
7635
- * Print DNS records in a simple format for manual setup
7636
- */
7637
- function printDnsRecordsSimple(records, rootDomain) {
7638
- logger$1.log("\n 📋 Required DNS Records:");
7639
- logger$1.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
7640
- for (const record of records) logger$1.log(` ${record.subdomain} → ${record.value} (A record)`);
7641
- logger$1.log("");
7007
+ function printDnsRecordsSimple(records, rootDomain) {
7008
+ logger$1.log("\n 📋 Required DNS Records:");
7009
+ logger$1.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
7010
+ for (const record of records) logger$1.log(` ${record.subdomain} → ${record.value} (A record)`);
7011
+ logger$1.log("");
7642
7012
  }
7643
7013
  /**
7644
7014
  * Create DNS records for a single domain using its configured provider
@@ -7865,315 +7235,643 @@ async function verifyDnsRecords(appHostnames, serverIp, state) {
7865
7235
  return results;
7866
7236
  }
7867
7237
  //#endregion
7868
- //#region src/docker/imageRef.ts
7238
+ //#region src/compose/dns.ts
7239
+ /**
7240
+ * A compose stage's DNS: the records its public hosts need, and the check that
7241
+ * they resolve to its server before a certificate is asked for.
7242
+ *
7243
+ * - **The server** (`serverAddress`): the stage's own secrets hold its public
7244
+ * addresses — `GKM_SERVER_IPV4`, and `GKM_SERVER_IPV6` where it has one.
7245
+ * Reserved for gkm: never handed to an app.
7246
+ * - **The plan** (`planStackDns`, pure): every public host the stack serves —
7247
+ * its apex, each app, each file server, the public log UI — pointed at the
7248
+ * server. An A record per host (and an AAAA with an IPv6 address), or, where
7249
+ * the domain's `dns` entry says `records: { mode: 'cname', target }`, one A
7250
+ * record for the target and a CNAME to it for every other host but the
7251
+ * apex, which DNS forbids a CNAME at.
7252
+ * - **Applying it** (`applyStackDns`): through the domain's provider in `dns`,
7253
+ * a record set at a time. A record with the right value is left alone; one
7254
+ * with another value is replaced and the change printed. A `manual` domain
7255
+ * is printed and nothing else. A dry run reads, prints and writes nothing.
7256
+ * - **The check** (`checkStackDns`): each host resolved with the system
7257
+ * resolver. One that is not the server stops the deploy with
7258
+ * {@link HostNotPointingAtServer}, before Caddy or Traefik ask Let's Encrypt
7259
+ * for a certificate it cannot get — and use up the rate limit trying.
7260
+ * - **The step** (`stageDns`): all three, as every deploy runs them — the plan applied through each provider,
7261
+ * what was written read back from the provider, and only the hosts no
7262
+ * provider wrote resolved publicly. A brand-new name is never looked up
7263
+ * before its record exists, so no resolver caches it as missing.
7264
+ */
7265
+ /** The line that says how to set the server's address. */
7266
+ function serverAddressHint(stage) {
7267
+ return `gkm secrets:set ${SERVER_IPV4_KEY} '<ip>' --stage ${stage}`;
7268
+ }
7269
+ /** A server address in the stage's secrets that is not an address. */
7270
+ var ServerAddressInvalid = class extends GkmError {
7271
+ stage;
7272
+ key;
7273
+ value;
7274
+ constructor(stage, key, value) {
7275
+ const ipv4 = key === SERVER_IPV4_KEY;
7276
+ super(`${key} in the '${stage}' stage's secrets is ${JSON.stringify(value)}, which is not an ${ipv4 ? "IPv4 address (like 203.0.113.10)" : "IPv6 address (like 2001:db8::10)"}. Set the server's public address: gkm secrets:set ${key} '<ip>' --stage ${stage}.`);
7277
+ this.stage = stage;
7278
+ this.key = key;
7279
+ this.value = value;
7280
+ this.name = "ServerAddressInvalid";
7281
+ }
7282
+ };
7869
7283
  /**
7870
- * An image reference, checked before it reaches `docker`.
7871
- *
7872
- * The parts come from the workspace — a registry in `gkm.config.ts`, an image
7873
- * name from the project, a tag from `--tag` — and an argument array already
7874
- * keeps them out of a shell. What it cannot do is stop `docker` reading a value
7875
- * that starts with `-` as one of its own flags, so a ref is held to Docker's
7876
- * own grammar (distribution/reference) and anything else is refused by name
7877
- * before a build starts.
7284
+ * A compose stage that serves real domains with no server address: its hosts
7285
+ * cannot be pointed or checked, and a certificate would be asked for blind.
7878
7286
  */
7879
- const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
7880
- const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
7881
- const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
7882
- 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,})?$`);
7883
- /** Docker refuses a repository name longer than this. */
7884
- const MAX_NAME_LENGTH = 255;
7885
- /** A value that is not an image reference was about to be built or pushed. */
7886
- var ImageRefInvalid = class extends GkmError {
7887
- ref;
7888
- constructor(ref) {
7889
- 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.`);
7890
- this.ref = ref;
7891
- this.name = "ImageRefInvalid";
7287
+ var ServerAddressMissing = class extends GkmError {
7288
+ stage;
7289
+ domain;
7290
+ constructor(stage, domain) {
7291
+ super(`The '${stage}' stage serves ${domain} and its secrets hold no ${SERVER_IPV4_KEY} — the public address of the server its stack runs on, which its hosts' DNS records point at and every deploy checks before asking for certificates. Set it: ${serverAddressHint(stage)} (and ${SERVER_IPV6_KEY} for an IPv6 address).`);
7292
+ this.stage = stage;
7293
+ this.domain = domain;
7294
+ this.name = "ServerAddressMissing";
7892
7295
  }
7893
7296
  };
7894
- /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
7895
- function validateImageRef(ref) {
7896
- const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
7897
- if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
7898
- return ref;
7297
+ /**
7298
+ * The server's address from the stage's secrets, checked — `undefined` when
7299
+ * `GKM_SERVER_IPV4` is not set.
7300
+ *
7301
+ * @throws {ServerAddressInvalid} for a value that is not an address
7302
+ */
7303
+ function serverAddress(stage, custom) {
7304
+ const ipv4 = custom?.[SERVER_IPV4_KEY]?.trim();
7305
+ const ipv6 = custom?.[SERVER_IPV6_KEY]?.trim();
7306
+ if (ipv6 && !isIPv6(ipv6)) throw new ServerAddressInvalid(stage, SERVER_IPV6_KEY, ipv6);
7307
+ if (!ipv4) return void 0;
7308
+ if (!isIPv4(ipv4)) throw new ServerAddressInvalid(stage, SERVER_IPV4_KEY, ipv4);
7309
+ return {
7310
+ ipv4,
7311
+ ...ipv6 ? { ipv6 } : {}
7312
+ };
7899
7313
  }
7900
- //#endregion
7901
- //#region src/docker/index.ts
7902
- const logger = output;
7903
- /** `docker build` failed; what docker printed above says why. */
7904
- var DockerBuildFailed = class extends Error {
7905
- imageRef;
7906
- constructor(imageRef, cause) {
7907
- super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
7908
- this.imageRef = imageRef;
7909
- this.name = "DockerBuildFailed";
7314
+ /**
7315
+ * A name that resolves to this machine by definition — `localhost` and every
7316
+ * `*.localhost` — and is in nobody's DNS.
7317
+ */
7318
+ function isLocalHost(host) {
7319
+ const name = host.toLowerCase().replace(/\.$/, "");
7320
+ return name === "localhost" || name.endsWith(".localhost");
7321
+ }
7322
+ /**
7323
+ * The server's address, required where the stage serves real domains — not
7324
+ * `*.localhost` ones, which point at this machine already.
7325
+ *
7326
+ * @throws {ServerAddressMissing} when the stage has a domain and no address
7327
+ * @throws {ServerAddressInvalid} for a value that is not an address
7328
+ */
7329
+ function requiredServerAddress(stage, domain, custom) {
7330
+ const server = serverAddress(stage, custom);
7331
+ if (!server && domain && !isLocalHost(domain)) throw new ServerAddressMissing(stage, domain);
7332
+ return server;
7333
+ }
7334
+ const MANAGED = [
7335
+ "A",
7336
+ "AAAA",
7337
+ "CNAME"
7338
+ ];
7339
+ /** Every public host the stack serves, once each, sorted. */
7340
+ function stackHosts(stack) {
7341
+ return [...new Set(stack.routes.map((route) => route.host))].sort();
7342
+ }
7343
+ /** The TTL a domain's records are written with. */
7344
+ function recordTtl(config) {
7345
+ const ttl = "ttl" in config ? config.ttl : void 0;
7346
+ if (ttl) return ttl;
7347
+ return config.provider === "godaddy" ? 600 : 300;
7348
+ }
7349
+ function providerName(config) {
7350
+ return typeof config.provider === "string" ? config.provider : "custom";
7351
+ }
7352
+ /**
7353
+ * A wildcard host: gkm writes one record per public host, so a stage on
7354
+ * another server — or a name someone else points elsewhere — is never caught
7355
+ * by a record it did not ask for.
7356
+ */
7357
+ var DnsWildcardRefused = class extends GkmError {
7358
+ stage;
7359
+ host;
7360
+ constructor(stage, host) {
7361
+ super(`'${stage}' would point ${host} at its server, and gkm never writes a wildcard record: each public host gets its own. Give the app a subdomain of its own instead.`);
7362
+ this.stage = stage;
7363
+ this.host = host;
7364
+ this.name = "DnsWildcardRefused";
7910
7365
  }
7911
7366
  };
7912
- /** `docker push` failed — usually the registry login or its permissions. */
7913
- var DockerPushFailed = class extends Error {
7914
- imageRef;
7915
- constructor(imageRef, cause) {
7916
- super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
7917
- this.imageRef = imageRef;
7918
- this.name = "DockerPushFailed";
7919
- }
7920
- };
7921
- /** `gkm docker --push` was asked for with nowhere to push to. */
7922
- var PushNeedsRegistry = class extends GkmError {
7923
- imageName;
7924
- constructor(imageName) {
7925
- super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
7926
- this.imageName = imageName;
7927
- this.name = "PushNeedsRegistry";
7367
+ /** The records a stage's hosts need — pure. */
7368
+ function planStackDns(input) {
7369
+ const { stage, server } = input;
7370
+ const dns = input.dns ? normalizeDnsConfig(input.dns) : {};
7371
+ const records = /* @__PURE__ */ new Map();
7372
+ const uncovered = [];
7373
+ const add = (host, domain, type, value) => {
7374
+ const name = extractSubdomain(host, domain);
7375
+ records.set(`${domain} ${name} ${type}`, {
7376
+ host,
7377
+ domain,
7378
+ name,
7379
+ type,
7380
+ value,
7381
+ ttl: recordTtl(dns[domain])
7382
+ });
7383
+ };
7384
+ const pointAt = (host, domain) => {
7385
+ add(host, domain, "A", server.ipv4);
7386
+ if (server.ipv6) add(host, domain, "AAAA", server.ipv6);
7387
+ };
7388
+ for (const raw of input.hosts) {
7389
+ const host = raw.toLowerCase();
7390
+ if (host.includes("*")) throw new DnsWildcardRefused(stage, host);
7391
+ const domain = findRootDomain(host, dns);
7392
+ if (!domain) {
7393
+ uncovered.push(host);
7394
+ continue;
7395
+ }
7396
+ const target = cnameTarget(dns[domain].records, stage);
7397
+ if (target) pointAt(target, domain);
7398
+ if (target && host !== domain && host !== target) add(host, domain, "CNAME", target);
7399
+ else pointAt(host, domain);
7400
+ }
7401
+ const domains = /* @__PURE__ */ new Map();
7402
+ for (const record of [...records.values()].sort((a, b) => a.domain.localeCompare(b.domain) || a.name.localeCompare(b.name) || a.type.localeCompare(b.type))) {
7403
+ const config = dns[record.domain];
7404
+ const plan = domains.get(record.domain) ?? {
7405
+ domain: record.domain,
7406
+ provider: providerName(config),
7407
+ config,
7408
+ records: []
7409
+ };
7410
+ plan.records.push(record);
7411
+ domains.set(record.domain, plan);
7412
+ }
7413
+ return {
7414
+ stage,
7415
+ server,
7416
+ domains: [...domains.values()],
7417
+ uncovered: uncovered.sort()
7418
+ };
7419
+ }
7420
+ /** Records a provider accepted and then did not read back with their value. */
7421
+ var DnsRecordsNotConfirmed = class extends GkmError {
7422
+ stage;
7423
+ domain;
7424
+ provider;
7425
+ records;
7426
+ constructor(stage, domain, provider, records) {
7427
+ const lines = records.map((r) => ` - ${r.host} ${r.type}: ${r.found.length > 0 ? r.found.join(", ") : "nothing"}, expected ${r.expected}`);
7428
+ super(`${provider} accepted '${stage}''s records for ${domain}, and reading them back does not show them:\n${lines.join("\n")}\nDeploy again; if it persists, check the records in the provider's console — another tool may be writing the same names.`);
7429
+ this.stage = stage;
7430
+ this.domain = domain;
7431
+ this.provider = provider;
7432
+ this.records = records;
7433
+ this.name = "DnsRecordsNotConfirmed";
7928
7434
  }
7929
7435
  };
7930
- /**
7931
- * Docker command implementation: one Dockerfile per deployable app, each
7932
- * building its image inside Docker from a turbo-pruned slice of the build
7933
- * root — nothing is built on the host — plus the build root's
7934
- * `.dockerignore` and `docker-compose.constructs.yml`.
7935
- */
7936
- async function dockerCommand(options) {
7937
- const cwd = options.cwd ?? process.cwd();
7938
- const loaded = await loadWorkspaceConfig(cwd);
7939
- const names = loaded.type === "single" ? Object.fromEntries(Object.keys(loaded.workspace.apps).map((name) => [name, resolveDockerConfig(loaded.raw).imageName])) : {};
7940
- const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names, loaded.background, options.stage);
7941
- if (options.build) for (const app of result.apps) await buildDockerImage(app, result.buildRoot, options);
7942
- if (options.push) for (const app of result.apps) await pushDockerImage(app.imageName, options, result.buildRoot);
7943
- return result;
7436
+ /** `api.example.com A 203.0.113.10 (TTL 600)` */
7437
+ function describe(record) {
7438
+ return `${record.host.padEnd(32)} ${record.type.padEnd(5)} ${record.value} (TTL ${record.ttl})`;
7439
+ }
7440
+ function printManual(plan, log, why) {
7441
+ log(` ${plan.domain}: ${why} — create these records at its DNS host:`);
7442
+ for (const record of plan.records) log(` ${describe(record)}`);
7443
+ return plan.records.map((r) => ({
7444
+ domain: r.domain,
7445
+ name: r.name,
7446
+ type: r.type,
7447
+ action: "manual",
7448
+ value: r.value,
7449
+ ttl: r.ttl
7450
+ }));
7944
7451
  }
7945
- /**
7946
- * Build one app's image, from the build root.
7947
- * Uses BuildKit for cache mount support
7948
- */
7949
- async function buildDockerImage(app, buildRoot, options) {
7950
- const tag = options.tag ?? "latest";
7951
- const registry = options.registry;
7952
- const fullImageName = validateImageRef(registry ? `${registry}/${app.imageName}:${tag}` : `${app.imageName}:${tag}`);
7953
- logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
7954
- try {
7955
- await run$1("docker", [
7956
- "build",
7957
- `--file=${relative(buildRoot, app.dockerfile)}`,
7958
- `--tag=${fullImageName}`,
7959
- "."
7960
- ], {
7961
- cwd: buildRoot,
7962
- env: {
7963
- ...process.env,
7964
- DOCKER_BUILDKIT: "1"
7965
- }
7452
+ /** The existing records of every planned name, for the types gkm manages. */
7453
+ async function existingRecords(provider, plan) {
7454
+ const names = [...new Set(plan.records.map((r) => r.name))];
7455
+ const wanted = names.flatMap((name) => MANAGED.map((type) => ({
7456
+ name,
7457
+ type
7458
+ })));
7459
+ if (provider.readRecords) return provider.readRecords(plan.domain, wanted);
7460
+ return (await provider.getRecords(plan.domain)).filter((r) => names.includes(r.name) && MANAGED.includes(r.type));
7461
+ }
7462
+ const same = (a, b) => a.trim().replace(/\.$/, "").toLowerCase() === b.trim().replace(/\.$/, "").toLowerCase();
7463
+ /**
7464
+ * What each domain's records need, compared with what is there: creates,
7465
+ * updates, and the deletes a change of mode needs — a name moving from an A
7466
+ * record to a CNAME, or back, cannot hold both. Nothing else is deleted.
7467
+ */
7468
+ function diffDomainDns(plan, existing) {
7469
+ const changes = [];
7470
+ const find = (name, type) => existing.find((r) => r.name === name && r.type === type);
7471
+ const planned = /* @__PURE__ */ new Map();
7472
+ for (const record of plan.records) {
7473
+ const types = planned.get(record.name) ?? /* @__PURE__ */ new Set();
7474
+ types.add(record.type);
7475
+ planned.set(record.name, types);
7476
+ }
7477
+ for (const [name, types] of planned) {
7478
+ const conflicting = types.has("CNAME") ? ["A", "AAAA"] : ["CNAME"];
7479
+ for (const type of conflicting) {
7480
+ const found = find(name, type);
7481
+ if (!found || found.values.length === 0) continue;
7482
+ changes.push({
7483
+ domain: plan.domain,
7484
+ name,
7485
+ type,
7486
+ action: "delete",
7487
+ previous: found.values.join(", ")
7488
+ });
7489
+ }
7490
+ }
7491
+ for (const record of plan.records) {
7492
+ const found = find(record.name, record.type);
7493
+ const base = {
7494
+ domain: plan.domain,
7495
+ name: record.name,
7496
+ type: record.type,
7497
+ value: record.value,
7498
+ ttl: record.ttl
7499
+ };
7500
+ if (!found || found.values.length === 0) changes.push({
7501
+ ...base,
7502
+ action: "create"
7503
+ });
7504
+ else if (found.values.length === 1 && same(found.values[0], record.value)) changes.push({
7505
+ ...base,
7506
+ action: "unchanged"
7507
+ });
7508
+ else changes.push({
7509
+ ...base,
7510
+ action: "update",
7511
+ previous: found.values.join(", ")
7966
7512
  });
7967
- logger.log(`✅ Docker image built: ${fullImageName}`);
7968
- } catch (error) {
7969
- throw new DockerBuildFailed(fullImageName, error);
7513
+ }
7514
+ return changes;
7515
+ }
7516
+ /** A host's full name from a domain and a relative name. */
7517
+ function fullName(domain, name) {
7518
+ return name === "@" ? domain : `${name}.${domain}`;
7519
+ }
7520
+ function printChange(change, dryRun, log) {
7521
+ const host = fullName(change.domain, change.name).padEnd(32);
7522
+ const type = change.type.padEnd(5);
7523
+ const would = dryRun ? "would " : "";
7524
+ switch (change.action) {
7525
+ case "unchanged":
7526
+ log(` ✓ ${host} ${type} ${change.value} (TTL ${change.ttl}) — up to date`);
7527
+ break;
7528
+ case "create":
7529
+ log(` + ${host} ${type} ${change.value} (TTL ${change.ttl}) — ${would}create`);
7530
+ break;
7531
+ case "update":
7532
+ log(` ~ ${host} ${type} ${change.previous} → ${change.value} (TTL ${change.ttl}) — ${would}update`);
7533
+ break;
7534
+ case "write":
7535
+ log(dryRun ? ` ? ${host} ${type} ${change.value} (TTL ${change.ttl}) — would write; current value unknown (key cannot read DNS)` : ` ! ${host} ${type} ${change.value} (TTL ${change.ttl}) — written (not diffed)`);
7536
+ break;
7537
+ case "delete": log(` - ${host} ${type} ${change.previous} — ${would}delete (a name cannot hold a CNAME and an A record)`);
7970
7538
  }
7971
7539
  }
7972
7540
  /**
7973
- * Push Docker image to registry
7541
+ * Each domain's records, through its provider — and, with `confirm`, what was
7542
+ * written read back from it. A record already holding its value is neither
7543
+ * written nor read again.
7544
+ *
7545
+ * @throws {DnsRecordsNotConfirmed} when a written record does not read back
7974
7546
  */
7975
- async function pushDockerImage(imageName, options, cwd) {
7976
- const tag = options.tag ?? "latest";
7977
- const registry = options.registry;
7978
- if (!registry) throw new PushNeedsRegistry(imageName);
7979
- const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
7980
- logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
7981
- try {
7982
- await run$1("docker", ["push", fullImageName], { cwd });
7983
- logger.log(`✅ Docker image pushed: ${fullImageName}`);
7984
- } catch (error) {
7985
- throw new DockerPushFailed(fullImageName, error);
7547
+ async function settleStackDns(plan, options = {}) {
7548
+ const dryRun = options.dryRun === true;
7549
+ const log = options.log ?? ((line) => console.log(line));
7550
+ const providerFor = options.providerFor ?? ((config) => createDnsProvider({
7551
+ config,
7552
+ ...options.credentials ? { godaddy: options.credentials } : {},
7553
+ ...options.hostinger ? { hostinger: options.hostinger } : {}
7554
+ }));
7555
+ const changes = [];
7556
+ const confirmed = /* @__PURE__ */ new Set();
7557
+ const unconfirmed = /* @__PURE__ */ new Set();
7558
+ const hostsOf = (domain) => new Set(domain.records.map((r) => r.host));
7559
+ for (const host of plan.uncovered) log(` ⚠ ${host} is under no domain in dns — point it at ${plan.server.ipv4} yourself`);
7560
+ for (const domain of plan.domains) {
7561
+ if (domain.provider === "manual") {
7562
+ changes.push(...printManual(domain, log, "manual"));
7563
+ for (const host of hostsOf(domain)) unconfirmed.add(host);
7564
+ continue;
7565
+ }
7566
+ const provider = await providerFor(domain.config);
7567
+ if (!provider) {
7568
+ changes.push(...printManual(domain, log, "manual"));
7569
+ for (const host of hostsOf(domain)) unconfirmed.add(host);
7570
+ continue;
7571
+ }
7572
+ log(` ${domain.domain} (${provider.name})${dryRun ? " — dry run" : ""}`);
7573
+ let existing;
7574
+ let diff;
7575
+ let unreadable = false;
7576
+ try {
7577
+ existing = await existingRecords(provider, domain);
7578
+ diff = diffDomainDns(domain, existing);
7579
+ } catch (error) {
7580
+ if (!(error instanceof DnsRecordsUnreadable)) throw error;
7581
+ log(` ℹ The ${provider.name} key cannot read ${domain.domain}'s DNS records, so changes cannot be diffed: every record is written (idempotent).`);
7582
+ unreadable = true;
7583
+ existing = [];
7584
+ diff = domain.records.map((r) => ({
7585
+ domain: r.domain,
7586
+ name: r.name,
7587
+ type: r.type,
7588
+ action: "write",
7589
+ value: r.value,
7590
+ ttl: r.ttl
7591
+ }));
7592
+ }
7593
+ for (const change of diff) printChange(change, dryRun, log);
7594
+ if (!plan.server.ipv6) for (const name of new Set(domain.records.map((r) => r.name))) {
7595
+ const aaaa = existing.find((r) => r.name === name && r.type === "AAAA");
7596
+ const planned = domain.records.find((r) => r.name === name && r.type === "A");
7597
+ if (aaaa && planned && aaaa.values.length > 0) log(` ⚠ ${fullName(domain.domain, name)} also has AAAA ${aaaa.values.join(", ")}: set ${SERVER_IPV6_KEY} in the '${plan.stage}' stage's secrets to the server's IPv6 address, or remove that record, or IPv6 clients go elsewhere`);
7598
+ }
7599
+ changes.push(...diff);
7600
+ const written = new Set(diff.filter((c) => c.action !== "unchanged").map((c) => fullName(c.domain, c.name)));
7601
+ for (const host of hostsOf(domain)) if (unreadable) {
7602
+ if (!dryRun) unconfirmed.add(host);
7603
+ } else if (!written.has(host)) confirmed.add(host);
7604
+ if (dryRun) continue;
7605
+ const deletes = diff.filter((c) => c.action === "delete");
7606
+ if (deletes.length > 0) await provider.deleteRecords(domain.domain, deletes.map((c) => ({
7607
+ name: c.name,
7608
+ type: c.type
7609
+ })));
7610
+ const writes = diff.filter((c) => c.action === "create" || c.action === "update" || c.action === "write");
7611
+ if (writes.length > 0) await provider.upsertRecords(domain.domain, writes.map((c) => ({
7612
+ name: c.name,
7613
+ type: c.type,
7614
+ value: c.value,
7615
+ ttl: c.ttl
7616
+ })));
7617
+ if (!options.confirm || unreadable || written.size === 0) continue;
7618
+ const rewritten = {
7619
+ ...domain,
7620
+ records: domain.records.filter((r) => written.has(r.host))
7621
+ };
7622
+ const after = await existingRecords(provider, rewritten);
7623
+ const missing = [];
7624
+ for (const record of rewritten.records) {
7625
+ const values = after.find((r) => r.name === record.name && r.type === record.type)?.values ?? [];
7626
+ if (values.length !== 1 || !same(values[0], record.value)) missing.push({
7627
+ host: record.host,
7628
+ type: record.type,
7629
+ expected: record.value,
7630
+ found: values
7631
+ });
7632
+ }
7633
+ if (missing.length > 0) throw new DnsRecordsNotConfirmed(plan.stage, domain.domain, provider.name, missing);
7634
+ for (const host of written) confirmed.add(host);
7635
+ log(` ✓ ${written.size} host${written.size === 1 ? "" : "s"} read back from ${provider.name}`);
7986
7636
  }
7637
+ for (const host of plan.uncovered) unconfirmed.add(host);
7638
+ return {
7639
+ changes,
7640
+ confirmed: [...confirmed].sort(),
7641
+ unconfirmed: [...unconfirmed].filter((h) => !confirmed.has(h)).sort()
7642
+ };
7987
7643
  }
7644
+ /** The system resolver: what the server's own clients would be told. */
7645
+ const systemLookup = async (host) => {
7646
+ return (await lookup(host, { all: true })).map((a) => a.address);
7647
+ };
7648
+ /** Hosts that do not resolve to the stage's server. */
7649
+ var HostNotPointingAtServer = class extends GkmError {
7650
+ stage;
7651
+ hosts;
7652
+ expected;
7653
+ fix;
7654
+ constructor(stage, hosts, expected, fix) {
7655
+ const want = expected.ipv6 ? `${expected.ipv4} (and ${expected.ipv6})` : expected.ipv4;
7656
+ const lines = hosts.map((h) => ` - ${h.host} → ${h.resolved.length > 0 ? h.resolved.join(", ") : `nothing${h.error ? ` (${h.error})` : ""}`}, expected ${want}`);
7657
+ super(`${hosts.length === 1 ? "A host" : `${hosts.length} hosts`} of '${stage}' do${hosts.length === 1 ? "es" : ""} not resolve to its server, so a certificate for ${hosts.length === 1 ? "it" : "them"} cannot be issued:\n${lines.join("\n")}\n${fix}\nRecords can take a few minutes to propagate. With a CDN or proxy in front of the server, pass --skip-dns.`);
7658
+ this.stage = stage;
7659
+ this.hosts = hosts;
7660
+ this.expected = expected;
7661
+ this.fix = fix;
7662
+ this.name = "HostNotPointingAtServer";
7663
+ }
7664
+ };
7988
7665
  /**
7989
- * Generate Dockerfiles for all apps in a workspace.
7990
- * @internal Exported for testing
7666
+ * Every host resolved: each must resolve to the server's IPv4 address and to
7667
+ * nothing but the server's addresses.
7668
+ *
7669
+ * @throws {HostNotPointingAtServer} naming every host that does not
7991
7670
  */
7992
- async function workspaceDockerCommand(workspace, manifest, imageNames = {}, background = {}, stage) {
7993
- const results = [];
7994
- const layout = imageLayout(workspace);
7995
- logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
7996
- logger.log(` Build root: ${layout.buildRoot}`);
7997
- logger.log(` Package manager: ${layout.tools.packageManager}${layout.tools.packageManagerVersion ? `@${layout.tools.packageManagerVersion}` : ""}, turbo@${layout.tools.turboVersion}`);
7998
- for (const [appName, app] of Object.entries(workspace.apps)) {
7999
- if (app.type === "mobile") {
8000
- logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
7671
+ async function checkStackDns(input) {
7672
+ const lookup = input.lookup ?? systemLookup;
7673
+ const { server } = input;
7674
+ const ours = new Set([server.ipv4, server.ipv6].filter(Boolean).map((a) => a.toLowerCase()));
7675
+ const bad = [];
7676
+ const ok = [];
7677
+ for (const host of input.hosts) {
7678
+ let resolved;
7679
+ try {
7680
+ resolved = [...new Set(await lookup(host))];
7681
+ } catch (error) {
7682
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : error instanceof Error ? error.message : String(error);
7683
+ bad.push({
7684
+ host,
7685
+ resolved: [],
7686
+ error: code
7687
+ });
8001
7688
  continue;
8002
7689
  }
8003
- const buildType = app.entry ? "entry" : app.type;
8004
- logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
8005
- const dockerfile = appDockerfile(appName, app, {
8006
- layout,
8007
- workspaceRoot: workspace.root,
8008
- apps: workspace.apps,
8009
- ...manifest ? { manifest } : {},
8010
- workspace,
8011
- ...stage ? { stage } : {}
8012
- });
8013
- const path = dockerfileOf(appName, app.path);
8014
- const dockerfilePath = join(workspace.root, path);
8015
- await mkdir(dirname(dockerfilePath), { recursive: true });
8016
- await writeFile(dockerfilePath, dockerfile);
8017
- logger.log(` Generated: ${path}`);
8018
- results.push({
8019
- appName,
8020
- type: app.type,
8021
- dockerfile: dockerfilePath,
8022
- imageName: imageNames[appName] ?? appName
7690
+ if (resolved.some((a) => a.toLowerCase() === server.ipv4.toLowerCase()) && resolved.every((a) => ours.has(a.toLowerCase()))) ok.push(host);
7691
+ else bad.push({
7692
+ host,
7693
+ resolved
8023
7694
  });
8024
7695
  }
8025
- for (const worker of manifest ? workerUnits(workspace, manifest, background) : []) {
8026
- logger.log(`\n 📄 Generating Dockerfile for ${worker.name} (worker ${worker.id}, built from ${worker.app})`);
8027
- const dockerfile = workerDockerfile(worker, workspace.apps[worker.app], {
8028
- layout,
8029
- workspaceRoot: workspace.root
8030
- });
8031
- const path = workerDockerfileOf(worker.name);
8032
- const dockerfilePath = join(workspace.root, path);
8033
- await mkdir(dirname(dockerfilePath), { recursive: true });
8034
- await writeFile(dockerfilePath, dockerfile);
8035
- logger.log(` Generated: ${path}`);
8036
- results.push({
8037
- appName: worker.name,
8038
- type: "backend",
8039
- dockerfile: dockerfilePath,
8040
- imageName: imageNames[worker.name] ?? worker.name
7696
+ if (bad.length > 0) throw new HostNotPointingAtServer(input.stage, bad, {
7697
+ ipv4: server.ipv4,
7698
+ ...server.ipv6 ? { ipv6: server.ipv6 } : {}
7699
+ }, dnsFix(input, bad));
7700
+ return ok;
7701
+ }
7702
+ /** How to fix it: the deploy, or the records to create by hand. */
7703
+ function dnsFix(input, bad) {
7704
+ const hosts = bad.map((h) => h.host);
7705
+ let plan;
7706
+ try {
7707
+ plan = planStackDns({
7708
+ stage: input.stage,
7709
+ hosts,
7710
+ server: input.server,
7711
+ dns: input.dns
8041
7712
  });
7713
+ } catch {
7714
+ plan = {
7715
+ stage: input.stage,
7716
+ server: input.server,
7717
+ domains: [],
7718
+ uncovered: hosts
7719
+ };
8042
7720
  }
8043
- const dockerignorePath = await ensureDockerignore(layout.buildRoot);
8044
- logger.log(`\n Ensured: ${dockerignorePath}`);
8045
- const composePath = await writeConstructsCompose(workspace);
8046
- logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
8047
- logger.log(`\n📋 Build commands (from ${layout.buildRoot}):`);
8048
- for (const result of results) {
8049
- const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
8050
- logger.log(` ${icon} docker build -f ${relative(layout.buildRoot, result.dockerfile)} -t ${result.imageName} .`);
8051
- }
8052
- printRunInstructions(workspace);
8053
- return {
8054
- apps: results,
8055
- dockerCompose: composePath,
8056
- dockerignore: dockerignorePath,
8057
- buildRoot: layout.buildRoot
8058
- };
8059
- }
7721
+ const automatic = plan.domains.filter((d) => d.provider !== "manual");
7722
+ const byHand = [...plan.domains.filter((d) => d.provider === "manual").flatMap((d) => d.records.map(describe)), ...plan.uncovered.flatMap((host) => [`${host.padEnd(32)} A ${input.server.ipv4}`, ...input.server.ipv6 ? [`${host.padEnd(32)} AAAA ${input.server.ipv6}`] : []])];
7723
+ const lines = [];
7724
+ if (automatic.length > 0) lines.push(`Fix: gkm deploy --stage ${input.stage} writes the records for ${automatic.map((d) => d.domain).join(", ")} through their provider (--dry-run shows them first).`);
7725
+ if (byHand.length > 0) lines.push(`${automatic.length > 0 ? "And create" : "Fix: create"} these records at the DNS host:`, ...byHand.map((line) => ` ${line}`));
7726
+ return lines.join("\n");
7727
+ }
7728
+ /** The environment variable each token-authenticated provider reads. */
7729
+ const DNS_TOKEN_KEYS = {
7730
+ godaddy: "GODADDY_API_TOKEN",
7731
+ hostinger: "HOSTINGER_API_TOKEN"
7732
+ };
8060
7733
  /**
8061
- * One app's Dockerfile, by what it is: a gkm backend bundled by `gkm build`,
8062
- * a backend with its own entry bundled by esbuild, or a site by its
8063
- * framework. The one choice every builder of images makes.
7734
+ * A domain whose records the deploy writes, and no token to write them with.
7735
+ * Raised before anything is built, started or written.
8064
7736
  */
8065
- function appDockerfile(appName, app, options) {
8066
- const image = appImageOptions(options.layout, appName, app, options.workspaceRoot, options.apps, app.type === "web" && options.manifest && options.workspace ? siteClientTelemetry(options.manifest, appName, options.workspace, options.stage) : void 0);
8067
- if (app.type === "web") return siteDockerfile(appName, app, {
8068
- image,
8069
- ...options.manifest ? { manifest: options.manifest } : {},
8070
- ...options.publicUrlArgs ? { publicUrlArgs: options.publicUrlArgs } : {}
7737
+ var DnsCredentialMissing = class extends GkmError {
7738
+ stage;
7739
+ domain;
7740
+ provider;
7741
+ key;
7742
+ constructor(stage, domain, provider, key) {
7743
+ super(`'${stage}''s hosts are under ${domain}, whose DNS is ${provider}'s, and the deploy writes their records — but ${key} is not set. In CI, add ${key} as a secret on the '${stage}' GitHub environment and pass it to the step that writes the records: env: { ${key}: \${{ secrets.${key} }} }. On this machine, export ${key} or run gkm login --provider ${provider}. With a CDN or proxy in front of the server, pass --skip-dns.`);
7744
+ this.stage = stage;
7745
+ this.domain = domain;
7746
+ this.provider = provider;
7747
+ this.key = key;
7748
+ this.name = "DnsCredentialMissing";
7749
+ }
7750
+ };
7751
+ /**
7752
+ * The `dns` domain a stage's own domain (`domains.<stage>`) is under — where
7753
+ * its hosts' records go — or none.
7754
+ */
7755
+ function stageDnsDomains(stage, domains, dns) {
7756
+ const own = domains?.[stage];
7757
+ if (!own || !dns || isLocalHost(own)) return [];
7758
+ const normalized = normalizeDnsConfig(dns);
7759
+ const root = findRootDomain(own.toLowerCase(), normalized);
7760
+ return root ? [{
7761
+ domain: root,
7762
+ config: normalized[root]
7763
+ }] : [];
7764
+ }
7765
+ /**
7766
+ * Each token the domains' providers need, from `credentials` — `manual`,
7767
+ * Route53 (the AWS SDK's own chain) and a provider object need none.
7768
+ *
7769
+ * @throws {DnsCredentialMissing} naming the first domain with no token
7770
+ */
7771
+ async function dnsCredentials(stage, domains, credentials) {
7772
+ const found = {};
7773
+ for (const { domain, config } of domains) {
7774
+ const provider = config.provider;
7775
+ if (provider !== "godaddy" && provider !== "hostinger") continue;
7776
+ if (found[provider]) continue;
7777
+ const token = await credentials.get({ kind: provider });
7778
+ if (!token?.token) throw new DnsCredentialMissing(stage, domain, provider, DNS_TOKEN_KEYS[provider]);
7779
+ found[provider] = token;
7780
+ }
7781
+ return found;
7782
+ }
7783
+ /**
7784
+ * A stage's DNS, as every deploy runs it: the
7785
+ * stage's own hosts planned, written through each domain's provider where
7786
+ * they differ, and read back from it. Only A, AAAA and CNAME records of
7787
+ * those names are touched; nothing is a wildcard, and no other name is read,
7788
+ * written or deleted. What no provider confirms — a `manual` domain, a host
7789
+ * under no domain in `dns`, a key that cannot read — is resolved publicly.
7790
+ * A dry run prints the plan and writes nothing.
7791
+ *
7792
+ * @throws {DnsCredentialMissing} before anything is read or written
7793
+ * @throws {DnsRecordsNotConfirmed} when a written record does not read back
7794
+ * @throws {HostNotPointingAtServer} when a resolved host is not the server
7795
+ */
7796
+ async function stageDns(input) {
7797
+ const { stage, server } = input;
7798
+ const log = input.log ?? ((line) => console.log(line));
7799
+ const plan = planStackDns({
7800
+ stage,
7801
+ hosts: input.hosts,
7802
+ server,
7803
+ dns: input.dns
8071
7804
  });
8072
- if (app.entry) return generateEntryDockerfile({
8073
- ...image,
8074
- entry: app.entry,
8075
- healthCheckPath: "/health"
7805
+ const tokens = await dnsCredentials(stage, plan.domains.map(({ domain, config }) => ({
7806
+ domain,
7807
+ config
7808
+ })), input.credentials);
7809
+ const applied = !input.dns ? {
7810
+ changes: [],
7811
+ confirmed: [],
7812
+ unconfirmed: [...plan.uncovered]
7813
+ } : await settleStackDns(plan, {
7814
+ ...input.dryRun ? { dryRun: true } : { confirm: true },
7815
+ log,
7816
+ credentials: {
7817
+ ...input.godaddy ?? {},
7818
+ ...tokens.godaddy ? { credential: tokens.godaddy } : {},
7819
+ log
7820
+ },
7821
+ ...tokens.hostinger ? { hostinger: tokens.hostinger } : {},
7822
+ ...input.providerFor ? { providerFor: input.providerFor } : {}
8076
7823
  });
8077
- return generateBackendDockerfile({
8078
- ...image,
8079
- healthCheckPath: "/health",
8080
- ...options.cache ? { cache: options.cache } : {}
7824
+ let resolved = [];
7825
+ if (applied.unconfirmed.length > 0) resolved = await checkStackDns({
7826
+ stage,
7827
+ hosts: applied.unconfirmed,
7828
+ server,
7829
+ dns: input.dns,
7830
+ ...input.lookup ? { lookup: input.lookup } : {}
8081
7831
  });
7832
+ return {
7833
+ plan,
7834
+ changes: applied.changes,
7835
+ confirmed: applied.confirmed,
7836
+ resolved
7837
+ };
8082
7838
  }
8083
- /** Where `gkm docker` writes a worker's Dockerfile. */
8084
- function workerDockerfileOf(worker) {
8085
- return `.gkm/docker/Dockerfile.${worker}`;
8086
- }
8087
- /**
8088
- * A Worker's Dockerfile: its host app's build, and the worker's bundle as the
8089
- * runner — see `generateWorkerDockerfile`.
8090
- */
8091
- function workerDockerfile(worker, host, options) {
8092
- return generateWorkerDockerfile({
8093
- ...appImageOptions(options.layout, worker.app, host, options.workspaceRoot),
8094
- imageName: worker.name,
8095
- port: WORKER_PORT,
8096
- worker: worker.id,
8097
- bundle: workerBundleName(worker.id),
8098
- healthCheckPath: "/health",
8099
- ...options.cache ? { cache: options.cache } : {}
8100
- });
8101
- }
8102
- /**
8103
- * A site's Dockerfile, with an `ARG` for every key its bundler inlines.
8104
- *
8105
- * The keys come from the site's declaration where there is one — every
8106
- * public key its edges give it, `VITE_UPLOADS_SERVER_URL` included — and an
8107
- * `ARG` the Dockerfile does not declare is a build argument the build drops,
8108
- * leaving the bundle with an empty URL.
8109
- */
8110
- function siteDockerfile(appName, app, options) {
8111
- const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
8112
- const publicUrlArgs = options.publicUrlArgs ?? (declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app));
8113
- const webOpts = {
8114
- ...options.image,
8115
- publicUrlArgs
8116
- };
8117
- switch (app.framework) {
8118
- case "vite": return generateViteStaticDockerfile(webOpts);
8119
- case "tanstack-start":
8120
- case "remix": return generateNodeWebDockerfile(webOpts);
8121
- default: return generateNextjsDockerfile(webOpts);
8122
- }
8123
- }
8124
- /**
8125
- * What a build root's `.dockerignore` must leave out of every image's
8126
- * context: dependencies installed for another platform, anything built on the
8127
- * host (every image builds from source), git, and gkm's own output —
8128
- * `.gkm/compose`'s env files hold a stage's secrets.
8129
- */
8130
- const REQUIRED_IGNORES = [
8131
- "**/node_modules",
8132
- ".git",
8133
- "**/dist",
8134
- "**/.next",
8135
- "**/.turbo",
8136
- "**/.gkm",
8137
- "**/.gkm/compose"
8138
- ];
8139
- /**
8140
- * Make sure the build root's `.dockerignore` leaves out what no image may
8141
- * hold: written whole where there is none, and each missing line appended to
8142
- * one the project keeps.
8143
- */
8144
- async function ensureDockerignore(buildRoot) {
8145
- const path = join(buildRoot, ".dockerignore");
8146
- if (!existsSync(path)) {
8147
- await writeFile(path, generateDockerignore());
8148
- return path;
8149
- }
8150
- const current = await readFile(path, "utf-8");
8151
- const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
8152
- const missing = REQUIRED_IGNORES.filter((line) => !lines.has(line));
8153
- if (missing.length === 0) return path;
8154
- await appendFile(path, `${current.endsWith("\n") || current === "" ? "" : "\n"}\n# gkm: images build from source, and a stack's env files hold its secrets\n${missing.join("\n")}\n`);
8155
- return path;
8156
- }
8157
- /**
8158
- * Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
8159
- * the apps beside the containers their constructs derive.
8160
- *
8161
- * Through reconcile rather than a second generator, so what `gkm docker`
8162
- * describes and what `gkm dev` runs cannot disagree. Nothing is started.
8163
- */
8164
- async function writeConstructsCompose(workspace) {
8165
- await reconcileWorkspace(workspace, {
8166
- stage: workspace.stages.local,
8167
- start: false
8168
- });
8169
- logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
8170
- return join(workspace.root, COMPOSE_PATH);
8171
- }
8172
- function printRunInstructions(workspace) {
8173
- const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
8174
- logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
8175
- logger.log(" gkm setup # starts the containers and creates what they hold");
8176
- logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
7839
+ //#endregion
7840
+ //#region src/providers/dns.ts
7841
+ var dns_exports = /* @__PURE__ */ __exportAll({
7842
+ composeStageHosts: () => composeStageHosts,
7843
+ deploysWithCompose: () => deploysWithCompose
7844
+ });
7845
+ /** Whether the workspace deploys through the compose target. */
7846
+ function deploysWithCompose(workspace) {
7847
+ return workspace.deploy?.default === "compose" || Object.values(workspace.apps).some((app) => app.resolvedDeployTarget === "compose");
7848
+ }
7849
+ /**
7850
+ * The hosts a compose stage serves: its stack composed as a deploy would —
7851
+ * with the secrets it would generate made up in memory, never written — and
7852
+ * every route's host read off it.
7853
+ */
7854
+ async function composeStageHosts(options) {
7855
+ const { workspace, stage, manifest } = options;
7856
+ const { deployedStackSecrets } = await import("./secrets-CwLauUB_.mjs").then((n) => n.n);
7857
+ const { composeStack } = await import("./stack-NrdH5ngX.mjs").then((n) => n.c);
7858
+ const { secrets } = deployedStackSecrets(workspace, stage, options.stored, manifest);
7859
+ return stackHosts(composeStack({
7860
+ workspace,
7861
+ manifest,
7862
+ runnables: options.runnables,
7863
+ background: options.background,
7864
+ stage,
7865
+ identity: deployIdentity(workspace, stage),
7866
+ images: {
7867
+ mode: "pull",
7868
+ tag: "dns",
7869
+ ...workspace.deploy?.registry ? { registry: workspace.deploy.registry } : {}
7870
+ },
7871
+ secrets,
7872
+ buildOnly: true,
7873
+ allowDevServices: true
7874
+ }));
8177
7875
  }
8178
7876
  //#endregion
8179
7877
  //#region src/providers/types.ts
@@ -8182,7 +7880,7 @@ const PROVIDER_KINDS = ["objects"];
8182
7880
  //#endregion
8183
7881
  //#region src/providers/notes.ts
8184
7882
  /** The constructs of a kind: `objects` is every bucket. */
8185
- function constructsOf$1(manifest, kind) {
7883
+ function constructsOf(manifest, kind) {
8186
7884
  return Object.entries(manifest).filter(([, d]) => d.kind === kind).map(([id]) => id).sort();
8187
7885
  }
8188
7886
  /** A stage that names `false` for a kind, and declares constructs of it. */
@@ -8198,9 +7896,9 @@ var StageProviderDisabled = class extends GkmError {
8198
7896
  this.name = "StageProviderDisabled";
8199
7897
  }
8200
7898
  };
8201
- /** The command a stage's providers are run with. */
7899
+ /** The command a stage's providers are run with: every deploy runs them. */
8202
7900
  function provisionCommand(stage) {
8203
- return `gkm setup --stage ${stage}`;
7901
+ return `gkm deploy --stage ${stage}`;
8204
7902
  }
8205
7903
  /**
8206
7904
  * The line a stage missing a key a provider would write is given: what
@@ -8212,7 +7910,7 @@ function provisionHint(workspace, kind, stage) {
8212
7910
  if (choice.mode !== "provider") return void 0;
8213
7911
  const provisioning = provisioningOf(kind, choice.name);
8214
7912
  if (!provisioning) return void 0;
8215
- return `deploy.${kind}.${stage} is ${choice.name}: ${provisionCommand(stage)} creates it and writes this key, with ${provisioning.describe}` + (provisioning.login ? `, or gkm login --provider ${provisioning.login}` : "");
7913
+ return `deploy.${kind}.${stage} is ${choice.name}: the deploy (${provisionCommand(stage)}) creates it and writes this key, with ${provisioning.describe}` + (provisioning.login ? `, or gkm login --provider ${provisioning.login}` : "");
8216
7914
  }
8217
7915
  /**
8218
7916
  * What a deploy's checks read off the stage's providers: whether each kind is
@@ -8235,191 +7933,11 @@ function stageProviderNotes(workspace, stage) {
8235
7933
  */
8236
7934
  function assertStageProvidersEnabled(workspace, manifest, stage) {
8237
7935
  for (const kind of PROVIDER_KINDS) {
8238
- const ids = constructsOf$1(manifest, kind);
7936
+ const ids = constructsOf(manifest, kind);
8239
7937
  if (ids.length === 0) continue;
8240
7938
  if (stageProvider(workspace, kind, stage).mode === "none") throw new StageProviderDisabled(kind, stage, ids);
8241
7939
  }
8242
7940
  }
8243
- //#endregion
8244
- //#region src/deploy/journal.ts
8245
- var DeployJournal = class DeployJournal {
8246
- store;
8247
- stage;
8248
- state;
8249
- resources;
8250
- version;
8251
- existed;
8252
- constructor(store, stage, state, resources, version, existed) {
8253
- this.store = store;
8254
- this.stage = stage;
8255
- this.state = state;
8256
- this.resources = resources;
8257
- this.version = version;
8258
- this.existed = existed;
8259
- }
8260
- /**
8261
- * The stage's journal — its stored state, or `initial` written as the
8262
- * stage's first state, so there is somewhere to record the very first
8263
- * resource before it is created.
8264
- */
8265
- static async open(store, stage, initial) {
8266
- const stored = await store.read(stage);
8267
- if (stored) return new DeployJournal(store, stage, stored.state, stored.resources, stored.version, true);
8268
- const state = initial();
8269
- const version = await store.write(stage, state, { expectedVersion: null });
8270
- return new DeployJournal(store, stage, state, {}, version, false);
8271
- }
8272
- record(key) {
8273
- return this.resources[key];
8274
- }
8275
- /** The keys a previous run marked `pending` and never saw created. */
8276
- unfinished() {
8277
- return Object.values(this.resources).filter((record) => record.status === "pending").map((record) => record.key);
8278
- }
8279
- /** Records that `entry` is about to be created. */
8280
- async pending(entry) {
8281
- await this.put({
8282
- ...entry,
8283
- status: "pending"
8284
- });
8285
- }
8286
- /** Records `entry` as existing with `id`; a no-op if it already is. */
8287
- async ready(entry, id) {
8288
- const current = this.resources[entry.key];
8289
- if (current?.status === "ready" && current.id === id) return;
8290
- await this.put({
8291
- ...entry,
8292
- status: "ready",
8293
- id
8294
- });
8295
- }
8296
- /**
8297
- * The resource `entry` names: by its recorded id, else by looking it up,
8298
- * else created — with `pending` written before the create and `ready`
8299
- * after, so a run that dies in between leaves a record the next run
8300
- * resolves by looking the resource up rather than creating another.
8301
- */
8302
- async ensure(entry, steps) {
8303
- const record = this.resources[entry.key];
8304
- let staleId;
8305
- if (record?.status === "ready" && record.id && steps.get) {
8306
- const known = await steps.get(record.id);
8307
- if (known) {
8308
- await this.ready(entry, steps.id(known));
8309
- return {
8310
- resource: known,
8311
- via: "recorded"
8312
- };
8313
- }
8314
- staleId = record.id;
8315
- }
8316
- const existing = await steps.find();
8317
- if (existing) {
8318
- await this.ready(entry, steps.id(existing));
8319
- return {
8320
- resource: existing,
8321
- via: record?.status === "pending" ? "resumed" : "found",
8322
- ...staleId ? { staleId } : {}
8323
- };
8324
- }
8325
- await this.pending(entry);
8326
- const created = await steps.create();
8327
- await this.ready(entry, steps.id(created));
8328
- return {
8329
- resource: created,
8330
- via: "created",
8331
- ...staleId ? { staleId } : {}
8332
- };
8333
- }
8334
- /** Writes the stage's state, keeping its resource records. */
8335
- async save() {
8336
- this.state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
8337
- this.version = await this.store.write(this.stage, this.state, { expectedVersion: this.version });
8338
- }
8339
- async put(record) {
8340
- this.version = await this.store.putResource(this.stage, record, { expectedVersion: this.version });
8341
- this.resources = {
8342
- ...this.resources,
8343
- [record.key]: {
8344
- ...record,
8345
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
8346
- }
8347
- };
8348
- }
8349
- };
8350
- //#endregion
8351
- //#region src/providers/aws.ts
8352
- /**
8353
- * The AWS account a provider provisions in — the stage's, found the way the
8354
- * SSM and Secrets Manager stores find it.
8355
- *
8356
- * 1. `--profile`, resolved on its own: exported keys left over from another
8357
- * account never win over a profile someone named.
8358
- * 2. `AWS_PROFILE`, else `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` — the
8359
- * deploy's own `aws` credential kind (`storedCredentials`).
8360
- * 3. The SDK's default chain: `~/.aws` default profile, SSO, a role. In CI,
8361
- * whatever `aws-actions/configure-aws-credentials` exported.
8362
- *
8363
- * None of them: the provider has no provisioning credentials, and the stage
8364
- * is treated as `external`.
8365
- */
8366
- async function awsProvisioningCredentials(lookup) {
8367
- const region = lookup.env.AWS_REGION ?? lookup.env.AWS_DEFAULT_REGION;
8368
- const where = region ? { region } : {};
8369
- if (lookup.profile) return {
8370
- profile: lookup.profile,
8371
- ...where
8372
- };
8373
- const stored = await storedCredentials({
8374
- env: lookup.env,
8375
- ...lookup.home ? { home: lookup.home } : {}
8376
- }).get({
8377
- kind: "aws",
8378
- stage: lookup.stage
8379
- });
8380
- if (stored) return stored;
8381
- try {
8382
- const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers");
8383
- await fromNodeProviderChain({
8384
- timeout: 1e3,
8385
- maxRetries: 0
8386
- })();
8387
- return {
8388
- chain: true,
8389
- ...where
8390
- };
8391
- } catch {
8392
- return;
8393
- }
8394
- }
8395
- /**
8396
- * An S3-compatible endpoint the environment points the SDK at — the AWS
8397
- * emulator in tests. The SDK reads it itself; it is read here too, because
8398
- * an emulator takes path-style requests, and a key written for one has to
8399
- * say where it is.
8400
- */
8401
- function awsEndpoint(env, service) {
8402
- return env[`AWS_ENDPOINT_URL_${service}`] ?? env.AWS_ENDPOINT_URL;
8403
- }
8404
- /** An SDK client's config for a provisioning credential. */
8405
- async function awsClientConfig(credential, region) {
8406
- if ("profile" in credential) {
8407
- const { fromIni } = await import("@aws-sdk/credential-providers");
8408
- return {
8409
- region,
8410
- credentials: fromIni({ profile: credential.profile })
8411
- };
8412
- }
8413
- if ("accessKeyId" in credential) return {
8414
- region,
8415
- credentials: {
8416
- accessKeyId: credential.accessKeyId,
8417
- secretAccessKey: credential.secretAccessKey,
8418
- ...credential.sessionToken ? { sessionToken: credential.sessionToken } : {}
8419
- }
8420
- };
8421
- return { region };
8422
- }
8423
7941
  const SUFFIX_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
8424
7942
  /** A name that S3 would refuse — what the name builder never returns. */
8425
7943
  var InvalidBucketName = class extends GkmError {
@@ -8450,940 +7968,789 @@ function fitParts(parts, max) {
8450
7968
  if (kept[longest].length <= 1) break;
8451
7969
  kept[longest] = kept[longest].slice(0, -1).replace(/-+$/, "");
8452
7970
  }
8453
- return kept.join("-");
8454
- }
8455
- /** Why `name` is not a valid bucket name, or undefined when it is. */
8456
- function bucketNameProblem(name) {
8457
- if (name.length < 3 || name.length > 63) return `it is ${name.length} characters, and S3 takes 3 to 63`;
8458
- if (!/^[a-z0-9-]+$/.test(name)) return "it may hold only lowercase letters, digits and -";
8459
- if (!/^[a-z0-9].*[a-z0-9]$/.test(name)) return "it must start and end with a letter or a digit";
8460
- if (name.startsWith("xn--") || name.startsWith("sthree-")) return "it starts with a prefix S3 reserves ('xn--', 'sthree-')";
8461
- if (name.endsWith("-s3alias") || name.endsWith("--ol-s3")) return "it ends with a suffix S3 reserves ('-s3alias', '--ol-s3')";
7971
+ return kept.join("-");
7972
+ }
7973
+ /** Why `name` is not a valid bucket name, or undefined when it is. */
7974
+ function bucketNameProblem(name) {
7975
+ if (name.length < 3 || name.length > 63) return `it is ${name.length} characters, and S3 takes 3 to 63`;
7976
+ if (!/^[a-z0-9-]+$/.test(name)) return "it may hold only lowercase letters, digits and -";
7977
+ if (!/^[a-z0-9].*[a-z0-9]$/.test(name)) return "it must start and end with a letter or a digit";
7978
+ if (name.startsWith("xn--") || name.startsWith("sthree-")) return "it starts with a prefix S3 reserves ('xn--', 'sthree-')";
7979
+ if (name.endsWith("-s3alias") || name.endsWith("--ol-s3")) return "it ends with a suffix S3 reserves ('-s3alias', '--ol-s3')";
7980
+ }
7981
+ /**
7982
+ * The good name: `<namespace>-<project>-<stage>-<bucket id>`, lowercased, no
7983
+ * dots, shortened to leave room for a clash's suffix.
7984
+ *
7985
+ * @throws {InvalidBucketName} when nothing usable is left
7986
+ */
7987
+ function bucketName(input) {
7988
+ const name = fitParts([
7989
+ input.scope,
7990
+ input.stage,
7991
+ input.id
7992
+ ], 56);
7993
+ const problem = bucketNameProblem(name);
7994
+ if (problem) throw new InvalidBucketName(name, problem);
7995
+ return name;
7996
+ }
7997
+ /** Six random `[a-z0-9]` characters. */
7998
+ function randomSuffix(random = randomInt) {
7999
+ let suffix = "";
8000
+ for (let i = 0; i < 6; i++) suffix += SUFFIX_ALPHABET[random(36)];
8001
+ return suffix;
8002
+ }
8003
+ /** The good name with a clash's suffix. */
8004
+ function suffixedBucketName(good, suffix) {
8005
+ return `${good}-${suffix}`;
8006
+ }
8007
+ /**
8008
+ * The IAM user a bucket is reached with on a stage:
8009
+ * `gkm-<project>-<stage>-<bucket id>`, at most 64 characters.
8010
+ */
8011
+ function iamUserName(input) {
8012
+ return fitParts([
8013
+ "gkm",
8014
+ input.scope,
8015
+ input.stage,
8016
+ input.id
8017
+ ], 64);
8018
+ }
8019
+ /** The path every user gkm creates is under, so it finds its own. */
8020
+ const IAM_USER_PATH = "/gkm/";
8021
+ /** The inline policy each user is given. */
8022
+ const IAM_POLICY_NAME = "gkm-bucket";
8023
+ //#endregion
8024
+ //#region src/deploy/devServices.ts
8025
+ /**
8026
+ * Mail and object storage on a deployed stage: real ones, or — only when
8027
+ * someone asked — the dev services the local stage runs.
8028
+ *
8029
+ * Locally a declared `Email` is Mailpit and a declared bucket is MinIO, and
8030
+ * nobody has to configure either. Deployed, the same two containers would be
8031
+ * a stage that silently delivers no mail and keeps its files on one
8032
+ * container's disk. So a deployed stage on a server target takes both from
8033
+ * its secrets — the mail server's URL and sending address, and each bucket's
8034
+ * URL — and a stage missing any of them fails before anything is built,
8035
+ * provisioned or written, naming every key at once.
8036
+ *
8037
+ * A bucket's credentials are not checked: they are optional, and come from
8038
+ * either of two places. The bucket's URL may carry a key of its own
8039
+ * (`s3://KEY:SECRET@uploads`), which wins; otherwise the S3 client's default
8040
+ * chain applies — the stage's shared `AWS_ACCESS_KEY_ID`/
8041
+ * `AWS_SECRET_ACCESS_KEY` when it set them, or a role.
8042
+ *
8043
+ * `--allow-dev-services` is the way out for a stage that is not production —
8044
+ * a preview, a demo: every construct the stage does not account for gets the
8045
+ * dev service the local stage runs for it — a bucket MinIO, mail Mailpit —
8046
+ * and the target derives the keys from it. What the stage does account for
8047
+ * still wins: a key set in its secrets, or a provider (`deploy.objects`)
8048
+ * that backs the kind. Every run that uses one says so, loudly.
8049
+ *
8050
+ * Pure: what each target runs is decided here from declarations and the
8051
+ * stage's values, and the targets do the running.
8052
+ */
8053
+ /**
8054
+ * The dev services a deployed stage can run — one per kind that has one on
8055
+ * every server target: a bucket's MinIO and mail's Mailpit. A cache or a
8056
+ * database is not here because a server stage runs its own anyway.
8057
+ */
8058
+ const DEV_SERVICES = ["minio", "mailpit"];
8059
+ /**
8060
+ * The S3 client's shared key pair. Optional: a bucket whose URL carries its own
8061
+ * key (`s3://KEY:SECRET@bucket`) signs with that, and one without signs with
8062
+ * this pair when the stage set it, or with whatever else the SDK's default
8063
+ * chain finds — a role.
8064
+ */
8065
+ const STORAGE_KEY_PAIR = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
8066
+ /** Dev services asked of a target that runs no containers. */
8067
+ var DevServicesNeedServerTarget = class extends GkmError {
8068
+ target;
8069
+ constructor(target) {
8070
+ super(`--allow-dev-services applies to server targets only, and '${target}' deploys to AWS, where a bucket is S3 and mail is the stage's own. Drop --allow-dev-services.`);
8071
+ this.target = target;
8072
+ this.name = "DevServicesNeedServerTarget";
8073
+ }
8074
+ };
8075
+ /**
8076
+ * `--allow-dev-services` given a value — the list it used to take. It is a
8077
+ * switch now: every construct the stage does not account for gets its dev
8078
+ * service.
8079
+ */
8080
+ var AllowDevServicesTakesNoValue = class extends GkmError {
8081
+ value;
8082
+ constructor(value) {
8083
+ super(`--allow-dev-services takes no value (it was given '${value}'). It runs the dev service for every bucket and mail the stage does not account for — no key in its secrets, no provider in deploy.<kind>.<stage> — so there is nothing to list. Pass --allow-dev-services on its own.`);
8084
+ this.value = value;
8085
+ this.name = "AllowDevServicesTakesNoValue";
8086
+ }
8087
+ };
8088
+ /**
8089
+ * Refuses `--allow-dev-services <value>` and `--allow-dev-services=<value>` on
8090
+ * a command line, with the reason, before the parser calls the value an
8091
+ * excess argument. `gkm deploy` and `gkm compose` take no positional
8092
+ * argument, so whatever follows the flag and is not a flag was meant for it.
8093
+ *
8094
+ * @throws {AllowDevServicesTakesNoValue}
8095
+ */
8096
+ function assertDevServicesFlag(argv) {
8097
+ for (let i = 0; i < argv.length; i++) {
8098
+ const arg = argv[i];
8099
+ if (arg.startsWith("--allow-dev-services=")) throw new AllowDevServicesTakesNoValue(arg.slice(arg.indexOf("=") + 1));
8100
+ const next = argv[i + 1];
8101
+ if (arg === "--allow-dev-services" && next && !next.startsWith("-")) throw new AllowDevServicesTakesNoValue(next);
8102
+ }
8103
+ }
8104
+ /**
8105
+ * A deployed stage with mail or object storage that its secrets do not
8106
+ * configure — every missing key, across every app, at once.
8107
+ */
8108
+ var ExternalServicesNotConfigured = class extends GkmError {
8109
+ stage;
8110
+ missing;
8111
+ constructor(stage, missing) {
8112
+ const standIns = missing.some((m) => m.service !== void 0);
8113
+ const lines = missing.map((m) => {
8114
+ const by = m.apps?.length ? `, read by ${m.apps.join(", ")}` : "";
8115
+ return ` gkm secrets:set ${m.key} '${m.example}' --stage ${stage}\n ${m.what}${by}` + (m.note ? `\n ${m.note}` : "") + (m.hint ? `\n ${m.hint}` : "");
8116
+ });
8117
+ super(`The stage '${stage}' is deployed, and a deployed stage's mail and object storage are real services: gkm runs no Mailpit or MinIO for it. Set ${missing.length === 1 ? "this key" : `these ${missing.length} keys`} in the stage's secrets:\n\n${lines.join("\n")}\n\n` + (standIns ? "For a stage that is not production — a preview, a demo — the dev services can run instead, with --allow-dev-services. Mailpit delivers no mail, and MinIO keeps every object on one container's disk.\n\n" : "") + `Or run: gkm secrets:add --stage ${stage}`);
8118
+ this.stage = stage;
8119
+ this.missing = missing;
8120
+ this.name = "ExternalServicesNotConfigured";
8121
+ }
8122
+ };
8123
+ /**
8124
+ * Every mail and storage construct a manifest declares, with the apps that
8125
+ * depend on each — what a target that provisions the whole manifest checks.
8126
+ */
8127
+ function manifestServiceDeclarations(manifest) {
8128
+ return Object.entries(manifest).flatMap(([id, declaration]) => {
8129
+ const { kind } = declaration;
8130
+ if (kind !== "email" && kind !== "objects" && kind !== "file-server") return [];
8131
+ const apps = dependentsOf(manifest, id).filter((caller) => {
8132
+ const callerKind = manifest[caller]?.kind;
8133
+ return callerKind === "rest-api" || callerKind === "site";
8134
+ }).map(appKey);
8135
+ return [{
8136
+ id,
8137
+ kind,
8138
+ ...declaration.kind === "file-server" ? { of: declaration.of } : {},
8139
+ ...apps.length ? { apps } : {}
8140
+ }];
8141
+ });
8142
+ }
8143
+ /**
8144
+ * Every key a stage must be given rather than derive — the one list both a
8145
+ * deploy's checks and `gkm secrets:add` read, so what a deploy refuses and
8146
+ * what the builder offers cannot drift.
8147
+ *
8148
+ * - Deployed, mail is its URL and its sending address, a bucket its URL, and
8149
+ * a file server its public URL. Locally all of it is Mailpit and MinIO.
8150
+ * - On every stage, a third party's credentials: nobody but the third party
8151
+ * can issue them.
8152
+ *
8153
+ * Never a derived value: a database URL, a generated secret, the seed.
8154
+ */
8155
+ function requiredStageKeys(input) {
8156
+ const domain = input.domain ?? "example.com";
8157
+ const keys = [];
8158
+ const add = (entry) => {
8159
+ if (!keys.some((k) => k.key === entry.key)) keys.push(entry);
8160
+ };
8161
+ const byId = (list) => [...list ?? []].sort((a, b) => a.id.localeCompare(b.id));
8162
+ const apps = (d) => d.apps?.length ? { apps: [...d.apps].sort() } : {};
8163
+ if (!input.local) {
8164
+ const services = byId(input.services);
8165
+ const objects = input.providers?.objects;
8166
+ const storage = objects?.accounted ? objects.hint ? { hint: objects.hint } : {} : { service: "minio" };
8167
+ for (const d of services.filter((d) => d.kind === "email")) {
8168
+ add({
8169
+ key: provideKey(d.id, "url"),
8170
+ id: d.id,
8171
+ kind: "email",
8172
+ what: `where '${d.id}' sends mail — any SMTP server`,
8173
+ example: `smtp://user:password@smtp.${domain}:587`,
8174
+ service: "mailpit",
8175
+ ...apps(d)
8176
+ });
8177
+ add({
8178
+ key: provideKey(d.id, "from"),
8179
+ id: d.id,
8180
+ kind: "email",
8181
+ what: `the address '${d.id}' sends from, on a domain the mail server has verified`,
8182
+ example: `noreply@${domain}`,
8183
+ service: "mailpit",
8184
+ ...apps(d)
8185
+ });
8186
+ }
8187
+ for (const d of services.filter((d) => d.kind === "objects")) add({
8188
+ key: provideKey(d.id, "url"),
8189
+ id: d.id,
8190
+ kind: "bucket",
8191
+ what: `the bucket '${d.id}' — S3, R2, or any S3-compatible store (add &endpoint=… for one that is not S3)`,
8192
+ note: `credentials are optional: a key for this bucket alone in the URL (s3://KEY:SECRET@${appKey(d.id)}?…) wins; without one, the shared AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or a role signs`,
8193
+ example: `s3://${appKey(d.id)}?region=eu-west-1`,
8194
+ ...storage,
8195
+ ...apps(d)
8196
+ });
8197
+ for (const d of services.filter((d) => d.kind === "file-server")) add({
8198
+ key: provideKey(d.id, "url"),
8199
+ id: d.id,
8200
+ kind: "file-server",
8201
+ what: `the public address '${d.id}' serves its bucket on — a CDN or the bucket's own domain`,
8202
+ example: `https://${appKey(d.id)}.${domain}`,
8203
+ ...storage,
8204
+ ...d.of ? { of: d.of } : {},
8205
+ ...apps(d)
8206
+ });
8207
+ }
8208
+ if (!input.local && input.server) add({
8209
+ key: SERVER_IPV4_KEY,
8210
+ id: "server",
8211
+ kind: "server",
8212
+ what: "the public IPv4 address of the server the stage's stack runs on — its hosts' DNS records point at it, and every deploy checks they do",
8213
+ note: `${SERVER_IPV6_KEY} adds its IPv6 address (AAAA records); neither is ever handed to an app`,
8214
+ example: "203.0.113.10"
8215
+ });
8216
+ for (const d of byId(input.credentials)) add({
8217
+ key: provideKey(d.id, "credentials"),
8218
+ id: d.id,
8219
+ kind: d.kind,
8220
+ what: d.kind === "external-api" ? `the credentials '${d.id}' was issued for this stage, as its schema describes them` : `the credential '${d.id}', as its schema describes it`,
8221
+ example: "{\"apiKey\":\"…\"}",
8222
+ ...apps(d)
8223
+ });
8224
+ return keys;
8225
+ }
8226
+ /**
8227
+ * Whether only the stage's secrets can supply `key` to a construct of `kind`
8228
+ * — what nothing on the stage derives.
8229
+ *
8230
+ * A third party's credentials, always. Deployed, also each generated secret
8231
+ * and keyring: the stage generated each one once, and every run reads the
8232
+ * same value back.
8233
+ */
8234
+ function suppliedOnly(kind, id, key, local) {
8235
+ if (kind === "credential") return true;
8236
+ if (kind === "external-api") return key === provideKey(id, "credentials");
8237
+ if (local) return false;
8238
+ return kind === "secret" || kind === "encryption";
8239
+ }
8240
+ /**
8241
+ * Where a deployed stage's mail and storage come from, and what it lacks.
8242
+ *
8243
+ * - Mail is external once its URL is set, and then needs its sending
8244
+ * address too. Unset, it is Mailpit where allowed — the address derived
8245
+ * unless set — and missing otherwise.
8246
+ * - A bucket is external once its URL is set; its credentials, in the URL or
8247
+ * the shared pair, are optional. Unset, it is MinIO where allowed, and
8248
+ * missing otherwise.
8249
+ * - A file server's URL is derived where its bucket is MinIO, unless set;
8250
+ * over an external bucket the stage must set it.
8251
+ *
8252
+ * The keys themselves are {@link requiredStageKeys}'s.
8253
+ */
8254
+ function externalServices(input) {
8255
+ const { supplied, allow } = input;
8256
+ const has = (key) => supplied[key] !== void 0;
8257
+ const required = requiredStageKeys({
8258
+ local: false,
8259
+ services: input.declarations,
8260
+ ...input.domain ? { domain: input.domain } : {},
8261
+ ...input.providers ? { providers: input.providers } : {}
8262
+ });
8263
+ const standIn = (k) => allow && k.service !== void 0 && !has(k.key);
8264
+ const mailpit = required.filter((k) => k.kind === "email" && k.key === provideKey(k.id, "url")).filter(standIn).map((k) => k.id);
8265
+ const minio = required.filter((k) => k.kind === "bucket").filter(standIn).map((k) => k.id);
8266
+ const covered = (k) => k.kind === "email" && mailpit.includes(k.id) || k.kind === "bucket" && minio.includes(k.id) || k.kind === "file-server" && k.of !== void 0 && minio.includes(k.of);
8267
+ return {
8268
+ missing: required.filter((k) => !has(k.key) && !covered(k)).map(({ key, id, what, note, example, service, apps, hint }) => ({
8269
+ key,
8270
+ id,
8271
+ what,
8272
+ ...note ? { note } : {},
8273
+ example,
8274
+ ...service ? { service } : {},
8275
+ ...apps ? { apps } : {},
8276
+ ...hint ? { hint } : {}
8277
+ })),
8278
+ minio,
8279
+ mailpit
8280
+ };
8281
+ }
8282
+ /**
8283
+ * `externalServices`, refusing a stage that lacks anything.
8284
+ *
8285
+ * @throws {ExternalServicesNotConfigured} naming every missing key
8286
+ */
8287
+ function assertExternalServices(input) {
8288
+ const services = externalServices(input);
8289
+ if (services.missing.length > 0) throw new ExternalServicesNotConfigured(input.stage, services.missing);
8290
+ return services;
8291
+ }
8292
+ /** The dev services a run uses, one entry per service it runs. */
8293
+ function devServicesUsed(services) {
8294
+ const used = [];
8295
+ if (services.mailpit.length > 0) used.push({
8296
+ service: "mailpit",
8297
+ ids: services.mailpit
8298
+ });
8299
+ if (services.minio.length > 0) used.push({
8300
+ service: "minio",
8301
+ ids: services.minio
8302
+ });
8303
+ return used;
8304
+ }
8305
+ /** The line a run prints for each dev service a deployed stage uses. */
8306
+ function devServiceWarning(stage, use) {
8307
+ return `⚠️ DEV SERVICE ON A DEPLOYED STAGE (${stage}): ${use.service === "mailpit" ? `Mailpit is catching mail for ${use.ids.join(", ")}. It delivers NO mail: nobody receives a sign-in link, a receipt or a reset` : `MinIO is storing ${use.ids.join(", ")}. Every object is on one container's disk, with no replication and no backup`}. It is not production-grade — set the stage's own keys in its secrets for that.`;
8308
+ }
8309
+ /**
8310
+ * Report each dev service a run uses: a loud line, and a `dev-service.used`
8311
+ * event a host can act on.
8312
+ */
8313
+ function reportDevServices(ctx, uses) {
8314
+ for (const use of uses) {
8315
+ ctx.logger.warn(`\n${devServiceWarning(ctx.stage, use)}`);
8316
+ ctx.emit({
8317
+ type: "dev-service.used",
8318
+ service: use.service,
8319
+ stage: ctx.stage,
8320
+ constructs: use.ids
8321
+ });
8322
+ }
8462
8323
  }
8324
+ //#endregion
8325
+ //#region src/deploy/actor.ts
8463
8326
  /**
8464
- * The good name: `<namespace>-<project>-<stage>-<bucket id>`, lowercased, no
8465
- * dots, shortened to leave room for a clash's suffix.
8466
- *
8467
- * @throws {InvalidBucketName} when nothing usable is left
8327
+ * Who wrote a stage's deploy state: a GitHub Actions run, or a person at a
8328
+ * machine. Recorded on every state write (`updatedBy`), on every resource
8329
+ * record and on every release (`releasedBy`), so "who put this here" has an
8330
+ * answer that does not depend on anyone's memory.
8468
8331
  */
8469
- function bucketName(input) {
8470
- const name = fitParts([
8471
- input.scope,
8472
- input.stage,
8473
- input.id
8474
- ], 56);
8475
- const problem = bucketNameProblem(name);
8476
- if (problem) throw new InvalidBucketName(name, problem);
8477
- return name;
8332
+ /** Whether `env` is a CI job — GitHub Actions or any runner that sets `CI`. */
8333
+ function isCi(env = process.env) {
8334
+ return env.GITHUB_ACTIONS === "true" || env.CI === "true";
8478
8335
  }
8479
- /** Six random `[a-z0-9]` characters. */
8480
- function randomSuffix(random = randomInt) {
8481
- let suffix = "";
8482
- for (let i = 0; i < 6; i++) suffix += SUFFIX_ALPHABET[random(36)];
8483
- return suffix;
8336
+ /** Who this process is writing as. */
8337
+ function currentActor(env = process.env) {
8338
+ if (env.GITHUB_ACTIONS === "true") {
8339
+ const server = env.GITHUB_SERVER_URL ?? "https://github.com";
8340
+ return {
8341
+ kind: "github",
8342
+ actor: env.GITHUB_ACTOR ?? "unknown",
8343
+ run: `${server}/${env.GITHUB_REPOSITORY ?? "unknown"}/actions/runs/${env.GITHUB_RUN_ID ?? "unknown"}`,
8344
+ ...env.GITHUB_WORKFLOW ? { workflow: env.GITHUB_WORKFLOW } : {}
8345
+ };
8346
+ }
8347
+ let user = "unknown";
8348
+ try {
8349
+ user = userInfo().username;
8350
+ } catch {}
8351
+ return {
8352
+ kind: "local",
8353
+ user,
8354
+ host: hostname()
8355
+ };
8484
8356
  }
8485
- /** The good name with a clash's suffix. */
8486
- function suffixedBucketName(good, suffix) {
8487
- return `${good}-${suffix}`;
8357
+ /** `octocat (deploy, https://github.com/…/runs/1)` or `ada@laptop`. */
8358
+ function describeActor(actor) {
8359
+ if (!actor) return "unknown";
8360
+ if (actor.kind === "local") return `${actor.user}@${actor.host}`;
8361
+ return `${actor.actor} (${actor.workflow ? `${actor.workflow}, ` : ""}${actor.run})`;
8362
+ }
8363
+ //#endregion
8364
+ //#region src/deploy/dnsResources.ts
8365
+ /** The resource type of a DNS record gkm wrote. */
8366
+ const DNS_RECORD_RESOURCE = "dns-record";
8367
+ /** A record's full name: `api.example.com`, or the domain for `@`. */
8368
+ function dnsRecordFqdn(domain, name) {
8369
+ return name === "@" || name === "" ? domain : `${name}.${domain}`;
8370
+ }
8371
+ /** `dns-record:<fqdn>:<type>` */
8372
+ function dnsResourceKey(fqdn, type) {
8373
+ return `${DNS_RECORD_RESOURCE}:${fqdn.toLowerCase()}:${type.toUpperCase()}`;
8374
+ }
8375
+ /** The resource record a written DNS record is kept as. */
8376
+ function dnsRecordResource(record) {
8377
+ const fqdn = dnsRecordFqdn(record.domain, record.name).toLowerCase();
8378
+ const type = record.type.toUpperCase();
8379
+ const data = {
8380
+ domain: record.domain,
8381
+ name: record.name,
8382
+ value: record.value,
8383
+ ttl: record.ttl,
8384
+ ...record.provider ? { provider: record.provider } : {}
8385
+ };
8386
+ return {
8387
+ key: dnsResourceKey(fqdn, type),
8388
+ type: DNS_RECORD_RESOURCE,
8389
+ id: `${fqdn} ${type}`,
8390
+ status: "ready",
8391
+ data
8392
+ };
8393
+ }
8394
+ /** Records that gkm wrote `record` — or updates the value it holds. */
8395
+ async function recordDnsResource(journal, record) {
8396
+ const { key, type, id, data } = dnsRecordResource(record);
8397
+ await journal.ready({
8398
+ key,
8399
+ type,
8400
+ ...data ? { data } : {}
8401
+ }, id);
8402
+ }
8403
+ /** Forgets a DNS record gkm deleted. */
8404
+ async function forgetDnsResource(journal, record) {
8405
+ await journal.forget(dnsResourceKey(dnsRecordFqdn(record.domain, record.name), record.type));
8488
8406
  }
8489
8407
  /**
8490
- * The IAM user a bucket is reached with on a stage:
8491
- * `gkm-<project>-<stage>-<bucket id>`, at most 64 characters.
8408
+ * The changes a DNS step made, kept: what it created, updated or wrote is
8409
+ * recorded, what it deleted is forgotten. A record it found already right is
8410
+ * recorded only if gkm wrote it before; one it left to a person is not.
8492
8411
  */
8493
- function iamUserName(input) {
8494
- return fitParts([
8495
- "gkm",
8496
- input.scope,
8497
- input.stage,
8498
- input.id
8499
- ], 64);
8500
- }
8501
- /** The path every user gkm creates is under, so it finds its own. */
8502
- const IAM_USER_PATH = "/gkm/";
8503
- /** The inline policy each user is given. */
8504
- const IAM_POLICY_NAME = "gkm-bucket";
8505
- //#endregion
8506
- //#region src/providers/s3/origins.ts
8507
- /**
8508
- * Which browser origins may call a bucket: the stage's sites that depend on
8509
- * an API using it.
8510
- *
8511
- * The same reading of the graph an API's own CORS origins are — its callers —
8512
- * taken one step further: a site that calls an API which hands it a presigned
8513
- * upload URL is a site whose browser PUTs to the bucket. A site that reads the
8514
- * bucket (or a file server over it) itself is one too. Nothing is written
8515
- * down: add a site that calls the API and the next provision run allows it.
8516
- */
8517
- /** The keys a reader of this bucket holds: its URL, and each server's over it. */
8518
- function bucketKeys(manifest, bucket) {
8519
- return [provideKey(bucket, "url"), ...Object.entries(manifest).filter(([, d]) => d.kind === "file-server" && d.of === bucket).map(([id]) => provideKey(id, "url"))];
8520
- }
8521
- function bucketOrigins(input) {
8522
- const { manifest, workspace, stage } = input;
8523
- const keys = bucketKeys(manifest, input.bucket);
8524
- const readers = Object.entries(manifest).filter(([, d]) => d.kind === "rest-api" || d.kind === "site").filter(([id]) => {
8525
- const env = appEnvKeys(manifest, appKey(id), input.runnables);
8526
- return env !== void 0 && keys.some((key) => env.has(key));
8527
- });
8528
- const sites = /* @__PURE__ */ new Set();
8529
- for (const [id, declaration] of readers) {
8530
- if (declaration.kind === "site") {
8531
- sites.add(id);
8532
- continue;
8533
- }
8534
- for (const caller of dependentsOf(manifest, id)) if (manifest[caller]?.kind === "site") sites.add(caller);
8535
- }
8536
- const origins = /* @__PURE__ */ new Set();
8537
- const unresolved = [];
8538
- for (const id of [...sites].sort()) {
8539
- const name = appKey(id);
8540
- const app = workspace.apps[name];
8541
- if (!app) {
8542
- unresolved.push(name);
8412
+ async function recordDnsChanges(journal, changes, providerOf) {
8413
+ for (const change of changes) {
8414
+ if (change.action === "delete") {
8415
+ await forgetDnsResource(journal, change);
8543
8416
  continue;
8544
8417
  }
8545
- try {
8546
- const host = resolveHost(name, app, stage, workspace.domains, isMainFrontendApp(name, app, workspace.apps));
8547
- origins.add(`https://${host}`);
8548
- } catch (error) {
8549
- if (!(error instanceof NoDomainForStage)) throw error;
8550
- unresolved.push(name);
8551
- }
8418
+ const written = change.action === "create" || change.action === "update" || change.action === "write";
8419
+ const known = change.action === "unchanged" && journal.record(dnsResourceKey(dnsRecordFqdn(change.domain, change.name), change.type)) !== void 0;
8420
+ if (!written && !known || change.value === void 0) continue;
8421
+ const provider = providerOf(change.domain);
8422
+ await recordDnsResource(journal, {
8423
+ domain: change.domain,
8424
+ name: change.name,
8425
+ type: change.type,
8426
+ value: change.value,
8427
+ ttl: change.ttl ?? 0,
8428
+ ...provider ? { provider } : {}
8429
+ });
8552
8430
  }
8553
- return {
8554
- origins: [...origins].sort(),
8555
- unresolved
8556
- };
8431
+ }
8432
+ /** Every DNS record gkm wrote for the stage, from its resource records. */
8433
+ function dnsResources(resources) {
8434
+ return Object.values(resources).filter((record) => record.type === DNS_RECORD_RESOURCE).map((record) => {
8435
+ const data = record.data ?? {};
8436
+ const [fqdn = "", type = ""] = (record.id ?? "").split(" ");
8437
+ return {
8438
+ ...data,
8439
+ type,
8440
+ fqdn
8441
+ };
8442
+ }).sort((a, b) => a.fqdn.localeCompare(b.fqdn) || a.type.localeCompare(b.type));
8557
8443
  }
8558
8444
  //#endregion
8559
- //#region src/providers/s3/policy.ts
8560
- const bucketArn = (bucket) => `arn:aws:s3:::${bucket}`;
8445
+ //#region src/docker/imageRef.ts
8561
8446
  /**
8562
- * What the app's key may do: objects in this bucket, and listing it — and
8563
- * nothing else in the account.
8447
+ * An image reference, checked before it reaches `docker`.
8564
8448
  *
8565
- * Multipart uploads are in it because a presigned upload of a large file is
8566
- * one, and an abandoned one is cleaned up by the app. Reading old versions is
8567
- * added only when the bucket keeps them.
8449
+ * The parts come from the workspace — a registry in `gkm.config.ts`, an image
8450
+ * name from the project, a tag from `--tag` — and an argument array already
8451
+ * keeps them out of a shell. What it cannot do is stop `docker` reading a value
8452
+ * that starts with `-` as one of its own flags, so a ref is held to Docker's
8453
+ * own grammar (distribution/reference) and anything else is refused by name
8454
+ * before a build starts.
8568
8455
  */
8569
- function userPolicy(bucket, options = {}) {
8570
- return {
8571
- Version: "2012-10-17",
8572
- Statement: [{
8573
- Sid: "GkmObjects",
8574
- Effect: "Allow",
8575
- Action: [
8576
- "s3:GetObject",
8577
- "s3:PutObject",
8578
- "s3:DeleteObject",
8579
- "s3:AbortMultipartUpload",
8580
- "s3:ListMultipartUploadParts",
8581
- ...options.versioning ? ["s3:GetObjectVersion"] : []
8582
- ],
8583
- Resource: `${bucketArn(bucket)}/*`
8584
- }, {
8585
- Sid: "GkmBucket",
8586
- Effect: "Allow",
8587
- Action: [
8588
- "s3:ListBucket",
8589
- "s3:GetBucketLocation",
8590
- "s3:ListBucketMultipartUploads"
8591
- ],
8592
- Resource: bucketArn(bucket)
8593
- }]
8594
- };
8456
+ const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
8457
+ const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
8458
+ const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
8459
+ 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,})?$`);
8460
+ /** Docker refuses a repository name longer than this. */
8461
+ const MAX_NAME_LENGTH = 255;
8462
+ /** A value that is not an image reference was about to be built or pushed. */
8463
+ var ImageRefInvalid = class extends GkmError {
8464
+ ref;
8465
+ constructor(ref) {
8466
+ 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.`);
8467
+ this.ref = ref;
8468
+ this.name = "ImageRefInvalid";
8469
+ }
8470
+ };
8471
+ /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
8472
+ function validateImageRef(ref) {
8473
+ const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
8474
+ if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
8475
+ return ref;
8595
8476
  }
8477
+ //#endregion
8478
+ //#region src/docker/index.ts
8479
+ const logger = output;
8480
+ /** `docker build` failed; what docker printed above says why. */
8481
+ var DockerBuildFailed = class extends Error {
8482
+ imageRef;
8483
+ constructor(imageRef, cause) {
8484
+ super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
8485
+ this.imageRef = imageRef;
8486
+ this.name = "DockerBuildFailed";
8487
+ }
8488
+ };
8489
+ /** `docker push` failed — usually the registry login or its permissions. */
8490
+ var DockerPushFailed = class extends Error {
8491
+ imageRef;
8492
+ constructor(imageRef, cause) {
8493
+ super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
8494
+ this.imageRef = imageRef;
8495
+ this.name = "DockerPushFailed";
8496
+ }
8497
+ };
8498
+ /** `gkm docker --push` was asked for with nowhere to push to. */
8499
+ var PushNeedsRegistry = class extends GkmError {
8500
+ imageName;
8501
+ constructor(imageName) {
8502
+ super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
8503
+ this.imageName = imageName;
8504
+ this.name = "PushNeedsRegistry";
8505
+ }
8506
+ };
8596
8507
  /**
8597
- * One open pattern as the resource it is public on. A `**` is S3's `*`; a
8598
- * single `*` is too, which crosses `/` where the construct's own check does
8599
- * not — the construct is the stricter of the two (see `bucketPolicy` in
8600
- * reconcile, which the local MinIO is given the same way).
8508
+ * Docker command implementation: one Dockerfile per deployable app, each
8509
+ * building its image inside Docker from a turbo-pruned slice of the build
8510
+ * root — nothing is built on the host — plus the build root's
8511
+ * `.dockerignore` and `docker-compose.constructs.yml`.
8601
8512
  */
8602
- function openResource(bucket, pattern) {
8603
- const path = pattern.replace(/^\/+/, "").replace(/\*\*/g, "*");
8604
- return `${bucketArn(bucket)}/${path}`;
8513
+ async function dockerCommand(options) {
8514
+ const cwd = options.cwd ?? process.cwd();
8515
+ const loaded = await loadWorkspaceConfig(cwd);
8516
+ const names = loaded.type === "single" ? Object.fromEntries(Object.keys(loaded.workspace.apps).map((name) => [name, resolveDockerConfig(loaded.raw).imageName])) : {};
8517
+ const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names, loaded.background, options.stage);
8518
+ if (options.build) for (const app of result.apps) await buildDockerImage(app, result.buildRoot, options);
8519
+ if (options.push) for (const app of result.apps) await pushDockerImage(app.imageName, options, result.buildRoot);
8520
+ return result;
8605
8521
  }
8606
8522
  /**
8607
- * The bucket's own policy: every request refused unless it came over TLS,
8608
- * and — only when a file server opens paths on it — anonymous `s3:GetObject`
8609
- * on exactly those prefixes.
8523
+ * Build one app's image, from the build root.
8524
+ * Uses BuildKit for cache mount support
8610
8525
  */
8611
- function bucketPolicy(bucket, open = []) {
8612
- const statements = [{
8613
- Sid: "GkmDenyInsecureTransport",
8614
- Effect: "Deny",
8615
- Principal: "*",
8616
- Action: "s3:*",
8617
- Resource: [bucketArn(bucket), `${bucketArn(bucket)}/*`],
8618
- Condition: { Bool: { "aws:SecureTransport": "false" } }
8619
- }];
8620
- if (open.length > 0) statements.push({
8621
- Sid: "GkmOpenPaths",
8622
- Effect: "Allow",
8623
- Principal: "*",
8624
- Action: "s3:GetObject",
8625
- Resource: [...new Set(open.map((p) => openResource(bucket, p)))].sort()
8626
- });
8627
- return {
8628
- Version: "2012-10-17",
8629
- Statement: statements
8630
- };
8526
+ async function buildDockerImage(app, buildRoot, options) {
8527
+ const tag = options.tag ?? "latest";
8528
+ const registry = options.registry;
8529
+ const fullImageName = validateImageRef(registry ? `${registry}/${app.imageName}:${tag}` : `${app.imageName}:${tag}`);
8530
+ logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
8531
+ try {
8532
+ await run$1("docker", [
8533
+ "build",
8534
+ `--file=${relative(buildRoot, app.dockerfile)}`,
8535
+ `--tag=${fullImageName}`,
8536
+ "."
8537
+ ], {
8538
+ cwd: buildRoot,
8539
+ env: {
8540
+ ...process.env,
8541
+ DOCKER_BUILDKIT: "1"
8542
+ }
8543
+ });
8544
+ logger.log(`✅ Docker image built: ${fullImageName}`);
8545
+ } catch (error) {
8546
+ throw new DockerBuildFailed(fullImageName, error);
8547
+ }
8631
8548
  }
8632
8549
  /**
8633
- * Block Public Access. All four flags on, unless the bucket has open paths:
8634
- * then a *policy* may make it public (`BlockPublicPolicy`,
8635
- * `RestrictPublicBuckets` off), and ACLs still may not.
8550
+ * Push Docker image to registry
8636
8551
  */
8637
- function publicAccessBlock(open) {
8638
- return {
8639
- BlockPublicAcls: true,
8640
- IgnorePublicAcls: true,
8641
- BlockPublicPolicy: !open,
8642
- RestrictPublicBuckets: !open
8643
- };
8644
- }
8645
- function corsRule(origins) {
8646
- return {
8647
- AllowedMethods: [
8648
- "GET",
8649
- "HEAD",
8650
- "POST",
8651
- "PUT"
8652
- ],
8653
- AllowedOrigins: [...new Set(origins)].sort(),
8654
- AllowedHeaders: ["*"],
8655
- ExposeHeaders: ["ETag"],
8656
- MaxAgeSeconds: 3e3
8657
- };
8552
+ async function pushDockerImage(imageName, options, cwd) {
8553
+ const tag = options.tag ?? "latest";
8554
+ const registry = options.registry;
8555
+ if (!registry) throw new PushNeedsRegistry(imageName);
8556
+ const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
8557
+ logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
8558
+ try {
8559
+ await run$1("docker", ["push", fullImageName], { cwd });
8560
+ logger.log(`✅ Docker image pushed: ${fullImageName}`);
8561
+ } catch (error) {
8562
+ throw new DockerPushFailed(fullImageName, error);
8563
+ }
8658
8564
  }
8659
8565
  /**
8660
- * A policy document in one canonical form — keys sorted, a lone string a
8661
- * one-element list, lists sorted — so one AWS reformatted reads as the same
8662
- * document rather than as drift.
8566
+ * Generate Dockerfiles for all apps in a workspace.
8567
+ * @internal Exported for testing
8663
8568
  */
8664
- function canonicalPolicy(value) {
8665
- return JSON.stringify(canonical(value));
8666
- }
8667
- function canonical(value, key) {
8668
- if (Array.isArray(value)) {
8669
- const items = value.map((v) => canonical(v));
8670
- return key === "Statement" ? items : items.map((v) => JSON.stringify(v)).sort();
8569
+ async function workspaceDockerCommand(workspace, manifest, imageNames = {}, background = {}, stage) {
8570
+ const results = [];
8571
+ const layout = imageLayout(workspace);
8572
+ logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
8573
+ logger.log(` Build root: ${layout.buildRoot}`);
8574
+ logger.log(` Package manager: ${layout.tools.packageManager}${layout.tools.packageManagerVersion ? `@${layout.tools.packageManagerVersion}` : ""}, turbo@${layout.tools.turboVersion}`);
8575
+ for (const [appName, app] of Object.entries(workspace.apps)) {
8576
+ if (app.type === "mobile") {
8577
+ logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
8578
+ continue;
8579
+ }
8580
+ const buildType = app.entry ? "entry" : app.type;
8581
+ logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
8582
+ const dockerfile = appDockerfile(appName, app, {
8583
+ layout,
8584
+ workspaceRoot: workspace.root,
8585
+ apps: workspace.apps,
8586
+ ...manifest ? { manifest } : {},
8587
+ workspace,
8588
+ ...stage ? { stage } : {}
8589
+ });
8590
+ const path = dockerfileOf(appName, app.path);
8591
+ const dockerfilePath = join(workspace.root, path);
8592
+ await mkdir(dirname(dockerfilePath), { recursive: true });
8593
+ await writeFile(dockerfilePath, dockerfile);
8594
+ logger.log(` Generated: ${path}`);
8595
+ results.push({
8596
+ appName,
8597
+ type: app.type,
8598
+ dockerfile: dockerfilePath,
8599
+ imageName: imageNames[appName] ?? appName
8600
+ });
8601
+ }
8602
+ for (const worker of manifest ? workerUnits(workspace, manifest, background) : []) {
8603
+ logger.log(`\n 📄 Generating Dockerfile for ${worker.name} (worker ${worker.id}, built from ${worker.app})`);
8604
+ const dockerfile = workerDockerfile(worker, workspace.apps[worker.app], {
8605
+ layout,
8606
+ workspaceRoot: workspace.root
8607
+ });
8608
+ const path = workerDockerfileOf(worker.name);
8609
+ const dockerfilePath = join(workspace.root, path);
8610
+ await mkdir(dirname(dockerfilePath), { recursive: true });
8611
+ await writeFile(dockerfilePath, dockerfile);
8612
+ logger.log(` Generated: ${path}`);
8613
+ results.push({
8614
+ appName: worker.name,
8615
+ type: "backend",
8616
+ dockerfile: dockerfilePath,
8617
+ imageName: imageNames[worker.name] ?? worker.name
8618
+ });
8671
8619
  }
8672
- if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => {
8673
- const v = value[k];
8674
- return [k, canonical(k === "Action" || k === "Resource" || k === "AWS" ? Array.isArray(v) ? v : [v] : v, k)];
8675
- }));
8676
- return value;
8677
- }
8678
- //#endregion
8679
- //#region src/providers/s3/index.ts
8680
- /** The tags gkm finds its own buckets and users by. */
8681
- const TAG_PROJECT = "gkm:project";
8682
- const TAG_STAGE = "gkm:stage";
8683
- const TAG_CONSTRUCT = "gkm:construct";
8684
- function bucketConstructs(manifest) {
8685
- return Object.entries(manifest).filter(([, d]) => d.kind === "objects").map(([id, d]) => ({
8686
- id,
8687
- versioned: d.versioned === true,
8688
- servers: Object.entries(manifest).filter(([, s]) => s.kind === "file-server" && s.of === id).map(([sid, s]) => ({
8689
- id: sid,
8690
- open: s.open ?? []
8691
- })).sort((a, b) => a.id.localeCompare(b.id))
8692
- })).sort((a, b) => a.id.localeCompare(b.id));
8693
- }
8694
- /**
8695
- * The region the stage's buckets are created in: the entry's, the secrets
8696
- * store's when it is in AWS, the credential's, `AWS_REGION`.
8697
- */
8698
- function s3Region(ctx, env = process.env) {
8699
- const store = ctx.workspace.secrets?.store;
8700
- const storeRegion = store && typeof store === "object" && "region" in store ? store.region : void 0;
8701
- const region = ctx.config.region ?? storeRegion ?? ctx.credential?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION;
8702
- if (!region) throw new S3RegionRequired(ctx.stage);
8703
- return region;
8704
- }
8705
- /**
8706
- * The address a file server over `bucket` serves on: the bucket's regional
8707
- * virtual-host endpoint, which is what `FileServer.url(key)` appends a key to
8708
- * — `https://<bucket>.s3.<region>.amazonaws.com/<key>`. Against an emulator,
8709
- * its path-style address.
8710
- */
8711
- function bucketPublicUrl(bucket, region, endpoint) {
8712
- if (endpoint) return `${endpoint.replace(/\/+$/, "")}/${bucket}`;
8713
- return `https://${bucket}.s3.${region}.amazonaws.com`;
8714
- }
8715
- /** What an AWS SDK error is called, whichever field carries it. */
8716
- function errorName(error) {
8717
- const e = error;
8718
- return e?.Code ?? e?.name ?? "";
8719
- }
8720
- /** A read whose "there is none" answer is one of `missing`. */
8721
- async function readOr(read, missing) {
8722
- try {
8723
- return await read();
8724
- } catch (error) {
8725
- if (missing.includes(errorName(error))) return void 0;
8726
- throw error;
8620
+ const dockerignorePath = await ensureDockerignore(layout.buildRoot);
8621
+ logger.log(`\n Ensured: ${dockerignorePath}`);
8622
+ const composePath = await writeConstructsCompose(workspace);
8623
+ logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
8624
+ logger.log(`\n📋 Build commands (from ${layout.buildRoot}):`);
8625
+ for (const result of results) {
8626
+ const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
8627
+ logger.log(` ${icon} docker build -f ${relative(layout.buildRoot, result.dockerfile)} -t ${result.imageName} .`);
8727
8628
  }
8728
- }
8729
- const defaultClients = async (credential, region) => {
8730
- const S3 = await import("@aws-sdk/client-s3");
8731
- const IAM = await import("@aws-sdk/client-iam");
8732
- const config = await awsClientConfig(credential, region);
8733
- const endpoint = awsEndpoint(process.env, "S3");
8629
+ printRunInstructions(workspace);
8734
8630
  return {
8735
- S3,
8736
- IAM,
8737
- s3: new S3.S3Client({
8738
- ...config,
8739
- ...endpoint ? {
8740
- endpoint,
8741
- forcePathStyle: true
8742
- } : {}
8743
- }),
8744
- iam: new IAM.IAMClient(config),
8745
- region,
8746
- ...endpoint ? { endpoint } : {}
8747
- };
8748
- };
8749
- /** For tests: what makes the clients. */
8750
- let makeClients = defaultClients;
8751
- async function ensureS3(ctx) {
8752
- const region = s3Region(ctx);
8753
- const clients = await makeClients(ctx.credential, region);
8754
- for (const construct of bucketConstructs(ctx.manifest)) await ensureBucket(ctx, clients, construct);
8755
- }
8756
- /** The keys gkm tags a bucket and a user with. */
8757
- function identityTags(ctx, id) {
8758
- return [
8759
- {
8760
- Key: TAG_PROJECT,
8761
- Value: ctx.identity.key
8762
- },
8763
- {
8764
- Key: TAG_STAGE,
8765
- Value: ctx.stage
8766
- },
8767
- {
8768
- Key: TAG_CONSTRUCT,
8769
- Value: id
8770
- }
8771
- ];
8772
- }
8773
- async function ensureBucket(ctx, clients, construct) {
8774
- const { id } = construct;
8775
- const nameInput = {
8776
- scope: ctx.identity.scope,
8777
- stage: ctx.stage,
8778
- id: appKey(id)
8631
+ apps: results,
8632
+ dockerCompose: composePath,
8633
+ dockerignore: dockerignorePath,
8634
+ buildRoot: layout.buildRoot
8779
8635
  };
8780
- const versioning = ctx.config.versioning ?? construct.versioned;
8781
- const open = [...new Set(construct.servers.flatMap((s) => s.open))].sort();
8782
- const { bucket, exists } = await ensureBucketName(ctx, clients, id, bucketName(nameInput));
8783
- await ensureBucketSettings(ctx, clients, {
8784
- id,
8785
- bucket,
8786
- exists,
8787
- versioning,
8788
- open
8789
- });
8790
- await ensureAccessKey(ctx, clients, {
8791
- id,
8792
- bucket,
8793
- ...await ensureUser(ctx, clients, id, iamUserName(nameInput), userPolicy(bucket, { versioning }))
8794
- });
8795
- const publicUrl = bucketPublicUrl(bucket, clients.region, clients.endpoint);
8796
- for (const server of construct.servers) {
8797
- const key = provideKey(server.id, "url");
8798
- const entry = {
8799
- key: `file-server:${server.id}`,
8800
- type: "url"
8801
- };
8802
- const recorded = ctx.state.record(entry.key)?.id;
8803
- const current = ctx.secrets[key];
8804
- if (current === publicUrl) {
8805
- await ctx.state.ready(entry, publicUrl);
8806
- continue;
8807
- }
8808
- if (current !== void 0 && current !== recorded) {
8809
- ctx.log(` ${key} is set to ${current} by you, and kept — the bucket's own address is ${publicUrl}`);
8810
- continue;
8811
- }
8812
- await ctx.change({
8813
- construct: server.id,
8814
- resource: key,
8815
- change: `set to ${publicUrl}`
8816
- }, async () => {
8817
- await ctx.writeSecrets({ [key]: publicUrl });
8818
- await ctx.state.ready(entry, publicUrl);
8819
- });
8820
- }
8821
8636
  }
8822
8637
  /**
8823
- * The bucket's name: the recorded one, else the good name, else the good
8824
- * name with a random suffix — up to {@link SUFFIX_ATTEMPTS} times.
8638
+ * One app's Dockerfile, by what it is: a gkm backend bundled by `gkm build`,
8639
+ * a backend with its own entry bundled by esbuild, or a site by its
8640
+ * framework. The one choice every builder of images makes.
8825
8641
  */
8826
- async function ensureBucketName(ctx, clients, id, good) {
8827
- const { S3, s3, region } = clients;
8828
- const key = `s3-bucket:${id}`;
8829
- const record = ctx.state.record(key);
8830
- const entry = (name) => ({
8831
- key,
8832
- type: "s3-bucket",
8833
- data: {
8834
- name,
8835
- region
8836
- }
8837
- });
8838
- /** Whether this account holds `name`: yes, no, or someone else does. */
8839
- const probe = async (name) => {
8840
- try {
8841
- await s3.send(new S3.HeadBucketCommand({ Bucket: name }));
8842
- return "ours";
8843
- } catch (error) {
8844
- const name = errorName(error);
8845
- if (name === "NotFound" || name === "NoSuchBucket") return "none";
8846
- if (name === "Forbidden" || name === "AccessDenied") return "taken";
8847
- throw error;
8848
- }
8849
- };
8850
- const create = async (name) => {
8851
- if (ctx.dryRun) {
8852
- const found = await probe(name);
8853
- if (found === "none") {
8854
- await ctx.change({
8855
- construct: id,
8856
- resource: `bucket ${name}`,
8857
- change: `create in ${region}`
8858
- }, async () => {});
8859
- return "created";
8860
- }
8861
- return found;
8862
- }
8863
- await ctx.state.pending(entry(name));
8864
- try {
8865
- await s3.send(new S3.CreateBucketCommand({
8866
- Bucket: name,
8867
- ...region === "us-east-1" ? {} : { CreateBucketConfiguration: { LocationConstraint: region } }
8868
- }));
8869
- } catch (error) {
8870
- const code = errorName(error);
8871
- if (code === "BucketAlreadyOwnedByYou") return "ours";
8872
- if (code === "BucketAlreadyExists") return "taken";
8873
- throw error;
8874
- }
8875
- await ctx.change({
8876
- construct: id,
8877
- resource: `bucket ${name}`,
8878
- change: `create in ${region}`
8879
- }, async () => {});
8880
- return "created";
8881
- };
8882
- const adopt = async (name) => {
8883
- const owner = (await readOr(() => s3.send(new S3.GetBucketTaggingCommand({ Bucket: name })), ["NoSuchTagSet", "NoSuchTagSetError"]))?.TagSet?.find((t) => t.Key === TAG_PROJECT)?.Value;
8884
- if (owner && owner !== ctx.identity.key) throw new BucketOwnedByAnotherProject(name, owner, ctx.identity.key);
8885
- };
8886
- if (record?.status === "ready" && record.id) {
8887
- const found = await probe(record.id);
8888
- if (found === "ours") return {
8889
- bucket: record.id,
8890
- exists: true
8891
- };
8892
- if (found === "taken") throw new RecordedBucketUnreachable(id, record.id);
8893
- const made = await create(record.id);
8894
- if (made === "taken") throw new RecordedBucketUnreachable(id, record.id);
8895
- if (!ctx.dryRun) await ctx.state.ready(entry(record.id), record.id);
8896
- return {
8897
- bucket: record.id,
8898
- exists: made === "ours"
8899
- };
8900
- }
8901
- const pending = record?.data?.name;
8902
- const names = [
8903
- ...typeof pending === "string" && pending !== good ? [pending] : [],
8904
- good,
8905
- ...Array.from({ length: 5 }, () => suffixedBucketName(good, randomSuffix()))
8906
- ];
8907
- const tried = [];
8908
- for (const name of names) {
8909
- tried.push(name);
8910
- const made = await create(name);
8911
- if (made === "taken") continue;
8912
- if (made === "ours") await adopt(name);
8913
- if (!ctx.dryRun) await ctx.state.ready(entry(name), name);
8914
- if (name !== good) ctx.log(` '${good}' is taken by another account; '${id}' is '${name}', recorded in the stage's state`);
8915
- return {
8916
- bucket: name,
8917
- exists: made === "ours"
8918
- };
8919
- }
8920
- throw new BucketNameUnavailable(id, tried.filter((n) => n !== pending));
8921
- }
8922
- /** Tags, Block Public Access, encryption, policy, versioning and CORS. */
8923
- async function ensureBucketSettings(ctx, clients, input) {
8924
- const { S3, s3 } = clients;
8925
- const { id, bucket } = input;
8926
- const Bucket = bucket;
8927
- const fresh = ctx.dryRun && !input.exists;
8928
- const read = async (fn, missing) => fresh ? void 0 : readOr(fn, missing);
8929
- const set = (change, apply) => ctx.change({
8930
- construct: id,
8931
- resource: `bucket ${bucket}`,
8932
- change
8933
- }, () => apply().then(() => void 0));
8934
- const tagged = await read(() => s3.send(new S3.GetBucketTaggingCommand({ Bucket })), ["NoSuchTagSet", "NoSuchTagSetError"]);
8935
- const ours = identityTags(ctx, id);
8936
- const others = (tagged?.TagSet ?? []).filter((t) => !ours.some((o) => o.Key === t.Key));
8937
- if (ours.some((o) => !tagged?.TagSet?.some((t) => t.Key === o.Key && t.Value === o.Value))) await set("tag", () => s3.send(new S3.PutBucketTaggingCommand({
8938
- Bucket,
8939
- Tagging: { TagSet: [...others, ...ours].map((t) => ({
8940
- Key: t.Key,
8941
- Value: t.Value
8942
- })) }
8943
- })));
8944
- const isOpen = input.open.length > 0;
8945
- const block = publicAccessBlock(isOpen);
8946
- const ensureBlock = async () => {
8947
- const now = (await read(() => s3.send(new S3.GetPublicAccessBlockCommand({ Bucket })), ["NoSuchPublicAccessBlockConfiguration"]))?.PublicAccessBlockConfiguration;
8948
- if (!(now && Object.keys(block).every((k) => (now[k] ?? false) === block[k]))) await set(isOpen ? "block public access (ACLs; the policy opens the open paths)" : "block all public access", () => s3.send(new S3.PutPublicAccessBlockCommand({
8949
- Bucket,
8950
- PublicAccessBlockConfiguration: block
8951
- })));
8952
- };
8953
- const policy = bucketPolicy(bucket, input.open);
8954
- const ensurePolicy = async () => {
8955
- const current = await read(() => s3.send(new S3.GetBucketPolicyCommand({ Bucket })), ["NoSuchBucketPolicy"]);
8956
- if (!(current?.Policy !== void 0 && canonicalPolicy(JSON.parse(current.Policy)) === canonicalPolicy(policy))) await set(isOpen ? `policy: TLS only, public read on ${input.open.join(", ")}` : "policy: TLS only", () => s3.send(new S3.PutBucketPolicyCommand({
8957
- Bucket,
8958
- Policy: JSON.stringify(policy)
8959
- })));
8960
- };
8961
- if (isOpen) {
8962
- await ensureBlock();
8963
- await ensurePolicy();
8964
- } else {
8965
- await ensurePolicy();
8966
- await ensureBlock();
8967
- }
8968
- if ((await read(() => s3.send(new S3.GetBucketEncryptionCommand({ Bucket })), ["ServerSideEncryptionConfigurationNotFoundError"]))?.ServerSideEncryptionConfiguration?.Rules?.[0]?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm !== "AES256" && !fresh) await set("encrypt by default (SSE-S3)", () => s3.send(new S3.PutBucketEncryptionCommand({
8969
- Bucket,
8970
- ServerSideEncryptionConfiguration: { Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } }] }
8971
- })));
8972
- const status = (await read(() => s3.send(new S3.GetBucketVersioningCommand({ Bucket })), []))?.Status;
8973
- if (input.versioning && status !== "Enabled") await set("enable versioning", () => s3.send(new S3.PutBucketVersioningCommand({
8974
- Bucket,
8975
- VersioningConfiguration: { Status: "Enabled" }
8976
- })));
8977
- else if (!input.versioning && status === "Enabled") await set("suspend versioning", () => s3.send(new S3.PutBucketVersioningCommand({
8978
- Bucket,
8979
- VersioningConfiguration: { Status: "Suspended" }
8980
- })));
8981
- const { origins, unresolved } = bucketOrigins({
8982
- workspace: ctx.workspace,
8983
- manifest: ctx.manifest,
8984
- runnables: ctx.runnables,
8985
- stage: ctx.stage,
8986
- bucket: id
8987
- });
8988
- if (unresolved.length > 0) ctx.log(` ${unresolved.join(", ")} ${unresolved.length === 1 ? "has" : "have"} no address on '${ctx.stage}' (domains.${ctx.stage}), so ${unresolved.length === 1 ? "is" : "are"} not in '${bucket}''s CORS`);
8989
- if (origins.length === 0) return;
8990
- const rule = corsRule(origins);
8991
- const cors = await read(() => s3.send(new S3.GetBucketCorsCommand({ Bucket })), ["NoSuchCORSConfiguration"]);
8992
- if (!(cors?.CORSRules?.length === 1 && canonicalCors(cors.CORSRules[0]) === canonicalCors(rule))) await set(`CORS for ${origins.join(", ")}`, () => s3.send(new S3.PutBucketCorsCommand({
8993
- Bucket,
8994
- CORSConfiguration: { CORSRules: [rule] }
8995
- })));
8996
- }
8997
- function canonicalCors(rule) {
8998
- const sorted = (list) => [...list ?? []].sort();
8999
- return JSON.stringify({
9000
- AllowedMethods: sorted(rule.AllowedMethods),
9001
- AllowedOrigins: sorted(rule.AllowedOrigins),
9002
- AllowedHeaders: sorted(rule.AllowedHeaders),
9003
- ExposeHeaders: sorted(rule.ExposeHeaders),
9004
- MaxAgeSeconds: rule.MaxAgeSeconds ?? null
8642
+ function appDockerfile(appName, app, options) {
8643
+ const image = appImageOptions(options.layout, appName, app, options.workspaceRoot, options.apps, app.type === "web" && options.manifest && options.workspace ? siteClientTelemetry(options.manifest, appName, options.workspace, options.stage) : void 0);
8644
+ if (app.type === "web") return siteDockerfile(appName, app, {
8645
+ image,
8646
+ ...options.manifest ? { manifest: options.manifest } : {},
8647
+ ...options.publicUrlArgs ? { publicUrlArgs: options.publicUrlArgs } : {}
9005
8648
  });
9006
- }
9007
- /** The bucket's IAM user and its inline policy. */
9008
- async function ensureUser(ctx, clients, id, user, policy) {
9009
- const { IAM, iam } = clients;
9010
- const entry = {
9011
- key: `iam-user:${id}`,
9012
- type: "iam-user"
9013
- };
9014
- const found = await readOr(() => iam.send(new IAM.GetUserCommand({ UserName: user })), ["NoSuchEntity", "NoSuchEntityException"]);
9015
- if (found?.User) {
9016
- const tags = await iam.send(new IAM.ListUserTagsCommand({ UserName: user }));
9017
- const tag = (key) => tags.Tags?.find((t) => t.Key === key)?.Value;
9018
- if (found.User.Path !== "/gkm/" || tag("gkm:project") !== ctx.identity.key || tag("gkm:stage") !== ctx.stage) throw new IamUserNotOwned(user, ctx.identity.key, ctx.stage);
9019
- await ctx.state.ready(entry, user);
9020
- } else await ctx.change({
9021
- construct: id,
9022
- resource: `IAM user ${user}`,
9023
- change: `create under ${IAM_USER_PATH}`
9024
- }, async () => {
9025
- await ctx.state.pending(entry);
9026
- await iam.send(new IAM.CreateUserCommand({
9027
- UserName: user,
9028
- Path: IAM_USER_PATH,
9029
- Tags: identityTags(ctx, id)
9030
- }));
9031
- await ctx.state.ready(entry, user);
8649
+ if (app.entry) return generateEntryDockerfile({
8650
+ ...image,
8651
+ entry: app.entry,
8652
+ healthCheckPath: "/health"
9032
8653
  });
9033
- const exists = Boolean(found?.User) || !ctx.dryRun;
9034
- const current = exists ? await readOr(() => iam.send(new IAM.GetUserPolicyCommand({
9035
- UserName: user,
9036
- PolicyName: IAM_POLICY_NAME
9037
- })), ["NoSuchEntity", "NoSuchEntityException"]) : void 0;
9038
- const document = current?.PolicyDocument ? JSON.parse(decodePolicy(current.PolicyDocument)) : void 0;
9039
- if (!document || canonicalPolicy(document) !== canonicalPolicy(policy)) await ctx.change({
9040
- construct: id,
9041
- resource: `IAM user ${user}`,
9042
- change: `policy ${IAM_POLICY_NAME}: this bucket only`
9043
- }, async () => {
9044
- await iam.send(new IAM.PutUserPolicyCommand({
9045
- UserName: user,
9046
- PolicyName: IAM_POLICY_NAME,
9047
- PolicyDocument: JSON.stringify(policy)
9048
- }));
8654
+ return generateBackendDockerfile({
8655
+ ...image,
8656
+ healthCheckPath: "/health",
8657
+ ...options.cache ? { cache: options.cache } : {}
9049
8658
  });
9050
- return {
9051
- user,
9052
- exists
9053
- };
9054
8659
  }
9055
- /** IAM returns a policy document URL-encoded; an emulator may not. */
9056
- function decodePolicy(document) {
9057
- try {
9058
- return decodeURIComponent(document);
9059
- } catch {
9060
- return document;
9061
- }
8660
+ /** Where `gkm docker` writes a worker's Dockerfile. */
8661
+ function workerDockerfileOf(worker) {
8662
+ return `.gkm/docker/Dockerfile.${worker}`;
9062
8663
  }
9063
8664
  /**
9064
- * The user's access key, in the stage's secrets as the bucket's URL.
9065
- *
9066
- * - First run: one key, written into the stage's secrets.
9067
- * - `--rotate-keys`: a second key, written in place of the first; the first
9068
- * stays active until the stage has deployed with the second, and the next
9069
- * run after that deploy — or `--retire-old-keys` — deletes it.
9070
- * - A key the secrets no longer hold (a store emptied by hand): replaced the
9071
- * same way, since its secret cannot be read back.
8665
+ * A Worker's Dockerfile: its host app's build, and the worker's bundle as the
8666
+ * runner — see `generateWorkerDockerfile`.
9072
8667
  */
9073
- async function ensureAccessKey(ctx, clients, input) {
9074
- const { IAM, iam } = clients;
9075
- const { id, bucket, user } = input;
9076
- const urlKey = provideKey(id, "url");
9077
- const entryKey = `iam-access-key:${id}`;
9078
- const record = ctx.state.record(entryKey);
9079
- const given = ctx.secrets[urlKey];
9080
- let inUrl;
9081
- if (given !== void 0) {
9082
- let parsed;
9083
- try {
9084
- parsed = s3Url.parse(given);
9085
- } catch {
9086
- parsed = void 0;
9087
- }
9088
- if (parsed?.bucket !== bucket) throw new BucketKeySetElsewhere(urlKey, bucket, parsed?.bucket ?? given.replace(/\/\/[^@]*@/, "//"), ctx.stage);
9089
- inUrl = parsed.accessKeyId;
9090
- }
9091
- const listed = input.exists ? (await iam.send(new IAM.ListAccessKeysCommand({ UserName: user }))).AccessKeyMetadata?.map((k) => k.AccessKeyId) ?? [] : [];
9092
- let current = record?.status === "ready" ? record.id : void 0;
9093
- let previous = typeof record?.data?.previous === "string" ? record.data.previous : void 0;
9094
- const rotatedAt = typeof record?.data?.rotatedAt === "string" ? record.data.rotatedAt : void 0;
9095
- const entry = (data = {}) => ({
9096
- key: entryKey,
9097
- type: "iam-access-key",
9098
- data: {
9099
- user,
9100
- ...data
9101
- }
9102
- });
9103
- if (previous && current) {
9104
- const deployed = rotatedAt !== void 0 && ctx.state.lastDeployedAt !== void 0 && ctx.state.lastDeployedAt > rotatedAt;
9105
- if (ctx.retireOldKeys || deployed) {
9106
- const old = previous;
9107
- const keep = current;
9108
- await ctx.change({
9109
- construct: id,
9110
- resource: `IAM user ${user}`,
9111
- change: `delete the rotated-out access key ${old}`
9112
- }, async () => {
9113
- if (listed.includes(old)) await iam.send(new IAM.DeleteAccessKeyCommand({
9114
- UserName: user,
9115
- AccessKeyId: old
9116
- }));
9117
- await ctx.state.ready(entry(), keep);
9118
- });
9119
- previous = void 0;
9120
- } else ctx.log(` ${id}: the old access key ${previous} stays active until '${ctx.stage}' is deployed with the new one; the next gkm setup --stage ${ctx.stage} after that deletes it (or pass --retire-old-keys)`);
9121
- }
9122
- if (!current && inUrl && listed.includes(inUrl)) {
9123
- await ctx.state.ready(entry(), inUrl);
9124
- current = inUrl;
9125
- }
9126
- if (current !== void 0 && listed.includes(current) && inUrl === current && !ctx.rotateKeys) return;
9127
- if (ctx.rotateKeys && previous) throw new RotationInProgress(id, previous, ctx.stage);
9128
- const outgoing = current && listed.includes(current) ? current : void 0;
9129
- if (listed.length >= 2) throw new AccessKeyLimit(user, listed);
9130
- const why = !current ? "create an access key" : ctx.rotateKeys ? "rotate: issue a new access key" : "issue a new access key (the secrets no longer hold the recorded one)";
9131
- await ctx.change({
9132
- construct: id,
9133
- resource: `IAM user ${user}`,
9134
- change: why
9135
- }, async () => {
9136
- await ctx.state.pending(entry(outgoing ? { previous: outgoing } : {}));
9137
- const key = (await iam.send(new IAM.CreateAccessKeyCommand({ UserName: user }))).AccessKey;
9138
- await ctx.writeSecrets({ [urlKey]: s3Url.build({
9139
- bucket,
9140
- region: clients.region,
9141
- ...clients.endpoint ? {
9142
- endpoint: clients.endpoint,
9143
- forcePathStyle: true
9144
- } : {},
9145
- accessKeyId: key.AccessKeyId,
9146
- secretAccessKey: key.SecretAccessKey
9147
- }) });
9148
- await ctx.state.ready(entry(outgoing ? {
9149
- previous: outgoing,
9150
- rotatedAt: (/* @__PURE__ */ new Date()).toISOString()
9151
- } : {}), key.AccessKeyId);
8668
+ function workerDockerfile(worker, host, options) {
8669
+ return generateWorkerDockerfile({
8670
+ ...appImageOptions(options.layout, worker.app, host, options.workspaceRoot),
8671
+ imageName: worker.name,
8672
+ port: WORKER_PORT,
8673
+ worker: worker.id,
8674
+ bundle: workerBundleName(worker.id),
8675
+ healthCheckPath: "/health",
8676
+ ...options.cache ? { cache: options.cache } : {}
9152
8677
  });
9153
- if (outgoing) ctx.log(` ${urlKey} holds the new key. Deploy '${ctx.stage}' now; the next gkm setup --stage ${ctx.stage} after that deploy deletes the old key ${outgoing}`);
9154
- }
9155
- /**
9156
- * Each provisioned bucket answers `HeadBucket` to the key in the stage's
9157
- * secrets — the one the apps will use. A bucket whose URL carries no key is
9158
- * not one this provider wrote, and the deploy's own check covers a URL that
9159
- * is missing.
9160
- */
9161
- async function verifyS3(ctx) {
9162
- const S3 = await import("@aws-sdk/client-s3");
9163
- for (const { id } of bucketConstructs(ctx.manifest)) {
9164
- const url = ctx.secrets[provideKey(id, "url")];
9165
- if (url === void 0) continue;
9166
- const address = s3Url.parse(url);
9167
- if (!address.accessKeyId || !address.secretAccessKey) continue;
9168
- const client = new S3.S3Client({
9169
- region: address.region ?? ctx.config.region ?? "us-east-1",
9170
- credentials: {
9171
- accessKeyId: address.accessKeyId,
9172
- secretAccessKey: address.secretAccessKey
9173
- },
9174
- ...address.endpoint ? { endpoint: address.endpoint } : {},
9175
- ...address.forcePathStyle ? { forcePathStyle: true } : {}
9176
- });
9177
- try {
9178
- await client.send(new S3.HeadBucketCommand({ Bucket: address.bucket }));
9179
- } catch (error) {
9180
- const name = errorName(error);
9181
- throw new ProvisionedBucketUnreachable(id, address.bucket, ctx.stage, name === "NotFound" ? "it does not exist" : name === "Forbidden" ? "the key is refused" : name || String(error));
9182
- } finally {
9183
- client.destroy();
9184
- }
9185
- }
9186
- }
9187
- //#endregion
9188
- //#region src/providers/registry.ts
9189
- /**
9190
- * The providers gkm ships, by kind and name — registered the way a deploy
9191
- * target's built-ins are (`target/builtins.ts`). A provider from a package is
9192
- * one more entry here once there is a loading story for it.
9193
- */
9194
- const BUILTIN_PROVIDERS = { objects: { s3: {
9195
- kind: "objects",
9196
- name: "s3",
9197
- check: checkS3Config,
9198
- provisioning: S3_PROVISIONING,
9199
- credentials: awsProvisioningCredentials,
9200
- runtimeKeys(manifest) {
9201
- return bucketConstructs(manifest).flatMap((b) => [provideKey(b.id, "url"), ...b.servers.map((s) => provideKey(s.id, "url"))]);
9202
- },
9203
- ensure: ensureS3,
9204
- verify: verifyS3
9205
- } } };
9206
- /** The provider `kind` names `name`, if gkm has one. */
9207
- function builtinProvider(kind, name) {
9208
- return Object.hasOwn(BUILTIN_PROVIDERS[kind], name) ? BUILTIN_PROVIDERS[kind][name] : void 0;
9209
- }
9210
- //#endregion
9211
- //#region src/providers/index.ts
9212
- /** The constructs of a kind: `objects` is every bucket. */
9213
- function constructsOf(manifest, kind) {
9214
- return Object.entries(manifest).filter(([, d]) => d.kind === kind).map(([id]) => id).sort();
9215
8678
  }
9216
8679
  /**
9217
- * What every deploy runs: each provider's `verify()` — the cheap check that
9218
- * what it created is still there and the stage's key reaches it. Nothing on
9219
- * the local stage, and nothing for a kind on `external`.
8680
+ * A site's Dockerfile, with an `ARG` for every key its bundler inlines.
9220
8681
  *
9221
- * @throws {StageProviderDisabled} for a declared kind the stage has none of
8682
+ * The keys come from the site's declaration where there is one — every
8683
+ * public key its edges give it, `VITE_UPLOADS_SERVER_URL` included — and an
8684
+ * `ARG` the Dockerfile does not declare is a build argument the build drops,
8685
+ * leaving the bundle with an empty URL.
9222
8686
  */
9223
- async function verifyStageProviders(input) {
9224
- assertStageProvidersEnabled(input.workspace, input.manifest, input.stage);
9225
- const verified = [];
9226
- for (const kind of PROVIDER_KINDS) {
9227
- if (constructsOf(input.manifest, kind).length === 0) continue;
9228
- const choice = stageProvider(input.workspace, kind, input.stage);
9229
- if (choice.mode !== "provider") continue;
9230
- const provider = builtinProvider(kind, choice.name);
9231
- await provider.verify({
9232
- workspace: input.workspace,
9233
- manifest: input.manifest,
9234
- stage: input.stage,
9235
- config: choice.config,
9236
- secrets: input.secrets
9237
- });
9238
- verified.push(`${kind}: ${provider.name}`);
8687
+ function siteDockerfile(appName, app, options) {
8688
+ const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
8689
+ const publicUrlArgs = options.publicUrlArgs ?? (declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app));
8690
+ const webOpts = {
8691
+ ...options.image,
8692
+ publicUrlArgs
8693
+ };
8694
+ switch (app.framework) {
8695
+ case "vite": return generateViteStaticDockerfile(webOpts);
8696
+ case "tanstack-start":
8697
+ case "remix": return generateNodeWebDockerfile(webOpts);
8698
+ default: return generateNextjsDockerfile(webOpts);
9239
8699
  }
9240
- return verified;
9241
8700
  }
9242
8701
  /**
9243
- * `ensure()` for every provider the stage configures — find or create, fix
9244
- * drift, record in the stage's state, write the runtime keys into its
9245
- * secrets. A provider without provisioning credentials leaves the stage as
9246
- * `external` and says how to supply them.
8702
+ * What a build root's `.dockerignore` must leave out of every image's
8703
+ * context: dependencies installed for another platform, anything built on the
8704
+ * host (every image builds from source), git, and gkm's own output —
8705
+ * `.gkm/compose`'s env files hold a stage's secrets.
9247
8706
  */
9248
- async function provisionStage(options) {
9249
- const { workspace, stage } = options;
9250
- const dryRun = options.dryRun === true;
9251
- const env = options.env ?? process.env;
9252
- const log = options.log ?? ((line) => console.log(line));
9253
- const named = PROVIDER_KINDS.some((kind) => stageProvider(workspace, kind, stage).mode === "provider");
9254
- let found = options.manifest;
9255
- let runnables = options.runnables ?? {};
9256
- if (!found && named) {
9257
- runnables = {};
9258
- found = await discover({
9259
- patterns: constructGlobs(workspace),
9260
- cwd: workspace.root,
9261
- runnables
9262
- });
9263
- }
9264
- const manifest = found ?? {};
9265
- const reports = [];
9266
- const configured = [];
9267
- for (const kind of PROVIDER_KINDS) {
9268
- const choice = stageProvider(workspace, kind, stage);
9269
- if (choice.mode !== "provider") {
9270
- reports.push({
9271
- kind,
9272
- mode: choice.mode,
9273
- actions: []
9274
- });
9275
- continue;
9276
- }
9277
- const provider = builtinProvider(kind, choice.name);
9278
- const report = {
9279
- kind,
9280
- mode: "provider",
9281
- provider: provider.name,
9282
- actions: []
9283
- };
9284
- reports.push(report);
9285
- if (constructsOf(manifest, kind).length === 0) {
9286
- log(` ${kind}: ${provider.name} — nothing declared to provision`);
9287
- continue;
9288
- }
9289
- const credential = await provider.credentials({
9290
- stage,
9291
- env,
9292
- ...options.profile ? { profile: options.profile } : {},
9293
- ...options.home ? { home: options.home } : {}
9294
- });
9295
- report.credentials = credential !== void 0;
9296
- if (credential === void 0) {
9297
- log(` ${kind}: ${provider.name} — no credentials to provision with, so '${stage}' is external for now: its keys (${provider.runtimeKeys(manifest).join(", ")}) are yours to set with gkm secrets:add --stage ${stage}. To have gkm create them, supply ${provider.provisioning.describe}` + (provider.provisioning.login ? `, or run gkm login --provider ${provider.provisioning.login}` : "") + `, and run ${provisionCommand(stage)}.`);
9298
- continue;
9299
- }
9300
- configured.push({
9301
- kind,
9302
- provider,
9303
- config: choice.config,
9304
- credential,
9305
- report
9306
- });
9307
- }
9308
- if (configured.length === 0) return reports;
9309
- const identity = deployIdentity(workspace, stage);
9310
- const secretsStore = await secretsStoreFor(workspace, stage, {
9311
- ...options.profile ? { profile: options.profile } : {},
9312
- ...options.home ? { home: options.home } : {}
9313
- });
9314
- let secrets = (await secretsStore.read(stage))?.custom ?? {};
9315
- const stateStore = await createStateStore({
9316
- config: workspace.state,
9317
- workspaceRoot: workspace.root,
9318
- workspaceName: workspace.name
9319
- });
9320
- const lock = dryRun ? void 0 : await stateStore.lock(stage, { operation: "provision" });
9321
- try {
9322
- const state = dryRun ? await readOnlyState(stateStore, stage) : journalState(await DeployJournal.open(stateStore, stage, () => createEmptyState(stage, "", "")));
9323
- const writeSecrets = async (values) => {
9324
- if (dryRun) return;
9325
- const stored = await secretsStore.read(stage) ?? initStageSecrets(stage);
9326
- const next = {
9327
- ...stored,
9328
- custom: {
9329
- ...stored.custom,
9330
- ...values
9331
- },
9332
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
9333
- };
9334
- await secretsStore.write(stage, next);
9335
- secrets = next.custom;
9336
- };
9337
- for (const entry of configured) {
9338
- log(` ${entry.kind}: ${entry.provider.name}${dryRun ? " (dry run)" : ""}`);
9339
- await entry.provider.ensure({
9340
- workspace,
9341
- manifest,
9342
- runnables,
9343
- stage,
9344
- identity,
9345
- config: entry.config,
9346
- credential: entry.credential,
9347
- get secrets() {
9348
- return secrets;
9349
- },
9350
- state,
9351
- dryRun,
9352
- rotateKeys: options.rotateKeys === true,
9353
- retireOldKeys: options.retireOldKeys === true,
9354
- async change(action, apply) {
9355
- if (!dryRun) await apply();
9356
- entry.report.actions.push(action);
9357
- log(` ${dryRun ? "would " : ""}${action.change} — ${action.resource}`);
9358
- },
9359
- writeSecrets,
9360
- log
9361
- });
9362
- if (entry.report.actions.length === 0) log(" up to date");
9363
- }
9364
- } finally {
9365
- await lock?.release();
8707
+ const REQUIRED_IGNORES = [
8708
+ "**/node_modules",
8709
+ ".git",
8710
+ "**/dist",
8711
+ "**/.next",
8712
+ "**/.turbo",
8713
+ "**/.gkm",
8714
+ "**/.gkm/compose"
8715
+ ];
8716
+ /**
8717
+ * Make sure the build root's `.dockerignore` leaves out what no image may
8718
+ * hold: written whole where there is none, and each missing line appended to
8719
+ * one the project keeps.
8720
+ */
8721
+ async function ensureDockerignore(buildRoot) {
8722
+ const path = join(buildRoot, ".dockerignore");
8723
+ if (!existsSync(path)) {
8724
+ await writeFile(path, generateDockerignore());
8725
+ return path;
9366
8726
  }
9367
- return reports;
8727
+ const current = await readFile(path, "utf-8");
8728
+ const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
8729
+ const missing = REQUIRED_IGNORES.filter((line) => !lines.has(line));
8730
+ if (missing.length === 0) return path;
8731
+ await appendFile(path, `${current.endsWith("\n") || current === "" ? "" : "\n"}\n# gkm: images build from source, and a stack's env files hold its secrets\n${missing.join("\n")}\n`);
8732
+ return path;
9368
8733
  }
9369
- /** The journal as `ensure()` sees it. */
9370
- function journalState(journal) {
9371
- return {
9372
- record: (key) => journal.record(key),
9373
- pending: (entry) => journal.pending(entry),
9374
- ready: (entry, id) => journal.ready(entry, id),
9375
- lastDeployedAt: journal.existed ? journal.state.lastDeployedAt : void 0
9376
- };
8734
+ /**
8735
+ * Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
8736
+ * the apps beside the containers their constructs derive.
8737
+ *
8738
+ * Through reconcile rather than a second generator, so what `gkm docker`
8739
+ * describes and what `gkm dev` runs cannot disagree. Nothing is started.
8740
+ */
8741
+ async function writeConstructsCompose(workspace) {
8742
+ await reconcileWorkspace(workspace, {
8743
+ stage: workspace.stages.local,
8744
+ start: false
8745
+ });
8746
+ logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
8747
+ return join(workspace.root, COMPOSE_PATH);
9377
8748
  }
9378
- /** What a dry run reads, and the writes it does not make. */
9379
- async function readOnlyState(store, stage) {
9380
- const stored = await store.read(stage);
9381
- return {
9382
- record: (key) => stored?.resources[key],
9383
- pending: async () => {},
9384
- ready: async () => {},
9385
- ...stored ? { lastDeployedAt: stored.state.lastDeployedAt } : {}
9386
- };
8749
+ function printRunInstructions(workspace) {
8750
+ const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
8751
+ logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
8752
+ logger.log(" gkm setup # starts the containers and creates what they hold");
8753
+ logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
9387
8754
  }
9388
8755
  //#endregion
9389
8756
  //#region src/secrets/stale.ts
@@ -9534,6 +8901,6 @@ function reportPlannedSeeds(planned, log) {
9534
8901
  for (const { folder, seeds } of planned) log(`🌱 ${folder}: would run ${seeds.length} (${seeds.join(", ")})`);
9535
8902
  }
9536
8903
  //#endregion
9537
- export { externalServices as $, LOCAL_LOGS_EMAIL as $n, isImageBuild as $t, setApplicationId as A, prepareEntryCredentials as An, applyPolicies as At, StateStoreWithoutLocking as B, applicationName as Bn, appEnvKeys as Bt, orchestrateDns as C, createCredentialsPreload as Cn, redisService as Ct, getGeneratedSecret as D, loadEnvFiles as Dn, LOCAL_CA_PATH as Dt, getBackupState as E, isPortAvailable as En, withRedisPassword as Et, createDnsProvider as F, FileSecretsStore as Fn, quoteIdentifier as Ft, AllowDevServicesTakesNoValue as G, markerOf as Gn, localRolePassword as Gt, contentVersion as H, imageName as Hn, networkEnv as Ht, DocumentStateStore as I, getKeyPath as In, LEGACY_LOCAL_LOGIN as It, ExternalServicesNotConfigured as J, initStageSecrets as Jn, TURBO_VERSION as Jt, DEV_SERVICES as K, ownershipMarker as Kn, composeBuildPaths as Kt, StageStateMissing as L, keystoreProject as Ln, ensurePostgresLogin as Lt, setGeneratedSecret as M, secretsStoreFor as Mn, bucketPolicies as Mt, setPostgresBackupId as N, storeFromConfig as Nn, postgresDatabaseNames as Nt, recordRelease as O, loadPortState as On, forgetState as Ot, DnsRecordsUnreadable as P, store_exports as Pn, postgresStatements as Pt, devServicesUsed as Q, withCustomSecret as Qn, imageBuildCredentials as Qt, StateLocked as R, projectKey as Rn, bucketClient as Rt, normalizeDnsConfig as S, primaryPortKey as Sn, STACK_CACHE as St, createEmptyState as T, findAvailablePort as Tn, stackRedis as Tt, createStateStore as U, imageRef as Un, workerEnvKeys as Ut, StateVersionConflict as V, deployIdentity as Vn, dockerfileOf as Vt, newLockHolder as W, isOwnedBy as Wn, envFor as Wt, assertDevServicesFlag as X, storage_exports as Xn, detectPackageManager as Xt, STORAGE_KEY_PAIR as Y, maskPassword as Yn, WORKER_PORT as Yt, assertExternalServices as Z, toEmbeddableSecrets as Zn, findBuildRoot as Zt, workerDockerfile as _, DEFAULT_IMAGES as _n, loadLocalCredentials as _t, provisionStage as a, composeFor as an, NoDomainForStage as ar, WorkerGenerator as at, extractSubdomain as b, portKeys as bn, withGeneratedSecrets as bt, StageProviderDisabled as c, planFor as cn, QueueGenerator as ct, stageProviderNotes as d, appTelemetry as dn, backendsOf as dt, cacheBackendFor as en, LOGS_SERVICE as er, manifestServiceDeclarations as et, DockerBuildFailed as f, isTelemetryKey as fn, constructGlobs as ft, ensureDockerignore as g, usesTelemetry as gn, LOCAL_RABBITMQ_USER as gt, dockerCommand as h, telemetryOf as hn, workspace_exports as ht, assertNoStaleSecrets as i, CLUSTER_DATABASE as in, withLogsPassword as ir, workerUnits as it, setBackupState as j, isRemoteStore as jn, applyPostgres as jt, recordRollback as k, loadSecretsForApp as kn, applyBuckets as kt, assertStageProvidersEnabled as l, resourceName as ln, CronGenerator as lt, appDockerfile as m, surfaceClientTraced as mn, reconcileWorkspace as mt, reportDatabaseRuns as n, providerOf as nn, logsService as nr, requiredStageKeys as nt, verifyStageProviders as o, EMULATOR_ACCESS_KEY_ID as on, isMainFrontendApp as or, workerBundleName as ot, DockerPushFailed as p, scopeTelemetryEnv as pn, derivedContainers as pt, DevServicesNeedServerTarget as q, projectName as qn, imageLayout as qt, reportPlannedSeeds as r, storageBackendFor as rn, stackLogs as rr, suppliedOnly as rt, DeployJournal as s, TELEMETRY_CONTAINER as sn, resolveHost as sr, workerEntryDir as st, DeploySeedsFailed as t, eventsBackendFor as tn, logsAccess as tr, reportDevServices as tt, provisionCommand as u, hostFor as un, FAKE_ENV as ut, workerDockerfileOf as v, describeServices as vn, postgresSuperuser as vt, verifyDnsRecords as w, createEntryWrapper as wn, runsRedis as wt, findRootDomain as x, portsOf as xn, REDIS_SERVICE as xt, validateImageRef as y, fakeKey as yn, readLocalCredentials as yt, StateStoreBusy as z, readKey as zn, pgClient as zt };
8904
+ export { systemLookup as $, imageRef as $n, workerEnvKeys as $t, requiredStageKeys as A, portKeys as An, withGeneratedSecrets as At, provisionCommand as B, prepareEntryCredentials as Bn, applyPolicies as Bt, STORAGE_KEY_PAIR as C, scopeTelemetryEnv as Cn, derivedContainers as Ct, externalServices as D, DEFAULT_IMAGES as Dn, loadLocalCredentials as Dt, devServicesUsed as E, usesTelemetry as En, LOCAL_RABBITMQ_USER as Et, iamUserName as F, findAvailablePort as Fn, stackRedis as Ft, HostNotPointingAtServer as G, FileSecretsStore as Gn, quoteIdentifier as Gt, PROVIDER_KINDS as H, secretsStoreFor as Hn, bucketPolicies as Ht, randomSuffix as I, isPortAvailable as In, withRedisPassword as It, requiredServerAddress as J, projectKey as Jn, bucketClient as Jt, dnsCredentials as K, getKeyPath as Kn, LEGACY_LOCAL_LOGIN as Kt, suffixedBucketName as L, loadEnvFiles as Ln, LOCAL_CA_PATH as Lt, IAM_POLICY_NAME as M, primaryPortKey as Mn, STACK_CACHE as Mt, IAM_USER_PATH as N, createCredentialsPreload as Nn, redisService as Nt, manifestServiceDeclarations as O, describeServices as On, postgresSuperuser as Ot, bucketName as P, createEntryWrapper as Pn, runsRedis as Pt, stageDnsDomains as Q, imageName as Qn, networkEnv as Qt, StageProviderDisabled as R, loadPortState as Rn, forgetState as Rt, ExternalServicesNotConfigured as S, isTelemetryKey as Sn, constructGlobs as St, assertExternalServices as T, telemetryOf as Tn, workspace_exports as Tt, deploysWithCompose as U, storeFromConfig as Un, postgresDatabaseNames as Ut, stageProviderNotes as V, isRemoteStore as Vn, applyPostgres as Vt, dns_exports as W, store_exports as Wn, postgresStatements as Wt, stackHosts as X, applicationName as Xn, appEnvKeys as Xt, serverAddressHint as Y, readKey as Yn, pgClient as Yt, stageDns as Z, deployIdentity as Zn, dockerfileOf as Zt, describeActor as _, TELEMETRY_CONTAINER as _n, resolveHost as _r, workerEntryDir as _t, DockerBuildFailed as a, WORKER_PORT as an, maskPassword as ar, getBackupState as at, DEV_SERVICES as b, hostFor as bn, FAKE_ENV as bt, dockerCommand as c, imageBuildCredentials as cn, withCustomSecret as cr, recordRollback as ct, workerDockerfileOf as d, eventsBackendFor as dn, logsAccess as dr, setGeneratedSecret as dt, envFor as en, isOwnedBy as er, orchestrateDns as et, validateImageRef as f, providerOf as fn, logsService as fr, setPostgresBackupId as ft, currentActor as g, EMULATOR_ACCESS_KEY_ID as gn, isMainFrontendApp as gr, workerBundleName as gt, recordDnsChanges as h, composeFor as hn, NoDomainForStage as hr, WorkerGenerator as ht, assertNoStaleSecrets as i, TURBO_VERSION as in, initStageSecrets as ir, createEmptyState as it, suppliedOnly as j, portsOf as jn, REDIS_SERVICE as jt, reportDevServices as k, fakeKey as kn, readLocalCredentials as kt, ensureDockerignore as l, isImageBuild as ln, LOCAL_LOGS_EMAIL as lr, setApplicationId as lt, dnsResources as m, CLUSTER_DATABASE as mn, withLogsPassword as mr, workerUnits as mt, reportDatabaseRuns as n, composeBuildPaths as nn, ownershipMarker as nr, asDokployState as nt, DockerPushFailed as o, detectPackageManager as on, storage_exports as or, getGeneratedSecret as ot, dnsRecordResource as p, storageBackendFor as pn, stackLogs as pr, DnsRecordsUnreadable as pt, isLocalHost as q, keystoreProject as qn, ensurePostgresLogin as qt, reportPlannedSeeds as r, imageLayout as rn, projectName as rr, createComposeState as rt, appDockerfile as s, findBuildRoot as sn, toEmbeddableSecrets as sr, recordRelease as st, DeploySeedsFailed as t, localRolePassword as tn, markerOf as tr, verifyDnsRecords as tt, workerDockerfile as u, cacheBackendFor as un, LOGS_SERVICE as ur, setBackupState as ut, isCi as v, planFor as vn, QueueGenerator as vt, assertDevServicesFlag as w, surfaceClientTraced as wn, reconcileWorkspace as wt, DevServicesNeedServerTarget as x, appTelemetry as xn, backendsOf as xt, AllowDevServicesTakesNoValue as y, resourceName as yn, CronGenerator as yt, assertStageProvidersEnabled as z, loadSecretsForApp as zn, applyBuckets as zt };
9538
8905
 
9539
- //# sourceMappingURL=seeds-CvGQJnPO.mjs.map
8906
+ //# sourceMappingURL=seeds-CTxTZa-R.mjs.map