@geekmidas/cli 10.0.0-alpha.63 → 10.0.0-alpha.65

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 (216) hide show
  1. package/README.md +1 -1
  2. package/dist/{HostingerProvider-mJ7YruxH.cjs → HostingerProvider-Dlmbfass.cjs} +3 -3
  3. package/dist/{HostingerProvider-mJ7YruxH.cjs.map → HostingerProvider-Dlmbfass.cjs.map} +1 -1
  4. package/dist/{HostingerProvider-DBBwpDjn.mjs → HostingerProvider-x9gQTAGZ.mjs} +2 -2
  5. package/dist/{HostingerProvider-DBBwpDjn.mjs.map → HostingerProvider-x9gQTAGZ.mjs.map} +1 -1
  6. package/dist/{LegacyStateStore-CP7SZwTP.cjs → LegacyStateStore-BGoo__sO.cjs} +2 -2
  7. package/dist/{LegacyStateStore-CP7SZwTP.cjs.map → LegacyStateStore-BGoo__sO.cjs.map} +1 -1
  8. package/dist/{LegacyStateStore-Q3GmKOdw.mjs → LegacyStateStore-BUu1nQ5u.mjs} +2 -2
  9. package/dist/{LegacyStateStore-Q3GmKOdw.mjs.map → LegacyStateStore-BUu1nQ5u.mjs.map} +1 -1
  10. package/dist/{LocalStateStore-sS8mvLeq.cjs → LocalStateStore-93urXyQe.cjs} +2 -2
  11. package/dist/{LocalStateStore-sS8mvLeq.cjs.map → LocalStateStore-93urXyQe.cjs.map} +1 -1
  12. package/dist/{LocalStateStore-BGIXcrvX.mjs → LocalStateStore-BRhMQRuy.mjs} +2 -2
  13. package/dist/{LocalStateStore-BGIXcrvX.mjs.map → LocalStateStore-BRhMQRuy.mjs.map} +1 -1
  14. package/dist/{OpenApiTsGenerator-CjHWFRDd.cjs → OpenApiTsGenerator-BWuDRHe9.cjs} +2 -2
  15. package/dist/{OpenApiTsGenerator-CjHWFRDd.cjs.map → OpenApiTsGenerator-BWuDRHe9.cjs.map} +1 -1
  16. package/dist/{OpenApiTsGenerator-CnAwhGxi.mjs → OpenApiTsGenerator-C6v-IeHt.mjs} +2 -2
  17. package/dist/{OpenApiTsGenerator-CnAwhGxi.mjs.map → OpenApiTsGenerator-C6v-IeHt.mjs.map} +1 -1
  18. package/dist/{S3StateStore-6jqITH2F.cjs → S3StateStore-F4dxw6kS.cjs} +2 -2
  19. package/dist/{S3StateStore-6jqITH2F.cjs.map → S3StateStore-F4dxw6kS.cjs.map} +1 -1
  20. package/dist/{S3StateStore-D7l_lWJ3.mjs → S3StateStore-pw1GhWwt.mjs} +2 -2
  21. package/dist/{S3StateStore-D7l_lWJ3.mjs.map → S3StateStore-pw1GhWwt.mjs.map} +1 -1
  22. package/dist/{SSMStateStore-B__F7IJa.cjs → SSMStateStore-Bt9acuAF.cjs} +2 -2
  23. package/dist/{SSMStateStore-B__F7IJa.cjs.map → SSMStateStore-Bt9acuAF.cjs.map} +1 -1
  24. package/dist/{SSMStateStore-CAQDVpoc.mjs → SSMStateStore-Dmj7zzFO.mjs} +2 -2
  25. package/dist/{SSMStateStore-CAQDVpoc.mjs.map → SSMStateStore-Dmj7zzFO.mjs.map} +1 -1
  26. package/dist/{bundler-DLfOn0ls.cjs → bundler-D6MlIfBT.cjs} +3 -3
  27. package/dist/{bundler-DLfOn0ls.cjs.map → bundler-D6MlIfBT.cjs.map} +1 -1
  28. package/dist/{bundler-DyWI41Iy.mjs → bundler-D_14JLZF.mjs} +3 -3
  29. package/dist/{bundler-DyWI41Iy.mjs.map → bundler-D_14JLZF.mjs.map} +1 -1
  30. package/dist/{cli-D-ZM-X2s.cjs → cli-BfH1deAT.cjs} +8 -7
  31. package/dist/{cli-D-ZM-X2s.cjs.map → cli-BfH1deAT.cjs.map} +1 -1
  32. package/dist/{cli-PkVF28LM.mjs → cli-P56vvrDQ.mjs} +5 -4
  33. package/dist/{cli-PkVF28LM.mjs.map → cli-P56vvrDQ.mjs.map} +1 -1
  34. package/dist/compose-391oEKj1.cjs +1982 -0
  35. package/dist/compose-391oEKj1.cjs.map +1 -0
  36. package/dist/compose-DprE3ASm.mjs +1862 -0
  37. package/dist/compose-DprE3ASm.mjs.map +1 -0
  38. package/dist/{config-C2BUosLm.d.mts → config-BonvTW8p.d.mts} +3 -3
  39. package/dist/{config-C2BUosLm.d.mts.map → config-BonvTW8p.d.mts.map} +1 -1
  40. package/dist/{config-CemHVcIr.d.cts → config-V_X_BMpm.d.cts} +3 -3
  41. package/dist/{config-CemHVcIr.d.cts.map → config-V_X_BMpm.d.cts.map} +1 -1
  42. package/dist/config.cjs +416 -17
  43. package/dist/config.cjs.map +1 -0
  44. package/dist/config.d.cts +3 -3
  45. package/dist/config.d.mts +3 -3
  46. package/dist/config.mjs +401 -2
  47. package/dist/config.mjs.map +1 -0
  48. package/dist/credentials-Bzbg23n8.cjs +298 -0
  49. package/dist/credentials-Bzbg23n8.cjs.map +1 -0
  50. package/dist/credentials-Dq1MnWQf.mjs +233 -0
  51. package/dist/credentials-Dq1MnWQf.mjs.map +1 -0
  52. package/dist/{define-CHBIEc5j.mjs → define-DR8xGYfL.mjs} +6 -3
  53. package/dist/define-DR8xGYfL.mjs.map +1 -0
  54. package/dist/{define-Cv_QvDrg.cjs → define-DzRz8FgH.cjs} +6 -3
  55. package/dist/define-DzRz8FgH.cjs.map +1 -0
  56. package/dist/deploy/public.cjs +39 -33
  57. package/dist/deploy/public.cjs.map +1 -1
  58. package/dist/deploy/public.d.cts +44 -5
  59. package/dist/deploy/public.d.cts.map +1 -1
  60. package/dist/deploy/public.d.mts +44 -5
  61. package/dist/deploy/public.d.mts.map +1 -1
  62. package/dist/deploy/public.mjs +15 -12
  63. package/dist/deploy/public.mjs.map +1 -1
  64. package/dist/{deploy-CGW-QY9D.mjs → deploy-9MBG_QLd.mjs} +11 -9
  65. package/dist/deploy-9MBG_QLd.mjs.map +1 -0
  66. package/dist/{deploy-BiMrP9i7.cjs → deploy-B80idPUZ.cjs} +17 -15
  67. package/dist/deploy-B80idPUZ.cjs.map +1 -0
  68. package/dist/{deploy-CG8bfFij.mjs → deploy-CZ5nlpsu.mjs} +16 -119
  69. package/dist/deploy-CZ5nlpsu.mjs.map +1 -0
  70. package/dist/{deploy-ABBI0lf1.cjs → deploy-Cy8igpQF.cjs} +48 -151
  71. package/dist/deploy-Cy8igpQF.cjs.map +1 -0
  72. package/dist/derive-B4vDpzgP.mjs +150 -0
  73. package/dist/derive-B4vDpzgP.mjs.map +1 -0
  74. package/dist/derive-DAbo49sm.cjs +161 -0
  75. package/dist/derive-DAbo49sm.cjs.map +1 -0
  76. package/dist/{discover-C_6COHEv.cjs → discover-DHdECueV.cjs} +6 -157
  77. package/dist/discover-DHdECueV.cjs.map +1 -0
  78. package/dist/{discover-BaT6bpnA.mjs → discover-LMrqsWSU.mjs} +8 -117
  79. package/dist/discover-LMrqsWSU.mjs.map +1 -0
  80. package/dist/{dokploy-CwdmlyQL.mjs → dokploy-BdtBePjp.mjs} +3 -3
  81. package/dist/{dokploy-CwdmlyQL.mjs.map → dokploy-BdtBePjp.mjs.map} +1 -1
  82. package/dist/{dokploy-QJxMhxdf.cjs → dokploy-iFY8MY-L.cjs} +3 -3
  83. package/dist/{dokploy-QJxMhxdf.cjs.map → dokploy-iFY8MY-L.cjs.map} +1 -1
  84. package/dist/{encryption-DXw3jj0c.cjs → encryption-B1wJwgae.cjs} +6 -6
  85. package/dist/{encryption-DXw3jj0c.cjs.map → encryption-B1wJwgae.cjs.map} +1 -1
  86. package/dist/{encryption-CbQG_ayW.mjs → encryption-DF_hgIpl.mjs} +5 -5
  87. package/dist/{encryption-CbQG_ayW.mjs.map → encryption-DF_hgIpl.mjs.map} +1 -1
  88. package/dist/{github-ciwbIwVa.mjs → github-B0YYE98x.mjs} +4 -4
  89. package/dist/{github-ciwbIwVa.mjs.map → github-B0YYE98x.mjs.map} +1 -1
  90. package/dist/{github-BH_mETav.cjs → github-CYcmTdFs.cjs} +4 -4
  91. package/dist/{github-BH_mETav.cjs.map → github-CYcmTdFs.cjs.map} +1 -1
  92. package/dist/{index-DrKzpHl-.d.mts → index-DPUAGJaK.d.cts} +16 -3
  93. package/dist/index-DPUAGJaK.d.cts.map +1 -0
  94. package/dist/{index-BtXDZQuF.d.cts → index-DwKD_8lt.d.mts} +16 -3
  95. package/dist/index-DwKD_8lt.d.mts.map +1 -0
  96. package/dist/index.cjs +280 -1849
  97. package/dist/index.cjs.map +1 -1
  98. package/dist/index.mjs +221 -1774
  99. package/dist/index.mjs.map +1 -1
  100. package/dist/local-BdH4XN0E.cjs +409 -0
  101. package/dist/local-BdH4XN0E.cjs.map +1 -0
  102. package/dist/local-CWR4Y_kW.mjs +350 -0
  103. package/dist/local-CWR4Y_kW.mjs.map +1 -0
  104. package/dist/{migrate-ImBKTBcW.mjs → migrate-B2qL5wj4.mjs} +8 -7
  105. package/dist/{migrate-ImBKTBcW.mjs.map → migrate-B2qL5wj4.mjs.map} +1 -1
  106. package/dist/{migrate-6oa9y_f2.cjs → migrate-q4pPAXtO.cjs} +21 -20
  107. package/dist/{migrate-6oa9y_f2.cjs.map → migrate-q4pPAXtO.cjs.map} +1 -1
  108. package/dist/openapi.cjs +2 -2
  109. package/dist/openapi.d.cts +1 -1
  110. package/dist/openapi.d.mts +1 -1
  111. package/dist/openapi.mjs +2 -2
  112. package/dist/provider-C9_-45d5.cjs +56 -0
  113. package/dist/provider-C9_-45d5.cjs.map +1 -0
  114. package/dist/provider-q4lsYzYG.mjs +45 -0
  115. package/dist/provider-q4lsYzYG.mjs.map +1 -0
  116. package/dist/{ready-Bpdnn2ON.mjs → ready-bQfu9GES.mjs} +3 -3
  117. package/dist/{ready-Bpdnn2ON.mjs.map → ready-bQfu9GES.mjs.map} +1 -1
  118. package/dist/{ready-BiG0rt4h.cjs → ready-brEhh68f.cjs} +3 -3
  119. package/dist/{ready-BiG0rt4h.cjs.map → ready-brEhh68f.cjs.map} +1 -1
  120. package/dist/reconcile/public.cjs +1 -1
  121. package/dist/reconcile/public.d.cts +1 -1
  122. package/dist/reconcile/public.d.mts +1 -1
  123. package/dist/reconcile/public.mjs +1 -1
  124. package/dist/resolve-DLkIXTct.mjs +262 -0
  125. package/dist/resolve-DLkIXTct.mjs.map +1 -0
  126. package/dist/resolve-N_i4XNd-.cjs +315 -0
  127. package/dist/resolve-N_i4XNd-.cjs.map +1 -0
  128. package/dist/{runtime-Dn_n-29S.d.cts → runtime-BnD7Nn96.d.cts} +2 -2
  129. package/dist/{runtime-CXPlkZzs.d.mts.map → runtime-BnD7Nn96.d.cts.map} +1 -1
  130. package/dist/{runtime-CXPlkZzs.d.mts → runtime-j-tmFL65.d.mts} +2 -2
  131. package/dist/{runtime-Dn_n-29S.d.cts.map → runtime-j-tmFL65.d.mts.map} +1 -1
  132. package/dist/sandbox/discover-worker.cjs +1 -1
  133. package/dist/sandbox/discover-worker.mjs +1 -1
  134. package/dist/sandbox-CL--ZDui.cjs +164 -0
  135. package/dist/sandbox-CL--ZDui.cjs.map +1 -0
  136. package/dist/sandbox-Dm247NGx.mjs +117 -0
  137. package/dist/sandbox-Dm247NGx.mjs.map +1 -0
  138. package/dist/sniffer-BSwaL-dX.cjs +2236 -0
  139. package/dist/sniffer-BSwaL-dX.cjs.map +1 -0
  140. package/dist/sniffer-BZ-OJO4Y.mjs +2068 -0
  141. package/dist/sniffer-BZ-OJO4Y.mjs.map +1 -0
  142. package/dist/sst-B2HbrDR3.mjs +430 -0
  143. package/dist/sst-B2HbrDR3.mjs.map +1 -0
  144. package/dist/sst-DcfsVH6x.cjs +453 -0
  145. package/dist/sst-DcfsVH6x.cjs.map +1 -0
  146. package/dist/{sniffer-aO_-s5vq.mjs → state-B8YwmUZU.mjs} +1204 -3236
  147. package/dist/state-B8YwmUZU.mjs.map +1 -0
  148. package/dist/{sniffer-D_SYc_pO.cjs → state-DF26e_lo.cjs} +1251 -3494
  149. package/dist/state-DF26e_lo.cjs.map +1 -0
  150. package/dist/{store-KvNQwYaB.cjs → store-CJF1Tqkq.cjs} +4 -4
  151. package/dist/{store-KvNQwYaB.cjs.map → store-CJF1Tqkq.cjs.map} +1 -1
  152. package/dist/{store-BS8A7NcA.mjs → store-CLiHnqOY.mjs} +2 -2
  153. package/dist/{store-BS8A7NcA.mjs.map → store-CLiHnqOY.mjs.map} +1 -1
  154. package/dist/target/public.cjs +6 -6
  155. package/dist/target/public.d.cts +3 -3
  156. package/dist/target/public.d.mts +3 -3
  157. package/dist/target/public.mjs +3 -3
  158. package/dist/terminal-Bs5E1oxm.cjs +88 -0
  159. package/dist/terminal-Bs5E1oxm.cjs.map +1 -0
  160. package/dist/terminal-CfbiYUbZ.mjs +84 -0
  161. package/dist/terminal-CfbiYUbZ.mjs.map +1 -0
  162. package/dist/{types-DIA7Li-J.d.cts → types-BCzMWBb6.d.mts} +84 -17
  163. package/dist/types-BCzMWBb6.d.mts.map +1 -0
  164. package/dist/{types-CfLKGfC_.d.mts → types-ByMWkXKP.d.cts} +85 -16
  165. package/dist/types-ByMWkXKP.d.cts.map +1 -0
  166. package/dist/utils-D2Nk1Nv2.cjs +205 -0
  167. package/dist/utils-D2Nk1Nv2.cjs.map +1 -0
  168. package/dist/utils-D_fxUqvU.mjs +156 -0
  169. package/dist/utils-D_fxUqvU.mjs.map +1 -0
  170. package/dist/vitest.cjs +1 -1
  171. package/dist/vitest.mjs +1 -1
  172. package/dist/workspace/index.cjs +3 -1
  173. package/dist/workspace/index.d.cts +3 -3
  174. package/dist/workspace/index.d.mts +3 -3
  175. package/dist/workspace/index.mjs +2 -2
  176. package/dist/{workspace-Kl4NG9IH.cjs → workspace-BewAHZIo.cjs} +41 -6
  177. package/dist/workspace-BewAHZIo.cjs.map +1 -0
  178. package/dist/{workspace-Dh7rr7l4.mjs → workspace-C06_pW4g.mjs} +30 -7
  179. package/dist/workspace-C06_pW4g.mjs.map +1 -0
  180. package/package.json +13 -13
  181. package/dist/config-BEJYKd4s.cjs +0 -648
  182. package/dist/config-BEJYKd4s.cjs.map +0 -1
  183. package/dist/config-LKKCVCnC.mjs +0 -547
  184. package/dist/config-LKKCVCnC.mjs.map +0 -1
  185. package/dist/define-CHBIEc5j.mjs.map +0 -1
  186. package/dist/define-Cv_QvDrg.cjs.map +0 -1
  187. package/dist/deploy-ABBI0lf1.cjs.map +0 -1
  188. package/dist/deploy-BiMrP9i7.cjs.map +0 -1
  189. package/dist/deploy-CG8bfFij.mjs.map +0 -1
  190. package/dist/deploy-CGW-QY9D.mjs.map +0 -1
  191. package/dist/discover-BaT6bpnA.mjs.map +0 -1
  192. package/dist/discover-C_6COHEv.cjs.map +0 -1
  193. package/dist/home-DOzBaWaL.cjs +0 -33
  194. package/dist/home-DOzBaWaL.cjs.map +0 -1
  195. package/dist/home-GI7pJl1M.mjs +0 -22
  196. package/dist/home-GI7pJl1M.mjs.map +0 -1
  197. package/dist/index-BtXDZQuF.d.cts.map +0 -1
  198. package/dist/index-DrKzpHl-.d.mts.map +0 -1
  199. package/dist/resolve-B1T0hUaR.mjs +0 -170
  200. package/dist/resolve-B1T0hUaR.mjs.map +0 -1
  201. package/dist/resolve-xytBTtG4.cjs +0 -217
  202. package/dist/resolve-xytBTtG4.cjs.map +0 -1
  203. package/dist/runtime-CMwtq0AL.cjs +0 -163
  204. package/dist/runtime-CMwtq0AL.cjs.map +0 -1
  205. package/dist/runtime-DbakJS8u.mjs +0 -134
  206. package/dist/runtime-DbakJS8u.mjs.map +0 -1
  207. package/dist/sniffer-D_SYc_pO.cjs.map +0 -1
  208. package/dist/sniffer-aO_-s5vq.mjs.map +0 -1
  209. package/dist/terminal-D0f1u0LG.cjs +0 -576
  210. package/dist/terminal-D0f1u0LG.cjs.map +0 -1
  211. package/dist/terminal-gMxAo9Dj.mjs +0 -517
  212. package/dist/terminal-gMxAo9Dj.mjs.map +0 -1
  213. package/dist/types-CfLKGfC_.d.mts.map +0 -1
  214. package/dist/types-DIA7Li-J.d.cts.map +0 -1
  215. package/dist/workspace-Dh7rr7l4.mjs.map +0 -1
  216. package/dist/workspace-Kl4NG9IH.cjs.map +0 -1
@@ -0,0 +1,1862 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
+ import { c as runOutput, s as run, t as LocalSandbox } from "./local-CWR4Y_kW.mjs";
3
+ import { r as discover } from "./discover-LMrqsWSU.mjs";
4
+ import { i as activeSandbox } from "./sandbox-Dm247NGx.mjs";
5
+ import { t as appKey } from "./derive-B4vDpzgP.mjs";
6
+ import { t as defineTarget } from "./define-DR8xGYfL.mjs";
7
+ import { t as initStageSecrets } from "./storage-BzaUxmqe.mjs";
8
+ import { f as imageRef } from "./store-CLiHnqOY.mjs";
9
+ import { $ as portKeys, B as networkEnv, C as isMainFrontendApp, D as constructGlobs, F as postgresStatements, G as hostFor, H as localRolePassword, L as composeFor, N as applyPostgres, R as pgClient, S as validateImageRef, U as planFor, V as envFor, X as DEFAULT_IMAGES, b as generateDockerignore, et as primaryPortKey, f as DeployJournal, g as appPackageName, p as withGeneratedSecrets, s as setDeployedImage, t as createEmptyState, v as siteDockerfile, w as resolveHost, x as generateSlimDockerfile, y as detectPackageManager, z as appEnvKeys } from "./state-B8YwmUZU.mjs";
10
+ import { n as DEFAULT_CACHE, r as DEFAULT_EVENTS } from "./fakes-C9RiT6XG.mjs";
11
+ import { createRequire } from "node:module";
12
+ import { dirname, extname, join } from "node:path";
13
+ import { existsSync } from "node:fs";
14
+ import { appendFile, chmod, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
15
+ import { DATABASE_FOLDERS, MIGRATIONS_ROOT, canonicalId, migrationTargets, provideKey, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+ import { spawn } from "node:child_process";
18
+ import pg from "pg";
19
+ import { stringify } from "yaml";
20
+ import { request } from "node:https";
21
+ //#region src/compose/docker.ts
22
+ /**
23
+ * The Docker operations `gkm compose` needs, behind an interface.
24
+ *
25
+ * Small on purpose, like reconcile's: the rules about *when* each runs — the
26
+ * registry is asked before anything is pulled, nothing is started for a tag
27
+ * that is missing — are asserted against a fake, and this file is only the
28
+ * wiring to the real CLI.
29
+ *
30
+ * Every call is an argument array. Image refs, tags and paths come from the
31
+ * workspace, and none of them reaches a shell.
32
+ */
33
+ /** `output` as `spawn` reads it. */
34
+ const STDIO = {
35
+ inherit: "inherit",
36
+ stderr: [
37
+ "ignore",
38
+ 2,
39
+ 2
40
+ ],
41
+ ignore: "ignore"
42
+ };
43
+ /** How a stack's docker commands run: where it is, where output goes. */
44
+ function runOptions(stack, env) {
45
+ return {
46
+ cwd: stack.cwd,
47
+ stdio: STDIO[stack.output ?? "inherit"],
48
+ ...env ? { env } : {},
49
+ ...stack.signal ? { signal: stack.signal } : {}
50
+ };
51
+ }
52
+ /** How long a stack has to pass its health checks. */
53
+ const WAIT_TIMEOUT_S = 180;
54
+ /** `docker compose port` printed nothing a port can be read from. */
55
+ var ServicePortUnknown = class extends Error {
56
+ service;
57
+ inside;
58
+ output;
59
+ constructor(service, inside, output) {
60
+ super(`'${service}' publishes no host port for ${inside} (docker said: ${output.trim() || "nothing"}). Is it running? \`docker compose ps\` shows the stack.`);
61
+ this.service = service;
62
+ this.inside = inside;
63
+ this.output = output;
64
+ this.name = "ServicePortUnknown";
65
+ }
66
+ };
67
+ function compose(stack, args) {
68
+ return [
69
+ "compose",
70
+ "-p",
71
+ stack.project,
72
+ "-f",
73
+ stack.file,
74
+ ...args
75
+ ];
76
+ }
77
+ /** Run a program and keep everything it said, whatever it exits with. */
78
+ function capture(command, args, timeoutMs = 6e4) {
79
+ return new Promise((resolve, reject) => {
80
+ const child = spawn(command, [...args], {
81
+ stdio: [
82
+ "ignore",
83
+ "pipe",
84
+ "pipe"
85
+ ],
86
+ shell: false
87
+ });
88
+ let stdout = "";
89
+ let stderr = "";
90
+ child.stdout.on("data", (chunk) => {
91
+ stdout += chunk.toString();
92
+ });
93
+ child.stderr.on("data", (chunk) => {
94
+ stderr += chunk.toString();
95
+ });
96
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
97
+ timer.unref?.();
98
+ child.on("error", (error) => {
99
+ clearTimeout(timer);
100
+ reject(error);
101
+ });
102
+ child.on("close", (code) => {
103
+ clearTimeout(timer);
104
+ resolve({
105
+ code,
106
+ stdout,
107
+ stderr
108
+ });
109
+ });
110
+ });
111
+ }
112
+ /**
113
+ * Whether a failed `docker manifest inspect` means the tag is not there, as
114
+ * opposed to the registry not answering.
115
+ *
116
+ * Read off the message because that is all the CLI gives. Anything not
117
+ * recognisably "no such tag" is treated as the registry being unreachable:
118
+ * a registry that refused credentials must not be reported as a tag that was
119
+ * never pushed, or the fix offered would be the wrong one.
120
+ */
121
+ function isMissingManifest(output) {
122
+ return /no such manifest|manifest unknown|manifest for .* not found|not found: manifest/i.test(output);
123
+ }
124
+ const dockerCompose = {
125
+ async lookup(ref) {
126
+ const { code, stdout, stderr } = await capture("docker", [
127
+ "manifest",
128
+ "inspect",
129
+ ref
130
+ ]);
131
+ if (code === 0) return {
132
+ ref,
133
+ status: "found"
134
+ };
135
+ const output = `${stderr}\n${stdout}`.trim();
136
+ return isMissingManifest(output) ? {
137
+ ref,
138
+ status: "missing"
139
+ } : {
140
+ ref,
141
+ status: "unreachable",
142
+ detail: output
143
+ };
144
+ },
145
+ async build(stack, services) {
146
+ await run("docker", compose(stack, ["build", ...services]), runOptions(stack, {
147
+ ...process.env,
148
+ DOCKER_BUILDKIT: "1"
149
+ }));
150
+ },
151
+ async pull(stack, services) {
152
+ await run("docker", compose(stack, ["pull", ...services]), runOptions(stack));
153
+ },
154
+ async up(stack, services) {
155
+ await run("docker", compose(stack, [
156
+ "up",
157
+ "-d",
158
+ "--wait",
159
+ `--wait-timeout=${WAIT_TIMEOUT_S}`,
160
+ ...services ? [] : ["--remove-orphans"],
161
+ ...services ?? []
162
+ ]), runOptions(stack));
163
+ },
164
+ async down(stack) {
165
+ await run("docker", compose(stack, ["down"]), runOptions(stack));
166
+ },
167
+ async port(stack, service, inside) {
168
+ const output = await runOutput("docker", compose(stack, [
169
+ "port",
170
+ service,
171
+ String(inside)
172
+ ]), {
173
+ cwd: stack.cwd,
174
+ ...stack.signal ? { signal: stack.signal } : {}
175
+ });
176
+ const port = Number(output.trim().split(":").pop());
177
+ if (!Number.isInteger(port) || port <= 0) throw new ServicePortUnknown(service, inside, output);
178
+ return port;
179
+ },
180
+ async copyOut(stack, service, from, to) {
181
+ await mkdir(dirname(to), { recursive: true });
182
+ await run("docker", compose(stack, [
183
+ "cp",
184
+ `${service}:${from}`,
185
+ to
186
+ ]), runOptions(stack));
187
+ },
188
+ async digest(ref) {
189
+ const { code, stdout } = await capture("docker", [
190
+ "image",
191
+ "inspect",
192
+ "--format={{json .RepoDigests}} {{.Id}}",
193
+ ref
194
+ ]);
195
+ if (code !== 0) return void 0;
196
+ const [json = "[]", id] = stdout.trim().split(" ");
197
+ const repository = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
198
+ try {
199
+ const digests = JSON.parse(json);
200
+ const match = Array.isArray(digests) ? digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`)) : void 0;
201
+ if (match) return match.slice(repository.length + 1);
202
+ } catch {}
203
+ return id || void 0;
204
+ }
205
+ };
206
+ //#endregion
207
+ //#region src/compose/images.ts
208
+ /**
209
+ * The tag a site's image carries for a release.
210
+ *
211
+ * A site's public URLs are inlined when it is built, so one bundle serves one
212
+ * stage: `v1.4.0-production` and `v1.4.0-staging` are two images of the same
213
+ * commit. A backend reads its URLs at runtime, so one image serves every stage
214
+ * and its tag is the release's alone.
215
+ */
216
+ function siteTag(tag, stage) {
217
+ return `${tag}-${stage}`;
218
+ }
219
+ /** Images a tag names that the registry does not have. */
220
+ var ImageTagNotFound = class extends Error {
221
+ tag;
222
+ refs;
223
+ constructor(tag, refs) {
224
+ super(`The registry has no image for ${refs.length === 1 ? "this app" : `${refs.length} apps`} at '${tag}':\n` + refs.map((ref) => ` - ${ref}`).join("\n") + `\nNothing was pulled or started. Push '${tag}' for every app from CI, pick a tag that exists, or run without --tag to build from this checkout.`);
225
+ this.tag = tag;
226
+ this.refs = refs;
227
+ this.name = "ImageTagNotFound";
228
+ }
229
+ };
230
+ /** The registry did not answer, or did not let this machine in. */
231
+ var RegistryUnreachable = class extends Error {
232
+ ref;
233
+ detail;
234
+ constructor(ref, detail) {
235
+ super(`Could not ask the registry about ${ref}: ${detail || "no answer"}\nNothing was pulled or started. Check the registry is reachable and that this machine is logged in to it (\`docker login <registry>\`).`);
236
+ this.ref = ref;
237
+ this.detail = detail;
238
+ this.name = "RegistryUnreachable";
239
+ }
240
+ };
241
+ /**
242
+ * Assert every image exists, before anything else happens.
243
+ *
244
+ * Asked all at once and reported all at once: a release missing two images is
245
+ * one problem with two names, and finding the second only after fixing the
246
+ * first is a slower way to learn the same thing.
247
+ *
248
+ * @throws {RegistryUnreachable} when the registry cannot say
249
+ * @throws {ImageTagNotFound} listing every missing image
250
+ */
251
+ async function assertImagesExist(docker, tag, images) {
252
+ const lookups = await Promise.all(images.map((image) => docker.lookup(image.ref)));
253
+ const unreachable = lookups.find((lookup) => lookup.status === "unreachable");
254
+ if (unreachable?.status === "unreachable") throw new RegistryUnreachable(unreachable.ref, unreachable.detail);
255
+ const missing = lookups.filter((lookup) => lookup.status === "missing").map((lookup) => lookup.ref);
256
+ if (missing.length > 0) throw new ImageTagNotFound(tag, missing);
257
+ }
258
+ //#endregion
259
+ //#region src/compose/caddyfile.ts
260
+ /** The Caddyfile for a stack's edge. */
261
+ function edgeCaddyfile(sites, options) {
262
+ const global = options.local ? `{
263
+ # No ACME and no public DNS: the CA is Caddy's own and the names resolve
264
+ # to this machine.
265
+ local_certs
266
+ auto_https disable_redirects
267
+ }` : options.email ? `{
268
+ email ${options.email}
269
+ }` : "";
270
+ const blocks = sites.map(({ host, upstream }) => `https://${host} {${options.local ? `
271
+ tls internal
272
+ ` : ""}
273
+ # Host and X-Forwarded-* are passed through as Caddy does by default, so
274
+ # an app builds redirects, cookies and links on the address a caller used.
275
+ reverse_proxy ${upstream} {
276
+ # Never buffer: a streamed response — server-sent events, a Next.js
277
+ # RSC payload — reaches the client as each chunk is written.
278
+ flush_interval -1
279
+ }
280
+ }`);
281
+ return `${HEADER}${global ? `${global}\n\n` : ""}${blocks.join("\n\n")}\n`;
282
+ }
283
+ const HEADER = `# Generated by gkm compose from the construct manifest — do not edit.
284
+ #
285
+ # One host per API and site, each in front of its own container.
286
+ `;
287
+ //#endregion
288
+ //#region src/compose/stack.ts
289
+ /**
290
+ * The stack a workspace runs as for one stage — pure.
291
+ *
292
+ * Everything `gkm compose` writes is decided here, as data: the compose file,
293
+ * the Caddyfile, one env file per backend, and the image each app runs. The
294
+ * command around it reads the workspace, asks the registry, writes the files
295
+ * and starts Docker; none of those decide anything, so all of it can be
296
+ * asserted without a daemon.
297
+ *
298
+ * The values come from the same derivations every other target uses. The
299
+ * plan is reconcile's `planFor`, the keys an app reads are `appEnvKeys`, and
300
+ * every URL is `envFor`'s — asked twice, once with each app at its public
301
+ * address and once on the compose network, because a browser and a sibling
302
+ * service reach the same API by different names.
303
+ */
304
+ /** Where a stack's files are written, relative to the workspace root. */
305
+ function stackDir(stage) {
306
+ return `.gkm/compose/${stage}`;
307
+ }
308
+ /**
309
+ * The compose project a stage's stack runs as.
310
+ *
311
+ * The deploy identity's scope, so two workspaces with one name in different
312
+ * namespaces never share containers or volumes — and the stage, so a
313
+ * machine can run two stages side by side.
314
+ */
315
+ function composeProject(identity) {
316
+ return `${identity.scope}-${identity.stage}`;
317
+ }
318
+ /** The ports the edge is published on, and the variables that move them. */
319
+ const EDGE_PORT_ENV = {
320
+ https: "GKM_COMPOSE_HTTPS_PORT",
321
+ http: "GKM_COMPOSE_HTTP_PORT"
322
+ };
323
+ /**
324
+ * Containers this target does not run.
325
+ *
326
+ * Object storage is the stage's own — an externally hosted bucket whose URL
327
+ * the stage's secrets hold — and so is a deployed stage's mail. The AWS
328
+ * emulator never applies: a stack is a server target, whose events are
329
+ * pg-boss.
330
+ */
331
+ const NOT_RUN = {
332
+ minio: true,
333
+ localstack: true,
334
+ caddy: true
335
+ };
336
+ /** Kinds whose `<ID>_URL` a stage must be given, because nothing here runs them. */
337
+ const EXTERNAL_KINDS = {
338
+ objects: true,
339
+ "file-server": true
340
+ };
341
+ /** The S3 client's credentials, read beside a bucket's URL when it has them. */
342
+ const STORAGE_KEYS = [
343
+ "AWS_ACCESS_KEY_ID",
344
+ "AWS_SECRET_ACCESS_KEY",
345
+ "AWS_REGION"
346
+ ];
347
+ /** A bucket the stack needs and the stage was not given. */
348
+ var BucketNotConfigured = class extends Error {
349
+ app;
350
+ id;
351
+ key;
352
+ stage;
353
+ constructor(app, id, key, stage) {
354
+ super(`'${app}' uses the bucket '${id}', and gkm compose runs no object storage: the stage needs an externally hosted bucket. Set its URL in the stage's secrets: gkm secrets:set ${key} 's3://…' --stage ${stage}`);
355
+ this.app = app;
356
+ this.id = id;
357
+ this.key = key;
358
+ this.stage = stage;
359
+ this.name = "BucketNotConfigured";
360
+ }
361
+ };
362
+ /** A value only the stage's secrets can hold, and they do not. */
363
+ var StageSecretMissing = class extends Error {
364
+ app;
365
+ key;
366
+ stage;
367
+ constructor(app, key, stage) {
368
+ super(`'${app}' reads ${key}, and the stage '${stage}' has none. Nothing in the stack can derive it; set it in the stage's secrets: gkm secrets:set ${key} '…' --stage ${stage}`);
369
+ this.app = app;
370
+ this.key = key;
371
+ this.stage = stage;
372
+ this.name = "StageSecretMissing";
373
+ }
374
+ };
375
+ /** A deployed stage's secrets with no seed to derive its passwords from. */
376
+ var StageSeedMissing = class extends Error {
377
+ stage;
378
+ constructor(stage) {
379
+ super(`The stage '${stage}' has no seed in its secrets, and a deployed stage's database passwords are derived from it. Run gkm compose without --dry-run once, or gkm deploy, to generate it.`);
380
+ this.stage = stage;
381
+ this.name = "StageSeedMissing";
382
+ }
383
+ };
384
+ /** A workspace with no API and no site to run. */
385
+ var NothingToCompose = class extends Error {
386
+ root;
387
+ constructor(root) {
388
+ super(`${root} declares no RestApi and no site, so a stack would run nothing. Workers and mobile apps are not run by gkm compose yet.`);
389
+ this.root = root;
390
+ this.name = "NothingToCompose";
391
+ }
392
+ };
393
+ /** A value that cannot be written to an env file a line at a time. */
394
+ var EnvValueMultiline = class extends Error {
395
+ app;
396
+ key;
397
+ constructor(app, key) {
398
+ super(`${key} for '${app}' spans more than one line, and an env file holds one value per line. Store it on one line — base64, or JSON — and decode it where it is read.`);
399
+ this.app = app;
400
+ this.key = key;
401
+ this.name = "EnvValueMultiline";
402
+ }
403
+ };
404
+ /** The services a stack's apps wait on and the edge waits on in turn. */
405
+ const HEALTHY = { condition: "service_healthy" };
406
+ /**
407
+ * An app's health check: its own server, asked on 127.0.0.1 — not
408
+ * `localhost`, which resolves to `::1` first in an Alpine container while a
409
+ * server may listen on IPv4 alone.
410
+ */
411
+ function probe(port, path) {
412
+ return {
413
+ test: [
414
+ "CMD",
415
+ "wget",
416
+ "-q",
417
+ "-O",
418
+ "/dev/null",
419
+ `http://127.0.0.1:${port}${path}`
420
+ ],
421
+ interval: "5s",
422
+ timeout: "3s",
423
+ retries: 20,
424
+ start_period: "10s"
425
+ };
426
+ }
427
+ /** The stack for one stage. */
428
+ function composeStack(input) {
429
+ const { workspace, manifest, stage, identity } = input;
430
+ const local = stage === workspace.stages.local;
431
+ const project = composeProject(identity);
432
+ const custom = input.secrets?.custom ?? {};
433
+ const https = input.ports?.https ?? 443;
434
+ const http = input.ports?.http ?? 80;
435
+ const plan = planFor(manifest, stage, provisionOrder(manifest), {
436
+ localStage: workspace.stages.local,
437
+ events: DEFAULT_EVENTS.server,
438
+ cache: DEFAULT_CACHE.server,
439
+ edge: false
440
+ });
441
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
442
+ const seed = local ? void 0 : input.secrets?.seed;
443
+ if (!local && !seed) throw new StageSeedMissing(stage);
444
+ const credential = {
445
+ master: local ? "geekmidas" : localRolePassword(workspace.name, plan, "master", seed),
446
+ ...seed ? { seed } : {}
447
+ };
448
+ const infra = plan.containers.filter((c) => !NOT_RUN[c]).filter((c) => local || c !== "mailpit").sort();
449
+ const apps = stackApps(workspace, manifest, plan, {
450
+ local,
451
+ stage,
452
+ https,
453
+ identity,
454
+ images: input.images
455
+ });
456
+ if (apps.length === 0) throw new NothingToCompose(workspace.root);
457
+ const derivation = {
458
+ project: workspace.name,
459
+ master: credential.master,
460
+ ...credential.seed ? { seed: credential.seed } : {}
461
+ };
462
+ const outside = networkEnv(plan, {
463
+ ...derivation,
464
+ addresses: Object.fromEntries(apps.map((app) => [app.id, app.url]))
465
+ });
466
+ const inside = networkEnv(plan, {
467
+ ...derivation,
468
+ addresses: Object.fromEntries(apps.map((app) => [app.id, `http://${app.name}:${app.port}`]))
469
+ });
470
+ const owners = /* @__PURE__ */ new Map();
471
+ for (const resource of plan.resources) {
472
+ owners.set(resource.envKey, resource);
473
+ if (resource.kind === "email") {
474
+ owners.set(provideKey(resource.id, "from"), resource);
475
+ owners.set(provideKey(resource.id, "inboxUrl"), resource);
476
+ }
477
+ if (resource.kind === "external-api") owners.set(provideKey(resource.id, "credentials"), resource);
478
+ }
479
+ const surfaceUrls = new Map(plan.resources.filter((r) => r.kind === "rest-api" || r.kind === "site").map((r) => [r.envKey, r.id]));
480
+ const appById = new Map(apps.map((app) => [app.id, app]));
481
+ /**
482
+ * What a key resolves to for one app, from the stage's secrets where only
483
+ * they can hold it, and the derivations otherwise.
484
+ */
485
+ const valueFor = (app, key, owner) => {
486
+ if (owner && EXTERNAL_KINDS[owner.kind] && key === owner.envKey) {
487
+ const value = custom[key];
488
+ if (value === void 0) throw new BucketNotConfigured(app.name, owner.id, key, stage);
489
+ return value;
490
+ }
491
+ if (owner && suppliedBy(owner, key, local)) {
492
+ const value = custom[key];
493
+ if (value === void 0) throw new StageSecretMissing(app.name, key, stage);
494
+ return value;
495
+ }
496
+ if (custom[key] !== void 0) return custom[key];
497
+ const surface = surfaceUrls.get(key);
498
+ if (app.kind === "rest-api" && surface && surface !== app.id) return inside[key];
499
+ return outside[key];
500
+ };
501
+ for (const app of apps) {
502
+ if (app.kind === "site") {
503
+ const site = manifest[app.id];
504
+ if (site?.kind !== "site") continue;
505
+ const args = {};
506
+ for (const [key, source] of Object.entries(publicEnvFor(site, manifest))) {
507
+ const value = valueFor(app, source, owners.get(source));
508
+ if (value !== void 0) args[key] = value;
509
+ }
510
+ if (app.build && Object.keys(args).length > 0) app.build.args = args;
511
+ continue;
512
+ }
513
+ const keys = appEnvKeys(manifest, app.name, input.runnables) ?? /* @__PURE__ */ new Set();
514
+ const values = {};
515
+ for (const key of [...keys].sort()) {
516
+ const owner = owners.get(key) ?? storageOwner(key, plan);
517
+ if (!local && owner?.kind === "email" && key.endsWith("_INBOX_URL")) continue;
518
+ if (STORAGE_KEYS.includes(key)) {
519
+ if (custom[key] !== void 0) values[key] = custom[key];
520
+ continue;
521
+ }
522
+ const value = valueFor(app, key, owner);
523
+ if (value !== void 0) values[key] = value;
524
+ }
525
+ const trusted = provideKey(app.id, "trustedOrigins");
526
+ if (keys.has(trusted)) {
527
+ const internal = (byId.get(app.id)?.callers ?? []).map((caller) => appById.get(caller)).filter((caller) => caller?.kind === "rest-api").map((caller) => `http://${caller.name}:${caller.port}`);
528
+ const origins = [...(values[trusted] ?? "").split(",").filter(Boolean), ...internal];
529
+ values[trusted] = [...new Set(origins)].join(",");
530
+ }
531
+ app.env = {
532
+ ...values,
533
+ NODE_ENV: "production",
534
+ PORT: String(app.port),
535
+ STAGE: stage
536
+ };
537
+ for (const [key, value] of Object.entries(app.env)) if (/[\r\n]/.test(value)) throw new EnvValueMultiline(app.name, key);
538
+ }
539
+ const compose = stackFile({
540
+ project,
541
+ plan,
542
+ infra,
543
+ apps,
544
+ master: credential.master,
545
+ https,
546
+ http
547
+ });
548
+ const caddyfile = edgeCaddyfile(apps.map((app) => ({
549
+ host: app.host,
550
+ upstream: `${app.name}:${app.port}`
551
+ })), { local });
552
+ const dockerfiles = {};
553
+ for (const app of apps) {
554
+ if (!app.build) continue;
555
+ dockerfiles[app.build.dockerfile] = app.kind === "site" ? siteDockerfile(app.name, workspace.apps[app.name], {
556
+ turboPackage: input.packages?.[app.name] ?? app.name,
557
+ packageManager: input.packageManager ?? "pnpm",
558
+ manifest
559
+ }) : generateSlimDockerfile({
560
+ imageName: app.name,
561
+ baseImage: "node:22-alpine",
562
+ port: app.port,
563
+ healthCheckPath: "/health",
564
+ prebuilt: true,
565
+ packageManager: input.packageManager ?? "pnpm",
566
+ bundle: `${app.path}/.gkm/server/dist/server.mjs`
567
+ });
568
+ }
569
+ return {
570
+ project,
571
+ stage,
572
+ local,
573
+ apps,
574
+ infra,
575
+ compose,
576
+ caddyfile,
577
+ dockerfiles,
578
+ plan,
579
+ credential
580
+ };
581
+ /** Whether only the stage's secrets can supply `key` to `owner`. */
582
+ function suppliedBy(owner, key, isLocal) {
583
+ if (owner.kind === "credential") return true;
584
+ if (owner.kind === "external-api") return key === provideKey(owner.id, "credentials");
585
+ if (isLocal) return false;
586
+ if (owner.kind === "secret" || owner.kind === "encryption") return true;
587
+ return owner.kind === "email" && !key.endsWith("_INBOX_URL");
588
+ }
589
+ }
590
+ /** Which bucket an S3 credential key belongs to — the first one declared. */
591
+ function storageOwner(key, plan) {
592
+ if (!STORAGE_KEYS.includes(key)) return void 0;
593
+ return plan.resources.find((r) => r.kind === "objects");
594
+ }
595
+ /** The apps a stack runs: every API and every site, at its stage address. */
596
+ function stackApps(workspace, manifest, plan, options) {
597
+ const apps = [];
598
+ const resources = new Map(plan.resources.map((r) => [r.id, r]));
599
+ for (const [id, declaration] of Object.entries(manifest)) {
600
+ if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
601
+ const name = appKey(id);
602
+ const app = workspace.apps[name];
603
+ const resource = resources.get(id);
604
+ if (!app || !resource || !app.port) continue;
605
+ const host = hostOf(name, app, workspace, resource, options);
606
+ const tag = declaration.kind === "site" ? siteTag(options.images.tag, options.stage) : options.images.tag;
607
+ const ref = validateImageRef(imageRef(options.identity, name, options.images.registry, tag));
608
+ apps.push({
609
+ app: name,
610
+ name,
611
+ id,
612
+ kind: declaration.kind,
613
+ path: app.path,
614
+ port: app.port,
615
+ host,
616
+ url: `https://${host}${options.local && options.https !== 443 ? `:${options.https}` : ""}`,
617
+ ref,
618
+ tag,
619
+ ...options.images.mode === "build" ? { build: { dockerfile: `${stackDir(options.stage)}/Dockerfile.${name}` } } : {}
620
+ });
621
+ }
622
+ return apps.sort((a, b) => a.name.localeCompare(b.name));
623
+ }
624
+ /**
625
+ * The host an app answers on for a stage: the `gkm dev` edge's
626
+ * `*.localhost` names for the local stage, and the stage's domain — by the
627
+ * same rule a deploy follows — for any other.
628
+ */
629
+ function hostOf(name, app, workspace, resource, options) {
630
+ if (options.local) return hostFor(resource, workspace.name);
631
+ return resolveHost(name, app, options.stage, workspace.deploy?.domains, isMainFrontendApp(name, app, workspace.apps));
632
+ }
633
+ /** The compose document: the infrastructure, the apps, and the edge. */
634
+ function stackFile(options) {
635
+ const { project, plan, infra, apps } = options;
636
+ const derived = composeFor({
637
+ ...plan,
638
+ containers: [...infra]
639
+ }, {
640
+ project,
641
+ ports: Object.fromEntries(portKeys(infra, plan.fakes).map((key) => [key, 0]))
642
+ });
643
+ const services = {};
644
+ const volumes = {
645
+ ...derived.volumes,
646
+ "caddy-data": {},
647
+ "caddy-config": {}
648
+ };
649
+ for (const name of infra) {
650
+ const { ports: _ports, depends_on, profiles: _profiles, ...service } = derived.services[name];
651
+ services[name] = {
652
+ ...service,
653
+ ...depends_on?.length ? { depends_on: Object.fromEntries(depends_on.map((dep) => [dep, HEALTHY])) } : {}
654
+ };
655
+ }
656
+ const postgres = services.postgres;
657
+ if (postgres) {
658
+ postgres.environment = {
659
+ ...postgres.environment,
660
+ POSTGRES_PASSWORD: options.master
661
+ };
662
+ postgres.ports = ["127.0.0.1::5432"];
663
+ }
664
+ for (const app of apps) services[app.name] = {
665
+ image: app.ref,
666
+ ...app.build ? { build: {
667
+ context: "../../..",
668
+ dockerfile: app.build.dockerfile,
669
+ ...app.build.args ? { args: app.build.args } : {}
670
+ } } : {},
671
+ restart: "unless-stopped",
672
+ ...app.kind === "rest-api" ? {
673
+ env_file: [{
674
+ path: `./${app.name}.env`,
675
+ format: "raw"
676
+ }],
677
+ ...infra.length ? { depends_on: Object.fromEntries(infra.map((name) => [name, HEALTHY])) } : {}
678
+ } : {},
679
+ healthcheck: probe(app.port, app.kind === "site" ? "/" : "/health")
680
+ };
681
+ services.caddy = {
682
+ image: DEFAULT_IMAGES.caddy,
683
+ restart: "unless-stopped",
684
+ ports: [`${options.https}:443`, `${options.http}:80`],
685
+ volumes: [
686
+ "./Caddyfile:/etc/caddy/Caddyfile:ro",
687
+ "caddy-data:/data",
688
+ "caddy-config:/config"
689
+ ],
690
+ depends_on: Object.fromEntries(apps.map((app) => [app.name, HEALTHY])),
691
+ healthcheck: {
692
+ test: [
693
+ "CMD",
694
+ "caddy",
695
+ "version"
696
+ ],
697
+ interval: "10s",
698
+ timeout: "5s",
699
+ retries: 5
700
+ }
701
+ };
702
+ return {
703
+ name: project,
704
+ services,
705
+ volumes
706
+ };
707
+ }
708
+ /** Render an app's env file: one `KEY=value` per line, read raw by compose. */
709
+ function envFile(env) {
710
+ return `${Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
711
+ }
712
+ //#endregion
713
+ //#region src/migrate/provider.ts
714
+ /**
715
+ * One construct's migrations and seeds, read from its folders.
716
+ *
717
+ * Two kinds of file, told apart by extension rather than by configuration: a
718
+ * script exports `up` (and may export `down`) — or `seed(db, { stage })`, in
719
+ * `seeds/` — and a
720
+ * `.sql` file is run whole. The second is what makes a folder independent of
721
+ * the client the app uses — Better Auth's schema is SQL whatever the
722
+ * application queries with.
723
+ */
724
+ const SCRIPTS = /* @__PURE__ */ new Set([
725
+ ".ts",
726
+ ".mts",
727
+ ".js",
728
+ ".mjs"
729
+ ]);
730
+ /** Two files in one folder that would be one migration. */
731
+ var DuplicateMigration = class extends Error {
732
+ folder;
733
+ migration;
734
+ constructor(folder, migration) {
735
+ super(`${folder} has two migrations named '${migration}' — a script and a .sql file with the same name. A migration is recorded by name, so only one of them could ever run; rename or remove the other.`);
736
+ this.folder = folder;
737
+ this.migration = migration;
738
+ this.name = "DuplicateMigration";
739
+ }
740
+ };
741
+ /** A script in a migrations folder that exports no `up`. */
742
+ var MigrationHasNoUp = class extends Error {
743
+ file;
744
+ constructor(file) {
745
+ super(`${file} is in a migrations folder but exports no \`up\` function. Export \`async function up(db: Kysely<unknown>)\`, or move the file out of the folder if it is not a migration.`);
746
+ this.file = file;
747
+ this.name = "MigrationHasNoUp";
748
+ }
749
+ };
750
+ /** A script in a seeds folder that exports no `seed`. */
751
+ var SeedHasNoSeed = class extends Error {
752
+ file;
753
+ constructor(file) {
754
+ super(`${file} is in a seeds folder but exports no \`seed\` function. Export \`async function seed(db, { stage })\`, or move the file out of the folder if it is not a seed.`);
755
+ this.file = file;
756
+ this.name = "SeedHasNoSeed";
757
+ }
758
+ };
759
+ /**
760
+ * The migration files in a folder, by name, without reading them.
761
+ *
762
+ * Also what `gkm dev` compares against a construct's history to report what
763
+ * is pending, which is why it is separate from loading them.
764
+ */
765
+ async function migrationFiles(folder) {
766
+ const entries = await readdir(folder, { withFileTypes: true }).catch((error) => {
767
+ if (error.code === "ENOENT") return [];
768
+ throw error;
769
+ });
770
+ const files = /* @__PURE__ */ new Map();
771
+ for (const entry of entries) {
772
+ if (!entry.isFile() || !isMigration(entry.name)) continue;
773
+ const name = entry.name.slice(0, -extname(entry.name).length);
774
+ if (files.has(name)) throw new DuplicateMigration(folder, name);
775
+ files.set(name, join(folder, entry.name));
776
+ }
777
+ return files;
778
+ }
779
+ /** A Kysely provider over one construct's folder. */
780
+ function folderProvider(folder, raw) {
781
+ return { async getMigrations() {
782
+ const migrations = {};
783
+ for (const [name, file] of await migrationFiles(folder)) migrations[name] = extname(file) === ".sql" ? await sqlMigration(file, raw) : await scriptMigration(file);
784
+ return migrations;
785
+ } };
786
+ }
787
+ /**
788
+ * The seeds in a folder, loaded, in name order.
789
+ *
790
+ * Every one runs on every pass — there is no history to consult — so the order
791
+ * is the only thing between two seeds where one reads what another writes.
792
+ * Name them to sort that way.
793
+ */
794
+ async function loadSeeds(folder, raw) {
795
+ const files = [...await migrationFiles(folder)].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
796
+ const seeds = [];
797
+ for (const [name, file] of files) seeds.push({
798
+ name,
799
+ run: extname(file) === ".sql" ? (await sqlMigration(file, raw)).up : await scriptSeed(file)
800
+ });
801
+ return seeds;
802
+ }
803
+ /**
804
+ * A seed script, imported as it is on disk now.
805
+ *
806
+ * Keyed by its modification time: a seed is run again after it is edited, and
807
+ * a process that has imported it once would otherwise run the old one.
808
+ */
809
+ async function scriptSeed(file) {
810
+ const url = pathToFileURL(file);
811
+ url.searchParams.set("mtime", String((await stat(file)).mtimeMs));
812
+ const module = await import(url.href);
813
+ if (typeof module.seed !== "function") throw new SeedHasNoSeed(file);
814
+ return module.seed;
815
+ }
816
+ function isMigration(file) {
817
+ if (file.endsWith(".d.ts")) return false;
818
+ if (/\.(spec|test)\.[cm]?[jt]s$/.test(file)) return false;
819
+ const extension = extname(file);
820
+ return extension === ".sql" || SCRIPTS.has(extension);
821
+ }
822
+ async function scriptMigration(file) {
823
+ const module = await import(pathToFileURL(file).href);
824
+ if (typeof module.up !== "function") throw new MigrationHasNoUp(file);
825
+ return {
826
+ up: module.up,
827
+ ...typeof module.down === "function" ? { down: module.down } : {}
828
+ };
829
+ }
830
+ /**
831
+ * A `.sql` file, run whole.
832
+ *
833
+ * One query rather than split on `;`: with no parameters, Postgres takes
834
+ * several statements in one message, and splitting by hand breaks on the first
835
+ * `;` inside a string or a function body. There is no `down` — a generated
836
+ * schema is rolled back by a migration that says how, not by guessing.
837
+ */
838
+ async function sqlMigration(file, raw) {
839
+ const statements = await readFile(file, "utf-8");
840
+ return { up: async (db) => {
841
+ await raw.raw(statements).execute(db);
842
+ } };
843
+ }
844
+ //#endregion
845
+ //#region src/migrate/databases.ts
846
+ /**
847
+ * Bring each construct's schema up to its migrations folder.
848
+ *
849
+ * Which constructs, in what order, from which folder, is `migrationTargets`'
850
+ * answer and nobody else's. What is decided here is only how one target is
851
+ * run: as its owner, in its own schema, with a history of its own.
852
+ */
853
+ /** A folder under `db/` that names no construct. */
854
+ var UnknownMigrationFolder = class extends Error {
855
+ folder;
856
+ known;
857
+ constructor(folder, known) {
858
+ super(`${folder} names no database construct, so its migrations would never run. ${known.length > 0 ? `The folders that do: ${known.join(", ")}.` : "This project declares no database."} Rename the folder after the construct it belongs to.`);
859
+ this.folder = folder;
860
+ this.known = known;
861
+ this.name = "UnknownMigrationFolder";
862
+ }
863
+ };
864
+ /** A construct was asked for by a name that takes no migrations. */
865
+ var NoSuchMigrationTarget = class extends Error {
866
+ construct;
867
+ known;
868
+ constructor(construct, known) {
869
+ super(`'${construct}' is not a database construct. ` + (known.length > 0 ? `The ones with migrations: ${known.join(", ")}.` : "This project declares no database."));
870
+ this.construct = construct;
871
+ this.known = known;
872
+ this.name = "NoSuchMigrationTarget";
873
+ }
874
+ };
875
+ /**
876
+ * A migration left directly in a construct's folder, where nothing reads it.
877
+ *
878
+ * Migrations moved into `migrations/` so seeds could sit beside them. A file
879
+ * still at the old level would otherwise never run, and nothing would say so.
880
+ */
881
+ var MigrationsOutsideFolder = class extends Error {
882
+ folder;
883
+ files;
884
+ constructor(folder, files) {
885
+ super(`${folder} has ${files.join(", ")} directly inside it, where nothing runs them. Migrations live in ${folder}/migrations/ and seeds in ${folder}/seeds/ — move ${files.length === 1 ? "it" : "them"} there.`);
886
+ this.folder = folder;
887
+ this.files = files;
888
+ this.name = "MigrationsOutsideFolder";
889
+ }
890
+ };
891
+ /** A folder inside a construct's folder that is neither `migrations` nor `seeds`. */
892
+ var UnknownDatabaseFolder = class extends Error {
893
+ folder;
894
+ constructor(folder) {
895
+ super(`${folder} is neither migrations/ nor seeds/, so nothing in it would ever run. A construct's folder holds those two; rename or remove it.`);
896
+ this.folder = folder;
897
+ this.name = "UnknownDatabaseFolder";
898
+ }
899
+ };
900
+ /** A seed failed; its own writes are rolled back, the ones before it stay. */
901
+ var SeedFailed = class extends Error {
902
+ construct;
903
+ seed;
904
+ cause;
905
+ constructor(construct, seed, cause) {
906
+ super(`${construct}: seed '${seed}' failed — ${cause instanceof Error ? cause.message : String(cause)}. Its writes were rolled back; fix it and run it again.`);
907
+ this.construct = construct;
908
+ this.seed = seed;
909
+ this.cause = cause;
910
+ this.name = "SeedFailed";
911
+ }
912
+ };
913
+ /** A construct with roles has no owner URL to migrate with. */
914
+ var NoOwnerCredential = class extends Error {
915
+ construct;
916
+ key;
917
+ constructor(construct, key) {
918
+ super(`${construct} has no ${key} to migrate with. Migrations run as the owner role — the runtime one cannot create a table — and nothing resolved its URL. Is the stage reconciled?`);
919
+ this.construct = construct;
920
+ this.key = key;
921
+ this.name = "NoOwnerCredential";
922
+ }
923
+ };
924
+ /** A migration failed; the ones before it in the run stay applied. */
925
+ var MigrationFailed = class extends Error {
926
+ construct;
927
+ migration;
928
+ cause;
929
+ constructor(construct, migration, cause) {
930
+ super(`${construct}: migration ${migration ? `'${migration}' ` : ""}failed — ${cause instanceof Error ? cause.message : String(cause)}`);
931
+ this.construct = construct;
932
+ this.migration = migration;
933
+ this.cause = cause;
934
+ this.name = "MigrationFailed";
935
+ }
936
+ };
937
+ /** The project's Kysely could not be found from where a database is declared. */
938
+ var KyselyNotFound = class extends Error {
939
+ from;
940
+ constructor(from) {
941
+ super(`Could not resolve 'kysely' from ${from}. Migrations run with the project's own Kysely — install it where the database is declared.`);
942
+ this.from = from;
943
+ this.name = "KyselyNotFound";
944
+ }
945
+ };
946
+ /** Apply every pending migration, construct by construct, parents first. */
947
+ async function migrateDatabases(options) {
948
+ const runs = [];
949
+ for (const target of await targetsFor(options)) {
950
+ if ((await migrationFiles(join(options.root, target.migrations))).size === 0) {
951
+ runs.push({
952
+ target,
953
+ applied: []
954
+ });
955
+ continue;
956
+ }
957
+ const applied = await withMigrator(options, target, async (migrator) => {
958
+ const { error, results = [] } = await migrator.migrateToLatest();
959
+ if (error) {
960
+ const failed = results.find((result) => result.status === "Error");
961
+ throw new MigrationFailed(target.id, failed?.migrationName, error);
962
+ }
963
+ return results.filter((result) => result.status === "Success").map((result) => result.migrationName);
964
+ });
965
+ runs.push({
966
+ target,
967
+ applied
968
+ });
969
+ }
970
+ return runs;
971
+ }
972
+ /**
973
+ * Run every construct's seeds, parents first — each one, every time, on every
974
+ * stage, production included. Each is handed the stage, so a seed decides
975
+ * what belongs where.
976
+ *
977
+ * Seeds are reference data kept up to date by being run again, so there is no
978
+ * history: a seed is an upsert, and a changed one is applied by the next run.
979
+ * Each runs in its own transaction, as the construct's owner, so a failure
980
+ * leaves nothing half-written. Assumes the schema is migrated —
981
+ * {@link migrateAndSeed} is what makes that true.
982
+ */
983
+ async function seedDatabases(options) {
984
+ const runs = [];
985
+ for (const target of await targetsFor(options)) {
986
+ const folder = join(options.root, target.seeds);
987
+ if ((await migrationFiles(folder)).size === 0) {
988
+ runs.push({
989
+ target,
990
+ seeded: []
991
+ });
992
+ continue;
993
+ }
994
+ const seeded = await withOwner(options, target, async (db, kysely) => {
995
+ const names = [];
996
+ for (const seed of await loadSeeds(folder, kysely.sql)) {
997
+ try {
998
+ await db.transaction().execute((trx) => seed.run(trx, { stage: options.stage }));
999
+ } catch (error) {
1000
+ throw new SeedFailed(target.id, seed.name, error);
1001
+ }
1002
+ names.push(seed.name);
1003
+ }
1004
+ return names;
1005
+ });
1006
+ runs.push({
1007
+ target,
1008
+ seeded
1009
+ });
1010
+ }
1011
+ return runs;
1012
+ }
1013
+ /**
1014
+ * Migrate, then seed: a seed only ever runs against the schema it was written
1015
+ * for.
1016
+ *
1017
+ * With `only`, the constructs it lives in are migrated too — a tenant's seed
1018
+ * cannot run before the database that owns its schema exists — and only it is
1019
+ * seeded.
1020
+ */
1021
+ async function migrateAndSeed(options) {
1022
+ const migrations = [];
1023
+ for (const target of await withParents(options)) migrations.push(...await migrateDatabases({
1024
+ ...options,
1025
+ only: target.id
1026
+ }));
1027
+ return {
1028
+ migrations,
1029
+ seeds: await seedDatabases(options)
1030
+ };
1031
+ }
1032
+ /** The targets `only` names, after the ones it lives in; all without it. */
1033
+ async function withParents(options) {
1034
+ const chosen = await targetsFor(options);
1035
+ if (!options.only) return chosen;
1036
+ const all = migrationTargets(options.manifest);
1037
+ const ids = /* @__PURE__ */ new Set();
1038
+ for (const target of chosen) {
1039
+ let current = target;
1040
+ while (current) {
1041
+ ids.add(current.id);
1042
+ const parent = current.of;
1043
+ current = parent ? all.find((t) => t.id === parent) : void 0;
1044
+ }
1045
+ }
1046
+ return all.filter((target) => ids.has(target.id));
1047
+ }
1048
+ /**
1049
+ * What each construct's folder has that its history does not — without
1050
+ * applying any of it. What `gkm dev` reports.
1051
+ */
1052
+ async function pendingMigrations(options) {
1053
+ const found = [];
1054
+ for (const target of await targetsFor(options)) {
1055
+ if ((await migrationFiles(join(options.root, target.migrations))).size === 0) continue;
1056
+ const pending = await withMigrator(options, target, async (migrator) => (await migrator.getMigrations()).filter((migration) => migration.executedAt === void 0).map((migration) => migration.name));
1057
+ if (pending.length > 0) found.push({
1058
+ target,
1059
+ pending
1060
+ });
1061
+ }
1062
+ return found;
1063
+ }
1064
+ /**
1065
+ * The targets this run covers, with every folder under `db/` accounted for.
1066
+ *
1067
+ * A folder that names no construct is an error rather than something to skip:
1068
+ * a typo in its name is otherwise a migration that silently never runs.
1069
+ */
1070
+ async function targetsFor(options) {
1071
+ const targets = migrationTargets(options.manifest);
1072
+ const folders = targets.map((target) => target.folder);
1073
+ for (const folder of await foldersIn(join(options.root, MIGRATIONS_ROOT))) {
1074
+ const relative = `${MIGRATIONS_ROOT}/${folder}`;
1075
+ if (!folders.includes(relative)) throw new UnknownMigrationFolder(relative, folders);
1076
+ await checkLayout(options.root, relative);
1077
+ }
1078
+ if (!options.only) return targets;
1079
+ const id = canonicalId(options.only);
1080
+ const chosen = targets.filter((target) => target.id === id);
1081
+ if (chosen.length === 0) throw new NoSuchMigrationTarget(options.only, targets.map((target) => target.id));
1082
+ return chosen;
1083
+ }
1084
+ /**
1085
+ * A construct's folder holds `migrations/` and `seeds/`, and nothing else —
1086
+ * anything beside them is a file or folder nothing would ever run.
1087
+ */
1088
+ async function checkLayout(root, folder) {
1089
+ const entries = await readdir(join(root, folder), { withFileTypes: true });
1090
+ const loose = entries.filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
1091
+ if (loose.length > 0) throw new MigrationsOutsideFolder(folder, loose);
1092
+ for (const entry of entries) if (entry.isDirectory() && !DATABASE_FOLDERS.includes(entry.name)) throw new UnknownDatabaseFolder(`${folder}/${entry.name}`);
1093
+ }
1094
+ async function foldersIn(dir) {
1095
+ return (await readdir(dir, { withFileTypes: true }).catch((error) => {
1096
+ if (error.code === "ENOENT") return [];
1097
+ throw error;
1098
+ })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1099
+ }
1100
+ /**
1101
+ * A migrator for one target, connected as its owner.
1102
+ *
1103
+ * No `migrationTableSchema`: the owner role's `search_path` is pinned to the
1104
+ * construct's schema, so the history lands there — `app.kysely_migration` for
1105
+ * the database, `auth_database.kysely_migration` for its tenant — and two
1106
+ * constructs in one Postgres never share one.
1107
+ *
1108
+ * Unordered migrations are allowed. Files are timestamped, so a branch merged
1109
+ * after another's migration ran carries an older one; refusing it would make
1110
+ * every such merge a manual repair.
1111
+ */
1112
+ async function withMigrator(options, target, run) {
1113
+ return withOwner(options, target, (db, { Migrator, sql }) => run(new Migrator({
1114
+ db,
1115
+ provider: folderProvider(join(options.root, target.migrations), sql),
1116
+ allowUnorderedMigrations: true
1117
+ })));
1118
+ }
1119
+ /**
1120
+ * One target's database, connected as its owner and closed afterwards — what
1121
+ * migrations and seeds both run as.
1122
+ */
1123
+ async function withOwner(options, target, run) {
1124
+ const kysely = await kyselyFrom(options.sources?.[target.id]?.file ?? (target.of ? options.sources?.[target.of]?.file : void 0) ?? join(options.root, "package.json"));
1125
+ const db = new kysely.Kysely({ dialect: new kysely.PostgresDialect({ pool: new pg.Pool(poolConfig(ownerUrl(options, target))) }) });
1126
+ try {
1127
+ return await run(db, kysely);
1128
+ } finally {
1129
+ await db.destroy();
1130
+ }
1131
+ }
1132
+ /**
1133
+ * The owner's URL, with no fallback to the runtime one.
1134
+ *
1135
+ * The runtime role can create nothing, so falling back would only move the
1136
+ * failure to the first `CREATE TABLE` and make it read like a permissions
1137
+ * bug. The one exception is a database declared `roles: false`, where there is
1138
+ * no owner — both URLs are the master credential by design.
1139
+ */
1140
+ function ownerUrl(options, target) {
1141
+ const key = provideKey(target.id, "ownerUrl");
1142
+ const owner = options.env[key];
1143
+ if (owner) return owner;
1144
+ const database = options.manifest[target.of ?? target.id];
1145
+ if (database?.kind === "database" && database.roles === false) {
1146
+ const url = options.env[provideKey(target.id, "url")];
1147
+ if (url) return url;
1148
+ }
1149
+ throw new NoOwnerCredential(target.id, key);
1150
+ }
1151
+ /**
1152
+ * Pool settings for a URL, with `?search_path=` as the startup option Postgres
1153
+ * reads. Only a `roles: false` URL carries it — a role pins its own.
1154
+ */
1155
+ function poolConfig(url) {
1156
+ const parsed = new URL(url);
1157
+ const searchPath = parsed.searchParams.get("search_path");
1158
+ if (!searchPath) return { connectionString: url };
1159
+ parsed.searchParams.delete("search_path");
1160
+ return {
1161
+ connectionString: parsed.toString(),
1162
+ options: `-c search_path=${searchPath}`
1163
+ };
1164
+ }
1165
+ /** Kysely as the project resolves it from `from`. */
1166
+ async function kyselyFrom(from) {
1167
+ const require = createRequire(from);
1168
+ try {
1169
+ const [main, migration] = await Promise.all([import(pathToFileURL(require.resolve("kysely")).href), import(pathToFileURL(require.resolve("kysely/migration")).href)]);
1170
+ return {
1171
+ Kysely: main.Kysely,
1172
+ PostgresDialect: main.PostgresDialect,
1173
+ sql: main.sql,
1174
+ Migrator: migration.Migrator
1175
+ };
1176
+ } catch {
1177
+ throw new KyselyNotFound(from);
1178
+ }
1179
+ }
1180
+ //#endregion
1181
+ //#region src/target/compose/health.ts
1182
+ /**
1183
+ * Asking each app of a stack whether it answers — through Caddy, over HTTPS,
1184
+ * the way a browser reaches it.
1185
+ *
1186
+ * `docker compose up --wait` already knows each container passed its own
1187
+ * health check, asked on 127.0.0.1 inside it. What that cannot say is whether
1188
+ * the stack is reachable: whether the edge routes the host to the app, and
1189
+ * whether the certificate it presents is one a client accepts. So the check
1190
+ * goes through the edge, by hostname, and verifies the certificate.
1191
+ */
1192
+ /** A request that got no answer in time. */
1193
+ var HealthRequestTimedOut = class extends Error {
1194
+ url;
1195
+ timeoutMs;
1196
+ constructor(url, timeoutMs) {
1197
+ super(`${url} did not answer within ${timeoutMs}ms. Check the stack is up with \`docker compose ps\`.`);
1198
+ this.url = url;
1199
+ this.timeoutMs = timeoutMs;
1200
+ this.name = "HealthRequestTimedOut";
1201
+ }
1202
+ };
1203
+ /** The real probe: one HTTPS GET, the certificate verified. */
1204
+ const httpsProbe = ({ url, ca, connectTo, timeoutMs, signal }) => {
1205
+ const target = new URL(url);
1206
+ return new Promise((resolve, reject) => {
1207
+ const req = request({
1208
+ host: connectTo ?? target.hostname,
1209
+ port: target.port || 443,
1210
+ servername: target.hostname,
1211
+ path: `${target.pathname}${target.search}`,
1212
+ method: "GET",
1213
+ headers: { host: target.host },
1214
+ ...ca ? { ca } : {},
1215
+ ...signal ? { signal } : {},
1216
+ timeout: timeoutMs
1217
+ }, (res) => {
1218
+ res.resume();
1219
+ res.on("end", () => resolve(res.statusCode ?? 0));
1220
+ res.on("error", reject);
1221
+ });
1222
+ req.on("timeout", () => req.destroy(new HealthRequestTimedOut(url, timeoutMs)));
1223
+ req.on("error", reject);
1224
+ req.end();
1225
+ });
1226
+ };
1227
+ /** Whether a status means the app answered as it should. */
1228
+ function isHealthy(status) {
1229
+ return status >= 200 && status < 400;
1230
+ }
1231
+ /** Apps that did not answer through the edge. */
1232
+ var ComposeAppsUnhealthy = class extends Error {
1233
+ project;
1234
+ apps;
1235
+ constructor(project, apps) {
1236
+ super(`${project} started, but ${apps.length === 1 ? "this app does" : "these apps do"} not answer through Caddy:\n` + apps.map(({ url, last }) => ` - ${url}: ${last}`).join("\n") + `\nSee why with \`docker compose -p ${project} logs caddy ${apps.map((a) => a.app).join(" ")}\`.`);
1237
+ this.project = project;
1238
+ this.apps = apps;
1239
+ this.name = "ComposeAppsUnhealthy";
1240
+ }
1241
+ };
1242
+ //#endregion
1243
+ //#region src/target/compose/phases.ts
1244
+ /**
1245
+ * The compose target's phases: a stage as one Docker Compose stack behind
1246
+ * Caddy, on the machine the deploy runs on.
1247
+ *
1248
+ * The order is the point of this file, and the rules it keeps are these:
1249
+ *
1250
+ * - A tag is checked before anything happens (`validate`). Every app's image
1251
+ * is asked of the registry first; one missing image stops the run naming
1252
+ * them all, and nothing is written, pulled or started.
1253
+ * - The databases exist before the apps do (`provision`). Postgres comes up
1254
+ * alone, its databases, roles and grants are created and its migrations
1255
+ * applied from this machine, and only then do the apps start — so an app
1256
+ * never boots against a schema that is not there yet.
1257
+ * - What a stage runs is recorded (`release`). Each app's image, the tag it
1258
+ * was released under and the digest it resolved to are written to the
1259
+ * stage's state, so "what is this stage running" has an exact answer.
1260
+ * - What was started answers (`verify`): each app is asked through Caddy,
1261
+ * over HTTPS, with the certificate verified.
1262
+ *
1263
+ * Docker, the registry, the bundler, Postgres and the health probe are
1264
+ * injected, so the rules are asserted without a daemon; the defaults are the
1265
+ * real ones.
1266
+ */
1267
+ /** A port variable that does not hold a port. */
1268
+ var EdgePortInvalid = class extends Error {
1269
+ variable;
1270
+ value;
1271
+ constructor(variable, value) {
1272
+ super(`${variable}=${value} is not a port. Set it to a number between 1 and 65535, or unset it for the default.`);
1273
+ this.variable = variable;
1274
+ this.value = value;
1275
+ this.name = "EdgePortInvalid";
1276
+ }
1277
+ };
1278
+ /** A build with no commit to tag its images with. */
1279
+ var NoGitRevision = class extends Error {
1280
+ root;
1281
+ constructor(root) {
1282
+ super(`Images built here are tagged with the commit they were built from, and ${root} is not in a git repository with a commit. Commit, or pass --tag to name the images yourself.`);
1283
+ this.root = root;
1284
+ this.name = "NoGitRevision";
1285
+ }
1286
+ };
1287
+ /** A backend's production bundle did not build. */
1288
+ var BundleFailed = class extends Error {
1289
+ app;
1290
+ exitCode;
1291
+ stderr;
1292
+ constructor(app, exitCode, stderr) {
1293
+ super(`Bundling '${app}' for production failed (exit code ${exitCode}).${stderr.trim() ? `\n${stderr.trim().slice(-4e3)}` : ""}\nRun \`gkm build --provider server --production\` in the app's directory to see it again.`);
1294
+ this.app = app;
1295
+ this.exitCode = exitCode;
1296
+ this.stderr = stderr;
1297
+ this.name = "BundleFailed";
1298
+ }
1299
+ };
1300
+ /** The CLI's own entry, which the bundle step runs, is not where it should be. */
1301
+ var CliEntryNotFound = class extends Error {
1302
+ from;
1303
+ constructor(from) {
1304
+ super(`Could not find bin/gkm.mjs above ${from}, which bundling a backend runs. Reinstall @geekmidas/cli.`);
1305
+ this.from = from;
1306
+ this.name = "CliEntryNotFound";
1307
+ }
1308
+ };
1309
+ /** The commit HEAD is at, short — and `-dirty` when the tree has changes. */
1310
+ async function gitRevision(root) {
1311
+ let sha;
1312
+ try {
1313
+ sha = (await runOutput("git", [
1314
+ "rev-parse",
1315
+ "--short",
1316
+ "HEAD"
1317
+ ], { cwd: root })).trim();
1318
+ } catch {
1319
+ throw new NoGitRevision(root);
1320
+ }
1321
+ if (!sha) throw new NoGitRevision(root);
1322
+ return (await runOutput("git", ["status", "--porcelain"], { cwd: root }).catch(() => "")).trim() ? `${sha}-dirty` : sha;
1323
+ }
1324
+ /** The edge's ports, from the environment — 443 and 80 when unset. */
1325
+ function edgePorts(env = process.env) {
1326
+ const read = (variable, fallback) => {
1327
+ const value = env[variable];
1328
+ if (value === void 0 || value === "") return fallback;
1329
+ const port = Number(value);
1330
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new EdgePortInvalid(variable, value);
1331
+ return port;
1332
+ };
1333
+ return {
1334
+ https: read(EDGE_PORT_ENV.https, 443),
1335
+ http: read(EDGE_PORT_ENV.http, 80)
1336
+ };
1337
+ }
1338
+ /** `bin/gkm.mjs` of the CLI that is running. */
1339
+ function cliEntry() {
1340
+ const from = dirname(fileURLToPath(import.meta.url));
1341
+ for (let dir = from; dirname(dir) !== dir; dir = dirname(dir)) {
1342
+ const entry = join(dir, "bin", "gkm.mjs");
1343
+ if (existsSync(entry)) return entry;
1344
+ }
1345
+ throw new CliEntryNotFound(from);
1346
+ }
1347
+ /** A cold production bundle is seconds; a hung one must not hold the lock. */
1348
+ const BUNDLE_TIMEOUT_MS = 6e5;
1349
+ /**
1350
+ * Bundle a backend with the gkm that is running — the same build a deploy
1351
+ * makes, so the image holds one file and no dependencies — in the run's
1352
+ * sandbox, because the build imports the project's code.
1353
+ */
1354
+ async function bundleInSandbox(appRoot, ctx) {
1355
+ const sandbox = activeSandbox() ?? new LocalSandbox({ root: ctx.cwd });
1356
+ const result = await sandbox.exec("node", [
1357
+ cliEntry(),
1358
+ "build",
1359
+ "--provider",
1360
+ "server",
1361
+ "--production"
1362
+ ], {
1363
+ cwd: appRoot,
1364
+ env: sandbox.env,
1365
+ timeoutMs: BUNDLE_TIMEOUT_MS,
1366
+ output: ctx.childOutput,
1367
+ signal: ctx.signal
1368
+ });
1369
+ if (result.exitCode !== 0) throw new BundleFailed(appRoot, result.exitCode, result.stderr);
1370
+ }
1371
+ /**
1372
+ * The real ones. Each app is asked for three minutes before it counts as
1373
+ * down: a deployed stage's first ACME certificate can take that long.
1374
+ */
1375
+ const defaultDeps = {
1376
+ docker: dockerCompose,
1377
+ bundle: bundleInSandbox,
1378
+ revision: gitRevision,
1379
+ sql: pgClient,
1380
+ migrate: migrateDatabases,
1381
+ probe: httpsProbe,
1382
+ healthAttempts: 90,
1383
+ healthIntervalMs: 2e3,
1384
+ env: process.env
1385
+ };
1386
+ /**
1387
+ * The stack the stage would run, and — for a tag — the registry's word that
1388
+ * every image it names exists. Nothing is written: a missing image stops the
1389
+ * run here, before a file, a secret or a container is touched.
1390
+ */
1391
+ async function validateCompose(ctx, deps, forced) {
1392
+ const { workspace, stage, identity, tag } = ctx;
1393
+ const root = workspace.root;
1394
+ const mode = forced ?? (ctx.tagGiven ? "pull" : "build");
1395
+ const runnables = {};
1396
+ const manifest = await discover({
1397
+ patterns: constructGlobs(workspace),
1398
+ cwd: root,
1399
+ runnables
1400
+ });
1401
+ const { secrets, generated } = await stageSecrets(ctx, manifest);
1402
+ const stack = composeStack({
1403
+ workspace,
1404
+ manifest,
1405
+ runnables,
1406
+ stage,
1407
+ identity,
1408
+ images: {
1409
+ mode,
1410
+ tag,
1411
+ ...workspace.deploy?.registry ? { registry: workspace.deploy.registry } : {}
1412
+ },
1413
+ secrets,
1414
+ ports: edgePorts(deps.env),
1415
+ packageManager: detectPackageManager(root),
1416
+ packages: Object.fromEntries(Object.entries(workspace.apps).map(([name, app]) => [name, appPackageName(root, name, app.path)]))
1417
+ });
1418
+ if (mode === "pull" && !ctx.dryRun) await assertImagesExist(deps.docker, tag, stack.apps);
1419
+ const dir = join(root, stackDir(stage));
1420
+ const previous = (await ctx.state.read(stage))?.state.images ?? {};
1421
+ return {
1422
+ mode,
1423
+ stack,
1424
+ dir,
1425
+ ref: {
1426
+ project: composeProject(identity),
1427
+ file: join(dir, "docker-compose.yml"),
1428
+ cwd: root,
1429
+ output: ctx.childOutput,
1430
+ signal: ctx.signal
1431
+ },
1432
+ secrets,
1433
+ generated,
1434
+ previous,
1435
+ files: [],
1436
+ images: {},
1437
+ changes: []
1438
+ };
1439
+ }
1440
+ /**
1441
+ * The stage's secrets — and, for a deployed stage, everything it generates
1442
+ * once: its seed, and each declared secret and keyring. Kept only once the
1443
+ * run goes ahead (`provision`), so a dry run, or a tag that is not there,
1444
+ * leaves the stage's secrets as it found them.
1445
+ */
1446
+ async function stageSecrets(ctx, manifest) {
1447
+ const stored = await ctx.secrets.read();
1448
+ if (ctx.stage === ctx.workspace.stages.local) return {
1449
+ secrets: stored,
1450
+ generated: []
1451
+ };
1452
+ const { secrets, generated } = withGeneratedSecrets(stored ?? initStageSecrets(ctx.stage), manifest);
1453
+ if (secrets.seed) ctx.secrets.mask(secrets.seed);
1454
+ for (const value of Object.values(secrets.custom ?? {})) ctx.secrets.mask(value);
1455
+ return {
1456
+ secrets,
1457
+ generated
1458
+ };
1459
+ }
1460
+ /**
1461
+ * A dry run: the stack's files, written so they can be read, and what a real
1462
+ * run would do — no image is built or pulled, no container touched, nothing
1463
+ * recorded and no secret kept.
1464
+ */
1465
+ async function planCompose(ctx, run) {
1466
+ if (run.generated.length > 0) ctx.logger.info(`🔑 "${ctx.stage}" has no ${run.generated.join(", ")} yet; this dry run used values it did not keep.`);
1467
+ run.files = await writeStack(ctx.cwd, run.dir, run.stack);
1468
+ printPlan(ctx, run);
1469
+ const planned = (change) => {
1470
+ run.changes.push(change);
1471
+ ctx.emit({
1472
+ type: "resource.planned",
1473
+ ...change
1474
+ });
1475
+ };
1476
+ for (const service of run.stack.infra) planned({
1477
+ key: `service:${service}`,
1478
+ resourceType: "service",
1479
+ action: "ensure"
1480
+ });
1481
+ for (const app of run.stack.apps) {
1482
+ planned({
1483
+ key: `image:${app.name}`,
1484
+ resourceType: "image",
1485
+ action: run.mode === "build" ? "build" : "reuse",
1486
+ id: app.ref
1487
+ });
1488
+ planned({
1489
+ key: `service:${app.name}`,
1490
+ resourceType: "service",
1491
+ action: run.previous[app.name] ? "reuse" : "create"
1492
+ });
1493
+ }
1494
+ planned({
1495
+ key: "service:caddy",
1496
+ resourceType: "service",
1497
+ action: "ensure"
1498
+ });
1499
+ }
1500
+ /**
1501
+ * What the apps run on: the stage's generated secrets kept, the stack's files
1502
+ * written, its infrastructure started, and its databases, roles, grants and
1503
+ * migrations applied.
1504
+ */
1505
+ async function provisionCompose(ctx, run, deps) {
1506
+ const { stack, ref } = run;
1507
+ if (run.generated.length > 0 && run.secrets) {
1508
+ await ctx.secrets.write(run.secrets);
1509
+ ctx.logger.info(`🔑 Generated for "${ctx.stage}" (${ctx.secrets.store}): ${run.generated.join(", ")}`);
1510
+ }
1511
+ run.files = await writeStack(ctx.cwd, run.dir, stack);
1512
+ printPlan(ctx, run);
1513
+ if (stack.infra.length > 0) {
1514
+ ctx.logger.info(`\n🗄️ Starting ${stack.infra.join(", ")}…`);
1515
+ await deps.docker.up(ref, stack.infra);
1516
+ for (const service of stack.infra) applied(ctx, run, {
1517
+ key: `service:${service}`,
1518
+ resourceType: "service",
1519
+ action: "ensure",
1520
+ id: `${stack.project}/${service}`,
1521
+ via: Object.keys(run.previous).length > 0 ? "recorded" : "created"
1522
+ });
1523
+ }
1524
+ if (stack.infra.includes("postgres")) await prepareDatabases(ctx, run, deps);
1525
+ }
1526
+ /** Create what the plan names in the stack's Postgres, then migrate it. */
1527
+ async function prepareDatabases(ctx, run, deps) {
1528
+ const { stack } = run;
1529
+ const workspace = ctx.workspace;
1530
+ const port = await deps.docker.port(run.ref, "postgres", 5432);
1531
+ const statements = postgresStatements(stack.plan, workspace.name, stack.credential.seed);
1532
+ if (statements.length > 0) {
1533
+ ctx.logger.info("🗄️ Creating databases, roles and grants…");
1534
+ await applyPostgres(deps.sql(port, stack.credential.master), statements);
1535
+ }
1536
+ const sources = {};
1537
+ const manifest = await discover({
1538
+ patterns: constructGlobs(workspace),
1539
+ cwd: workspace.root,
1540
+ sources
1541
+ });
1542
+ const env = envFor(stack.plan, {
1543
+ ports: { [primaryPortKey("postgres")]: port },
1544
+ project: workspace.name,
1545
+ master: stack.credential.master,
1546
+ ...stack.credential.seed ? { seed: stack.credential.seed } : {}
1547
+ });
1548
+ const runs = await deps.migrate({
1549
+ root: workspace.root,
1550
+ manifest,
1551
+ sources,
1552
+ env
1553
+ });
1554
+ for (const { target, applied } of runs) if (applied.length > 0) ctx.logger.info(`🗄️ ${target.migrations}: applied ${applied.length}`);
1555
+ }
1556
+ /**
1557
+ * Each app's image: built from this checkout — every backend bundled first,
1558
+ * on the host, with this CLI — or pulled at the tag. Nothing live changes.
1559
+ */
1560
+ async function buildCompose(ctx, run, deps) {
1561
+ const { stack, ref } = run;
1562
+ const apps = stack.apps.map((app) => app.name);
1563
+ if (run.mode === "build") {
1564
+ for (const app of stack.apps.filter((a) => a.kind === "rest-api")) {
1565
+ ctx.signal.throwIfAborted();
1566
+ ctx.logger.info(`\n📦 Bundling ${app.name}…`);
1567
+ await deps.bundle(join(ctx.cwd, app.path), ctx);
1568
+ }
1569
+ ctx.logger.info("\n🐳 Building images…");
1570
+ await deps.docker.build(ref, apps);
1571
+ } else {
1572
+ ctx.logger.info(`\n🐳 Pulling ${ctx.tag}…`);
1573
+ await deps.docker.pull(ref, apps);
1574
+ }
1575
+ for (const app of stack.apps) {
1576
+ const digest = await deps.docker.digest(app.ref);
1577
+ run.images[app.name] = {
1578
+ ref: app.ref,
1579
+ tag: app.tag,
1580
+ ...digest ? { digest } : {}
1581
+ };
1582
+ ctx.emit({
1583
+ type: "artifact.built",
1584
+ app: app.name,
1585
+ imageRef: app.ref,
1586
+ ...digest ? { digest } : {}
1587
+ });
1588
+ run.changes.push({
1589
+ key: `image:${app.name}`,
1590
+ resourceType: "image",
1591
+ action: run.mode === "build" ? "build" : "reuse",
1592
+ id: digest ?? app.ref
1593
+ });
1594
+ }
1595
+ }
1596
+ /**
1597
+ * The whole stack up — anything it no longer defines removed — and each
1598
+ * app's image recorded in the stage's state.
1599
+ */
1600
+ async function releaseCompose(ctx, run, deps) {
1601
+ const { stack, ref } = run;
1602
+ ctx.logger.info("\n🚀 Starting the stack…");
1603
+ await deps.docker.up(ref);
1604
+ if (stack.local) await deps.docker.copyOut(ref, "caddy", "/data/caddy/pki/authorities/local/root.crt", caFile(run)).catch(() => {});
1605
+ await recordImages(ctx, run);
1606
+ for (const app of stack.apps) {
1607
+ const image = run.images[app.name];
1608
+ applied(ctx, run, {
1609
+ key: `service:${app.name}`,
1610
+ resourceType: "service",
1611
+ action: "ensure",
1612
+ id: `${stack.project}/${app.name}`,
1613
+ via: run.previous[app.name] ? "recorded" : "created"
1614
+ });
1615
+ ctx.emit({
1616
+ type: "app.deployed",
1617
+ app: app.name,
1618
+ applicationId: `${stack.project}/${app.name}`,
1619
+ imageRef: image?.ref ?? app.ref,
1620
+ url: app.url
1621
+ });
1622
+ }
1623
+ ctx.logger.info(`\n✅ ${stack.project} is running:`);
1624
+ for (const app of stack.apps) ctx.logger.info(` ${app.name.padEnd(12)} ${app.url}`);
1625
+ if (stack.local) ctx.logger.info(`\n🔐 Certificates are from Caddy's local CA: NODE_EXTRA_CA_CERTS=${join(stackDir(ctx.stage), "caddy-root.crt")}`);
1626
+ }
1627
+ /**
1628
+ * Each app's image in the stage's state — the same `images` a Dokploy deploy
1629
+ * records: the ref it runs, the tag it was released under, and the digest
1630
+ * the tag resolved to.
1631
+ */
1632
+ async function recordImages(ctx, run) {
1633
+ const journal = await DeployJournal.open(ctx.state, ctx.stage, () => createEmptyState(ctx.stage, "", ""));
1634
+ for (const app of run.stack.apps) {
1635
+ const image = run.images[app.name] ?? {
1636
+ ref: app.ref,
1637
+ tag: app.tag
1638
+ };
1639
+ setDeployedImage(journal.state, app.name, image);
1640
+ }
1641
+ journal.state.identity = ctx.identity.key;
1642
+ await journal.save();
1643
+ }
1644
+ /**
1645
+ * Each app asked through Caddy, over HTTPS, the certificate verified: an API
1646
+ * at `/health`, a site at `/`. On the local stage the edge is this machine and
1647
+ * its certificates are Caddy's internal CA's, so the CA `release` copied out
1648
+ * is the one trusted and the request goes to 127.0.0.1.
1649
+ */
1650
+ async function verifyCompose(ctx, run, deps) {
1651
+ const { stack } = run;
1652
+ const ca = stack.local ? await readFile(caFile(run), "utf-8").catch(() => void 0) : void 0;
1653
+ ctx.logger.info("\n🩺 Checking each app through Caddy…");
1654
+ const down = (await Promise.all(stack.apps.map((app) => checkApp(ctx, app, deps, {
1655
+ ca,
1656
+ local: stack.local
1657
+ })))).filter((result) => !result.healthy).map(({ app, url, last }) => ({
1658
+ app,
1659
+ url,
1660
+ last
1661
+ }));
1662
+ if (down.length > 0) throw new ComposeAppsUnhealthy(stack.project, down);
1663
+ }
1664
+ async function checkApp(ctx, app, deps, options) {
1665
+ const url = `${app.url}${app.kind === "site" ? "/" : "/health"}`;
1666
+ let last = "not asked";
1667
+ for (let attempt = 1; attempt <= deps.healthAttempts; attempt++) {
1668
+ ctx.signal.throwIfAborted();
1669
+ let status;
1670
+ try {
1671
+ status = await deps.probe({
1672
+ url,
1673
+ ...options.ca ? { ca: options.ca } : {},
1674
+ ...options.local ? { connectTo: "127.0.0.1" } : {},
1675
+ timeoutMs: 1e4,
1676
+ signal: ctx.signal
1677
+ });
1678
+ last = `HTTP ${status}`;
1679
+ } catch (error) {
1680
+ last = error instanceof Error ? error.message : String(error);
1681
+ }
1682
+ const healthy = status !== void 0 && isHealthy(status);
1683
+ ctx.emit({
1684
+ type: "health.checked",
1685
+ app: app.name,
1686
+ url,
1687
+ healthy,
1688
+ ...status !== void 0 ? { status } : {},
1689
+ attempt
1690
+ });
1691
+ if (healthy) {
1692
+ ctx.logger.info(` ✓ ${app.name.padEnd(12)} ${url} (${last})`);
1693
+ return {
1694
+ app: app.name,
1695
+ url,
1696
+ last,
1697
+ healthy
1698
+ };
1699
+ }
1700
+ if (attempt < deps.healthAttempts) await new Promise((resolve) => setTimeout(resolve, deps.healthIntervalMs));
1701
+ }
1702
+ ctx.logger.warn(` ✗ ${app.name.padEnd(12)} ${url} (${last})`);
1703
+ return {
1704
+ app: app.name,
1705
+ url,
1706
+ last,
1707
+ healthy: false
1708
+ };
1709
+ }
1710
+ function composeResult(ctx, run) {
1711
+ const apps = run.stack.apps.map((app) => {
1712
+ const image = run.images[app.name];
1713
+ return {
1714
+ appName: app.name,
1715
+ type: app.kind === "site" ? "web" : "backend",
1716
+ success: !ctx.dryRun,
1717
+ applicationId: `${run.stack.project}/${app.name}`,
1718
+ imageRef: image?.ref ?? app.ref,
1719
+ ...image?.digest ? { digest: image.digest } : {},
1720
+ url: app.url
1721
+ };
1722
+ });
1723
+ return {
1724
+ apps,
1725
+ projectId: run.stack.project,
1726
+ successCount: ctx.dryRun ? 0 : apps.length,
1727
+ failedCount: 0,
1728
+ stage: ctx.stage,
1729
+ identity: ctx.identity.key,
1730
+ tag: ctx.tag,
1731
+ dryRun: ctx.dryRun,
1732
+ environmentId: "",
1733
+ skipped: [...ctx.skipped],
1734
+ urls: Object.fromEntries(run.stack.apps.map((app) => [app.name, app.url])),
1735
+ changes: run.changes
1736
+ };
1737
+ }
1738
+ function caFile(run) {
1739
+ return join(run.dir, "caddy-root.crt");
1740
+ }
1741
+ function applied(ctx, run, change) {
1742
+ const { via, ...rest } = change;
1743
+ run.changes.push(rest);
1744
+ ctx.emit({
1745
+ type: "resource.applied",
1746
+ ...rest,
1747
+ via
1748
+ });
1749
+ }
1750
+ /** Write the stack's files: owner-only, since the env files hold secrets. */
1751
+ async function writeStack(root, dir, stack) {
1752
+ await mkdir(dir, {
1753
+ recursive: true,
1754
+ mode: 448
1755
+ });
1756
+ await chmod(dir, 448);
1757
+ const files = [];
1758
+ const write = async (path, content, mode = 420) => {
1759
+ await mkdir(dirname(path), { recursive: true });
1760
+ await writeFile(path, content, { mode });
1761
+ await chmod(path, mode);
1762
+ files.push(path);
1763
+ };
1764
+ await write(join(dir, "docker-compose.yml"), composeYaml(stack));
1765
+ await write(join(dir, "Caddyfile"), stack.caddyfile);
1766
+ for (const app of stack.apps) if (app.env) await write(join(dir, `${app.name}.env`), envFile(app.env), 384);
1767
+ for (const [path, content] of Object.entries(stack.dockerfiles)) await write(join(root, path), content);
1768
+ await ignoreStacks(root);
1769
+ return files;
1770
+ }
1771
+ /** Make sure the build context leaves `.gkm/compose` out. */
1772
+ async function ignoreStacks(root) {
1773
+ const path = join(root, ".dockerignore");
1774
+ if (!existsSync(path)) {
1775
+ await writeFile(path, generateDockerignore());
1776
+ return;
1777
+ }
1778
+ const current = await readFile(path, "utf-8");
1779
+ if (/^\/?\.gkm\/compose\/?$|^\/?\.gkm\/?$/m.test(current)) return;
1780
+ await appendFile(path, `${current.endsWith("\n") ? "" : "\n"}\n# gkm compose's stacks: each app's env file holds its stage's secrets\n.gkm/compose\n`);
1781
+ }
1782
+ function composeYaml(stack) {
1783
+ const stage = stack.local ? "" : ` --stage ${stack.stage}`;
1784
+ return `# Generated by gkm compose from the construct manifest — do not edit.
1785
+ # The ${stack.stage} stage's APIs and sites behind one Caddy. Each backend
1786
+ # reads exactly the keys in its own env file beside this one.
1787
+ #
1788
+ # gkm compose${stage} start or update it
1789
+ # gkm compose${stage} --down stop it
1790
+ ${stringify(stack.compose, {
1791
+ lineWidth: 0,
1792
+ aliasDuplicateObjects: false
1793
+ })}`;
1794
+ }
1795
+ function printPlan(ctx, run) {
1796
+ const { stack, mode, files } = run;
1797
+ ctx.logger.info(`\n🧱 ${stack.project} — stage ${stack.stage}`);
1798
+ for (const app of stack.apps) ctx.logger.info(` ${app.name.padEnd(12)} ${app.url.padEnd(40)} ${mode} ${app.ref}`);
1799
+ if (stack.infra.length > 0) ctx.logger.info(` infrastructure: ${stack.infra.join(", ")}`);
1800
+ ctx.logger.info(`\n📝 Wrote ${files.length} file(s) under ${stackDir(stack.stage)}/`);
1801
+ }
1802
+ //#endregion
1803
+ //#region src/target/compose/index.ts
1804
+ /**
1805
+ * The built-in `compose` target: a stage as one Docker Compose stack behind
1806
+ * Caddy, on the machine that runs the deploy.
1807
+ *
1808
+ * ```bash
1809
+ * gkm deploy --target compose --stage production --tag v1.4.0 # what CI pushed
1810
+ * gkm deploy --target compose --stage production # built from here
1811
+ * gkm deploy --target compose --stage development # the local stage
1812
+ * ```
1813
+ *
1814
+ * - `validate`: the stack (`composeStack`, pure) and, for a tag, every image
1815
+ * looked up in the registry — `ImageTagNotFound` before anything changes
1816
+ * - `plan`: the stack's files and what a run would create or reuse
1817
+ * - `provision`: generated secrets kept, files written, infrastructure up,
1818
+ * databases, roles, grants and migrations
1819
+ * - `build`: each backend bundled on the host and every image built — or, for
1820
+ * a tag, pulled
1821
+ * - `release`: `up --wait --remove-orphans`, and each app's image, tag and
1822
+ * digest in the stage's state
1823
+ * - `verify`: each app asked through Caddy over HTTPS
1824
+ *
1825
+ * `gkm compose` is this target with a few more switches (`--build`, `--pull`,
1826
+ * `--down`), run through the same `deploy()`.
1827
+ */
1828
+ var compose_exports = /* @__PURE__ */ __exportAll({ composeTarget: () => composeTarget });
1829
+ /** The compose target, with its Docker, bundler and probe as given. */
1830
+ function composeTarget(options = {}) {
1831
+ const { mode, report, ...given } = options;
1832
+ const deps = {
1833
+ ...defaultDeps,
1834
+ ...given
1835
+ };
1836
+ return defineTarget({
1837
+ name: "compose",
1838
+ runtime: "server",
1839
+ capabilities: {
1840
+ rollback: false,
1841
+ migrations: "target",
1842
+ images: true,
1843
+ localStage: true
1844
+ },
1845
+ credentials: [],
1846
+ tag: ({ cwd }) => deps.revision(cwd),
1847
+ validate: (ctx) => validateCompose(ctx, deps, mode),
1848
+ plan: (ctx, run) => planCompose(ctx, run),
1849
+ provision: (ctx, run) => provisionCompose(ctx, run, deps),
1850
+ build: (ctx, run) => buildCompose(ctx, run, deps),
1851
+ release: (ctx, run) => releaseCompose(ctx, run, deps),
1852
+ verify: (ctx, run) => verifyCompose(ctx, run, deps),
1853
+ result: (ctx, run) => {
1854
+ report?.(run);
1855
+ return composeResult(ctx, run);
1856
+ }
1857
+ });
1858
+ }
1859
+ //#endregion
1860
+ export { SeedHasNoSeed as _, MigrationsOutsideFolder as a, dockerCompose as b, SeedFailed as c, migrateAndSeed as d, migrateDatabases as f, MigrationHasNoUp as g, DuplicateMigration as h, MigrationFailed as i, UnknownDatabaseFolder as l, seedDatabases as m, compose_exports as n, NoOwnerCredential as o, pendingMigrations as p, KyselyNotFound as r, NoSuchMigrationTarget as s, composeTarget as t, UnknownMigrationFolder as u, composeProject as v, stackDir as y };
1861
+
1862
+ //# sourceMappingURL=compose-DprE3ASm.mjs.map