@geekmidas/cli 10.0.0-alpha.5 → 10.0.0-alpha.51

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 (267) hide show
  1. package/README.md +196 -257
  2. package/bin/adjacent-tsconfig.mjs +97 -0
  3. package/bin/gkm.mjs +16 -2
  4. package/bin/owning-tsconfig-jsx-register.mjs +18 -0
  5. package/bin/owning-tsconfig-jsx.mjs +108 -0
  6. package/dist/{HostingerProvider-h3EbEp-Z.cjs → HostingerProvider-BZFGYYVA.cjs} +21 -22
  7. package/dist/HostingerProvider-BZFGYYVA.cjs.map +1 -0
  8. package/dist/{HostingerProvider-Cft7lEYr.mjs → HostingerProvider-GF6NAZAK.mjs} +20 -21
  9. package/dist/HostingerProvider-GF6NAZAK.mjs.map +1 -0
  10. package/dist/{LocalStateProvider-Db9SEjyH.cjs → LocalStateProvider-7HR6SS2t.cjs} +11 -6
  11. package/dist/LocalStateProvider-7HR6SS2t.cjs.map +1 -0
  12. package/dist/{LocalStateProvider-Bn6tS-oL.mjs → LocalStateProvider-D7QyJoLM.mjs} +10 -4
  13. package/dist/LocalStateProvider-D7QyJoLM.mjs.map +1 -0
  14. package/dist/{openapi-CA0yJUTN.cjs → OpenApiTsGenerator-BN0PIZxd.cjs} +157 -372
  15. package/dist/OpenApiTsGenerator-BN0PIZxd.cjs.map +1 -0
  16. package/dist/{openapi-kkQbsRVj.mjs → OpenApiTsGenerator-BzIGy8KE.mjs} +128 -324
  17. package/dist/OpenApiTsGenerator-BzIGy8KE.mjs.map +1 -0
  18. package/dist/{Route53Provider-RdpUDJQk.mjs → Route53Provider-Cvj40A8I.mjs} +12 -12
  19. package/dist/Route53Provider-Cvj40A8I.mjs.map +1 -0
  20. package/dist/{Route53Provider-di2fMM5N.cjs → Route53Provider-DVILBsvV.cjs} +20 -21
  21. package/dist/Route53Provider-DVILBsvV.cjs.map +1 -0
  22. package/dist/{SSMStateProvider-BRt2py-A.mjs → SSMStateProvider-B0Hu1MKZ.mjs} +13 -4
  23. package/dist/SSMStateProvider-B0Hu1MKZ.mjs.map +1 -0
  24. package/dist/{SSMStateProvider-DyX8s9HI.cjs → SSMStateProvider-VrQ-yeqd.cjs} +17 -9
  25. package/dist/SSMStateProvider-VrQ-yeqd.cjs.map +1 -0
  26. package/dist/aws-DkHdTV-p.cjs +61 -0
  27. package/dist/aws-DkHdTV-p.cjs.map +1 -0
  28. package/dist/aws-DojzQnLd.mjs +61 -0
  29. package/dist/aws-DojzQnLd.mjs.map +1 -0
  30. package/dist/backends-B9V2XD2M.cjs +82 -0
  31. package/dist/backends-B9V2XD2M.cjs.map +1 -0
  32. package/dist/backends-CSuLl0YP.mjs +59 -0
  33. package/dist/backends-CSuLl0YP.mjs.map +1 -0
  34. package/dist/{backup-provisioner-BrsHwYG0.cjs → backup-provisioner-BmhIO_Yr.cjs} +21 -19
  35. package/dist/backup-provisioner-BmhIO_Yr.cjs.map +1 -0
  36. package/dist/{backup-provisioner-Bgj5Vi_L.mjs → backup-provisioner-DIVCbfee.mjs} +10 -7
  37. package/dist/backup-provisioner-DIVCbfee.mjs.map +1 -0
  38. package/dist/{bundler-Be8Zg4Wd.mjs → bundler--fyybf1N.mjs} +19 -30
  39. package/dist/bundler--fyybf1N.mjs.map +1 -0
  40. package/dist/{bundler-BY1OmV1e.cjs → bundler-By6_sXiL.cjs} +20 -32
  41. package/dist/bundler-By6_sXiL.cjs.map +1 -0
  42. package/dist/{config-C21ocYNd.cjs → config-Cvr5fCbn.cjs} +210 -210
  43. package/dist/config-Cvr5fCbn.cjs.map +1 -0
  44. package/dist/{config-WRdsQYZK.mjs → config-SOkDH-n1.mjs} +143 -154
  45. package/dist/config-SOkDH-n1.mjs.map +1 -0
  46. package/dist/config.cjs +9 -6
  47. package/dist/config.d.cts +62 -33
  48. package/dist/config.d.cts.map +1 -1
  49. package/dist/config.d.mts +62 -33
  50. package/dist/config.d.mts.map +1 -1
  51. package/dist/config.mjs +3 -5
  52. package/dist/deploy/sniffer-envkit-patch.cjs +18 -10
  53. package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -1
  54. package/dist/deploy/sniffer-envkit-patch.d.cts +0 -2
  55. package/dist/deploy/sniffer-envkit-patch.d.cts.map +1 -1
  56. package/dist/deploy/sniffer-envkit-patch.d.mts +0 -2
  57. package/dist/deploy/sniffer-envkit-patch.d.mts.map +1 -1
  58. package/dist/deploy/sniffer-envkit-patch.mjs +10 -2
  59. package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -1
  60. package/dist/deploy/sniffer-hooks.cjs +13 -7
  61. package/dist/deploy/sniffer-hooks.cjs.map +1 -1
  62. package/dist/deploy/sniffer-hooks.d.cts +1 -2
  63. package/dist/deploy/sniffer-hooks.d.cts.map +1 -1
  64. package/dist/deploy/sniffer-hooks.d.mts +1 -2
  65. package/dist/deploy/sniffer-hooks.d.mts.map +1 -1
  66. package/dist/deploy/sniffer-hooks.mjs +9 -2
  67. package/dist/deploy/sniffer-hooks.mjs.map +1 -1
  68. package/dist/deploy/sniffer-loader.cjs +16 -7
  69. package/dist/deploy/sniffer-loader.cjs.map +1 -1
  70. package/dist/deploy/sniffer-loader.d.cts +1 -1
  71. package/dist/deploy/sniffer-loader.d.mts +1 -1
  72. package/dist/deploy/sniffer-loader.mjs +13 -2
  73. package/dist/deploy/sniffer-loader.mjs.map +1 -1
  74. package/dist/deploy/sniffer-routes-worker.cjs +11 -11
  75. package/dist/deploy/sniffer-routes-worker.cjs.map +1 -1
  76. package/dist/deploy/sniffer-routes-worker.d.cts +1 -1
  77. package/dist/deploy/sniffer-routes-worker.d.mts +1 -1
  78. package/dist/deploy/sniffer-routes-worker.mjs +3 -3
  79. package/dist/deploy/sniffer-routes-worker.mjs.map +1 -1
  80. package/dist/deploy/sniffer-worker.cjs +15 -6
  81. package/dist/deploy/sniffer-worker.cjs.map +1 -1
  82. package/dist/deploy/sniffer-worker.d.cts +1 -3
  83. package/dist/deploy/sniffer-worker.d.cts.map +1 -1
  84. package/dist/deploy/sniffer-worker.d.mts +1 -3
  85. package/dist/deploy/sniffer-worker.d.mts.map +1 -1
  86. package/dist/deploy/sniffer-worker.mjs +15 -4
  87. package/dist/deploy/sniffer-worker.mjs.map +1 -1
  88. package/dist/{discover-CAlFgYec.mjs → discover-Cf2jAcgF.mjs} +56 -63
  89. package/dist/discover-Cf2jAcgF.mjs.map +1 -0
  90. package/dist/{discover-DTmFeloL.cjs → discover-DXNviAh-.cjs} +86 -110
  91. package/dist/discover-DXNviAh-.cjs.map +1 -0
  92. package/dist/{emit-CiZS_5Ub.mjs → emit-C1e8qD08.mjs} +13 -26
  93. package/dist/emit-C1e8qD08.mjs.map +1 -0
  94. package/dist/{emit-CbcRolsa.cjs → emit-Dmc1nvdS.cjs} +31 -57
  95. package/dist/emit-Dmc1nvdS.cjs.map +1 -0
  96. package/dist/encryption-Bt8SHRI6.cjs +91 -0
  97. package/dist/encryption-Bt8SHRI6.cjs.map +1 -0
  98. package/dist/encryption-DRCXp-U4.mjs +90 -0
  99. package/dist/encryption-DRCXp-U4.mjs.map +1 -0
  100. package/dist/github-BGx4U61b.cjs +213 -0
  101. package/dist/github-BGx4U61b.cjs.map +1 -0
  102. package/dist/github-U_frvZgD.mjs +213 -0
  103. package/dist/github-U_frvZgD.mjs.map +1 -0
  104. package/dist/{index-CHB4tSzX.d.cts → index-DYh5TrsZ.d.mts} +4 -23
  105. package/dist/index-DYh5TrsZ.d.mts.map +1 -0
  106. package/dist/{index-BThUzEIp.d.mts → index-fU3qHy7c.d.cts} +4 -23
  107. package/dist/index-fU3qHy7c.d.cts.map +1 -0
  108. package/dist/index.cjs +9561 -7232
  109. package/dist/index.cjs.map +1 -1
  110. package/dist/index.d.cts +1 -1
  111. package/dist/index.d.mts +1 -1
  112. package/dist/index.mjs +9479 -7177
  113. package/dist/index.mjs.map +1 -1
  114. package/dist/migrate-dx4I0epp.cjs +743 -0
  115. package/dist/migrate-dx4I0epp.cjs.map +1 -0
  116. package/dist/migrate-soUQtku3.mjs +736 -0
  117. package/dist/migrate-soUQtku3.mjs.map +1 -0
  118. package/dist/openapi.cjs +198 -8
  119. package/dist/openapi.cjs.map +1 -0
  120. package/dist/openapi.d.cts +53 -15
  121. package/dist/openapi.d.cts.map +1 -1
  122. package/dist/openapi.d.mts +53 -15
  123. package/dist/openapi.d.mts.map +1 -1
  124. package/dist/openapi.mjs +193 -5
  125. package/dist/openapi.mjs.map +1 -0
  126. package/dist/ready-WfT-eWlm.mjs +861 -0
  127. package/dist/ready-WfT-eWlm.mjs.map +1 -0
  128. package/dist/ready-lr2Fm-ep.cjs +932 -0
  129. package/dist/ready-lr2Fm-ep.cjs.map +1 -0
  130. package/dist/reconcile/public.cjs +3 -5
  131. package/dist/reconcile/public.d.cts +30 -33
  132. package/dist/reconcile/public.d.cts.map +1 -1
  133. package/dist/reconcile/public.d.mts +30 -33
  134. package/dist/reconcile/public.d.mts.map +1 -1
  135. package/dist/reconcile/public.mjs +3 -4
  136. package/dist/{reconcile-CFs8C_1a.mjs → reconcile--I7V4Klj.mjs} +4 -7
  137. package/dist/reconcile--I7V4Klj.mjs.map +1 -0
  138. package/dist/{reconcile-D0pimNiy.cjs → reconcile-CuCQBa5-.cjs} +5 -8
  139. package/dist/reconcile-CuCQBa5-.cjs.map +1 -0
  140. package/dist/rolldown-runtime-BhDjJH2R.mjs +15 -0
  141. package/dist/{chunk-CUT6urMc.cjs → rolldown-runtime-C0BPl7ul.cjs} +23 -10
  142. package/dist/storage-BzaUxmqe.mjs +124 -0
  143. package/dist/storage-BzaUxmqe.mjs.map +1 -0
  144. package/dist/storage-CSGoC3bK.cjs +152 -0
  145. package/dist/storage-CSGoC3bK.cjs.map +1 -0
  146. package/dist/store-DQgxT_WR.cjs +211 -0
  147. package/dist/store-DQgxT_WR.cjs.map +1 -0
  148. package/dist/store-DeGATCS-.mjs +182 -0
  149. package/dist/store-DeGATCS-.mjs.map +1 -0
  150. package/dist/types-CT0ymlBk.d.mts +6230 -0
  151. package/dist/types-CT0ymlBk.d.mts.map +1 -0
  152. package/dist/types-DYDT4FfE.d.cts +6231 -0
  153. package/dist/types-DYDT4FfE.d.cts.map +1 -0
  154. package/dist/vitest.cjs +65 -0
  155. package/dist/vitest.cjs.map +1 -0
  156. package/dist/vitest.d.cts +29 -0
  157. package/dist/vitest.d.cts.map +1 -0
  158. package/dist/vitest.d.mts +29 -0
  159. package/dist/vitest.d.mts.map +1 -0
  160. package/dist/vitest.mjs +65 -0
  161. package/dist/vitest.mjs.map +1 -0
  162. package/dist/workspace/index.cjs +3 -4
  163. package/dist/workspace/index.d.cts +3 -3
  164. package/dist/workspace/index.d.mts +3 -3
  165. package/dist/workspace/index.mjs +2 -3
  166. package/dist/{workspace-BcucjwvA.cjs → workspace-BcCiLzTO.cjs} +1040 -1120
  167. package/dist/workspace-BcCiLzTO.cjs.map +1 -0
  168. package/dist/{credentials-BBIRJVp4.mjs → workspace-Dp2zK_Nk.mjs} +2212 -1732
  169. package/dist/workspace-Dp2zK_Nk.mjs.map +1 -0
  170. package/dist/{credentials-G1JwBoXH.cjs → workspace-GBzR_hID.cjs} +2348 -1849
  171. package/dist/workspace-GBzR_hID.cjs.map +1 -0
  172. package/dist/{workspace-BFMrAKqI.mjs → workspace-bOsks1uk.mjs} +871 -974
  173. package/dist/workspace-bOsks1uk.mjs.map +1 -0
  174. package/package.json +29 -20
  175. package/dist/CachedStateProvider-BLo7zv7S.cjs +0 -3
  176. package/dist/CachedStateProvider-BxoAm-B2.mjs +0 -3
  177. package/dist/CachedStateProvider-CqPdW7CB.cjs +0 -60
  178. package/dist/CachedStateProvider-CqPdW7CB.cjs.map +0 -1
  179. package/dist/CachedStateProvider-DTniehMP.mjs +0 -54
  180. package/dist/CachedStateProvider-DTniehMP.mjs.map +0 -1
  181. package/dist/HostingerProvider-Cft7lEYr.mjs.map +0 -1
  182. package/dist/HostingerProvider-h3EbEp-Z.cjs.map +0 -1
  183. package/dist/LocalStateProvider-Bn6tS-oL.mjs.map +0 -1
  184. package/dist/LocalStateProvider-Db9SEjyH.cjs.map +0 -1
  185. package/dist/Route53Provider-RdpUDJQk.mjs.map +0 -1
  186. package/dist/Route53Provider-di2fMM5N.cjs.map +0 -1
  187. package/dist/SSMStateProvider-BRt2py-A.mjs.map +0 -1
  188. package/dist/SSMStateProvider-DyX8s9HI.cjs.map +0 -1
  189. package/dist/backup-provisioner-Bgj5Vi_L.mjs.map +0 -1
  190. package/dist/backup-provisioner-BrsHwYG0.cjs.map +0 -1
  191. package/dist/bundler-BY1OmV1e.cjs.map +0 -1
  192. package/dist/bundler-Be8Zg4Wd.mjs.map +0 -1
  193. package/dist/chunk-Duj1WY3L.mjs +0 -7
  194. package/dist/config-C21ocYNd.cjs.map +0 -1
  195. package/dist/config-WRdsQYZK.mjs.map +0 -1
  196. package/dist/credentials-B5zRHdAl.mjs +0 -141
  197. package/dist/credentials-B5zRHdAl.mjs.map +0 -1
  198. package/dist/credentials-BBIRJVp4.mjs.map +0 -1
  199. package/dist/credentials-BapzjyV5.cjs +0 -195
  200. package/dist/credentials-BapzjyV5.cjs.map +0 -1
  201. package/dist/credentials-G1JwBoXH.cjs.map +0 -1
  202. package/dist/discover-CAlFgYec.mjs.map +0 -1
  203. package/dist/discover-DTmFeloL.cjs.map +0 -1
  204. package/dist/dokploy-api-BS2xnJLZ.mjs +0 -3
  205. package/dist/dokploy-api-BsVH0y_s.cjs +0 -718
  206. package/dist/dokploy-api-BsVH0y_s.cjs.map +0 -1
  207. package/dist/dokploy-api-BuxDRo3l.cjs +0 -3
  208. package/dist/dokploy-api-aTFPz7_4.mjs +0 -706
  209. package/dist/dokploy-api-aTFPz7_4.mjs.map +0 -1
  210. package/dist/emit-CbcRolsa.cjs.map +0 -1
  211. package/dist/emit-CiZS_5Ub.mjs.map +0 -1
  212. package/dist/encryption-C6jaU0hE.cjs +0 -54
  213. package/dist/encryption-C6jaU0hE.cjs.map +0 -1
  214. package/dist/encryption-CHzzeR8a.cjs +0 -4
  215. package/dist/encryption-CQUncu7v.mjs +0 -3
  216. package/dist/encryption-D-abyhZx.mjs +0 -42
  217. package/dist/encryption-D-abyhZx.mjs.map +0 -1
  218. package/dist/fullstack-secrets-C-GGohke.cjs +0 -111
  219. package/dist/fullstack-secrets-C-GGohke.cjs.map +0 -1
  220. package/dist/fullstack-secrets-D1o9-G5l.mjs +0 -87
  221. package/dist/fullstack-secrets-D1o9-G5l.mjs.map +0 -1
  222. package/dist/generator-CNzBw7g5.cjs +0 -289
  223. package/dist/generator-CNzBw7g5.cjs.map +0 -1
  224. package/dist/generator-VE2JrFlY.mjs +0 -253
  225. package/dist/generator-VE2JrFlY.mjs.map +0 -1
  226. package/dist/index-BThUzEIp.d.mts.map +0 -1
  227. package/dist/index-CHB4tSzX.d.cts.map +0 -1
  228. package/dist/openapi-CA0yJUTN.cjs.map +0 -1
  229. package/dist/openapi-kkQbsRVj.mjs.map +0 -1
  230. package/dist/openapi-react-query-Byltvm_7.mjs +0 -167
  231. package/dist/openapi-react-query-Byltvm_7.mjs.map +0 -1
  232. package/dist/openapi-react-query-CmJ1xDbn.cjs +0 -173
  233. package/dist/openapi-react-query-CmJ1xDbn.cjs.map +0 -1
  234. package/dist/openapi-react-query.cjs +0 -4
  235. package/dist/openapi-react-query.d.cts +0 -11
  236. package/dist/openapi-react-query.d.cts.map +0 -1
  237. package/dist/openapi-react-query.d.mts +0 -11
  238. package/dist/openapi-react-query.d.mts.map +0 -1
  239. package/dist/openapi-react-query.mjs +0 -4
  240. package/dist/reconcile-CFs8C_1a.mjs.map +0 -1
  241. package/dist/reconcile-D0pimNiy.cjs.map +0 -1
  242. package/dist/setup-B5_oHHUj.cjs +0 -11
  243. package/dist/setup-Cm2UnAdh.cjs +0 -473
  244. package/dist/setup-Cm2UnAdh.cjs.map +0 -1
  245. package/dist/setup-DXvRLTZN.mjs +0 -443
  246. package/dist/setup-DXvRLTZN.mjs.map +0 -1
  247. package/dist/setup-ZijS4qeC.mjs +0 -11
  248. package/dist/storage-BJmzYxt8.cjs +0 -363
  249. package/dist/storage-BJmzYxt8.cjs.map +0 -1
  250. package/dist/storage-BM9hGpgv.cjs +0 -7
  251. package/dist/storage-Bi24WfoW.mjs +0 -297
  252. package/dist/storage-Bi24WfoW.mjs.map +0 -1
  253. package/dist/storage-CYlGwWTj.mjs +0 -3
  254. package/dist/sync-CmEuNZsB.mjs +0 -76
  255. package/dist/sync-CmEuNZsB.mjs.map +0 -1
  256. package/dist/sync-D306yOWu.cjs +0 -4
  257. package/dist/sync-SizaChmC.cjs +0 -93
  258. package/dist/sync-SizaChmC.cjs.map +0 -1
  259. package/dist/sync-hOqAWHR6.mjs +0 -3
  260. package/dist/types-C-arnQ1r.d.mts +0 -2184
  261. package/dist/types-C-arnQ1r.d.mts.map +0 -1
  262. package/dist/types-abJQuk7m.d.cts +0 -2184
  263. package/dist/types-abJQuk7m.d.cts.map +0 -1
  264. package/dist/workspace-BFMrAKqI.mjs.map +0 -1
  265. package/dist/workspace-BcucjwvA.cjs.map +0 -1
  266. package/dist/workspace-DdJss8I6.cjs +0 -9
  267. package/dist/workspace-k4l3rsFh.mjs +0 -8
@@ -1,321 +1,266 @@
1
- import { DEFAULT_CACHE, DEFAULT_EMAIL, DEFAULT_STORAGE, discover } from "./discover-CAlFgYec.mjs";
2
- import { appKey, getAppNameFromCwd, hostOf, loadWorkspaceAppInfo } from "./config-WRdsQYZK.mjs";
3
- import { allConstructGlobs, getDependencyEnvVars } from "./workspace-BFMrAKqI.mjs";
4
- import { generatePgBossUrl } from "./generator-VE2JrFlY.mjs";
5
- import { readStageSecrets, secretsExist, toEmbeddableSecrets } from "./storage-Bi24WfoW.mjs";
6
- import { existsSync, readFileSync } from "node:fs";
1
+ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
+ import { t as discover } from "./discover-Cf2jAcgF.mjs";
3
+ import { c as loadWorkspaceAppInfo, f as appKey, l as loadWorkspaceConfig, r as NotInAnApp, t as ConfigNotFound } from "./config-SOkDH-n1.mjs";
4
+ import { E as TEST_STAGE, t as allConstructGlobs } from "./workspace-bOsks1uk.mjs";
5
+ import { i as toEmbeddableSecrets } from "./storage-BzaUxmqe.mjs";
6
+ import { i as FileSecretsStore, n as secretsStoreFor } from "./store-DeGATCS-.mjs";
7
+ import { l as readFakes, u as DEFAULT_CACHE } from "./ready-WfT-eWlm.mjs";
8
+ import { n as eventsBackendFor, r as providerOf, t as cacheBackendFor } from "./backends-CSuLl0YP.mjs";
9
+ import { existsSync } from "node:fs";
10
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
11
+ import { networkInterfaces } from "node:os";
7
12
  import { dirname, join, resolve } from "node:path";
8
- import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
9
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
- import { execFile, execSync } from "node:child_process";
13
+ import { execFile, spawn } from "node:child_process";
14
+ import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase } from "@geekmidas/manifest";
11
15
  import { createServer } from "node:net";
12
16
  import { config } from "dotenv";
13
- import { parse as parse$1, stringify } from "yaml";
17
+ import { createHash } from "node:crypto";
18
+ import { formatKeyring } from "@geekmidas/constructs/encryption";
19
+ import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
20
+ import * as snsUrl from "@geekmidas/events/sns/url";
21
+ import * as sqsUrl from "@geekmidas/events/sqs/url";
14
22
  import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
23
+ import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-sdk/client-sns";
24
+ import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
15
25
  import { Client } from "pg";
26
+ import { stringify } from "yaml";
16
27
  import { promisify } from "node:util";
17
- import { createHash } from "node:crypto";
18
- import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
19
28
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
20
-
21
- //#region src/workspace/backends.ts
22
- const CACHE_BACKENDS = [
23
- "upstash",
24
- "elasticache",
25
- "db"
26
- ];
27
- const EMAIL_BACKENDS = [
28
- "resend",
29
- "ses",
30
- "smtp"
31
- ];
32
- const STORAGE_BACKENDS = [
33
- "minio",
34
- "s3",
35
- "r2"
36
- ];
37
- /**
38
- * The cache backend a workspace selected, or the one its target implies.
39
- *
40
- * Defaulted here rather than by every caller, so "what happens when nobody
41
- * said" is answered once and the answer is greppable — and now answered by
42
- * *where it deploys*, because there is no single right answer for both a
43
- * Lambda and a box running its own Postgres.
44
- *
45
- * `on` is the deploy target, not where this code happens to be running. A
46
- * local `gkm dev` for an AWS app still defaults to Upstash, because a backend
47
- * that differed between local and deployed would be worse than a slower one —
48
- * which is the property the type's own documentation asks for.
49
- */
50
- function cacheBackendOf(value, on = "aws") {
51
- return isBackend(value, CACHE_BACKENDS) ? value : DEFAULT_CACHE[on];
52
- }
53
- /** The email backend a workspace selected, defaulting to SES on every target. */
54
- function emailBackendOf(value) {
55
- return isBackend(value, EMAIL_BACKENDS) ? value : DEFAULT_EMAIL;
56
- }
57
- /** The storage backend a workspace selected, or the one its target implies. */
58
- function storageBackendOf(value, on = "aws") {
59
- return isBackend(value, STORAGE_BACKENDS) ? value : DEFAULT_STORAGE[on];
60
- }
61
- /**
62
- * Which family of defaults a workspace's deploy target belongs to.
63
- *
64
- * One question, asked once: **does this target run containers the project
65
- * controls?** Dokploy and a bare server do, so a bucket or a cache can live
66
- * beside the app; AWS, Vercel and Cloudflare do not, so the default has to be
67
- * something managed. That is the whole of the distinction, and it is why
68
- * `MainProvider` is the right axis rather than the deploy target's own name —
69
- * three targets share one answer.
70
- *
71
- * An AWS deploy names no `deploy.default` at all, because it goes through SST,
72
- * which is why the absence is what selects `aws`.
73
- */
74
- function providerOf(workspace) {
75
- const target = workspace.deploy?.default;
76
- return target === "dokploy" || target === "server" ? "server" : "aws";
77
- }
78
- /**
79
- * Whether a string names a backend, as opposed to being one of the other things
80
- * a `services:` value can be.
81
- *
82
- * A misspelling falls through to the default rather than throwing, which is the
83
- * wrong trade for something with a cost attached — so callers that can report it
84
- * should use {@link unknownBackend} to say so.
85
- */
86
- function isBackend(value, backends) {
87
- return typeof value === "string" && backends.includes(value);
88
- }
89
-
90
- //#endregion
91
- //#region src/reconcile/caddyfile.ts
92
- /**
93
- * The kinds the edge serves.
94
- *
95
- * Everything that owns a *public address*. Deployed, each of these has a real
96
- * hostname and a certificate, so this is what makes the local shape the same
97
- * one rather than a near-enough one — and the application is indifferent
98
- * either way, because it reads whichever address was injected and never
99
- * composes one.
100
- *
101
- * Adding a kind here is the whole of adding it to the edge.
102
- */
103
- const EDGE_KINDS = {
104
- "file-server": true,
105
- "rest-api": true,
106
- site: true
107
- };
29
+ //#region src/credentials/index.ts
30
+ const logger = console;
108
31
  /**
109
- * How the edge reaches the host, from inside its container.
110
- *
111
- * A surface and a site are served by processes `gkm dev` starts on the host,
112
- * not by containers — so the edge has to leave Docker's network to reach them.
113
- * Docker Desktop resolves this name already; on Linux the compose definition
114
- * maps it to the host gateway.
32
+ * Load environment files
33
+ * @internal Exported for testing
115
34
  */
116
- const HOST_GATEWAY = "host.docker.internal";
35
+ function loadEnvFiles(envConfig, cwd = process.cwd()) {
36
+ const loaded = [];
37
+ const missing = [];
38
+ const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
39
+ for (const envFile of envFiles) {
40
+ const envPath = resolve(cwd, envFile);
41
+ if (existsSync(envPath)) {
42
+ config({
43
+ path: envPath,
44
+ override: true,
45
+ quiet: true
46
+ });
47
+ loaded.push(envFile);
48
+ } else if (envConfig) missing.push(envFile);
49
+ }
50
+ return {
51
+ loaded,
52
+ missing
53
+ };
54
+ }
117
55
  /**
118
- * The suffix every local host shares.
119
- *
120
- * `.localhost` rather than a made-up TLD because it resolves to loopback
121
- * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
122
- * subdomains of it, and Caddy issues internal certificates for it by default.
56
+ * Check if a port is available
57
+ * @internal Exported for testing
123
58
  */
124
- const LOCAL_TLD = "localhost";
59
+ async function isPortAvailable(port) {
60
+ return new Promise((resolve) => {
61
+ const server = createServer();
62
+ server.once("error", (err) => {
63
+ if (err.code === "EADDRINUSE") resolve(false);
64
+ else resolve(false);
65
+ });
66
+ server.once("listening", () => {
67
+ server.close();
68
+ resolve(true);
69
+ });
70
+ server.listen(port);
71
+ });
72
+ }
125
73
  /**
126
- * The sites a plan implies, one per file server.
127
- *
128
- * The leading label is the *server's* stage-scoped name rather than the
129
- * bucket's, which is the difference that matters: two servers over one bucket
130
- * are a legitimate arrangement — two cache behaviours, one origin — and naming
131
- * the host after the bucket would make them collide.
74
+ * Find an available port starting from the preferred port
75
+ * @internal Exported for testing
132
76
  */
133
- function sitesFor(plan, project, addresses = {}) {
134
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
135
- const sites = [];
136
- for (const resource of plan.resources) {
137
- if (!EDGE_KINDS[resource.kind]) continue;
138
- const host = hostFor(resource, project);
139
- if (resource.kind === "file-server") {
140
- const origin = resource.of ? byId.get(resource.of) : void 0;
141
- if (!origin) continue;
142
- sites.push({
143
- host,
144
- upstream: "http://minio:9000",
145
- rewrite: `/${origin.name}{uri}`
146
- });
147
- continue;
148
- }
149
- const address = addresses[resource.id];
150
- if (!address) continue;
151
- const port = portOf(address);
152
- if (!port) continue;
153
- sites.push({
154
- host,
155
- upstream: `http://${HOST_GATEWAY}:${port}`
156
- });
77
+ async function findAvailablePort(preferredPort, maxAttempts = 10) {
78
+ for (let i = 0; i < maxAttempts; i++) {
79
+ const port = preferredPort + i;
80
+ if (await isPortAvailable(port)) return port;
81
+ logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
157
82
  }
158
- return sites;
83
+ throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
159
84
  }
160
- /** The port a local address answers on, defaulting by scheme. */
161
- function portOf(address) {
85
+ const PORT_STATE_PATH = ".gkm/ports.json";
86
+ /**
87
+ * Load saved port state from .gkm/ports.json.
88
+ * @internal Exported for testing
89
+ */
90
+ async function loadPortState(workspaceRoot) {
162
91
  try {
163
- const { port, protocol } = new URL(address);
164
- return port || (protocol === "https:" ? "443" : "80");
92
+ const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
93
+ return JSON.parse(raw);
165
94
  } catch {
166
- return void 0;
95
+ return {};
167
96
  }
168
97
  }
169
98
  /**
170
- * Where one file server answers, including the stage its name carries.
171
- *
172
- * The project is a label rather than a decoration: it is what keeps two
173
- * checkouts from answering on one address. Omitted when there is no project to
174
- * name, because an empty label makes `uploads..localhost` — a host nothing
175
- * resolves and no certificate can be issued for.
99
+ * Save port state to .gkm/ports.json.
100
+ * @internal Exported for testing
176
101
  */
177
- function hostFor(resource, project) {
178
- return [
179
- resource.name,
180
- project,
181
- LOCAL_TLD
182
- ].filter(Boolean).join(".");
102
+ async function savePortState(workspaceRoot, ports) {
103
+ const dir = join(workspaceRoot, ".gkm");
104
+ await mkdir(dir, { recursive: true });
105
+ await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
183
106
  }
184
107
  /**
185
- * The static root config, which only ever imports.
186
- *
187
- * Written once and identical for every stage, because the *sites* are what
188
- * differ and they live in their own files. That split is not tidiness: one
189
- * container serves every stage — the same way one Postgres holds `orders` and
190
- * `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
191
- * routes `gkm dev` is serving, and each command silently breaking the other.
108
+ * Load and flatten secrets for an app from encrypted storage.
109
+ * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
110
+ * @internal Exported for testing
192
111
  */
193
- function caddyfileRoot() {
194
- return `${GENERATED}
195
- {
196
- # No ACME, no public DNS: the CA is local and so are the names.
197
- local_certs
198
- auto_https disable_redirects
112
+ async function loadSecretsForApp(store, stage, appName) {
113
+ let secrets = {};
114
+ const stageSecrets = await store.read(stage);
115
+ if (stageSecrets) {
116
+ logger.log(`🔐 Loading secrets from stage: ${stage}`);
117
+ secrets = toEmbeddableSecrets(stageSecrets);
118
+ }
119
+ if (Object.keys(secrets).length === 0) return {};
120
+ if (!appName) return secrets;
121
+ const prefix = appName.toUpperCase();
122
+ const mapped = { ...secrets };
123
+ const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
124
+ if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
125
+ return mapped;
199
126
  }
200
-
201
- # One file per stage, so two stages share this edge the way they already share
202
- # one Postgres and one MinIO.
203
- import /etc/caddy/sites/*.caddy
204
- `;
127
+ /**
128
+ * Walk up the directory tree to find the root containing .gkm/secrets/.
129
+ * @internal Exported for testing
130
+ */
131
+ function findSecretsRoot(startDir) {
132
+ let dir = startDir;
133
+ while (dir !== "/") {
134
+ if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
135
+ const parent = dirname(dir);
136
+ if (parent === dir) break;
137
+ dir = parent;
138
+ }
139
+ return startDir;
205
140
  }
206
141
  /**
207
- * One stage's sites, as the file imported above.
208
- *
209
- * `rewrite` rather than a path prefix on the address: the bucket is the
210
- * origin's business, and the client asks for the key it stored.
211
- *
212
- * `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
213
- * and signs on the Host header, so forwarding the requested hostname would make
214
- * it look for a bucket named after the domain.
142
+ * Generate the credentials injection code snippet.
143
+ * This is the common logic used by both entry wrapper and exec preload.
144
+ * @internal
215
145
  */
216
- function toCaddyfile(sites) {
217
- if (sites.length === 0) return `${GENERATED}
218
- # No file server is declared in this stage, so it routes nothing.
219
- `;
220
- const blocks = sites.map(({ host, upstream, rewrite }) => `
221
- https://${host} {
222
- tls internal
146
+ function generateCredentialsInjection(secretsJsonPath) {
147
+ return `import { existsSync, readFileSync } from 'node:fs';
223
148
 
224
- reverse_proxy ${upstream} {${rewrite ? `
225
- rewrite ${rewrite}
226
- # MinIO routes and signs on the Host header, so forwarding the requested
227
- # hostname would make it look for a bucket named after the domain.
228
- header_up Host {upstream_hostport}` : `
229
- # The application reads whatever address was injected, so it has to see
230
- # the one a caller used — that is what makes a redirect, a cookie domain
231
- # and a generated link point back here rather than at the upstream.
232
- header_up Host {host}
233
- header_up X-Forwarded-Proto {scheme}`}
234
- }
235
- }`);
236
- return `${GENERATED}${blocks.join("\n")}\n`;
149
+ // Inject dev secrets via globalThis and process.env
150
+ // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
151
+ // Object.assign on the Credentials export only mutates one module copy.
152
+ const secretsPath = '${secretsJsonPath}';
153
+ if (existsSync(secretsPath)) {
154
+ const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
155
+ globalThis.__gkm_credentials__ = secrets;
156
+ Object.assign(process.env, secrets);
237
157
  }
238
- const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
239
- #
240
- # One host per declared file server, in front of the object store that holds its
241
- # objects. This is the shape a file server has deployed — a domain serving a
242
- # bucket — which MinIO alone cannot produce: its virtual-host mode reads the
243
- # leading label as the bucket name.
244
- #
245
- # Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
246
- # root and point Node at it, so nothing has to be installed to use these URLs
247
- # from code; a browser needs the root trusted once.
248
158
  `;
249
-
250
- //#endregion
251
- //#region src/reconcile/clients.ts
252
- /** The credential local containers are brought up with. */
253
- const LOCAL_USER$2 = "geekmidas";
159
+ }
254
160
  /**
255
- * A Postgres client that opens one connection per database it is asked about.
256
- *
257
- * `CREATE DATABASE` cannot run from a connection to the database being created,
258
- * and a schema must be created from inside its own database — so the database is
259
- * part of each query rather than fixed when the client is built.
161
+ * Create a preload script that injects secrets into Credentials.
162
+ * Used by `gkm exec` to inject secrets before running any command.
163
+ * @internal Exported for testing
260
164
  */
261
- function pgClient(port) {
262
- return { async query(database, sql, values) {
263
- const client = new Client({
264
- host: "localhost",
265
- port,
266
- user: LOCAL_USER$2,
267
- password: LOCAL_USER$2,
268
- database: database ?? LOCAL_USER$2
269
- });
270
- await client.connect();
271
- try {
272
- const result = await client.query(sql, values);
273
- return result.rows;
274
- } finally {
275
- await client.end();
276
- }
277
- } };
165
+ async function createCredentialsPreload(preloadPath, secretsJsonPath) {
166
+ const content = `/**
167
+ * Credentials preload generated by 'gkm exec'
168
+ * This file is loaded via NODE_OPTIONS="--import <path>"
169
+ */
170
+ ${generateCredentialsInjection(secretsJsonPath)}`;
171
+ await writeFile(preloadPath, content);
278
172
  }
279
- /** A bucket client pointed at the local MinIO. */
280
- function bucketClient(port) {
281
- const s3 = new S3Client({
282
- region: "us-east-1",
283
- endpoint: `http://localhost:${port}`,
284
- forcePathStyle: true,
285
- credentials: {
286
- accessKeyId: LOCAL_USER$2,
287
- secretAccessKey: LOCAL_USER$2
288
- }
289
- });
173
+ /**
174
+ * Create a wrapper script that injects secrets before importing the entry file.
175
+ * @internal Exported for testing
176
+ */
177
+ async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
178
+ const content = `#!/usr/bin/env node
179
+ /**
180
+ * Entry wrapper generated by 'gkm dev --entry'
181
+ */
182
+ ${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
183
+ ` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
184
+ await import('${entryPath}');
185
+ `;
186
+ await writeFile(wrapperPath, content);
187
+ }
188
+ /**
189
+ * Prepare credentials for dev/exec/test modes.
190
+ * Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
191
+ * injects PORT, dependency URLs, and writes credentials JSON.
192
+ *
193
+ * @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
194
+ * @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
195
+ * @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
196
+ * @internal Exported for testing
197
+ */
198
+ async function prepareEntryCredentials(options) {
199
+ const cwd = options.cwd ?? process.cwd();
200
+ let workspaceAppPort;
201
+ let secretsRoot = cwd;
202
+ let appName;
203
+ let appInfo;
204
+ let workspace;
205
+ try {
206
+ appInfo = await loadWorkspaceAppInfo(cwd);
207
+ workspace = appInfo.workspace;
208
+ workspaceAppPort = appInfo.app.port;
209
+ secretsRoot = appInfo.workspaceRoot;
210
+ appName = appInfo.appName;
211
+ } catch (error) {
212
+ if (!(error instanceof NotInAnApp || error instanceof ConfigNotFound)) throw error;
213
+ workspace = await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
214
+ secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
215
+ }
216
+ if (appName && workspace && options.explicitPort === void 0) {
217
+ const { assignAppPorts, describeHolder, holderOf, withAppPorts } = await import("./index.mjs").then((n) => n.a);
218
+ const assigned = await assignAppPorts(workspace, [appName], {
219
+ free: isPortAvailable,
220
+ holder: holderOf
221
+ });
222
+ for (const { app, from, to, holder } of assigned.moved) logger.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
223
+ workspace = withAppPorts(workspace, assigned.ports);
224
+ workspaceAppPort = assigned.ports[appName] ?? workspaceAppPort;
225
+ }
226
+ if (appName) {
227
+ const { APP_TAG_ENV, appTag } = await import("./index.mjs").then((n) => n.a);
228
+ process.env[APP_TAG_ENV] = appTag(secretsRoot, appName);
229
+ }
230
+ const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
231
+ const stage = options.stage ?? workspace?.stages.local;
232
+ const store = stage ? workspace ? await secretsStoreFor(workspace, stage) : new FileSecretsStore(secretsRoot) : void 0;
233
+ const credentials = store && stage ? await loadSecretsForApp(store, stage, appName) : {};
234
+ credentials.PORT = String(resolvedPort);
235
+ if (appInfo?.app.type === "mobile") credentials.RCT_METRO_PORT = String(resolvedPort);
236
+ const declaredKeys = [];
237
+ let reconciled;
238
+ if (workspace) {
239
+ const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
240
+ reconciled = await reconcileWorkspace(workspace, {
241
+ stage: options.reconcileStage ?? workspace.stages.local,
242
+ start: options.startDocker ?? false
243
+ });
244
+ Object.assign(credentials, reconciled.env);
245
+ declaredKeys.push(...Object.keys(reconciled.env));
246
+ if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
247
+ }
248
+ const secretsDir = join(secretsRoot, ".gkm");
249
+ await mkdir(secretsDir, { recursive: true });
250
+ const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
251
+ const secretsJsonPath = join(secretsDir, secretsFileName);
252
+ await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
290
253
  return {
291
- async exists(bucket) {
292
- try {
293
- await s3.send(new HeadBucketCommand({ Bucket: bucket }));
294
- return true;
295
- } catch {
296
- return false;
297
- }
298
- },
299
- async create(bucket) {
300
- await s3.send(new CreateBucketCommand({ Bucket: bucket }));
301
- },
302
- async policy(bucket) {
303
- try {
304
- const result = await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }));
305
- return result.Policy;
306
- } catch {
307
- return void 0;
308
- }
309
- },
310
- async setPolicy(bucket, policy) {
311
- await s3.send(new PutBucketPolicyCommand({
312
- Bucket: bucket,
313
- Policy: policy
314
- }));
315
- }
254
+ credentials,
255
+ resolvedPort,
256
+ secretsJsonPath,
257
+ appName,
258
+ secretsRoot,
259
+ appInfo,
260
+ declaredKeys,
261
+ ...reconciled ? { reconciled } : {}
316
262
  };
317
263
  }
318
-
319
264
  //#endregion
320
265
  //#region src/reconcile/containers.ts
321
266
  /**
@@ -348,7 +293,7 @@ function postgresImage(version) {
348
293
  */
349
294
  const DEFAULT_IMAGES = {
350
295
  postgres: postgresImage(DEFAULT_POSTGRES_VERSION),
351
- minio: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
296
+ minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
352
297
  mailpit: "axllent/mailpit:latest",
353
298
  redis: "redis:8-alpine",
354
299
  "redis-http": "hiett/serverless-redis-http:latest",
@@ -376,7 +321,8 @@ const PORTS = {
376
321
  }, {
377
322
  key: "minio-console",
378
323
  inside: 9001,
379
- label: "minio console"
324
+ label: "minio console",
325
+ web: true
380
326
  }],
381
327
  mailpit: [{
382
328
  key: "mailpit",
@@ -385,7 +331,8 @@ const PORTS = {
385
331
  }, {
386
332
  key: "mailpit-web",
387
333
  inside: 8025,
388
- label: "mailpit inbox"
334
+ label: "mailpit inbox",
335
+ web: true
389
336
  }],
390
337
  redis: [{
391
338
  key: "redis",
@@ -404,7 +351,8 @@ const PORTS = {
404
351
  }, {
405
352
  key: "rabbitmq-management",
406
353
  inside: 15672,
407
- label: "rabbitmq console"
354
+ label: "rabbitmq console",
355
+ web: true
408
356
  }],
409
357
  localstack: [{
410
358
  key: "localstack",
@@ -417,10 +365,48 @@ const PORTS = {
417
365
  label: "https edge"
418
366
  }]
419
367
  };
420
- /** The ports a container needs published. Empty for one gkm does not know. */
421
- function portsOf(container) {
368
+ /** The port key — and container name, for an image — of an API's fake. */
369
+ function fakeKey(id) {
370
+ return `${kebabCase(id)}-fake`;
371
+ }
372
+ /**
373
+ * The ports a container needs published. Empty for one gkm does not know.
374
+ *
375
+ * `fakes` answers for the containers no table can: an external API's fake
376
+ * listens wherever its declaration says.
377
+ */
378
+ function portsOf(container, fakes = {}) {
379
+ const fake = fakes[container];
380
+ if (fake?.port !== void 0) return [{
381
+ key: container,
382
+ inside: fake.port,
383
+ label: `${fake.id} fake`
384
+ }];
422
385
  return PORTS[container] ?? [];
423
386
  }
387
+ /**
388
+ * Every port the containers publish — not only the one an app connects to.
389
+ *
390
+ * The consoles and the inbox are most of why these are real containers, and an
391
+ * inbox nobody can find is an inbox nobody opens: a sign-in link gets fished
392
+ * out of `docker ps` instead.
393
+ */
394
+ function serviceAddresses(containers, ports, fakes = {}) {
395
+ return containers.flatMap((container) => portsOf(container, fakes).flatMap((port) => {
396
+ const assigned = ports[port.key];
397
+ if (assigned === void 0) return [];
398
+ return [{
399
+ container,
400
+ label: port.label,
401
+ address: port.web ? `http://localhost:${assigned}` : `localhost:${assigned}`
402
+ }];
403
+ }));
404
+ }
405
+ /** The lines `gkm dev` and `gkm setup` print under the services heading. */
406
+ function describeServices(services) {
407
+ const width = Math.max(0, ...services.map(({ label }) => label.length));
408
+ return services.map(({ label, address }) => ` ${label.padEnd(width)} ${address}`);
409
+ }
424
410
  /** The key a container's primary port is allocated under. */
425
411
  function primaryPortKey(container) {
426
412
  return portsOf(container)[0]?.key ?? container;
@@ -431,8 +417,9 @@ function primaryPortKey(container) {
431
417
  * What allocation is handed: it assigns ports to names and knows nothing about
432
418
  * what listens on them.
433
419
  */
434
- function portKeys(containers) {
435
- return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
420
+ function portKeys(containers, fakes = {}) {
421
+ const keys = [...containers].sort().flatMap((container) => portsOf(container, fakes).map((port) => port.key));
422
+ return [.../* @__PURE__ */ new Set([...keys, ...Object.keys(fakes).sort()])];
436
423
  }
437
424
  /**
438
425
  * The named volume a container's data lives in.
@@ -441,945 +428,1716 @@ function portKeys(containers) {
441
428
  * survives days of dev is noise rather than state.
442
429
  */
443
430
  function volumeOf(container) {
444
- const volumes = {
431
+ return {
445
432
  postgres: "postgres-data",
446
433
  minio: "minio-data",
447
434
  redis: "redis-data",
448
435
  rabbitmq: "rabbitmq-data",
449
436
  localstack: "localstack-data",
450
437
  caddy: "caddy-data"
451
- };
452
- return volumes[container];
438
+ }[container];
453
439
  }
454
-
455
440
  //#endregion
456
- //#region src/reconcile/compose.ts
457
- /** The credential every derived container is brought up with locally. */
458
- const LOCAL_USER$1 = "geekmidas";
441
+ //#region src/reconcile/caddyfile.ts
459
442
  /**
460
- * The token the local cache accepts.
443
+ * The kinds the edge serves.
461
444
  *
462
- * Fixed rather than generated: it is only ever reachable on a port this project
463
- * published, and the URL that carries it is derived, so nothing is made safer
464
- * by making it unguessable and a stable one keeps `redis-cli` sessions working.
445
+ * Everything that owns a *public address*. Deployed, each of these has a real
446
+ * hostname and a certificate, so this is what makes the local shape the same
447
+ * one rather than a near-enough one — and the application is indifferent
448
+ * either way, because it reads whichever address was injected and never
449
+ * composes one.
450
+ *
451
+ * Adding a kind here is the whole of adding it to the edge.
465
452
  */
466
- const LOCAL_TOKEN$1 = "geekmidas";
453
+ const EDGE_KINDS = {
454
+ "file-server": true,
455
+ "rest-api": true,
456
+ site: true
457
+ };
467
458
  /**
468
- * Build the compose document for a plan.
459
+ * How the edge reaches the host, from inside its container.
469
460
  *
470
- * Every container is published on its assigned port, never on the image's
471
- * default: the point of allocation is that two projects can run at once, and
472
- * one container publishing 5432 puts that back.
461
+ * A surface and a site are served by processes `gkm dev` starts on the host,
462
+ * not by containers — so the edge has to leave Docker's network to reach them.
463
+ * Docker Desktop resolves this name already; on Linux the compose definition
464
+ * maps it to the host gateway.
473
465
  */
474
- function composeFor(plan, options) {
475
- const services = {};
476
- const volumes = {};
477
- for (const container of [...plan.containers].sort()) {
478
- const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
479
- const image = options.images?.[container] ?? (declared ? postgresImage(declared) : DEFAULT_IMAGES[container]);
480
- if (!image) throw new UnknownContainer(container);
481
- services[container] = define(container, image, publish(container, options.ports));
482
- const volume = volumeOf(container);
483
- if (volume) volumes[volume] = {};
466
+ const HOST_GATEWAY = "host.docker.internal";
467
+ /**
468
+ * The suffix every local host shares.
469
+ *
470
+ * `.localhost` rather than a made-up TLD because it resolves to loopback
471
+ * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
472
+ * subdomains of it, and Caddy issues internal certificates for it by default.
473
+ */
474
+ const LOCAL_TLD = "localhost";
475
+ /**
476
+ * The sites a plan implies, one per file server.
477
+ *
478
+ * The leading label is the *server's* stage-scoped name rather than the
479
+ * bucket's, which is the difference that matters: two servers over one bucket
480
+ * are a legitimate arrangement — two cache behaviours, one origin — and naming
481
+ * the host after the bucket would make them collide.
482
+ */
483
+ function sitesFor(plan, project, addresses = {}) {
484
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
485
+ const sites = [];
486
+ for (const resource of plan.resources) {
487
+ if (!EDGE_KINDS[resource.kind]) continue;
488
+ const host = hostFor(resource, project);
489
+ if (resource.kind === "file-server") {
490
+ const origin = resource.of ? byId.get(resource.of) : void 0;
491
+ if (!origin) continue;
492
+ sites.push({
493
+ host,
494
+ upstream: "http://minio:9000",
495
+ rewrite: `/${origin.name}{uri}`
496
+ });
497
+ continue;
498
+ }
499
+ const address = addresses[resource.id];
500
+ if (!address) continue;
501
+ const port = portOf(address);
502
+ if (!port) continue;
503
+ sites.push({
504
+ host,
505
+ upstream: `http://${HOST_GATEWAY}:${port}`
506
+ });
484
507
  }
485
- return {
486
- name: options.project,
487
- services,
488
- ...Object.keys(volumes).length ? { volumes } : {}
489
- };
490
- }
491
- /** Render a compose document as the file it becomes. */
492
- function toYaml(file) {
493
- return `# Generated by gkm from the construct manifest — do not edit.
494
- # Containers are derived from what the app declares; images and anything no
495
- # construct implies are yours, in gkm.config.ts.
496
- #
497
- # Use "gkm dev" or "gkm test" to start these. Running "docker compose up"
498
- # directly will not inject secrets.
499
- ${stringify(file, { lineWidth: 0 })}`;
508
+ return sites;
500
509
  }
501
- /** A container with no image, from config or from the defaults. */
502
- var UnknownContainer = class extends Error {
503
- /** The container that could not be resolved. */
504
- container;
505
- /** The containers that have a default, so the caller can see the set. */
506
- known = Object.keys(DEFAULT_IMAGES).sort();
507
- constructor(container) {
508
- super("No image is known for this container; pin one in config");
509
- this.name = "UnknownContainer";
510
- this.container = container;
510
+ /** The port a local address answers on, defaulting by scheme. */
511
+ function portOf(address) {
512
+ try {
513
+ const { port, protocol } = new URL(address);
514
+ return port || (protocol === "https:" ? "443" : "80");
515
+ } catch {
516
+ return;
511
517
  }
512
- };
513
- /** One container's definition, published on the port it was assigned. */
514
- function define(container, image, published) {
515
- switch (container) {
516
- case "postgres": return {
517
- image,
518
- restart: "unless-stopped",
519
- ports: published,
520
- environment: {
521
- POSTGRES_USER: LOCAL_USER$1,
522
- POSTGRES_PASSWORD: LOCAL_USER$1,
523
- POSTGRES_DB: LOCAL_USER$1
524
- },
525
- volumes: ["postgres-data:/var/lib/postgresql"],
526
- healthcheck: {
527
- test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER$1}`],
528
- interval: "5s",
529
- timeout: "3s",
530
- retries: 10,
531
- start_period: "10s"
532
- }
533
- };
534
- case "minio": return {
535
- image,
536
- restart: "unless-stopped",
537
- ports: published,
538
- environment: {
539
- MINIO_ROOT_USER: LOCAL_USER$1,
540
- MINIO_ROOT_PASSWORD: LOCAL_USER$1
541
- },
542
- command: "server --console-address \":9001\" /data",
543
- volumes: ["minio-data:/data"],
544
- healthcheck: {
545
- test: [
546
- "CMD",
547
- "mc",
548
- "ready",
549
- "local"
550
- ],
551
- interval: "10s",
552
- timeout: "5s",
553
- retries: 5,
554
- start_period: "10s"
555
- }
556
- };
557
- case "caddy": return {
558
- image,
559
- restart: "unless-stopped",
560
- ports: published,
561
- volumes: [
562
- "./Caddyfile:/etc/caddy/Caddyfile:ro",
563
- "./caddy-sites:/etc/caddy/sites:ro",
564
- "caddy-data:/data"
565
- ],
566
- extra_hosts: ["host.docker.internal:host-gateway"],
567
- healthcheck: {
568
- test: [
569
- "CMD",
570
- "caddy",
571
- "version"
572
- ],
573
- interval: "10s",
574
- timeout: "5s",
575
- retries: 5
576
- }
577
- };
578
- case "mailpit": return {
579
- image,
580
- restart: "unless-stopped",
581
- ports: published,
582
- environment: {
583
- MP_SMTP_AUTH_ACCEPT_ANY: "1",
584
- MP_SMTP_AUTH_ALLOW_INSECURE: "1"
585
- },
586
- healthcheck: {
587
- test: [
588
- "CMD",
589
- "wget",
590
- "-q",
591
- "--spider",
592
- "http://localhost:8025"
593
- ],
594
- interval: "10s",
595
- timeout: "5s",
596
- retries: 5
597
- }
598
- };
599
- case "redis": return {
600
- image,
601
- restart: "unless-stopped",
602
- ports: published,
603
- volumes: ["redis-data:/data"],
604
- healthcheck: {
605
- test: [
606
- "CMD",
607
- "redis-cli",
608
- "ping"
609
- ],
610
- interval: "5s",
611
- timeout: "3s",
612
- retries: 5
613
- }
614
- };
615
- case "redis-http": return {
616
- image,
617
- restart: "unless-stopped",
618
- ports: published,
619
- environment: {
620
- SRH_MODE: "env",
621
- SRH_TOKEN: LOCAL_TOKEN$1,
622
- SRH_CONNECTION_STRING: "redis://redis:6379"
623
- },
624
- depends_on: ["redis"],
625
- healthcheck: {
626
- test: [
627
- "CMD",
628
- "wget",
629
- "-q",
630
- "-O",
631
- "/dev/null",
632
- "http://127.0.0.1:80/"
633
- ],
634
- interval: "5s",
635
- timeout: "3s",
636
- retries: 10
637
- }
638
- };
639
- case "rabbitmq": return {
640
- image,
641
- restart: "unless-stopped",
642
- ports: published,
643
- environment: {
644
- RABBITMQ_DEFAULT_USER: LOCAL_USER$1,
645
- RABBITMQ_DEFAULT_PASS: LOCAL_USER$1
646
- },
647
- volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
648
- healthcheck: {
649
- test: [
650
- "CMD",
651
- "rabbitmq-diagnostics",
652
- "-q",
653
- "ping"
654
- ],
655
- interval: "10s",
656
- timeout: "5s",
657
- retries: 5
658
- }
518
+ }
519
+ /**
520
+ * Where one file server answers, including the stage its name carries.
521
+ *
522
+ * The project is a label rather than a decoration: it is what keeps two
523
+ * checkouts from answering on one address. Omitted when there is no project to
524
+ * name, because an empty label makes `uploads..localhost` — a host nothing
525
+ * resolves and no certificate can be issued for.
526
+ */
527
+ function hostFor(resource, project) {
528
+ return [
529
+ resource.root && project ? stageOf(resource) : labelOf(resource),
530
+ project,
531
+ LOCAL_TLD
532
+ ].filter(Boolean).join(".");
533
+ }
534
+ /**
535
+ * A resource's own label: its `subdomain` where it declared one — the label a
536
+ * deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
537
+ * still answer on different hosts.
538
+ */
539
+ function labelOf(resource) {
540
+ if (!resource.subdomain) return resource.name;
541
+ const stage = stageOf(resource);
542
+ return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
543
+ }
544
+ /** The stage a resource's name carries, or nothing for the local stage's. */
545
+ function stageOf(resource) {
546
+ const base = resource.id.toLowerCase();
547
+ return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
548
+ }
549
+ /**
550
+ * The static root config, which only ever imports.
551
+ *
552
+ * Written once and identical for every stage, because the *sites* are what
553
+ * differ and they live in their own files. That split is not tidiness: one
554
+ * container serves every stage — the same way one Postgres holds `orders` and
555
+ * `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
556
+ * routes `gkm dev` is serving, and each command silently breaking the other.
557
+ */
558
+ function caddyfileRoot() {
559
+ return `${GENERATED}
560
+ {
561
+ # No ACME, no public DNS: the CA is local and so are the names.
562
+ local_certs
563
+ auto_https disable_redirects
564
+ }
565
+
566
+ # One file per stage, so two stages share this edge the way they already share
567
+ # one Postgres and one MinIO.
568
+ import /etc/caddy/sites/*.caddy
569
+ `;
570
+ }
571
+ /**
572
+ * One stage's sites, as the file imported above.
573
+ *
574
+ * `rewrite` rather than a path prefix on the address: the bucket is the
575
+ * origin's business, and the client asks for the key it stored.
576
+ *
577
+ * `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
578
+ * and signs on the Host header, so forwarding the requested hostname would make
579
+ * it look for a bucket named after the domain.
580
+ */
581
+ function toCaddyfile(sites) {
582
+ if (sites.length === 0) return `${GENERATED}
583
+ # No file server is declared in this stage, so it routes nothing.
584
+ `;
585
+ const blocks = sites.map(({ host, upstream, rewrite }) => `
586
+ https://${host} {
587
+ tls internal
588
+
589
+ reverse_proxy ${upstream} {${rewrite ? `
590
+ rewrite ${rewrite}
591
+ # MinIO routes and signs on the Host header, so forwarding the requested
592
+ # hostname would make it look for a bucket named after the domain.
593
+ header_up Host {upstream_hostport}` : `
594
+ # The application reads whatever address was injected, so it has to see
595
+ # the one a caller used — that is what makes a redirect, a cookie domain
596
+ # and a generated link point back here rather than at the upstream.
597
+ header_up Host {host}
598
+ header_up X-Forwarded-Proto {scheme}`}
599
+ }
600
+ }`);
601
+ return `${GENERATED}${blocks.join("\n")}\n`;
602
+ }
603
+ const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
604
+ #
605
+ # One host per declared file server, in front of the object store that holds its
606
+ # objects. This is the shape a file server has deployed — a domain serving a
607
+ # bucket — which MinIO alone cannot produce: its virtual-host mode reads the
608
+ # leading label as the bucket name.
609
+ #
610
+ # Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
611
+ # root and point Node at it, so nothing has to be installed to use these URLs
612
+ # from code; a browser needs the root trusted once.
613
+ `;
614
+ //#endregion
615
+ //#region src/reconcile/emulator.ts
616
+ /**
617
+ * The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
618
+ * locally — the facts the compose file, the env writer and the applier must
619
+ * agree on.
620
+ *
621
+ * The emulator has one account and answers with deterministic ARNs and queue
622
+ * URLs, which is what lets a connection string be composed before the topic
623
+ * or queue it names has been created.
624
+ */
625
+ /** The emulator's single account. */
626
+ const EMULATOR_ACCOUNT = "000000000000";
627
+ const EMULATOR_REGION = "us-east-1";
628
+ /**
629
+ * The emulator's credential. The `LSIA` prefix is what LocalStack required on
630
+ * issued keys; floci does not care, and keeping it means a project that pins
631
+ * the old image still works.
632
+ */
633
+ const EMULATOR_CREDENTIALS = {
634
+ accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
635
+ secretAccessKey: "geekmidas"
636
+ };
637
+ /** The emulator's address from the host, on the port it was published on. */
638
+ function emulatorEndpoint(port) {
639
+ return `http://localhost:${port}`;
640
+ }
641
+ function emulatorTopicArn(name) {
642
+ return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
643
+ }
644
+ function emulatorQueueUrl(name, port) {
645
+ return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
646
+ }
647
+ //#endregion
648
+ //#region src/workspace/rootSite.ts
649
+ /**
650
+ * Which site the base domain points at — deployed and locally alike.
651
+ *
652
+ * Three rules, in order, and the last one is the point:
653
+ *
654
+ * 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
655
+ * 2. **Named `web`** — the convention wins, because people already rely on it.
656
+ * 3. **Declared `root: true`** — the site says so itself.
657
+ *
658
+ * Anything else throws. It used to take the *first* site it iterated, and that
659
+ * order came from the manifest, which is built in glob traversal order — so two
660
+ * sites with neither named `web` meant renaming a file could move the root
661
+ * domain, silently.
662
+ *
663
+ * One rule for both targets: deployed the root site gets the base domain, and
664
+ * locally it gets the project's bare host behind the edge. Before this was
665
+ * shared, the local edge put every site on a subdomain, so what a developer
666
+ * browsed was not the shape production served.
667
+ *
668
+ * @returns the root site's name, or `undefined` when there are no sites
669
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
670
+ */
671
+ function rootSite(sites) {
672
+ if (sites.length === 0) return void 0;
673
+ if (sites.length === 1) return sites[0].name;
674
+ if (sites.some(({ name }) => name === "web")) return "web";
675
+ const declared = sites.filter(({ root }) => root === true);
676
+ if (declared.length === 1) return declared[0].name;
677
+ throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
678
+ }
679
+ /** Several sites could hold the base domain, and none of them says it does. */
680
+ var AmbiguousRootSite = class extends Error {
681
+ sites;
682
+ tooMany;
683
+ constructor(sites, tooMany = false) {
684
+ super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
685
+ this.sites = sites;
686
+ this.tooMany = tooMany;
687
+ this.name = "AmbiguousRootSite";
688
+ }
689
+ };
690
+ //#endregion
691
+ //#region src/reconcile/plan.ts
692
+ /**
693
+ * The reconcile plan — what a manifest wants to exist, for one stage.
694
+ *
695
+ * Pure. Turning the plan into running containers and provisioned resources is
696
+ * the applier's job; deciding *what* should exist is this, so the decisions can
697
+ * be asserted as data rather than by starting Docker.
698
+ *
699
+ * One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
700
+ * reconciles `test`; they differ only in what the resources are called, never in
701
+ * what infrastructure exists. Roles are cluster-scoped in Postgres while grants
702
+ * are per-object, so one container and one role pair serve both.
703
+ */
704
+ /**
705
+ * Which container serves a kind.
706
+ *
707
+ * Derived kinds map to the same container as their parent — a schema tenant
708
+ * lives in its parent's Postgres — so the set is deduplicated rather than
709
+ * counted.
710
+ */
711
+ const CONTAINERS = {
712
+ database: "postgres",
713
+ "database-reader": "postgres",
714
+ "database-schema": "postgres",
715
+ objects: "minio",
716
+ "file-server": "caddy",
717
+ email: "mailpit"
718
+ };
719
+ /**
720
+ * The container a cache backend needs locally.
721
+ *
722
+ * Each one matches the protocol its deployed form speaks, which is the property
723
+ * worth having: a cache that behaves differently in the two places is worse than
724
+ * a slower one.
725
+ *
726
+ * `db` is deliberately absent, the same way `pgboss` is absent from
727
+ * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
728
+ * declares, so mapping it here would start a container for a project that needs
729
+ * none.
730
+ */
731
+ const CACHE_CONTAINERS = {
732
+ upstash: "redis-http",
733
+ elasticache: "redis"
734
+ };
735
+ /**
736
+ * Kinds that resolve a value without anything running.
737
+ *
738
+ * A secret is the case: it has no address, so there is nothing to start and
739
+ * nothing to connect to — but it still appears in the plan, because the target
740
+ * still has to resolve it.
741
+ */
742
+ const CONTAINERLESS = {
743
+ secret: true,
744
+ credential: true,
745
+ encryption: true,
746
+ "rest-api": true,
747
+ site: true,
748
+ "mobile-app": true,
749
+ "external-api": true
750
+ };
751
+ /**
752
+ * Containers that cannot run alone.
753
+ *
754
+ * The one case is the cache: `serverless-redis-http` is a proxy and needs the
755
+ * Redis it proxies. Kept here rather than in the compose definition so the plan
756
+ * -- which is what decides what must be running -- still names everything.
757
+ */
758
+ const REQUIRES = { "redis-http": ["redis"] };
759
+ /**
760
+ * The role each kind's one key plays, where it is not a URL.
761
+ *
762
+ * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
763
+ * construct declares, this is what the local target resolves, and both derive
764
+ * the key from the same `provideKey` so they cannot disagree.
765
+ */
766
+ const ROLES = {
767
+ queue: "publisherConnectionString",
768
+ topic: "publisherConnectionString",
769
+ credential: "credentials",
770
+ "mobile-app": "scheme"
771
+ };
772
+ /** The kinds whose container is the events backend's rather than their own. */
773
+ const EVENT_KINDS = {
774
+ queue: true,
775
+ topic: true
776
+ };
777
+ /**
778
+ * Which kinds provision something *inside* their container.
779
+ *
780
+ * A database creates a database, a bucket creates a bucket; mail creates
781
+ * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
782
+ * to name. Both still appear in the plan, because both still resolve a URL; the
783
+ * flag is the difference between "must be running" and "must be created".
784
+ */
785
+ const PROVISIONS = {
786
+ database: true,
787
+ "database-reader": true,
788
+ "database-schema": true,
789
+ objects: true,
790
+ cache: true,
791
+ "file-server": true
792
+ };
793
+ /**
794
+ * The container each events backend needs, where it needs one of its own.
795
+ *
796
+ * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
797
+ * schema tenant in a database the manifest already declares, so mapping it here
798
+ * would start a Postgres for a project that declared no database at all.
799
+ *
800
+ * This one comes from config rather than from a declaration because there are
801
+ * no `topic` or `queue` kinds yet. When they land the mapping moves to
802
+ * {@link CONTAINERS} keyed by kind, and this constant goes with them.
803
+ */
804
+ const EVENT_CONTAINERS = {
805
+ sns: "localstack",
806
+ rabbitmq: "rabbitmq"
807
+ };
808
+ /** The backend a project gets when it declares queues or topics and says nothing. */
809
+ const DEFAULT_EVENTS = "pgboss";
810
+ /**
811
+ * How a resource of each kind separates its stage suffix.
812
+ *
813
+ * Postgres identifiers are snake_case and a hyphen would need quoting
814
+ * everywhere; buckets and queues are kebab and disallow underscores in some
815
+ * providers. The suffix is the same word either way.
816
+ */
817
+ const SEPARATORS = {
818
+ database: "_",
819
+ "database-reader": "_",
820
+ "database-schema": "_",
821
+ objects: "-"
822
+ };
823
+ /**
824
+ * The stage-scoped name for a resource.
825
+ *
826
+ * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
827
+ * developer's psql history, saved connections, and muscle memory survive a
828
+ * change of stage name. Every other stage is suffixed.
829
+ */
830
+ function resourceName(id, kind, stage, localStage) {
831
+ const base = id.toLowerCase();
832
+ if (stage === localStage) return base;
833
+ return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
834
+ }
835
+ /**
836
+ * The container a kind needs, or `undefined` if it needs none.
837
+ *
838
+ * Queues and topics have no container of their own — they live in whichever
839
+ * broker the project selected, which is why the backend is an argument here
840
+ * rather than a second pass that adds containers after the fact.
841
+ */
842
+ function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
843
+ if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
844
+ if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
845
+ if (kind === "cache") {
846
+ if (derived) return CONTAINERS.database;
847
+ return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
848
+ }
849
+ return CONTAINERS[kind];
850
+ }
851
+ /**
852
+ * Build the plan for a manifest and stage.
853
+ *
854
+ * `order` is the caller's provisioning order — parents before children — which
855
+ * `provisionOrder` produces from the manifest. Passing it in keeps this
856
+ * function free of the traversal and makes the ordering assertable on its own.
857
+ */
858
+ function planFor(manifest, stage, order, options = {}) {
859
+ const containers = /* @__PURE__ */ new Set();
860
+ const resources = [];
861
+ const fakes = {};
862
+ const events = options.events ?? "pgboss";
863
+ const cache = options.cache ?? DEFAULT_CACHE.aws;
864
+ for (const id of order) {
865
+ const declaration = manifest[id];
866
+ if (!declaration) continue;
867
+ const local = declaration.kind === "external-api" ? options.fakes?.[id] : void 0;
868
+ const fake = local && {
869
+ key: fakeKey(id),
870
+ ...local
659
871
  };
660
- case "localstack": return {
661
- image,
662
- restart: "unless-stopped",
663
- ports: published,
664
- environment: {
665
- AWS_DEFAULT_REGION: "us-east-1",
666
- AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
667
- AWS_SECRET_ACCESS_KEY: LOCAL_USER$1
668
- },
669
- volumes: ["localstack-data:/var/lib/localstack"],
670
- healthcheck: {
671
- test: [
672
- "CMD",
673
- "curl",
674
- "-f",
675
- "http://localhost:4566/_localstack/health"
676
- ],
677
- interval: "10s",
678
- timeout: "5s",
679
- retries: 5,
680
- start_period: "10s"
681
- }
872
+ if (fake) fakes[fake.key] = {
873
+ id,
874
+ ...fake.image ? {
875
+ image: fake.image,
876
+ port: fake.port
877
+ } : {}
682
878
  };
683
- default: throw new UnknownContainer(container);
879
+ const container = fake?.image ? fake.key : containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
880
+ if (!container && !CONTAINERLESS[declaration.kind]) continue;
881
+ if (container) {
882
+ containers.add(container);
883
+ for (const required of REQUIRES[container] ?? []) containers.add(required);
884
+ }
885
+ resources.push({
886
+ id,
887
+ kind: declaration.kind,
888
+ ...container ? { container } : {},
889
+ name: resourceName(id, declaration.kind, stage, options.localStage),
890
+ envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
891
+ provisions: PROVISIONS[declaration.kind] === true,
892
+ ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
893
+ ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
894
+ ...declaration.kind === "mobile-app" ? {
895
+ publicEnv: publicEnvFor(declaration, manifest),
896
+ ...declaration.scheme ? { scheme: declaration.scheme } : {}
897
+ } : {},
898
+ ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
899
+ ...(declaration.kind === "rest-api" || declaration.kind === "site") && declaration.subdomain ? { subdomain: declaration.subdomain } : {},
900
+ ...fake ? { fake: {
901
+ key: fake.key,
902
+ credentials: fake.credentials
903
+ } } : {},
904
+ ...declaration.kind === "external-api" && !fake ? { url: declaration.url } : {},
905
+ ..."of" in declaration ? { of: declaration.of } : {},
906
+ ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
907
+ ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
908
+ ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
909
+ ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
910
+ });
911
+ }
912
+ if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
913
+ for (const extra of options.extraContainers ?? []) containers.add(extra);
914
+ if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
915
+ if (cache === "db") {
916
+ const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
917
+ if (placeless.length > 0) {
918
+ const databases = resources.filter((r) => r.kind === "database");
919
+ const [only] = databases;
920
+ if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
921
+ if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
922
+ for (const resource of placeless) resource.of = only.id;
923
+ }
684
924
  }
925
+ const sites = resources.filter((r) => r.kind === "site");
926
+ const root = rootSite(sites.map((r) => {
927
+ const declared = manifest[r.id];
928
+ return {
929
+ name: appKey(r.id),
930
+ ...declared?.kind === "site" && declared.root ? { root: true } : {}
931
+ };
932
+ }));
933
+ for (const site of sites) if (appKey(site.id) === root) site.root = true;
934
+ const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
935
+ return {
936
+ stage,
937
+ events,
938
+ cache,
939
+ containers: [...containers],
940
+ resources,
941
+ ...Object.keys(fakes).length ? { fakes } : {},
942
+ ...workerBroker ? { workerBroker: true } : {}
943
+ };
685
944
  }
686
945
  /**
687
- * The published port mappings for a container.
946
+ * Queues or topics were declared with pg-boss and no database to hold them.
688
947
  *
689
- * Every port is looked up by its own key rather than being derived as an offset
690
- * from the container's first, so MinIO's console cannot land on whatever was
691
- * allocated to the next container.
948
+ * Not a reconcile failure but a manifest one: the fix is a line of application
949
+ * code either way — declare a database, or select a backend that brings its own
950
+ * broker.
692
951
  */
693
- function publish(container, ports) {
694
- return portsOf(container).map((port) => {
695
- const assigned = ports[port.key];
696
- if (assigned === void 0) throw new UnassignedPort(port.key);
697
- return `${assigned}:${port.inside}`;
698
- });
699
- }
700
- /** A planned port that allocation never assigned. */
701
- var UnassignedPort = class extends Error {
702
- /** The port key that went unassigned, e.g. `minio-console`. */
703
- key;
704
- constructor(key) {
705
- super("No port was assigned for this container port");
706
- this.name = "UnassignedPort";
707
- this.key = key;
952
+ var CacheNeedsDatabase = class extends Error {
953
+ ids;
954
+ constructor(ids) {
955
+ super(`A cache backed by the database needs a declared database, and none was declared, and on this deploy target that is where a cache lives. Declare one. Caches affected: ${ids.join(", ")}.`);
956
+ this.ids = ids;
957
+ this.name = "CacheNeedsDatabase";
708
958
  }
709
959
  };
710
-
711
- //#endregion
712
- //#region src/reconcile/docker.ts
713
- const run = promisify(execFile);
714
- /** How long to wait for containers to pass their health checks. */
715
- const HEALTH_TIMEOUT_MS = 12e4;
716
- const dockerCli = {
717
- async publishedPort(composePath, service, inside) {
718
- try {
719
- const { stdout } = await run("docker", [
720
- "compose",
721
- "-f",
722
- composePath,
723
- "port",
724
- service,
725
- String(inside)
726
- ]);
727
- const port = Number(stdout.trim().split(":").pop());
728
- return Number.isFinite(port) && port > 0 ? port : void 0;
729
- } catch {
730
- return void 0;
731
- }
732
- },
733
- async up(composePath, services) {
734
- await run("docker", [
735
- "compose",
736
- "-f",
737
- composePath,
738
- "up",
739
- "-d",
740
- "--remove-orphans",
741
- "--wait",
742
- `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
743
- ...services
744
- ]);
745
- },
746
- async copyOut(composePath, service, from, to) {
747
- await mkdir(dirname(to), { recursive: true });
748
- await run("docker", [
749
- "compose",
750
- "-f",
751
- composePath,
752
- "cp",
753
- `${service}:${from}`,
754
- to
755
- ]);
756
- },
757
- async reload(composePath, service) {
758
- await run("docker", [
759
- "compose",
760
- "-f",
761
- composePath,
762
- "exec",
763
- "-T",
764
- service,
765
- "caddy",
766
- "reload",
767
- "--config",
768
- "/etc/caddy/Caddyfile"
769
- ]);
770
- },
771
- async healthy(composePath, services) {
772
- if (services.length === 0) return true;
773
- try {
774
- const { stdout } = await run("docker", [
775
- "compose",
776
- "-f",
777
- composePath,
778
- "ps",
779
- "--format",
780
- "json",
781
- ...services
782
- ]);
783
- const running = /* @__PURE__ */ new Set();
784
- for (const line of stdout.trim().split("\n").filter(Boolean)) {
785
- const entry = JSON.parse(line);
786
- const ok = entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy");
787
- if (ok && entry.Service) running.add(entry.Service);
788
- }
789
- return services.every((service) => running.has(service));
790
- } catch {
791
- return false;
792
- }
960
+ /**
961
+ * A `'db'` cache — a server target's default — in an app that declares more
962
+ * than one database.
963
+ *
964
+ * The target says *a* database and the graph offers several, so there is no
965
+ * answer to give — and the wrong kind of failure would be to pick one, since a
966
+ * cache silently landing in the wrong database is a bug that surfaces as
967
+ * missing rows much later. The fix is a stronger statement in application code:
968
+ * `orders.cache('Sessions')` names the database, which is a fact about the
969
+ * application rather than a deployment choice.
970
+ */
971
+ var CacheIsAmbiguous = class extends Error {
972
+ ids;
973
+ databases;
974
+ constructor(ids, databases) {
975
+ super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than as a bare new Cache(). Caches affected: ${ids.join(", ")}.`);
976
+ this.ids = ids;
977
+ this.databases = databases;
978
+ this.name = "CacheIsAmbiguous";
979
+ }
980
+ };
981
+ var PgBossNeedsDatabase = class extends Error {
982
+ ids;
983
+ constructor(ids) {
984
+ super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one: on this deploy target, events are carried by pg-boss. Queues and topics affected: ${ids.join(", ")}.`);
985
+ this.ids = ids;
986
+ this.name = "PgBossNeedsDatabase";
793
987
  }
794
988
  };
795
-
796
989
  //#endregion
797
- //#region src/reconcile/plan.ts
798
- /** The stage whose resources carry no suffix. */
799
- const DEFAULT_STAGE = "development";
990
+ //#region src/reconcile/env.ts
800
991
  /**
801
- * Which container serves a kind.
992
+ * The URLs a reconciled stage injects.
802
993
  *
803
- * Derived kinds map to the same container as their parent — a schema tenant
804
- * lives in its parent's Postgres — so the set is deduplicated rather than
805
- * counted.
994
+ * One `<NAME>_URL` per construct, and the protocol picks the driver. This is the
995
+ * seam between the target that writes a URL and the client that parses it, so
996
+ * every component of it is read off the resource — never inherited from ambient
997
+ * environment. A bucket can live in a different region than the function reading
998
+ * it, and `AWS_REGION` in a Lambda is the *function's* region, so a URL that
999
+ * omits `?region=` breaks cross-region silently at runtime.
1000
+ *
1001
+ * Locally that self-containment is what lets the port be whatever was free: the
1002
+ * app reads `ORDERS_URL` and never sees a host or a port.
806
1003
  */
807
- const CONTAINERS = {
808
- database: "postgres",
809
- "database-reader": "postgres",
810
- "database-schema": "postgres",
811
- objects: "minio",
812
- "file-server": "caddy",
813
- email: "mailpit"
814
- };
815
1004
  /**
816
- * The container a cache backend needs locally.
1005
+ * The credential local containers are brought up with.
817
1006
  *
818
- * Each one matches the protocol its deployed form speaks, which is the property
819
- * worth having: a cache that behaves differently in the two places is worse than
820
- * a slower one.
1007
+ * The design's `roles: false` case — both URLs fall back to the cluster's master
1008
+ * credential. A deliberate downgrade, not a default: it holds until the role DDL
1009
+ * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
1010
+ * and the owner URL stops being reachable from any manifest edge.
1011
+ */
1012
+ const LOCAL_USER$2 = "geekmidas";
1013
+ /** The host containers are published on. */
1014
+ const LOCAL_HOST = "localhost";
1015
+ /** The region local buckets claim, so the client has one to sign with. */
1016
+ const LOCAL_REGION = "us-east-1";
1017
+ /**
1018
+ * The credentials the S3 client resolves for MinIO.
821
1019
  *
822
- * `db` is deliberately absent, the same way `pgboss` is absent from
823
- * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
824
- * declares, so mapping it here would start a container for a project that needs
825
- * none.
1020
+ * An `s3://` URL deliberately carries none: deployed, the SDK reads them from
1021
+ * the execution role, so a URL that embedded a key would be one more thing to
1022
+ * rotate and one more thing to leak into a log. Locally there is no role, and
1023
+ * the same chain reads these — which is why they are injected beside the URL
1024
+ * rather than written into it.
826
1025
  */
827
- const CACHE_CONTAINERS = {
828
- upstash: "redis-http",
829
- elasticache: "redis"
1026
+ const STORAGE_CREDENTIALS = {
1027
+ AWS_ACCESS_KEY_ID: LOCAL_USER$2,
1028
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER$2,
1029
+ AWS_REGION: LOCAL_REGION
830
1030
  };
1031
+ /** The token the local cache proxy accepts. Matches the compose definition. */
1032
+ const LOCAL_TOKEN$1 = "geekmidas";
1033
+ /** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
1034
+ const MAILPIT_INBOX_PORT = "mailpit-web";
1035
+ /** The schema pg-boss keeps its tables in, inside the declared database. */
1036
+ const PGBOSS_SCHEMA = "pgboss";
1037
+ /** The exchange every local topic and queue shares on RabbitMQ. */
1038
+ const RABBITMQ_EXCHANGE = "geekmidas.events";
831
1039
  /**
832
- * Kinds that resolve a value without anything running.
1040
+ * Every env key a reconciled plan resolves, and its value.
1041
+ *
1042
+ * Keys come from the declaration rather than being re-derived, so what the build
1043
+ * publishes and what the app reads cannot drift.
1044
+ */
1045
+ function envFor(plan, options) {
1046
+ const env = {};
1047
+ const resolved = {};
1048
+ for (const resource of plan.resources) {
1049
+ const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
1050
+ if (url) resolved[resource.id] = url;
1051
+ }
1052
+ for (const resource of plan.resources) {
1053
+ const url = resolved[resource.id];
1054
+ if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved, {
1055
+ plan,
1056
+ ...options.addresses ? { addresses: options.addresses } : {},
1057
+ ...options.lanAddress ? { lanAddress: options.lanAddress } : {},
1058
+ ...options.metroPorts ? { metroPorts: options.metroPorts } : {}
1059
+ }));
1060
+ if (url) env[resource.envKey] = url;
1061
+ if (isDatabase(resource.kind) && resource.roles !== false) {
1062
+ const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
1063
+ if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
1064
+ }
1065
+ if (resource.kind === "external-api" && url && resource.fake) env[provideKey(resource.id, "credentials")] = resource.fake.credentials;
1066
+ if (resource.kind === "email" && url) {
1067
+ env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
1068
+ const inbox = options.ports[MAILPIT_INBOX_PORT];
1069
+ if (inbox !== void 0) env[provideKey(resource.id, "inboxUrl")] = `http://${LOCAL_HOST}:${inbox}`;
1070
+ }
1071
+ }
1072
+ Object.assign(env, publicEnv(plan, env, options.addresses));
1073
+ Object.assign(env, brokerEnv(plan, options.ports));
1074
+ if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
1075
+ return env;
1076
+ }
1077
+ /**
1078
+ * The values a site's bundler inlines, under the names it inlines them by.
1079
+ *
1080
+ * A rename and nothing more. `API_URL` was resolved once, by the same code that
1081
+ * resolved it for the server; a site reads the same value under `VITE_API_URL`
1082
+ * because that prefix is how its bundler is told to ship it. Nothing is derived
1083
+ * twice, so a site and its API cannot come to disagree about where the API is.
1084
+ *
1085
+ * A source that resolved to nothing is skipped rather than written empty: an
1086
+ * inlined empty string is a frontend that builds and then fails at runtime
1087
+ * against `http:///`, where a missing variable fails at build with the name of
1088
+ * the thing that is missing.
1089
+ */
1090
+ function publicEnv(plan, resolved, addresses = {}) {
1091
+ const env = {};
1092
+ const owners = new Map(plan.resources.map((r) => [r.envKey, r.id]));
1093
+ for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
1094
+ const value = (resource.kind === "mobile-app" ? addresses[owners.get(source) ?? ""] : void 0) ?? resolved[source];
1095
+ if (value) env[key] = value;
1096
+ }
1097
+ return env;
1098
+ }
1099
+ /**
1100
+ * What a surface publishes beyond its own address: who may call it, and where
1101
+ * a cookie set by it is readable.
833
1102
  *
834
- * A secret is the case: it has no address, so there is nothing to start and
835
- * nothing to connect to — but it still appears in the plan, because the target
836
- * still has to resolve it.
1103
+ * Both are read off the same list — the constructs that declared an edge to
1104
+ * this surface — which is why neither appears in application code. A surface
1105
+ * that listed its own callers would be edited every time something new called
1106
+ * it, and the thing being edited is already recorded in the graph.
1107
+ *
1108
+ * An empty origin list is written, and a surface with no address writes nothing
1109
+ * at all. The two cases look alike and are not: "nothing depends on this yet" is
1110
+ * a real state a target should publish, while "this surface has no address here"
1111
+ * means it is not running in this stage, and keys belong with the address they
1112
+ * describe.
837
1113
  */
838
- const CONTAINERLESS = {
839
- secret: true,
840
- credential: true,
841
- "rest-api": true,
842
- site: true
843
- };
1114
+ function surfaceEnv(resource, url, resolved = {}, local = {}) {
1115
+ if (!url) return {};
1116
+ const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
1117
+ const domain = cookieDomain([url, ...origins]);
1118
+ const mobile = (resource.callers ?? []).map((caller) => local.plan?.resources.find((r) => r.id === caller && r.kind === "mobile-app")).filter((caller) => Boolean(caller));
1119
+ const hosts = [...local.lanAddress ? [local.lanAddress] : [], LOCAL_HOST];
1120
+ const schemes = mobile.flatMap((caller) => {
1121
+ const scheme = resolved[caller.id];
1122
+ const port = local.metroPorts?.[caller.id];
1123
+ return scheme ? mobileOrigins(scheme, {
1124
+ hosts,
1125
+ ...port ? { port } : {}
1126
+ }) : [];
1127
+ });
1128
+ const own = local.addresses?.[resource.id];
1129
+ const device = mobile.length > 0 && local.lanAddress && own ? own.replace(/\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `//${local.lanAddress}`) : void 0;
1130
+ return {
1131
+ [provideKey(resource.id, "trustedOrigins")]: [...origins, ...new Set(schemes)].join(","),
1132
+ ...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {},
1133
+ ...device ? { [provideKey(resource.id, "deviceUrl")]: device } : {}
1134
+ };
1135
+ }
1136
+ /** An address reduced to the origin a browser compares against. */
1137
+ function originOf(address) {
1138
+ try {
1139
+ return new URL(address).origin;
1140
+ } catch {
1141
+ return;
1142
+ }
1143
+ }
844
1144
  /**
845
- * Containers that cannot run alone.
1145
+ * The runtime role of whatever a derived node hangs off.
846
1146
  *
847
- * The one case is the cache: `serverless-redis-http` is a proxy and needs the
848
- * Redis it proxies. Kept here rather than in the compose definition so the plan
849
- * -- which is what decides what must be running -- still names everything.
1147
+ * A reader's parent may itself be a schema tenant, so this follows `of` by one
1148
+ * hop rather than assuming the cluster — the reader of a tenant reads that
1149
+ * tenant's schema, not the database's.
850
1150
  */
851
- const REQUIRES = { "redis-http": ["redis"] };
1151
+ function parentRole(resource, plan) {
1152
+ const parent = plan.resources.find((r) => r.id === resource.of);
1153
+ return parent ? localRole(parent) : resource.name;
1154
+ }
1155
+ /** Whether a kind connects to Postgres and therefore has an owner role. */
1156
+ function isDatabase(kind) {
1157
+ return kind === "database" || kind === "database-schema";
1158
+ }
852
1159
  /**
853
- * The role each kind's one key plays, where it is not a URL.
1160
+ * The URL a migrator connects with — the owner role, which can create, alter
1161
+ * and drop.
854
1162
  *
855
- * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
856
- * construct declares, this is what the local target resolves, and both derive
857
- * the key from the same `provideKey` so they cannot disagree.
1163
+ * Never in `provides`, which is the whole point. A handler that could reach this
1164
+ * could `DROP TABLE`, and the security property the role split exists for is
1165
+ * that it cannot: the value is injected by `gkm exec` for the migrate step and
1166
+ * by nothing else.
858
1167
  */
859
- const ROLES = {
860
- queue: "publisherConnectionString",
861
- topic: "publisherConnectionString",
862
- credential: "credential"
863
- };
864
- /** The kinds whose container is the events backend's rather than their own. */
865
- const EVENT_KINDS = {
866
- queue: true,
867
- topic: true
868
- };
1168
+ function ownerUrl(resource, plan, ports, project) {
1169
+ if (!resource.container) return void 0;
1170
+ const port = ports[primaryPortKey(resource.container)];
1171
+ if (port === void 0) return void 0;
1172
+ const owner = ownerRole(localRole(resource));
1173
+ return postgres(port, rootDatabase(resource, plan), {
1174
+ user: owner,
1175
+ password: localRolePassword(project, plan, owner)
1176
+ });
1177
+ }
869
1178
  /**
870
- * Which kinds provision something *inside* their container.
1179
+ * The shared broker keys, when anything is published or consumed.
871
1180
  *
872
- * A database creates a database, a bucket creates a bucket; mail creates
873
- * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
874
- * to name. Both still appear in the plan, because both still resolve a URL; the
875
- * flag is the difference between "must be running" and "must be created".
1181
+ * A queue's own key is the producer's. These two are the *connection* itself,
1182
+ * which locally is one broker for every queue and topic in the project: the
1183
+ * generated pollers open a single connection and subscribe each worker by name
1184
+ * on it. Deployed there is no such thing — a Lambda is handed its own event
1185
+ * source — so this pair exists for the local target and says so.
876
1186
  */
877
- const PROVISIONS = {
878
- database: true,
879
- "database-reader": true,
880
- "database-schema": true,
881
- objects: true,
882
- cache: true,
883
- "file-server": true
884
- };
1187
+ function brokerEnv(plan, ports) {
1188
+ const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
1189
+ const publisher = carrier ? urlFor(carrier, plan, ports, "") : plan.workerBroker ? workerBroker(plan, ports) : void 0;
1190
+ if (!publisher) return {};
1191
+ if (plan.events === "sns") return {};
1192
+ return { EVENT_PUBLISHER_CONNECTION_STRING: publisher };
1193
+ }
885
1194
  /**
886
- * The container each events backend needs, where it needs one of its own.
887
- *
888
- * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
889
- * schema tenant in a database the manifest already declares, so mapping it here
890
- * would start a Postgres for a project that declared no database at all.
891
- *
892
- * This one comes from config rather than from a declaration because there are
893
- * no `topic` or `queue` kinds yet. When they land the mapping moves to
894
- * {@link CONTAINERS} keyed by kind, and this constant goes with them.
1195
+ * The broker a worker schedules its crons through, when nothing else declared
1196
+ * one — pg-boss in the declared database, on that database's port.
895
1197
  */
896
- const EVENT_CONTAINERS = {
897
- sns: "localstack",
898
- rabbitmq: "rabbitmq"
899
- };
900
- /** The backend a project gets when it declares queues or topics and says nothing. */
901
- const DEFAULT_EVENTS = "pgboss";
1198
+ function workerBroker(plan, ports) {
1199
+ const database = plan.resources.find((r) => r.kind === "database");
1200
+ if (!database?.container) return void 0;
1201
+ const port = ports[primaryPortKey(database.container)];
1202
+ return port === void 0 ? void 0 : broker(plan, port);
1203
+ }
902
1204
  /**
903
- * How a resource of each kind separates its stage suffix.
1205
+ * The URL for one planned resource.
904
1206
  *
905
- * Postgres identifiers are snake_case and a hyphen would need quoting
906
- * everywhere; buckets and queues are kebab and disallow underscores in some
907
- * providers. The suffix is the same word either way.
1207
+ * Every part of it comes from the resource and the port it was published on.
908
1208
  */
909
- const SEPARATORS = {
910
- database: "_",
911
- "database-reader": "_",
912
- "database-schema": "_",
913
- objects: "-"
914
- };
1209
+ function urlFor(resource, plan, ports, project, addresses = {}) {
1210
+ if (resource.kind === "secret") return localSecret(project, plan, resource);
1211
+ if (resource.kind === "encryption") return localKeyring(project, plan, resource);
1212
+ if (resource.kind === "mobile-app") return schemeBase(project, resource.scheme);
1213
+ if (resource.kind === "credential") return void 0;
1214
+ if (resource.kind === "external-api") {
1215
+ if (!resource.fake) return resource.url === void 0 ? void 0 : externalApiUrl({
1216
+ id: resource.id,
1217
+ url: resource.url
1218
+ }, plan.stage);
1219
+ const port = ports[resource.fake.key];
1220
+ return port === void 0 ? void 0 : `http://${LOCAL_HOST}:${port}`;
1221
+ }
1222
+ if (resource.kind === "rest-api" || resource.kind === "site") {
1223
+ const address = addresses[resource.id];
1224
+ const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
1225
+ return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
1226
+ }
1227
+ if (!resource.container) return void 0;
1228
+ const port = ports[primaryPortKey(resource.container)];
1229
+ if (port === void 0) return void 0;
1230
+ switch (resource.kind) {
1231
+ case "database":
1232
+ case "database-schema":
1233
+ case "database-reader": {
1234
+ const database = rootDatabase(resource, plan);
1235
+ if (resource.roles === false) {
1236
+ const schema = schemaOf(resource, plan);
1237
+ return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
1238
+ }
1239
+ const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
1240
+ return postgres(port, database, {
1241
+ user: role,
1242
+ password: localRolePassword(project, plan, role)
1243
+ });
1244
+ }
1245
+ case "email": return `smtp://${LOCAL_HOST}:${port}`;
1246
+ case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
1247
+ case "file-server": {
1248
+ const origin = plan.resources.find((r) => r.id === resource.of);
1249
+ if (!origin) return void 0;
1250
+ return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
1251
+ }
1252
+ case "cache":
1253
+ if (resource.of) {
1254
+ const parent = plan.resources.find((r) => r.id === resource.of);
1255
+ const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
1256
+ return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
1257
+ }
1258
+ switch (plan.cache) {
1259
+ case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
1260
+ default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
1261
+ }
1262
+ case "queue":
1263
+ case "topic": return broker(plan, port, resource);
1264
+ default: return;
1265
+ }
1266
+ }
915
1267
  /**
916
- * The stage-scoped name for a resource.
1268
+ * The producer's connection string, composed from the backend the plan chose.
917
1269
  *
918
- * The default stage carries no suffix, so `gkm dev` keeps using `orders` and a
919
- * developer's psql history, saved connections, and muscle memory survive this
920
- * change. Every other stage is suffixed.
1270
+ * The protocol is what picks the transport, so a producer never branches: the
1271
+ * same `.publish()` reaches pg-boss here and SQS deployed because the string it
1272
+ * was handed said so.
921
1273
  */
922
- function resourceName(id, kind, stage) {
923
- const base = id.toLowerCase();
924
- if (stage === DEFAULT_STAGE) return base;
925
- return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
1274
+ function broker(plan, port, resource) {
1275
+ switch (plan.events) {
1276
+ case "pgboss": {
1277
+ const database = plan.resources.find((r) => r.kind === "database");
1278
+ if (!database) throw new PgBossNeedsDatabase([]);
1279
+ return `pgboss://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
1280
+ }
1281
+ case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
1282
+ case "sns": {
1283
+ if (!resource) throw new UnprovisionedEventsBackend("sns");
1284
+ const endpoint = emulatorEndpoint(port);
1285
+ const address = resource.kind === "topic" ? snsUrl.build({
1286
+ topicArn: emulatorTopicArn(resource.name),
1287
+ region: EMULATOR_REGION,
1288
+ endpoint
1289
+ }) : sqsUrl.build({
1290
+ queueUrl: emulatorQueueUrl(resource.name, port),
1291
+ region: EMULATOR_REGION,
1292
+ endpoint
1293
+ });
1294
+ const url = new URL(address);
1295
+ url.searchParams.set("accessKeyId", EMULATOR_CREDENTIALS.accessKeyId);
1296
+ url.searchParams.set("secretAccessKey", EMULATOR_CREDENTIALS.secretAccessKey);
1297
+ return url.toString();
1298
+ }
1299
+ }
1300
+ }
1301
+ /** A local Postgres URL, master credential and all. */
1302
+ function postgres(port, database, credential = {
1303
+ user: LOCAL_USER$2,
1304
+ password: LOCAL_USER$2
1305
+ }) {
1306
+ return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
926
1307
  }
927
1308
  /**
928
- * The container a kind needs, or `undefined` if it needs none.
1309
+ * The role a handler connects as, for one database or tenant.
929
1310
  *
930
- * Queues and topics have no container of their own — they live in whichever
931
- * broker the project selected, which is why the backend is an argument here
932
- * rather than a second pass that adds containers after the fact.
1311
+ * The construct's id, lowercased and stage-scoped exactly as its database or
1312
+ * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
1313
+ * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
1314
+ * in Postgres, which is why the suffix is not optional here the way it is for a
1315
+ * schema.
933
1316
  */
934
- function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
935
- if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
936
- if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
937
- if (kind === "cache") {
938
- if (derived) return CONTAINERS.database;
939
- return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
940
- }
941
- return CONTAINERS[kind];
1317
+ function localRole(resource) {
1318
+ return resource.name;
942
1319
  }
943
1320
  /**
944
- * Build the plan for a manifest and stage.
1321
+ * The password for a derived local role.
945
1322
  *
946
- * `order` is the caller's provisioning order — parents before children — which
947
- * `provisionOrder` produces from the manifest. Passing it in keeps this
948
- * function free of the traversal and makes the ordering assertable on its own.
1323
+ * Derived rather than random, for the same reason the local secret is: a
1324
+ * password that changed on every `gkm dev` would lock a developer out of the
1325
+ * data they had a moment ago. Seeded by the project so two checkouts do not
1326
+ * share one, and by the stage so `test` and `development` do not.
1327
+ *
1328
+ * It is a *local* credential by construction — the cluster is on loopback and
1329
+ * the seed is a checkout path — and no deployed target uses this function.
949
1330
  */
950
- function planFor(manifest, stage, order, options = {}) {
951
- const containers = /* @__PURE__ */ new Set();
952
- const resources = [];
953
- const events = options.events ?? DEFAULT_EVENTS;
954
- const cache = options.cache ?? DEFAULT_CACHE.aws;
955
- for (const id of order) {
956
- const declaration = manifest[id];
957
- if (!declaration) continue;
958
- const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
959
- if (!container && !CONTAINERLESS[declaration.kind]) continue;
960
- if (container) {
961
- containers.add(container);
962
- for (const required of REQUIRES[container] ?? []) containers.add(required);
963
- }
964
- resources.push({
965
- id,
966
- kind: declaration.kind,
967
- ...container ? { container } : {},
968
- name: resourceName(id, declaration.kind, stage),
969
- envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
970
- provisions: PROVISIONS[declaration.kind] === true,
971
- ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
972
- ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
973
- ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
974
- ..."of" in declaration ? { of: declaration.of } : {},
975
- ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
976
- ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
977
- ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
978
- ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
979
- });
1331
+ function localRolePassword(project, plan, role) {
1332
+ return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
1333
+ }
1334
+ /**
1335
+ * A cache's address: its database's URL, carrying the table it reads.
1336
+ *
1337
+ * Appended rather than kept beside the URL as a second key, for the reason the
1338
+ * design gives everywhere else — an address and what it opens are one fact, and
1339
+ * two keys are one more thing to keep in step.
1340
+ */
1341
+ function withTable(url, table) {
1342
+ const parsed = new URL(url);
1343
+ parsed.searchParams.set("table", table);
1344
+ return parsed.toString();
1345
+ }
1346
+ /**
1347
+ * The physical database a derived resource lives in.
1348
+ *
1349
+ * Walks `of` to the top rather than reading the immediate parent, because a
1350
+ * reader's parent may itself be a schema tenant — two hops from the database
1351
+ * that actually exists.
1352
+ */
1353
+ function rootDatabase(resource, plan) {
1354
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
1355
+ let current = resource;
1356
+ while (current.of) {
1357
+ const parent = byId.get(current.of);
1358
+ if (!parent) break;
1359
+ current = parent;
980
1360
  }
981
- if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
982
- for (const extra of options.extraContainers ?? []) containers.add(extra);
983
- if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
984
- if (cache === "db") {
985
- const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
986
- if (placeless.length > 0) {
987
- const databases = resources.filter((r) => r.kind === "database");
988
- const [only] = databases;
989
- if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
990
- if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
991
- for (const resource of placeless) resource.of = only.id;
992
- }
1361
+ return current.name;
1362
+ }
1363
+ /** The schema a derived resource puts on its search path, if it has one. */
1364
+ function schemaOf(resource, plan) {
1365
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
1366
+ let current = resource;
1367
+ while (current) {
1368
+ if (current.kind === "database-schema") return current.schema;
1369
+ current = current.of ? byId.get(current.of) : void 0;
993
1370
  }
994
- return {
995
- stage,
996
- events,
997
- cache,
998
- containers: [...containers],
999
- resources
1000
- };
1001
1371
  }
1002
1372
  /**
1003
- * Queues or topics were declared with pg-boss and no database to hold them.
1373
+ * A broker address asked for without the topic or queue it would name.
1004
1374
  *
1005
- * Not a reconcile failure but a manifest one: the fix is a line of application
1006
- * code either way — declare a database, or select a backend that brings its own
1007
- * broker.
1375
+ * On SNS each topic and queue is its own address, so there is no project-wide
1376
+ * one to hand out — a cron's schedule store, say, cannot live there.
1008
1377
  */
1009
- var CacheNeedsDatabase = class extends Error {
1010
- constructor(ids) {
1011
- super(`A cache backed by the database needs a declared database, and none was declared. Declare one, or set services.cache to 'upstash' or 'elasticache'. Caches affected: ${ids.join(", ")}.`);
1012
- this.ids = ids;
1013
- this.name = "CacheNeedsDatabase";
1378
+ var UnprovisionedEventsBackend = class extends Error {
1379
+ backend;
1380
+ constructor(backend) {
1381
+ super(`'${backend}' has no single broker address: each topic and queue is addressed by its own ARN. Something asked for one without naming the topic or queue.`);
1382
+ this.backend = backend;
1383
+ this.name = "UnprovisionedEventsBackend";
1014
1384
  }
1015
1385
  };
1016
1386
  /**
1017
- * `services.cache: 'db'` in an app that declares more than one database.
1387
+ * The keyring an `Encryption` construct resolves to locally.
1388
+ *
1389
+ * Derived like a secret — stable across restarts, distinct per project, stage
1390
+ * and construct, never written to disk — so what `gkm dev` encrypted yesterday
1391
+ * still opens today. One key, because nothing local is worth rotating: a
1392
+ * server stage's keyring is generated and rotated in its secrets instead.
1393
+ */
1394
+ function localKeyring(project, plan, resource) {
1395
+ const derive = (purpose) => createHash("sha256").update(`${project}:${plan.stage}:${resource.id}:${purpose}`).digest();
1396
+ return formatKeyring({
1397
+ keys: [{
1398
+ id: "k1",
1399
+ key: derive("k1")
1400
+ }],
1401
+ index: derive("index")
1402
+ });
1403
+ }
1404
+ /**
1405
+ * The value a secret resolves to locally.
1018
1406
  *
1019
- * The config says *a* database and the graph offers several, so there is no
1020
- * answer to give — and the wrong kind of failure would be to pick one, since a
1021
- * cache silently landing in the wrong database is a bug that surfaces as
1022
- * missing rows much later. The fix is a stronger statement in application code:
1023
- * `orders.cache('Sessions')` names the database, which is a fact about the
1024
- * application rather than a deployment choice.
1407
+ * A hash of what identifies it, so it is stable, distinct per project, stage
1408
+ * and construct, and never written to disk as a literal. Deployed this is the
1409
+ * one kind whose value does *not* come from here — a secret manager generates
1410
+ * it once and the target reads it back.
1025
1411
  */
1026
- var CacheIsAmbiguous = class extends Error {
1027
- constructor(ids, databases) {
1028
- super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than with services.cache. Caches affected: ${ids.join(", ")}.`);
1029
- this.ids = ids;
1030
- this.databases = databases;
1031
- this.name = "CacheIsAmbiguous";
1032
- }
1033
- };
1034
- var PgBossNeedsDatabase = class extends Error {
1035
- constructor(ids) {
1036
- super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one, or set services.events to 'rabbitmq' or 'sns'. Queues and topics affected: ${ids.join(", ")}.`);
1037
- this.ids = ids;
1038
- this.name = "PgBossNeedsDatabase";
1039
- }
1040
- };
1041
-
1412
+ function localSecret(project, plan, resource) {
1413
+ return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
1414
+ }
1042
1415
  //#endregion
1043
- //#region src/reconcile/env.ts
1416
+ //#region src/reconcile/apps.ts
1044
1417
  /**
1045
- * The credential local containers are brought up with.
1418
+ * The apps, as services beside the containers their constructs derive.
1046
1419
  *
1047
- * The design's `roles: false` case — both URLs fall back to the cluster's master
1048
- * credential. A deliberate downgrade, not a default: it holds until the role DDL
1049
- * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
1050
- * and the owner URL stops being reachable from any manifest edge.
1420
+ * `gkm docker` used to write these by hand: every backend got
1421
+ * `DATABASE_URL=postgresql://…@postgres:5432/app` and `REDIS_URL=redis://redis`,
1422
+ * which was right only for a project whose single database happened to be
1423
+ * called `Database` — an `Orders` database publishes `ORDERS_URL`, a schema
1424
+ * tenant has a URL of its own, and buckets and mail were not wired at all.
1425
+ *
1426
+ * Now an app's environment is exactly what reconcile hands `gkm dev`: every key
1427
+ * the declared constructs provide, from the same derivation. The only
1428
+ * difference is where the addresses point — at the containers by service name
1429
+ * on the compose network, rather than at `localhost` on the ports published for
1430
+ * the host.
1431
+ *
1432
+ * Always from the *local* stage's plan, whichever stage is being reconciled:
1433
+ * `gkm test` rewrites the same file, and running the tests must not quietly
1434
+ * point these apps at the test databases.
1051
1435
  */
1052
- const LOCAL_USER = "geekmidas";
1053
- /** The host containers are published on. */
1054
- const LOCAL_HOST = "localhost";
1055
- /** The region local buckets claim, so the client has one to sign with. */
1056
- const LOCAL_REGION = "us-east-1";
1436
+ /** The profile app services carry, so `gkm dev` starts only the containers. */
1437
+ const APPS_PROFILE = "apps";
1057
1438
  /**
1058
- * The credentials the S3 client resolves for MinIO.
1439
+ * Where an app's image is built from.
1059
1440
  *
1060
- * An `s3://` URL deliberately carries none: deployed, the SDK reads them from
1061
- * the execution role, so a URL that embedded a key would be one more thing to
1062
- * rotate and one more thing to leak into a log. Locally there is no role, and
1063
- * the same chain reads these — which is why they are injected beside the URL
1064
- * rather than written into it.
1441
+ * An app at the project root is a single-app project, whose `gkm docker`
1442
+ * writes `.gkm/docker/Dockerfile`; a workspace writes one per app.
1065
1443
  */
1066
- const STORAGE_CREDENTIALS = {
1067
- AWS_ACCESS_KEY_ID: LOCAL_USER,
1068
- AWS_SECRET_ACCESS_KEY: LOCAL_USER,
1069
- AWS_REGION: LOCAL_REGION
1070
- };
1071
- /** The token the local cache proxy accepts. Matches the compose definition. */
1072
- const LOCAL_TOKEN = "geekmidas";
1073
- /** The schema pg-boss keeps its tables in, inside the declared database. */
1074
- const PGBOSS_SCHEMA = "pgboss";
1075
- /** The exchange every local topic and queue shares on RabbitMQ. */
1076
- const RABBITMQ_EXCHANGE = "geekmidas.events";
1444
+ function dockerfileOf(appName, path) {
1445
+ return path === "." ? ".gkm/docker/Dockerfile" : `.gkm/docker/Dockerfile.${appName}`;
1446
+ }
1077
1447
  /**
1078
- * Every env key a reconciled plan resolves, and its value.
1448
+ * Every construct key, addressed across the compose network.
1079
1449
  *
1080
- * Keys come from the declaration rather than being re-derived, so what the build
1081
- * publishes and what the app reads cannot drift.
1450
+ * `envFor` is asked with a placeholder port for every port key, so each
1451
+ * `localhost:<placeholder>` in its answer names exactly one container port —
1452
+ * two containers listening on the same port inside cannot be confused — and is
1453
+ * rewritten to `<container>:<inside>`.
1082
1454
  */
1083
- function envFor(plan, options) {
1084
- const env = {};
1085
- const resolved = {};
1086
- for (const resource of plan.resources) {
1087
- const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
1088
- if (url) resolved[resource.id] = url;
1455
+ function inNetworkEnv(workspace, manifest, fakes = {}) {
1456
+ const plan = planFor(manifest, workspace.stages.local, provisionOrder(manifest), {
1457
+ localStage: workspace.stages.local,
1458
+ ...backendsOf(workspace),
1459
+ fakes
1460
+ });
1461
+ const placeholders = {};
1462
+ const targets = /* @__PURE__ */ new Map();
1463
+ let next = 49152;
1464
+ for (const container of [...plan.containers].sort()) for (const port of portsOf(container, plan.fakes)) {
1465
+ placeholders[port.key] = next;
1466
+ targets.set(next, `${container}:${port.inside}`);
1467
+ next += 1;
1089
1468
  }
1090
- for (const resource of plan.resources) {
1091
- const url = resolved[resource.id];
1092
- if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
1093
- if (url) env[resource.envKey] = url;
1094
- if (isDatabase(resource.kind) && resource.roles !== false) {
1095
- const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
1096
- if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
1469
+ for (const key of portKeys(plan.containers, plan.fakes)) placeholders[key] ??= next++;
1470
+ const env = envFor(plan, {
1471
+ ports: placeholders,
1472
+ project: workspace.name,
1473
+ addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
1474
+ });
1475
+ const rewrite = (value) => value.replace(/(?:localhost|127\.0\.0\.1):(\d+)/g, (match, port) => {
1476
+ return targets.get(Number(port)) ?? match;
1477
+ });
1478
+ return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
1479
+ }
1480
+ /**
1481
+ * The keys one app's environment may hold: what its own declaration provides
1482
+ * and requires, and what each construct it has an edge to provides.
1483
+ *
1484
+ * The edges, not the manifest. Every app used to be handed every key the
1485
+ * workspace resolved — a web app got the database's owner URL and the auth
1486
+ * server's signing secret, and the API got the auth server's database — which
1487
+ * is exactly what a declared edge exists to rule out. A deploy composes an
1488
+ * app's environment from its edges; so does this.
1489
+ *
1490
+ * One hop: a surface an app calls is reached over HTTP, so its URL is the
1491
+ * app's business and its database is not. A site also gets the public
1492
+ * variant of each key its bundle inlines (`NEXT_PUBLIC_API_URL`), and a surface
1493
+ * the edges of the workers whose crons and subscribers its server runs.
1494
+ *
1495
+ * `undefined` for an app no declaration describes, which gets nothing but its
1496
+ * port.
1497
+ */
1498
+ function appEnvKeys(manifest, appName, runnables = {}) {
1499
+ const entry = Object.entries(manifest).find(([id, d]) => (d.kind === "rest-api" || d.kind === "site" || d.kind === "mobile-app") && appKey(id) === appName);
1500
+ if (!entry) return void 0;
1501
+ const [id, declaration] = entry;
1502
+ const keys = /* @__PURE__ */ new Set([
1503
+ ...declaration.provides ?? [],
1504
+ ...declaration.requires ?? [],
1505
+ ...declaration.kind === "rest-api" ? declaration.endpoints.flatMap((endpoint) => endpoint.requires ?? []) : []
1506
+ ]);
1507
+ const edges = [...dependenciesOf(declaration).map((edge) => edge.target), ...runnables[id] ?? []];
1508
+ if (declaration.kind === "rest-api" && declaration.endpoints.length === 0) {
1509
+ const runsWorkers = Object.values(manifest).some((other) => other.kind === "worker");
1510
+ for (const [otherId, other] of Object.entries(manifest)) {
1511
+ if (other.kind !== "worker") continue;
1512
+ edges.push(...dependenciesOf(other).map((edge) => edge.target), ...runnables[otherId] ?? []);
1513
+ keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
1514
+ }
1515
+ if (runsWorkers) for (const other of Object.values(manifest)) {
1516
+ if (other.kind !== "queue" && other.kind !== "topic") continue;
1517
+ for (const key of other.provides ?? []) keys.add(key);
1097
1518
  }
1098
- if (resource.kind === "email" && url) env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
1099
1519
  }
1100
- Object.assign(env, publicEnv(plan, env));
1101
- Object.assign(env, brokerEnv(plan, options.ports));
1102
- if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
1103
- return env;
1520
+ for (const id of edges) {
1521
+ const target = manifest[id];
1522
+ if (!target) continue;
1523
+ for (const key of target.provides ?? []) keys.add(key);
1524
+ if (target.kind === "queue" || target.kind === "topic") keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
1525
+ if (target.kind === "objects") {
1526
+ keys.add("AWS_ACCESS_KEY_ID");
1527
+ keys.add("AWS_SECRET_ACCESS_KEY");
1528
+ keys.add("AWS_REGION");
1529
+ }
1530
+ }
1531
+ if (declaration.kind === "site" || declaration.kind === "mobile-app") for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
1532
+ return keys;
1533
+ }
1534
+ /** One compose service per app the workspace runs, behind the apps profile. */
1535
+ function appServices(workspace, manifest, containers, runnables = {}, fakes = {}) {
1536
+ const env = inNetworkEnv(workspace, manifest, fakes);
1537
+ const services = {};
1538
+ for (const [name, app] of Object.entries(workspace.apps)) {
1539
+ if (app.type === "mobile" || !app.port) continue;
1540
+ const key = appKey(name);
1541
+ const health = app.type === "web" ? "/" : "/health";
1542
+ const allowed = appEnvKeys(manifest, name, runnables);
1543
+ const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
1544
+ services[key] = {
1545
+ image: `${key}:\${TAG:-latest}`,
1546
+ build: {
1547
+ context: ".",
1548
+ dockerfile: dockerfileOf(name, app.path)
1549
+ },
1550
+ profiles: [APPS_PROFILE],
1551
+ ports: [`${app.port}:${app.port}`],
1552
+ environment: {
1553
+ NODE_ENV: "production",
1554
+ PORT: String(app.port),
1555
+ ...own
1556
+ },
1557
+ ...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
1558
+ healthcheck: {
1559
+ test: [
1560
+ "CMD",
1561
+ "wget",
1562
+ "-q",
1563
+ "--spider",
1564
+ `http://localhost:${app.port}${health}`
1565
+ ],
1566
+ interval: "10s",
1567
+ timeout: "5s",
1568
+ retries: 5
1569
+ }
1570
+ };
1571
+ }
1572
+ return services;
1104
1573
  }
1574
+ //#endregion
1575
+ //#region src/reconcile/clients.ts
1105
1576
  /**
1106
- * The values a site's bundler inlines, under the names it inlines them by.
1577
+ * The real Postgres and MinIO clients the applier runs against.
1107
1578
  *
1108
- * A rename and nothing more. `API_URL` was resolved once, by the same code that
1109
- * resolved it for the server; a site reads the same value under `VITE_API_URL`
1110
- * because that prefix is how its bundler is told to ship it. Nothing is derived
1111
- * twice, so a site and its API cannot come to disagree about where the API is.
1579
+ * Kept apart from `provision.ts` so the rules there — what gets created, in what
1580
+ * order, and never dropping anything — are asserted against fakes, while the
1581
+ * drivers live here where there is nothing to assert but wiring.
1112
1582
  *
1113
- * A source that resolved to nothing is skipped rather than written empty: an
1114
- * inlined empty string is a frontend that builds and then fails at runtime
1115
- * against `http:///`, where a missing variable fails at build with the name of
1116
- * the thing that is missing.
1583
+ * Both are constructed from the same local addresses the env writer composes,
1584
+ * so the applier cannot end up talking to a different container than the app.
1117
1585
  */
1118
- function publicEnv(plan, resolved) {
1119
- const env = {};
1120
- for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
1121
- const value = resolved[source];
1122
- if (value) env[key] = value;
1123
- }
1124
- return env;
1125
- }
1586
+ /** The credential local containers are brought up with. */
1587
+ const LOCAL_USER$1 = "geekmidas";
1126
1588
  /**
1127
- * What a surface publishes beyond its own address: who may call it, and where
1128
- * a cookie set by it is readable.
1129
- *
1130
- * Both are read off the same list — the constructs that declared an edge to
1131
- * this surface — which is why neither appears in application code. A surface
1132
- * that listed its own callers would be edited every time something new called
1133
- * it, and the thing being edited is already recorded in the graph.
1589
+ * A Postgres client that opens one connection per database it is asked about.
1134
1590
  *
1135
- * An empty origin list is written, and a surface with no address writes nothing
1136
- * at all. The two cases look alike and are not: "nothing depends on this yet" is
1137
- * a real state a target should publish, while "this surface has no address here"
1138
- * means it is not running in this stage, and keys belong with the address they
1139
- * describe.
1591
+ * `CREATE DATABASE` cannot run from a connection to the database being created,
1592
+ * and a schema must be created from inside its own database — so the database is
1593
+ * part of each query rather than fixed when the client is built.
1140
1594
  */
1141
- function surfaceEnv(resource, url, resolved = {}) {
1142
- if (!url) return {};
1143
- const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
1144
- const domain = cookieDomain([url, ...origins]);
1595
+ function pgClient(port) {
1596
+ return { async query(database, sql, values) {
1597
+ const client = new Client({
1598
+ host: "localhost",
1599
+ port,
1600
+ user: LOCAL_USER$1,
1601
+ password: LOCAL_USER$1,
1602
+ database: database ?? LOCAL_USER$1
1603
+ });
1604
+ await client.connect();
1605
+ try {
1606
+ return (await client.query(sql, values)).rows;
1607
+ } finally {
1608
+ await client.end();
1609
+ }
1610
+ } };
1611
+ }
1612
+ /** A bucket client pointed at the local MinIO. */
1613
+ function bucketClient(port) {
1614
+ const s3 = new S3Client({
1615
+ region: "us-east-1",
1616
+ endpoint: `http://localhost:${port}`,
1617
+ forcePathStyle: true,
1618
+ credentials: {
1619
+ accessKeyId: LOCAL_USER$1,
1620
+ secretAccessKey: LOCAL_USER$1
1621
+ }
1622
+ });
1623
+ return {
1624
+ async exists(bucket) {
1625
+ try {
1626
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
1627
+ return true;
1628
+ } catch {
1629
+ return false;
1630
+ }
1631
+ },
1632
+ async create(bucket) {
1633
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
1634
+ },
1635
+ async policy(bucket) {
1636
+ try {
1637
+ return (await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }))).Policy;
1638
+ } catch {
1639
+ return;
1640
+ }
1641
+ },
1642
+ async setPolicy(bucket, policy) {
1643
+ await s3.send(new PutBucketPolicyCommand({
1644
+ Bucket: bucket,
1645
+ Policy: policy
1646
+ }));
1647
+ }
1648
+ };
1649
+ }
1650
+ /** A topic and queue client pointed at the local AWS emulator. */
1651
+ function carrierClient(port) {
1652
+ const config = {
1653
+ region: EMULATOR_REGION,
1654
+ endpoint: emulatorEndpoint(port),
1655
+ credentials: EMULATOR_CREDENTIALS
1656
+ };
1657
+ const sns = new SNSClient(config);
1658
+ const sqs = new SQSClient(config);
1145
1659
  return {
1146
- [provideKey(resource.id, "trustedOrigins")]: origins.join(","),
1147
- ...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {}
1660
+ async topicExists(name) {
1661
+ try {
1662
+ await sns.send(new GetTopicAttributesCommand({ TopicArn: emulatorTopicArn(name) }));
1663
+ return true;
1664
+ } catch {
1665
+ return false;
1666
+ }
1667
+ },
1668
+ async createTopic(name) {
1669
+ await sns.send(new CreateTopicCommand({ Name: name }));
1670
+ },
1671
+ async queueExists(name) {
1672
+ try {
1673
+ await sqs.send(new GetQueueUrlCommand({ QueueName: name }));
1674
+ return true;
1675
+ } catch {
1676
+ return false;
1677
+ }
1678
+ },
1679
+ async createQueue(name) {
1680
+ await sqs.send(new CreateQueueCommand({ QueueName: name }));
1681
+ }
1148
1682
  };
1149
1683
  }
1150
- /** An address reduced to the origin a browser compares against. */
1151
- function originOf(address) {
1152
- try {
1153
- return new URL(address).origin;
1154
- } catch {
1155
- return void 0;
1156
- }
1157
- }
1684
+ //#endregion
1685
+ //#region src/reconcile/compose.ts
1158
1686
  /**
1159
- * The runtime role of whatever a derived node hangs off.
1687
+ * The compose document a plan implies.
1160
1688
  *
1161
- * A reader's parent may itself be a schema tenant, so this follows `of` by one
1162
- * hop rather than assuming the cluster — the reader of a tenant reads that
1163
- * tenant's schema, not the database's.
1689
+ * The third declaration of the same containers disappears here: today a
1690
+ * `services: ['postgres','minio']` block is kept in step with the code by hand,
1691
+ * which is the triplication this design exists to remove, in the one place you
1692
+ * touch every day. A `database` implies Postgres; the list is derived.
1693
+ *
1694
+ * Returns an object rather than a string for the same reason `plan` is pure —
1695
+ * what should exist is asserted as data, and turning it into YAML is one line at
1696
+ * the edge.
1697
+ *
1698
+ * What stays config is unchanged: images and versions are pinned explicitly, and
1699
+ * a container no construct implies is still declared by hand. Neutral facts come
1700
+ * from the declaration, provider detail from config.
1164
1701
  */
1165
- function parentRole(resource, plan) {
1166
- const parent = plan.resources.find((r) => r.id === resource.of);
1167
- return parent ? localRole(parent) : resource.name;
1168
- }
1169
- /** Whether a kind connects to Postgres and therefore has an owner role. */
1170
- function isDatabase(kind) {
1171
- return kind === "database" || kind === "database-schema";
1172
- }
1702
+ /** The credential every derived container is brought up with locally. */
1703
+ const LOCAL_USER = "geekmidas";
1173
1704
  /**
1174
- * The URL a migrator connects with — the owner role, which can create, alter
1175
- * and drop.
1705
+ * The token the local cache accepts.
1176
1706
  *
1177
- * Never in `provides`, which is the whole point. A handler that could reach this
1178
- * could `DROP TABLE`, and the security property the role split exists for is
1179
- * that it cannot: the value is injected by `gkm exec` for the migrate step and
1180
- * by nothing else.
1707
+ * Fixed rather than generated: it is only ever reachable on a port this project
1708
+ * published, and the URL that carries it is derived, so nothing is made safer
1709
+ * by making it unguessable and a stable one keeps `redis-cli` sessions working.
1181
1710
  */
1182
- function ownerUrl(resource, plan, ports, project) {
1183
- if (!resource.container) return void 0;
1184
- const port = ports[primaryPortKey(resource.container)];
1185
- if (port === void 0) return void 0;
1186
- const owner = ownerRole(localRole(resource));
1187
- return postgres(port, rootDatabase(resource, plan), {
1188
- user: owner,
1189
- password: localRolePassword(project, plan, owner)
1190
- });
1191
- }
1711
+ const LOCAL_TOKEN = "geekmidas";
1192
1712
  /**
1193
- * The shared broker keys, when anything is published or consumed.
1713
+ * Build the compose document for a plan.
1194
1714
  *
1195
- * A queue's own key is the producer's. These two are the *connection* itself,
1196
- * which locally is one broker for every queue and topic in the project: the
1197
- * generated pollers open a single connection and subscribe each worker by name
1198
- * on it. Deployed there is no such thing — a Lambda is handed its own event
1199
- * source — so this pair exists for the local target and says so.
1715
+ * Every container is published on its assigned port, never on the image's
1716
+ * default: the point of allocation is that two projects can run at once, and
1717
+ * one container publishing 5432 puts that back.
1200
1718
  */
1201
- function brokerEnv(plan, ports) {
1202
- const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
1203
- if (!carrier) return {};
1204
- const publisher = urlFor(carrier, plan, ports, "");
1205
- if (!publisher) return {};
1719
+ function composeFor(plan, options) {
1720
+ const services = {};
1721
+ const volumes = {};
1722
+ for (const container of [...plan.containers].sort()) {
1723
+ const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
1724
+ const fake = plan.fakes?.[container];
1725
+ const image = declared ? postgresImage(declared) : fake?.image ?? DEFAULT_IMAGES[container];
1726
+ if (!image) throw new UnknownContainer(container);
1727
+ const published = publish(container, options.ports, plan.fakes);
1728
+ services[container] = fake ? {
1729
+ image,
1730
+ restart: "unless-stopped",
1731
+ ports: published
1732
+ } : define(container, image, published);
1733
+ const volume = volumeOf(container);
1734
+ if (volume) volumes[volume] = {};
1735
+ }
1206
1736
  return {
1207
- EVENT_PUBLISHER_CONNECTION_STRING: publisher,
1208
- EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
1737
+ name: options.project,
1738
+ services: {
1739
+ ...services,
1740
+ ...options.apps
1741
+ },
1742
+ ...Object.keys(volumes).length ? { volumes } : {}
1209
1743
  };
1210
1744
  }
1211
- /**
1212
- * The URL for one planned resource.
1213
- *
1214
- * Every part of it comes from the resource and the port it was published on.
1215
- */
1216
- function urlFor(resource, plan, ports, project, addresses = {}) {
1217
- if (resource.kind === "secret") return localSecret(project, plan, resource);
1218
- if (resource.kind === "credential") return void 0;
1219
- if (resource.kind === "rest-api" || resource.kind === "site") {
1220
- const address = addresses[resource.id];
1221
- const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
1222
- return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
1745
+ /** Render a compose document as the file it becomes. */
1746
+ function toYaml(file) {
1747
+ return `# Generated by gkm from the construct manifest — do not edit.
1748
+ # Containers are derived from what the app declares. Image pins and anything
1749
+ # no construct implies go in your own docker-compose.yml, which is merged over
1750
+ # this file and wins:
1751
+ #
1752
+ # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
1753
+ #
1754
+ # "gkm dev" and "gkm test" start the containers. The apps sit behind the
1755
+ # "apps" profile: run "gkm setup", then
1756
+ # docker compose -f docker-compose.constructs.yml --profile apps up --build
1757
+ ${stringify(file, { lineWidth: 0 })}`;
1758
+ }
1759
+ /** A container with no image, from config or from the defaults. */
1760
+ var UnknownContainer = class extends Error {
1761
+ /** The container that could not be resolved. */
1762
+ container;
1763
+ /** The containers that have a default, so the caller can see the set. */
1764
+ known = Object.keys(DEFAULT_IMAGES).sort();
1765
+ constructor(container) {
1766
+ super("No image is known for this container; pin one in config");
1767
+ this.name = "UnknownContainer";
1768
+ this.container = container;
1223
1769
  }
1224
- if (!resource.container) return void 0;
1225
- const port = ports[primaryPortKey(resource.container)];
1226
- if (port === void 0) return void 0;
1227
- switch (resource.kind) {
1228
- case "database":
1229
- case "database-schema":
1230
- case "database-reader": {
1231
- const database = rootDatabase(resource, plan);
1232
- if (resource.roles === false) {
1233
- const schema = schemaOf(resource, plan);
1234
- return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
1770
+ };
1771
+ /** One container's definition, published on the port it was assigned. */
1772
+ function define(container, image, published) {
1773
+ switch (container) {
1774
+ case "postgres": return {
1775
+ image,
1776
+ restart: "unless-stopped",
1777
+ ports: published,
1778
+ environment: {
1779
+ POSTGRES_USER: LOCAL_USER,
1780
+ POSTGRES_PASSWORD: LOCAL_USER,
1781
+ POSTGRES_DB: LOCAL_USER
1782
+ },
1783
+ volumes: ["postgres-data:/var/lib/postgresql"],
1784
+ healthcheck: {
1785
+ test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
1786
+ interval: "5s",
1787
+ timeout: "3s",
1788
+ retries: 10,
1789
+ start_period: "10s"
1235
1790
  }
1236
- const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
1237
- return postgres(port, database, {
1238
- user: role,
1239
- password: localRolePassword(project, plan, role)
1240
- });
1241
- }
1242
- case "email": return `smtp://${LOCAL_HOST}:${port}`;
1243
- case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
1244
- case "file-server": {
1245
- const origin = plan.resources.find((r) => r.id === resource.of);
1246
- if (!origin) return void 0;
1247
- return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
1248
- }
1249
- case "cache":
1250
- if (resource.of) {
1251
- const parent = plan.resources.find((r) => r.id === resource.of);
1252
- const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
1253
- return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
1791
+ };
1792
+ case "minio": return {
1793
+ image,
1794
+ restart: "unless-stopped",
1795
+ ports: published,
1796
+ environment: {
1797
+ MINIO_ROOT_USER: LOCAL_USER,
1798
+ MINIO_ROOT_PASSWORD: LOCAL_USER
1799
+ },
1800
+ command: "server --console-address \":9001\" /data",
1801
+ volumes: ["minio-data:/data"],
1802
+ healthcheck: {
1803
+ test: [
1804
+ "CMD",
1805
+ "mc",
1806
+ "ready",
1807
+ "local"
1808
+ ],
1809
+ interval: "10s",
1810
+ timeout: "5s",
1811
+ retries: 5,
1812
+ start_period: "10s"
1813
+ }
1814
+ };
1815
+ case "caddy": return {
1816
+ image,
1817
+ restart: "unless-stopped",
1818
+ ports: published,
1819
+ volumes: [
1820
+ "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
1821
+ "./.gkm/caddy-sites:/etc/caddy/sites:ro",
1822
+ "caddy-data:/data"
1823
+ ],
1824
+ extra_hosts: ["host.docker.internal:host-gateway"],
1825
+ healthcheck: {
1826
+ test: [
1827
+ "CMD",
1828
+ "caddy",
1829
+ "version"
1830
+ ],
1831
+ interval: "10s",
1832
+ timeout: "5s",
1833
+ retries: 5
1834
+ }
1835
+ };
1836
+ case "mailpit": return {
1837
+ image,
1838
+ restart: "unless-stopped",
1839
+ ports: published,
1840
+ environment: {
1841
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
1842
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1"
1843
+ },
1844
+ healthcheck: {
1845
+ test: [
1846
+ "CMD",
1847
+ "wget",
1848
+ "-q",
1849
+ "--spider",
1850
+ "http://localhost:8025"
1851
+ ],
1852
+ interval: "10s",
1853
+ timeout: "5s",
1854
+ retries: 5
1855
+ }
1856
+ };
1857
+ case "redis": return {
1858
+ image,
1859
+ restart: "unless-stopped",
1860
+ ports: published,
1861
+ volumes: ["redis-data:/data"],
1862
+ healthcheck: {
1863
+ test: [
1864
+ "CMD",
1865
+ "redis-cli",
1866
+ "ping"
1867
+ ],
1868
+ interval: "5s",
1869
+ timeout: "3s",
1870
+ retries: 5
1871
+ }
1872
+ };
1873
+ case "redis-http": return {
1874
+ image,
1875
+ restart: "unless-stopped",
1876
+ ports: published,
1877
+ environment: {
1878
+ SRH_MODE: "env",
1879
+ SRH_TOKEN: LOCAL_TOKEN,
1880
+ SRH_CONNECTION_STRING: "redis://redis:6379"
1881
+ },
1882
+ depends_on: ["redis"],
1883
+ healthcheck: {
1884
+ test: [
1885
+ "CMD",
1886
+ "wget",
1887
+ "-q",
1888
+ "-O",
1889
+ "/dev/null",
1890
+ "http://127.0.0.1:80/"
1891
+ ],
1892
+ interval: "5s",
1893
+ timeout: "3s",
1894
+ retries: 10
1254
1895
  }
1255
- switch (plan.cache) {
1256
- case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
1257
- default: return `http://:${LOCAL_TOKEN}@${LOCAL_HOST}:${port}`;
1896
+ };
1897
+ case "rabbitmq": return {
1898
+ image,
1899
+ restart: "unless-stopped",
1900
+ ports: published,
1901
+ environment: {
1902
+ RABBITMQ_DEFAULT_USER: LOCAL_USER,
1903
+ RABBITMQ_DEFAULT_PASS: LOCAL_USER
1904
+ },
1905
+ volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
1906
+ healthcheck: {
1907
+ test: [
1908
+ "CMD",
1909
+ "rabbitmq-diagnostics",
1910
+ "-q",
1911
+ "ping"
1912
+ ],
1913
+ interval: "10s",
1914
+ timeout: "5s",
1915
+ retries: 5
1258
1916
  }
1259
- case "queue":
1260
- case "topic": return broker(plan, port);
1261
- default: return void 0;
1262
- }
1263
- }
1264
- /**
1265
- * The producer's connection string, composed from the backend the plan chose.
1266
- *
1267
- * The protocol is what picks the transport, so a producer never branches: the
1268
- * same `.publish()` reaches pg-boss here and SQS deployed because the string it
1269
- * was handed said so.
1270
- */
1271
- function broker(plan, port) {
1272
- switch (plan.events) {
1273
- case "pgboss": {
1274
- const database = plan.resources.find((r) => r.kind === "database");
1275
- if (!database) throw new PgBossNeedsDatabase([]);
1276
- return `pgboss://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
1277
- }
1278
- case "rabbitmq": return `rabbitmq://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
1279
- case "sns": throw new UnprovisionedEventsBackend("sns");
1917
+ };
1918
+ case "localstack": return {
1919
+ image,
1920
+ restart: "unless-stopped",
1921
+ ports: published,
1922
+ extra_hosts: ["host.docker.internal:host-gateway"],
1923
+ environment: {
1924
+ AWS_DEFAULT_REGION: EMULATOR_REGION,
1925
+ AWS_ACCESS_KEY_ID: EMULATOR_CREDENTIALS.accessKeyId,
1926
+ AWS_SECRET_ACCESS_KEY: EMULATOR_CREDENTIALS.secretAccessKey
1927
+ },
1928
+ volumes: ["localstack-data:/var/lib/localstack"],
1929
+ healthcheck: {
1930
+ test: [
1931
+ "CMD",
1932
+ "bash",
1933
+ "-c",
1934
+ "exec 3<>/dev/tcp/localhost/4566"
1935
+ ],
1936
+ interval: "10s",
1937
+ timeout: "5s",
1938
+ retries: 5,
1939
+ start_period: "10s"
1940
+ }
1941
+ };
1942
+ default: throw new UnknownContainer(container);
1280
1943
  }
1281
1944
  }
1282
- /** A local Postgres URL, master credential and all. */
1283
- function postgres(port, database, credential = {
1284
- user: LOCAL_USER,
1285
- password: LOCAL_USER
1286
- }) {
1287
- return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
1288
- }
1289
1945
  /**
1290
- * The role a handler connects as, for one database or tenant.
1946
+ * The published port mappings for a container.
1291
1947
  *
1292
- * The construct's id, lowercased and stage-scoped exactly as its database or
1293
- * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
1294
- * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
1295
- * in Postgres, which is why the suffix is not optional here the way it is for a
1296
- * schema.
1948
+ * Every port is looked up by its own key rather than being derived as an offset
1949
+ * from the container's first, so MinIO's console cannot land on whatever was
1950
+ * allocated to the next container.
1297
1951
  */
1298
- function localRole(resource) {
1299
- return resource.name;
1952
+ function publish(container, ports, fakes) {
1953
+ return portsOf(container, fakes).map((port) => {
1954
+ const assigned = ports[port.key];
1955
+ if (assigned === void 0) throw new UnassignedPort(port.key);
1956
+ return `${assigned}:${port.inside}`;
1957
+ });
1300
1958
  }
1959
+ /** A planned port that allocation never assigned. */
1960
+ var UnassignedPort = class extends Error {
1961
+ /** The port key that went unassigned, e.g. `minio-console`. */
1962
+ key;
1963
+ constructor(key) {
1964
+ super("No port was assigned for this container port");
1965
+ this.name = "UnassignedPort";
1966
+ this.key = key;
1967
+ }
1968
+ };
1969
+ //#endregion
1970
+ //#region src/reconcile/docker.ts
1301
1971
  /**
1302
- * The password for a derived local role.
1972
+ * The real Docker, behind the small interface reconcile needs.
1303
1973
  *
1304
- * Derived rather than random, for the same reason the local secret is: a
1305
- * password that changed on every `gkm dev` would lock a developer out of the
1306
- * data they had a moment ago. Seeded by the project so two checkouts do not
1307
- * share one, and by the stage so `test` and `development` do not.
1974
+ * Shells out to `docker compose` rather than talking to the daemon socket: it is
1975
+ * what a developer runs by hand when something is wrong, so the two cannot
1976
+ * disagree, and it keeps Docker out of this package's dependencies.
1308
1977
  *
1309
- * It is a *local* credential by construction — the cluster is on loopback and
1310
- * the seed is a checkout path — and no deployed target uses this function.
1978
+ * Every call is scoped to the generated compose file. Nothing here can touch a
1979
+ * container this project did not generate.
1311
1980
  */
1312
- function localRolePassword(project, plan, role) {
1313
- return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
1314
- }
1981
+ const run = promisify(execFile);
1315
1982
  /**
1316
- * A cache's address: its database's URL, carrying the table it reads.
1983
+ * The generated file, then the project's own if it has one.
1317
1984
  *
1318
- * Appended rather than kept beside the URL as a second key, for the reason the
1319
- * design gives everywhere else — an address and what it opens are one fact, and
1320
- * two keys are one more thing to keep in step.
1985
+ * Merged in that order so the project's wins — an `image:` there pins a
1986
+ * container this file derived, and a service there is added beside them.
1321
1987
  */
1322
- function withTable(url, table) {
1323
- const parsed = new URL(url);
1324
- parsed.searchParams.set("table", table);
1325
- return parsed.toString();
1988
+ function composeFiles(composePath) {
1989
+ const project = join(dirname(composePath), "docker-compose.yml");
1990
+ return [
1991
+ "-f",
1992
+ composePath,
1993
+ ...existsSync(project) ? ["-f", project] : []
1994
+ ];
1326
1995
  }
1327
- /**
1328
- * The physical database a derived resource lives in.
1329
- *
1330
- * Walks `of` to the top rather than reading the immediate parent, because a
1331
- * reader's parent may itself be a schema tenant — two hops from the database
1332
- * that actually exists.
1333
- */
1334
- function rootDatabase(resource, plan) {
1335
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1336
- let current = resource;
1337
- while (current.of) {
1338
- const parent = byId.get(current.of);
1339
- if (!parent) break;
1340
- current = parent;
1996
+ /** How long to wait for containers to pass their health checks. */
1997
+ const HEALTH_TIMEOUT_MS = 12e4;
1998
+ /** `docker compose up` exited unsuccessfully; its own output said why. */
1999
+ var ComposeUpFailed = class extends Error {
2000
+ services;
2001
+ code;
2002
+ constructor(services, code) {
2003
+ super(`docker compose could not start ${services.join(", ")} (exit ${code ?? "signal"}). Its output above names the container; \`docker compose logs <service>\` shows why it stopped.`);
2004
+ this.services = services;
2005
+ this.code = code;
2006
+ this.name = "ComposeUpFailed";
1341
2007
  }
1342
- return current.name;
2008
+ };
2009
+ /** Run compose with its output on this terminal rather than captured. */
2010
+ function shown(args, services) {
2011
+ return new Promise((resolve, reject) => {
2012
+ const child = spawn("docker", args, { stdio: [
2013
+ "ignore",
2014
+ "inherit",
2015
+ "inherit"
2016
+ ] });
2017
+ child.on("error", reject);
2018
+ child.on("close", (code) => code === 0 ? resolve() : reject(new ComposeUpFailed(services, code)));
2019
+ });
1343
2020
  }
1344
- /** The schema a derived resource puts on its search path, if it has one. */
1345
- function schemaOf(resource, plan) {
1346
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1347
- let current = resource;
1348
- while (current) {
1349
- if (current.kind === "database-schema") return current.schema;
1350
- current = current.of ? byId.get(current.of) : void 0;
2021
+ const dockerCli = {
2022
+ async publishedPort(composePath, service, inside) {
2023
+ try {
2024
+ const { stdout } = await run("docker", [
2025
+ "compose",
2026
+ ...composeFiles(composePath),
2027
+ "port",
2028
+ service,
2029
+ String(inside)
2030
+ ]);
2031
+ const port = Number(stdout.trim().split(":").pop());
2032
+ return Number.isFinite(port) && port > 0 ? port : void 0;
2033
+ } catch {
2034
+ return;
2035
+ }
2036
+ },
2037
+ async up(composePath, services, options = {}) {
2038
+ const args = [
2039
+ "compose",
2040
+ ...composeFiles(composePath),
2041
+ "up",
2042
+ "-d",
2043
+ "--remove-orphans",
2044
+ "--wait",
2045
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
2046
+ ...services
2047
+ ];
2048
+ if (options.show) return shown(args, services);
2049
+ await run("docker", args);
2050
+ },
2051
+ async copyOut(composePath, service, from, to) {
2052
+ await mkdir(dirname(to), { recursive: true });
2053
+ await run("docker", [
2054
+ "compose",
2055
+ ...composeFiles(composePath),
2056
+ "cp",
2057
+ `${service}:${from}`,
2058
+ to
2059
+ ]);
2060
+ },
2061
+ async reload(composePath, service) {
2062
+ await run("docker", [
2063
+ "compose",
2064
+ ...composeFiles(composePath),
2065
+ "exec",
2066
+ "-T",
2067
+ service,
2068
+ "caddy",
2069
+ "reload",
2070
+ "--config",
2071
+ "/etc/caddy/Caddyfile"
2072
+ ]);
2073
+ },
2074
+ async healthy(composePath, services) {
2075
+ if (services.length === 0) return true;
2076
+ try {
2077
+ const { stdout } = await run("docker", [
2078
+ "compose",
2079
+ ...composeFiles(composePath),
2080
+ "ps",
2081
+ "--format",
2082
+ "json",
2083
+ ...services
2084
+ ]);
2085
+ const running = /* @__PURE__ */ new Set();
2086
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
2087
+ const entry = JSON.parse(line);
2088
+ if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
2089
+ }
2090
+ return services.every((service) => running.has(service));
2091
+ } catch {
2092
+ return false;
2093
+ }
1351
2094
  }
1352
- return void 0;
1353
- }
2095
+ };
2096
+ //#endregion
2097
+ //#region src/reconcile/lan.ts
1354
2098
  /**
1355
- * A backend whose local addresses cannot be derived yet.
1356
- *
1357
- * SNS and SQS are addressed by ARN, which means the topic and queue have to be
1358
- * created in LocalStack before a URL can name them. Until that provisioning
1359
- * lands, saying so is better than handing out a string that fails on first use.
2099
+ * This machine's address on the local network — what a phone running the app
2100
+ * reaches it on.
1360
2101
  */
1361
- var UnprovisionedEventsBackend = class extends Error {
1362
- constructor(backend) {
1363
- super(`The local target cannot derive addresses for '${backend}' yet — its topics and queues are named by ARN, which nothing creates locally. Use 'pgboss' (the default) or 'rabbitmq' for local development.`);
1364
- this.backend = backend;
1365
- this.name = "UnprovisionedEventsBackend";
1366
- }
1367
- };
1368
2102
  /**
1369
- * The value a secret resolves to locally.
2103
+ * The first private IPv4 address of an interface that is up and not loopback.
1370
2104
  *
1371
- * A hash of what identifies it, so it is stable, distinct per project, stage
1372
- * and construct, and never written to disk as a literal. Deployed this is the
1373
- * one kind whose value does *not* come from here — a secret manager generates
1374
- * it once and the target reads it back.
2105
+ * Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
2106
+ * would mean listening for a phone on the open internet, and a link-local one
2107
+ * (`169.254/16`) is what an interface has when it found no network at all.
2108
+ * Undefined when there is none — a laptop on no network still runs, its mobile
2109
+ * app reaching only a simulator.
1375
2110
  */
1376
- function localSecret(project, plan, resource) {
1377
- return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
2111
+ function lanAddress(interfaces = networkInterfaces()) {
2112
+ for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
2113
+ if (entry.family !== "IPv4" || entry.internal) continue;
2114
+ if (isPrivate(entry.address)) return entry.address;
2115
+ }
2116
+ }
2117
+ function isPrivate(address) {
2118
+ return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
1378
2119
  }
1379
-
1380
2120
  //#endregion
1381
2121
  //#region src/reconcile/ports.ts
1382
2122
  /**
2123
+ * Port allocation — why two projects can run at once.
2124
+ *
2125
+ * Publishing `5432:5432` means the second project on your machine cannot start.
2126
+ * Nothing solves that except giving each project its own ports, and what makes
2127
+ * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
2128
+ * never sees a host or a port, so reconcile is free to bind whatever is
2129
+ * available and compose the URL from what it bound. An app reading `DB_HOST` and
2130
+ * `DB_PORT` separately could not be moved this way.
2131
+ *
2132
+ * Assignments are **persisted, not recomputed**. Recomputing means adding a
2133
+ * construct renumbers its neighbours, and every running container, saved
2134
+ * connection string, and `psql` history goes stale at once.
2135
+ *
2136
+ * Pure, with the liveness probe injected — a collision with another project's
2137
+ * stack is a fact about the machine, and keeping it a parameter is what lets the
2138
+ * rules be asserted without binding a socket.
2139
+ */
2140
+ /**
1383
2141
  * The window ports are drawn from.
1384
2142
  *
1385
2143
  * Above the registered range so nothing here needs privileges, and below the
@@ -1437,6 +2195,27 @@ async function allocate(project, containers, existing = {}, probe = isPortFree)
1437
2195
  }
1438
2196
  return assignments;
1439
2197
  }
2198
+ /**
2199
+ * The ports to keep: what running containers are observed on, then what was
2200
+ * saved — minus any saved entry whose port something else already holds.
2201
+ *
2202
+ * Spreading one over the other let an observed port override its own key and
2203
+ * leave a *different* key still saved on the same number: two services on one
2204
+ * port, and whichever answered first won. Saved keys for containers the plan no
2205
+ * longer has — a backend switched and back — were the usual source. Observed
2206
+ * wins because it is what is actually bound; a dropped entry is simply
2207
+ * allocated again.
2208
+ */
2209
+ function keptPorts(saved = {}, observed = {}) {
2210
+ const kept = { ...observed };
2211
+ const taken = new Set(Object.values(observed));
2212
+ for (const [key, port] of Object.entries(saved).sort(([a], [b]) => a < b ? -1 : 1)) {
2213
+ if (key in kept || taken.has(port)) continue;
2214
+ kept[key] = port;
2215
+ taken.add(port);
2216
+ }
2217
+ return kept;
2218
+ }
1440
2219
  /** The window was exhausted. */
1441
2220
  var NoPortAvailable = class extends Error {
1442
2221
  /** Where the search started. */
@@ -1477,17 +2256,33 @@ async function nextFree(from, taken, probe) {
1477
2256
  * rather than as a connection failure ten minutes later.
1478
2257
  */
1479
2258
  function isPortFree(port) {
1480
- return new Promise((resolve$1) => {
2259
+ return new Promise((resolve) => {
1481
2260
  const server = createServer();
1482
- server.once("error", () => resolve$1(false));
1483
- server.once("listening", () => server.close(() => resolve$1(true)));
2261
+ server.once("error", () => resolve(false));
2262
+ server.once("listening", () => server.close(() => resolve(true)));
1484
2263
  server.listen(port, "0.0.0.0");
1485
2264
  });
1486
2265
  }
1487
-
1488
2266
  //#endregion
1489
2267
  //#region src/reconcile/provision.ts
1490
2268
  /**
2269
+ * Creating what the URLs name.
2270
+ *
2271
+ * Convergent and idempotent: every statement asks whether the thing exists
2272
+ * first, so reconciling repeatedly is free and a half-applied run recovers by
2273
+ * being run again. That property is what lets `gkm dev` do this on every start.
2274
+ *
2275
+ * The line the design draws is at data, not at side effects — this creates
2276
+ * databases, schemas, and buckets, and it never seeds, resets, or drops. A
2277
+ * developer who wants their data gone asks for it explicitly.
2278
+ *
2279
+ * Statement generation is separated from application for the reason the design
2280
+ * gives: the same DDL has to run in-process locally and from a provisioner in a
2281
+ * VPC, and one implementation is what stops "works locally, fails deployed".
2282
+ * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
2283
+ * growing its own.
2284
+ */
2285
+ /**
1491
2286
  * The Postgres work a plan implies.
1492
2287
  *
1493
2288
  * Databases before the schemas inside them, which `provisionOrder` already
@@ -1584,6 +2379,7 @@ function rolesFor(resource, schema, plan, project) {
1584
2379
  owner,
1585
2380
  ...reader ? { reader } : {},
1586
2381
  schema,
2382
+ ...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
1587
2383
  passwords: {
1588
2384
  runtime: localRolePassword(project, plan, runtime),
1589
2385
  owner: localRolePassword(project, plan, owner),
@@ -1629,8 +2425,7 @@ async function applyPostgres(client, statements) {
1629
2425
  });
1630
2426
  continue;
1631
2427
  }
1632
- const rows = await client.query(statement.database, statement.exists.sql, statement.exists.values);
1633
- if (rows.length > 0) {
2428
+ if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
1634
2429
  applied.push({
1635
2430
  id: statement.id,
1636
2431
  describe: statement.describe,
@@ -1697,6 +2492,66 @@ function bucketPolicy(bucket, open) {
1697
2492
  });
1698
2493
  }
1699
2494
  /**
2495
+ * The topics and queues to create on the AWS emulator.
2496
+ *
2497
+ * Only on the `sns` backend: pg-boss makes its own tables on first connect and
2498
+ * a RabbitMQ exchange is declared by the client, but an SNS topic and an SQS
2499
+ * queue must exist before anything can publish to them.
2500
+ */
2501
+ function carrierNames(plan) {
2502
+ if (plan.events !== "sns") return {
2503
+ topics: [],
2504
+ queues: []
2505
+ };
2506
+ const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
2507
+ return {
2508
+ topics: named("topic"),
2509
+ queues: named("queue")
2510
+ };
2511
+ }
2512
+ /** Create each topic and queue the plan names, skipping those that exist. */
2513
+ async function applyCarriers(client, carriers) {
2514
+ const applied = [];
2515
+ for (const name of carriers.topics) {
2516
+ const created = !await client.topicExists(name);
2517
+ if (created) await client.createTopic(name);
2518
+ applied.push({
2519
+ id: name,
2520
+ describe: `topic ${name}`,
2521
+ created
2522
+ });
2523
+ }
2524
+ for (const name of carriers.queues) {
2525
+ const created = !await client.queueExists(name);
2526
+ if (created) await client.createQueue(name);
2527
+ applied.push({
2528
+ id: name,
2529
+ describe: `queue ${name}`,
2530
+ created
2531
+ });
2532
+ }
2533
+ return applied;
2534
+ }
2535
+ /** The Postgres databases the plan creates — each is a `CREATE DATABASE`. */
2536
+ function postgresDatabaseNames(plan) {
2537
+ return plan.resources.filter((resource) => resource.kind === "database" && resource.provisions && resource.container === "postgres").map((resource) => resource.name);
2538
+ }
2539
+ /**
2540
+ * Whether every one of these databases exists. A database dropped behind the
2541
+ * recorded state's back — by another checkout's test teardown, against the
2542
+ * same container — makes the state a lie, and only asking Postgres finds out.
2543
+ * Unreachable counts as missing: applying again is what finds out why.
2544
+ */
2545
+ async function databasesExist(client, names) {
2546
+ if (names.length === 0) return true;
2547
+ try {
2548
+ const rows = await client.query(void 0, "SELECT datname FROM pg_catalog.pg_database WHERE datname = ANY($1)", [names]);
2549
+ return new Set(rows.map((row) => row.datname)).size === names.length;
2550
+ } catch {
2551
+ return false;
2552
+ }
2553
+ }
2554
+ /**
1700
2555
  * Apply each served bucket's open-path policy, skipping those already correct.
1701
2556
  *
1702
2557
  * Compared as parsed JSON rather than as text: the same policy re-serialised
@@ -1755,9 +2610,20 @@ async function applyBuckets(client, buckets) {
1755
2610
  }
1756
2611
  return applied;
1757
2612
  }
1758
-
1759
2613
  //#endregion
1760
2614
  //#region src/reconcile/state.ts
2615
+ /**
2616
+ * What reconcile records, and the hash that lets it do nothing.
2617
+ *
2618
+ * Reconciling on every `gkm dev` is only acceptable if the converged case is
2619
+ * free, so the desired plan is hashed: matching hash plus healthy containers
2620
+ * means there is nothing to do. Without that, the automatic reconcile the design
2621
+ * asks for would tax every start.
2622
+ *
2623
+ * Almost nothing needs recording — roles are queried, migrations have their own
2624
+ * table, containers are remembered by Docker. Only ports and this hash have no
2625
+ * other owner.
2626
+ */
1761
2627
  const STATE_DIR = ".gkm/reconcile";
1762
2628
  /**
1763
2629
  * The hash of everything a reconcile would act on.
@@ -1786,7 +2652,7 @@ async function loadState(root, stage) {
1786
2652
  const state = JSON.parse(raw);
1787
2653
  return state.stage === stage ? state : void 0;
1788
2654
  } catch {
1789
- return void 0;
2655
+ return;
1790
2656
  }
1791
2657
  }
1792
2658
  /** Record what was applied, so the next run can skip it. */
@@ -1795,14 +2661,46 @@ async function saveState(root, state) {
1795
2661
  await mkdir(dirname(path), { recursive: true });
1796
2662
  await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
1797
2663
  }
2664
+ /**
2665
+ * Forget what was applied, so the next reconcile of this stage converges from
2666
+ * nothing — creating again whatever was dropped underneath the record.
2667
+ */
2668
+ async function forgetState(root, stage) {
2669
+ await rm(statePath(root, stage), { force: true });
2670
+ }
1798
2671
  function statePath(root, stage) {
1799
2672
  return join(root, STATE_DIR, `${stage}.json`);
1800
2673
  }
1801
-
1802
2674
  //#endregion
1803
2675
  //#region src/reconcile/index.ts
1804
- /** Where the generated compose file is written, relative to the project root. */
1805
- const COMPOSE_PATH = ".gkm/docker-compose.yml";
2676
+ /**
2677
+ * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
2678
+ * all call.
2679
+ *
2680
+ * Three phases, and only this one has side effects: declaring is pure (`gkm
2681
+ * build` and the dev watcher), running reads `<NAME>_URL` and provisions
2682
+ * nothing. Reconcile computes the desired state, compares, and applies the
2683
+ * difference — which is what makes "when does it run" answerable with
2684
+ * *whenever, repeatedly*.
2685
+ *
2686
+ * It is safe to run automatically because its blast radius is entirely local:
2687
+ * this project's containers, this project's `.gkm/`. Nothing it does can reach a
2688
+ * cloud. The line is drawn at data, not at side effects — it allocates ports,
2689
+ * writes compose, starts containers, and creates roles; it never seeds, resets,
2690
+ * or drops.
2691
+ *
2692
+ * Docker is injected rather than imported so the rules above can be asserted
2693
+ * without a daemon. The default implementation is the real one.
2694
+ */
2695
+ /**
2696
+ * Where the generated compose file is written, relative to the project root.
2697
+ *
2698
+ * At the root, beside the project's own `docker-compose.yml`, because the two
2699
+ * are one stack: this file is everything the constructs imply and is never
2700
+ * edited, that one is anything they cannot express — an image pin, an extra
2701
+ * service — and is merged over it, so it wins.
2702
+ */
2703
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
1806
2704
  /**
1807
2705
  * The local edge's config, beside the compose file that mounts it.
1808
2706
  *
@@ -1838,32 +2736,37 @@ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
1838
2736
  * hash and one health check.
1839
2737
  */
1840
2738
  async function reconcile(options) {
1841
- const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient } = options;
2739
+ const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient, carriers = carrierClient } = options;
1842
2740
  const plan = planFor(manifest, stage, provisionOrder(manifest), {
2741
+ localStage: options.localStage,
1843
2742
  events: options.events,
1844
2743
  cache: options.cache,
1845
2744
  extraContainers: options.extraContainers,
2745
+ ...options.fakes ? { fakes: options.fakes } : {},
1846
2746
  ...options.edge === void 0 ? {} : { edge: options.edge }
1847
2747
  });
1848
2748
  const composePath = join(root, COMPOSE_PATH);
1849
- const observed = await observedPorts(docker, composePath, plan.containers);
1850
- const ports = await allocate(project, portKeys(plan.containers), {
1851
- ...options.saved,
1852
- ...observed
1853
- }, probe);
2749
+ const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
2750
+ const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
1854
2751
  const compose = composeFor(plan, {
1855
2752
  project,
1856
2753
  ports,
1857
- ...options.images ? { images: options.images } : {}
2754
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
1858
2755
  });
1859
2756
  const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
1860
- const hash = planHash(plan, compose, { caddyfile });
1861
- const addresses = addressesFor(plan.containers, ports);
2757
+ const hash = planHash(plan, compose, {
2758
+ caddyfile,
2759
+ postgres: postgresStatements(plan, project).map((statement) => statement.create).join(";\n")
2760
+ });
2761
+ const services = serviceAddresses(plan.containers, ports, plan.fakes);
2762
+ const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
1862
2763
  const env = envFor(plan, {
1863
2764
  ports,
1864
2765
  project,
1865
2766
  ...options.mailFrom ? { mailFrom: options.mailFrom } : {},
1866
- ...options.addresses ? { addresses: options.addresses } : {}
2767
+ ...options.addresses ? { addresses: options.addresses } : {},
2768
+ ...lan ? { lanAddress: lan } : {},
2769
+ ...options.metroPorts ? { metroPorts: options.metroPorts } : {}
1867
2770
  });
1868
2771
  if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
1869
2772
  const result = {
@@ -1871,678 +2774,255 @@ async function reconcile(options) {
1871
2774
  plan,
1872
2775
  compose,
1873
2776
  ports,
1874
- addresses,
2777
+ services,
1875
2778
  env,
1876
2779
  provisioned: [],
2780
+ fakes: options.fakes ?? {},
1877
2781
  hash,
1878
2782
  changed: false
1879
2783
  };
1880
2784
  const recorded = await loadState(root, stage);
1881
- const converged = recorded?.hash === hash && (!start || await docker.healthy(composePath, plan.containers));
1882
- if (converged) return result;
2785
+ const postgresPort = ports[primaryPortKey("postgres")];
2786
+ if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort), postgresDatabaseNames(plan)))) return result;
1883
2787
  await write(composePath, toYaml(compose));
1884
2788
  if (plan.containers.includes("caddy")) {
1885
2789
  await write(join(root, CADDYFILE_PATH), caddyfileRoot());
1886
- await write(join(root, caddySitesPath(stage)), caddyfile);
1887
- }
1888
- if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
1889
- if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
1890
- if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
1891
- const provisioned = start && provision ? await create(plan, ports, sql, buckets, project) : [];
1892
- await saveState(root, {
1893
- hash,
1894
- stage
1895
- });
1896
- return {
1897
- ...result,
1898
- provisioned,
1899
- changed: true
1900
- };
1901
- }
1902
- /**
1903
- * Create the databases, schemas, and buckets the plan names.
1904
- *
1905
- * Idempotent throughout, so this runs on every non-converged reconcile rather
1906
- * than being something to remember.
1907
- */
1908
- async function create(plan, ports, sql, buckets, project) {
1909
- const applied = [];
1910
- const postgresPort = ports[primaryPortKey("postgres")];
1911
- const statements = postgresStatements(plan, project);
1912
- if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
1913
- const minioPort = ports[primaryPortKey("minio")];
1914
- const names = bucketNames(plan);
1915
- const policies = bucketPolicies(plan);
1916
- if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
1917
- const client = buckets(minioPort);
1918
- if (names.length > 0) applied.push(...await applyBuckets(client, names));
1919
- if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
1920
- }
1921
- return applied;
1922
- }
1923
- /**
1924
- * The host ports of containers already running.
1925
- *
1926
- * Keyed the same way allocation keys them, so the two merge without either
1927
- * knowing about the other.
1928
- */
1929
- async function observedPorts(docker, composePath, containers) {
1930
- const observed = {};
1931
- for (const container of containers) for (const port of portsOf(container)) {
1932
- const running = await docker.publishedPort(composePath, container, port.inside);
1933
- if (running !== void 0) observed[port.key] = running;
1934
- }
1935
- return observed;
1936
- }
1937
- /**
1938
- * Where each container can be reached on this machine.
1939
- *
1940
- * The primary port only — a container's console is for a human to open, not
1941
- * something an app connects to.
1942
- */
1943
- function addressesFor(containers, ports) {
1944
- const addresses = {};
1945
- for (const container of containers) {
1946
- const port = ports[primaryPortKey(container)];
1947
- if (port !== void 0) addresses[container] = `localhost:${port}`;
1948
- }
1949
- return addresses;
1950
- }
1951
- /** Write a file only when its content would change, so mtimes stay meaningful. */
1952
- async function write(path, content) {
1953
- try {
1954
- if (await readFile(path, "utf-8") === content) return;
1955
- } catch {}
1956
- await mkdir(dirname(path), { recursive: true });
1957
- await writeFile(path, content);
1958
- }
1959
-
1960
- //#endregion
1961
- //#region src/reconcile/workspace.ts
1962
- /**
1963
- * Every constructs glob in the workspace, resolved against its app.
1964
- *
1965
- * Absolute because discovery globs from a single cwd while apps live in
1966
- * different directories.
1967
- */
1968
- function constructGlobs(workspace) {
1969
- return allConstructGlobs(workspace);
1970
- }
1971
- /** Whether any app has adopted the constructs glob. */
1972
- function usesConstructs(workspace) {
1973
- return constructGlobs(workspace).length > 0;
1974
- }
1975
- /**
1976
- * Image pins, by container. The config half of the derived/config split.
1977
- *
1978
- * Read from `services.images` rather than from the backend keys beside it.
1979
- * They used to share one key, which is how `cache: true` came to mean "start a
1980
- * Redis" — the last way a container could exist because config asked for one
1981
- * instead of because something declared it.
1982
- */
1983
- function imagePins(workspace) {
1984
- return { ...workspace.services.images };
1985
- }
1986
- /**
1987
- * The containers a workspace's declarations imply, without reconciling.
1988
- *
1989
- * The same derivation `reconcileWorkspace` makes, available to callers that
1990
- * need to know *what would run* without starting anything or writing ports —
1991
- * `gkm setup` deciding which credentials to generate, `gkm docker` writing a
1992
- * compose file. Those used to each read a boolean per service out of config,
1993
- * which is how a container could exist because config asked for one rather than
1994
- * because something declared it.
1995
- */
1996
- async function derivedContainers(workspace, stage, manifest) {
1997
- const found = manifest ?? await discover({
1998
- patterns: constructGlobs(workspace),
1999
- cwd: workspace.root
2000
- });
2001
- return planFor(found, stage, provisionOrder(found), {
2002
- events: workspace.services.events,
2003
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
2004
- }).containers;
2005
- }
2006
- /**
2007
- * Reconcile a workspace for one stage.
2008
- *
2009
- * Ports are loaded and saved around the call rather than inside it, so the loop
2010
- * stays a pure-ish function of its inputs and the store keeps its single owner.
2011
- */
2012
- async function reconcileWorkspace(workspace, options) {
2013
- const manifest = options.manifest ?? await discover({
2014
- patterns: constructGlobs(workspace),
2015
- cwd: workspace.root
2016
- });
2017
- const result = await reconcile({
2018
- root: workspace.root,
2019
- project: workspace.name,
2020
- manifest,
2021
- stage: options.stage,
2022
- events: workspace.services.events,
2023
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
2024
- images: imagePins(workspace),
2025
- saved: await loadPortState(workspace.root),
2026
- addresses: surfaceAddresses(workspace, manifest),
2027
- ...options.start === void 0 ? {} : { start: options.start }
2028
- });
2029
- await savePortState(workspace.root, { ...result.ports });
2030
- return result;
2031
- }
2032
- /**
2033
- * Where each declared surface and site answers locally.
2034
- *
2035
- * The addresses come from the ports the workspace already assigns; which
2036
- * construct sits at which one comes from the manifest. That split is the point:
2037
- * this used to answer "who may call whom" by listing every app the workspace
2038
- * runs, which is a different question that happened to give the same answer in
2039
- * a single-repo workspace and no answer at all deployed. Now it answers only
2040
- * "where does this construct answer", and the graph answers the rest.
2041
- *
2042
- * A site is matched to its app by `path`, because that is the one thing a site
2043
- * declaration and a workspace app both name. Surfaces are matched to the
2044
- * backend app: every `rest-api` in a process answers on that process's port, so
2045
- * an app serving both its own API and an auth server publishes one address
2046
- * twice — which is exactly what it does at runtime.
2047
- */
2048
- function surfaceAddresses(workspace, manifest) {
2049
- const addresses = {};
2050
- for (const [id, declaration] of Object.entries(manifest)) {
2051
- if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
2052
- const host = declaration.kind === "site" ? appKey(id) : appKeyOfHost(manifest, id);
2053
- const app = host ? workspace.apps[host] : void 0;
2054
- if (app?.port) addresses[id] = localAddress(app.port);
2055
- }
2056
- return addresses;
2057
- }
2058
- /** The app key serving a surface, following a mount to its host. */
2059
- function appKeyOfHost(manifest, id) {
2060
- const host = hostOf(manifest, id);
2061
- return host ? appKey(host) : void 0;
2062
- }
2063
- /** Where a local process answers, given the port the workspace gave it. */
2064
- function localAddress(port) {
2065
- return `http://localhost:${port}`;
2066
- }
2067
-
2068
- //#endregion
2069
- //#region src/credentials/index.ts
2070
- const logger = console;
2071
- /**
2072
- * Load environment files
2073
- * @internal Exported for testing
2074
- */
2075
- function loadEnvFiles(envConfig, cwd = process.cwd()) {
2076
- const loaded = [];
2077
- const missing = [];
2078
- const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
2079
- for (const envFile of envFiles) {
2080
- const envPath = resolve(cwd, envFile);
2081
- if (existsSync(envPath)) {
2082
- config({
2083
- path: envPath,
2084
- override: true,
2085
- quiet: true
2086
- });
2087
- loaded.push(envFile);
2088
- } else if (envConfig) missing.push(envFile);
2089
- }
2090
- return {
2091
- loaded,
2092
- missing
2093
- };
2094
- }
2095
- /**
2096
- * Check if a port is available
2097
- * @internal Exported for testing
2098
- */
2099
- async function isPortAvailable(port) {
2100
- return new Promise((resolve$1) => {
2101
- const server = createServer();
2102
- server.once("error", (err) => {
2103
- if (err.code === "EADDRINUSE") resolve$1(false);
2104
- else resolve$1(false);
2105
- });
2106
- server.once("listening", () => {
2107
- server.close();
2108
- resolve$1(true);
2109
- });
2110
- server.listen(port);
2111
- });
2112
- }
2113
- /**
2114
- * Find an available port starting from the preferred port
2115
- * @internal Exported for testing
2116
- */
2117
- async function findAvailablePort(preferredPort, maxAttempts = 10) {
2118
- for (let i = 0; i < maxAttempts; i++) {
2119
- const port = preferredPort + i;
2120
- if (await isPortAvailable(port)) return port;
2121
- logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
2122
- }
2123
- throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
2124
- }
2125
- const PORT_STATE_PATH = ".gkm/ports.json";
2126
- /**
2127
- * Parse docker-compose.yml and extract all port mappings that use env var interpolation.
2128
- * Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
2129
- * Fixed port mappings like `'5050:80'` are skipped.
2130
- * @internal Exported for testing
2131
- */
2132
- function parseComposePortMappings(composePath) {
2133
- if (!existsSync(composePath)) return [];
2134
- const content = readFileSync(composePath, "utf-8");
2135
- const compose = parse$1(content);
2136
- if (!compose?.services) return [];
2137
- const results = [];
2138
- for (const [serviceName, serviceConfig] of Object.entries(compose.services)) for (const portMapping of serviceConfig?.ports ?? []) {
2139
- const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
2140
- if (match?.[1] && match[2] && match[3]) results.push({
2141
- service: serviceName,
2142
- envVar: match[1],
2143
- defaultPort: Number(match[2]),
2144
- containerPort: Number(match[3])
2145
- });
2146
- }
2147
- return results;
2148
- }
2149
- /**
2150
- * Load saved port state from .gkm/ports.json.
2151
- * @internal Exported for testing
2152
- */
2153
- async function loadPortState(workspaceRoot) {
2154
- try {
2155
- const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
2156
- return JSON.parse(raw);
2157
- } catch {
2158
- return {};
2159
- }
2160
- }
2161
- /**
2162
- * Save port state to .gkm/ports.json.
2163
- * @internal Exported for testing
2164
- */
2165
- async function savePortState(workspaceRoot, ports) {
2166
- const dir = join(workspaceRoot, ".gkm");
2167
- await mkdir(dir, { recursive: true });
2168
- await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
2169
- }
2170
- /**
2171
- * Check if a project's own Docker container is running and return its host port.
2172
- * Uses `docker compose port` scoped to the project's compose file.
2173
- * @internal Exported for testing
2174
- */
2175
- function getContainerHostPort(workspaceRoot, service, containerPort) {
2176
- try {
2177
- const result = execSync(`docker compose port ${service} ${containerPort}`, {
2178
- cwd: workspaceRoot,
2179
- stdio: "pipe"
2180
- }).toString().trim();
2181
- const match = result.match(/:(\d+)$/);
2182
- return match ? Number(match[1]) : null;
2183
- } catch {
2184
- return null;
2790
+ await write(join(root, caddySitesPath(stage)), caddyfile);
2791
+ await pruneCaddySites(root, [
2792
+ stage,
2793
+ options.localStage,
2794
+ TEST_STAGE
2795
+ ]);
2185
2796
  }
2186
- }
2187
- /**
2188
- * Resolve host ports for Docker services by parsing docker-compose.yml.
2189
- * Priority: running container → saved state → find available port.
2190
- * Persists resolved ports to .gkm/ports.json.
2191
- * @internal Exported for testing
2192
- */
2193
- async function resolveServicePorts(workspaceRoot) {
2194
- const composePath = join(workspaceRoot, "docker-compose.yml");
2195
- const mappings = parseComposePortMappings(composePath);
2196
- if (mappings.length === 0) return {
2197
- dockerEnv: {},
2198
- ports: {},
2199
- mappings: []
2200
- };
2201
- const savedState = await loadPortState(workspaceRoot);
2202
- const dockerEnv = {};
2203
- const ports = {};
2204
- const assignedPorts = /* @__PURE__ */ new Set();
2205
- logger.log("\n🔌 Resolving service ports...");
2206
- for (const mapping of mappings) {
2207
- const containerPort = getContainerHostPort(workspaceRoot, mapping.service, mapping.containerPort);
2208
- if (containerPort !== null) {
2209
- ports[mapping.envVar] = containerPort;
2210
- dockerEnv[mapping.envVar] = String(containerPort);
2211
- assignedPorts.add(containerPort);
2212
- logger.log(` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`);
2213
- continue;
2214
- }
2215
- const savedPort = savedState[mapping.envVar];
2216
- if (savedPort && !assignedPorts.has(savedPort) && await isPortAvailable(savedPort)) {
2217
- ports[mapping.envVar] = savedPort;
2218
- dockerEnv[mapping.envVar] = String(savedPort);
2219
- assignedPorts.add(savedPort);
2220
- logger.log(` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`);
2221
- continue;
2222
- }
2223
- let resolvedPort = await findAvailablePort(mapping.defaultPort);
2224
- while (assignedPorts.has(resolvedPort)) resolvedPort = await findAvailablePort(resolvedPort + 1);
2225
- ports[mapping.envVar] = resolvedPort;
2226
- dockerEnv[mapping.envVar] = String(resolvedPort);
2227
- assignedPorts.add(resolvedPort);
2228
- if (resolvedPort !== mapping.defaultPort) logger.log(` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`);
2229
- else logger.log(` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`);
2797
+ if (start && plan.containers.length > 0) {
2798
+ options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
2799
+ await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
2230
2800
  }
2231
- await savePortState(workspaceRoot, ports);
2801
+ if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
2802
+ if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
2803
+ if (start && provision) options.progress?.("🗄️ Creating what the constructs declare — databases, roles, buckets, topics");
2804
+ const provisioned = start && provision ? await create(plan, ports, sql, buckets, carriers, project) : [];
2805
+ await saveState(root, {
2806
+ hash,
2807
+ stage
2808
+ });
2232
2809
  return {
2233
- dockerEnv,
2234
- ports,
2235
- mappings
2810
+ ...result,
2811
+ provisioned,
2812
+ changed: true
2236
2813
  };
2237
2814
  }
2238
2815
  /**
2239
- * Replace a port in a URL string.
2240
- * Handles both `hostname:port` and `localhost:port` patterns.
2241
- * @internal Exported for testing
2816
+ * Create the databases, schemas, buckets, topics and queues the plan names.
2817
+ *
2818
+ * Idempotent throughout, so this runs on every non-converged reconcile rather
2819
+ * than being something to remember.
2242
2820
  */
2243
- function replacePortInUrl(url, oldPort, newPort) {
2244
- if (oldPort === newPort) return url;
2245
- let result = url.replace(new RegExp(`:${oldPort}(?=[/?#]|$)`, "g"), `:${newPort}`);
2246
- result = result.replace(new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, "gi"), `%3A${newPort}`);
2247
- return result;
2821
+ async function create(plan, ports, sql, buckets, emulatorCarriers, project) {
2822
+ const applied = [];
2823
+ const postgresPort = ports[primaryPortKey("postgres")];
2824
+ const statements = postgresStatements(plan, project);
2825
+ if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
2826
+ const minioPort = ports[primaryPortKey("minio")];
2827
+ const names = bucketNames(plan);
2828
+ const policies = bucketPolicies(plan);
2829
+ if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
2830
+ const client = buckets(minioPort);
2831
+ if (names.length > 0) applied.push(...await applyBuckets(client, names));
2832
+ if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
2833
+ }
2834
+ const emulatorPort = ports[primaryPortKey("localstack")];
2835
+ const carriers = carrierNames(plan);
2836
+ if (emulatorPort !== void 0 && carriers.topics.length + carriers.queues.length > 0) applied.push(...await applyCarriers(emulatorCarriers(emulatorPort), carriers));
2837
+ return applied;
2248
2838
  }
2249
2839
  /**
2250
- * Rewrite connection URLs and port vars in secrets with resolved ports.
2251
- * Uses the parsed compose mappings to determine which default ports to replace.
2252
- * Pure transform — does not modify secrets on disk.
2253
- * @internal Exported for testing
2840
+ * The host ports of containers already running.
2841
+ *
2842
+ * Keyed the same way allocation keys them, so the two merge without either
2843
+ * knowing about the other.
2254
2844
  */
2255
- function rewriteUrlsWithPorts(secrets, resolvedPorts) {
2256
- const { ports, mappings } = resolvedPorts;
2257
- const result = { ...secrets };
2258
- const portReplacements = [];
2259
- const serviceNames = /* @__PURE__ */ new Set();
2260
- for (const mapping of mappings) {
2261
- serviceNames.add(mapping.service);
2262
- const resolved = ports[mapping.envVar];
2263
- if (resolved !== void 0) portReplacements.push({
2264
- defaultPort: mapping.defaultPort,
2265
- resolvedPort: resolved
2266
- });
2267
- }
2268
- for (const [key, value] of Object.entries(result)) {
2269
- if (!key.endsWith("_HOST")) continue;
2270
- if (serviceNames.has(value)) result[key] = "localhost";
2271
- }
2272
- for (const [key, value] of Object.entries(result)) {
2273
- if (!key.endsWith("_PORT")) continue;
2274
- for (const { defaultPort, resolvedPort } of portReplacements) if (value === String(defaultPort)) result[key] = String(resolvedPort);
2275
- }
2276
- for (const [key, value] of Object.entries(result)) {
2277
- if (!key.endsWith("_URL") && !key.endsWith("_ENDPOINT") && !key.endsWith("_CONNECTION_STRING") && key !== "DATABASE_URL") continue;
2278
- let rewritten = value;
2279
- for (const name of serviceNames) rewritten = rewritten.replace(new RegExp(`@${name}:`, "g"), "@localhost:");
2280
- for (const { defaultPort, resolvedPort } of portReplacements) rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
2281
- result[key] = rewritten;
2845
+ async function observedPorts(docker, composePath, containers, fakes) {
2846
+ const observed = {};
2847
+ for (const container of containers) for (const port of portsOf(container, fakes)) {
2848
+ const running = await docker.publishedPort(composePath, container, port.inside);
2849
+ if (running !== void 0) observed[port.key] = running;
2282
2850
  }
2283
- return result;
2851
+ return observed;
2284
2852
  }
2853
+ /** Write a file only when its content would change, so mtimes stay meaningful. */
2285
2854
  /**
2286
- * Build the environment variables to pass to `docker compose up`.
2287
- * Merges process.env, secrets, and port mappings so that Docker Compose
2288
- * can interpolate variables like ${POSTGRES_USER} correctly.
2289
- * @internal Exported for testing
2855
+ * Remove the routes of stages this project no longer reconciles.
2856
+ *
2857
+ * Only the local stage and `test` are ever written, so any other file is left
2858
+ * over from a stage that was renamed — `development` becoming `dev`. Caddy
2859
+ * imports every file, and the leftover declares the same hosts as its
2860
+ * replacement: it refuses the whole config as ambiguous, and every URL behind
2861
+ * the edge goes with it.
2290
2862
  */
2291
- function buildDockerComposeEnv(secretsEnv, portEnv) {
2292
- return {
2293
- ...process.env,
2294
- ...secretsEnv,
2295
- ...portEnv
2296
- };
2863
+ async function pruneCaddySites(root, stages) {
2864
+ const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
2865
+ const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
2866
+ for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
2297
2867
  }
2298
- /**
2299
- * Parse all service names from a docker-compose.yml file.
2300
- * @internal Exported for testing
2301
- */
2302
- function parseComposeServiceNames(composePath) {
2303
- if (!existsSync(composePath)) return [];
2304
- const content = readFileSync(composePath, "utf-8");
2305
- const compose = parse$1(content);
2306
- return Object.keys(compose?.services ?? {});
2868
+ async function write(path, content) {
2869
+ try {
2870
+ if (await readFile(path, "utf-8") === content) return;
2871
+ } catch {}
2872
+ await mkdir(dirname(path), { recursive: true });
2873
+ await writeFile(path, content);
2307
2874
  }
2875
+ //#endregion
2876
+ //#region src/reconcile/workspace.ts
2308
2877
  /**
2309
- * Start docker-compose services for a single-app project (no workspace config).
2310
- * Starts all services defined in docker-compose.yml.
2878
+ * Workspace config → reconcile.
2879
+ *
2880
+ * Nothing here is read from a `services:` block — there is none. Which
2881
+ * containers exist comes from the manifest; which backend a cache or a broker
2882
+ * resolves to comes from the deploy target; and an image pin is the project's
2883
+ * own `docker-compose.yml`, merged over the generated file.
2884
+ *
2885
+ * Shared by `gkm setup`, `gkm dev`, and `gkm test` so all three converge on the
2886
+ * same state, differing only in the stage they pass.
2311
2887
  */
2312
- async function startComposeServices(cwd, portEnv, secretsEnv) {
2313
- const composeFile = join(cwd, "docker-compose.yml");
2314
- if (!existsSync(composeFile)) return;
2315
- const servicesToStart = parseComposeServiceNames(composeFile);
2316
- if (servicesToStart.length === 0) return;
2317
- logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
2318
- try {
2319
- execSync(`docker compose up -d ${servicesToStart.join(" ")}`, {
2320
- cwd,
2321
- stdio: "inherit",
2322
- env: buildDockerComposeEnv(secretsEnv, portEnv)
2323
- });
2324
- logger.log("✅ Services started");
2325
- } catch (error) {
2326
- logger.error("❌ Failed to start services:", error.message);
2327
- throw error;
2328
- }
2329
- }
2888
+ var workspace_exports = /* @__PURE__ */ __exportAll({
2889
+ FAKE_ENV: () => FAKE_ENV,
2890
+ backendsOf: () => backendsOf,
2891
+ constructGlobs: () => constructGlobs,
2892
+ derivedContainers: () => derivedContainers,
2893
+ fakesApply: () => fakesApply,
2894
+ metroPorts: () => metroPorts,
2895
+ reconcileWorkspace: () => reconcileWorkspace,
2896
+ surfaceAddresses: () => surfaceAddresses
2897
+ });
2330
2898
  /**
2331
- * Start docker-compose services for a workspace.
2332
- * Discovers all services from docker-compose.yml and starts everything
2333
- * except app services (which are managed by turbo).
2334
- * @internal Exported for testing
2899
+ * Every constructs glob in the workspace, resolved against its app.
2900
+ *
2901
+ * Absolute because discovery globs from a single cwd while apps live in
2902
+ * different directories.
2335
2903
  */
2336
- async function startWorkspaceServices(workspace, portEnv, secretsEnv) {
2337
- const composeFile = join(workspace.root, "docker-compose.yml");
2338
- if (!existsSync(composeFile)) return;
2339
- const allServices = parseComposeServiceNames(composeFile);
2340
- const appNames = new Set(Object.keys(workspace.apps));
2341
- const servicesToStart = allServices.filter((name) => !appNames.has(name));
2342
- if (servicesToStart.length === 0) return;
2343
- logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
2344
- try {
2345
- execSync(`docker compose up -d ${servicesToStart.join(" ")}`, {
2346
- cwd: workspace.root,
2347
- stdio: "inherit",
2348
- env: buildDockerComposeEnv(secretsEnv, portEnv)
2349
- });
2350
- logger.log("✅ Services started");
2351
- } catch (error) {
2352
- logger.error("❌ Failed to start services:", error.message);
2353
- throw error;
2354
- }
2904
+ function constructGlobs(workspace) {
2905
+ return allConstructGlobs(workspace);
2906
+ }
2907
+ /** The backends a workspace's target implies, as the plan takes them. */
2908
+ function backendsOf(workspace) {
2909
+ const on = providerOf(workspace);
2910
+ return {
2911
+ events: eventsBackendFor(on),
2912
+ cache: cacheBackendFor(on)
2913
+ };
2355
2914
  }
2356
2915
  /**
2357
- * Load and flatten secrets for an app from encrypted storage.
2358
- * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
2359
- * @internal Exported for testing
2916
+ * The containers a workspace's declarations imply, without reconciling.
2917
+ *
2918
+ * The same derivation `reconcileWorkspace` makes, available to callers that
2919
+ * need to know *what would run* without starting anything or writing ports —
2920
+ * `gkm setup` deciding which credentials to generate, `gkm docker` writing a
2921
+ * compose file. Those used to each read a boolean per service out of config,
2922
+ * which is how a container could exist because config asked for one rather than
2923
+ * because something declared it.
2360
2924
  */
2361
- async function loadSecretsForApp(secretsRoot, appName, stages = ["dev", "development"]) {
2362
- let secrets = {};
2363
- for (const stage of stages) if (secretsExist(stage, secretsRoot)) {
2364
- const stageSecrets = await readStageSecrets(stage, secretsRoot);
2365
- if (stageSecrets) {
2366
- logger.log(`🔐 Loading secrets from stage: ${stage}`);
2367
- secrets = toEmbeddableSecrets(stageSecrets);
2368
- break;
2369
- }
2370
- }
2371
- if (Object.keys(secrets).length === 0) return {};
2372
- if (!appName) return secrets;
2373
- const prefix = appName.toUpperCase();
2374
- const mapped = { ...secrets };
2375
- const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
2376
- if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
2377
- return mapped;
2925
+ async function derivedContainers(workspace, stage, manifest) {
2926
+ const found = manifest ?? await discover({
2927
+ patterns: constructGlobs(workspace),
2928
+ cwd: workspace.root
2929
+ });
2930
+ return planFor(found, stage, provisionOrder(found), {
2931
+ localStage: workspace.stages.local,
2932
+ ...backendsOf(workspace)
2933
+ }).containers;
2378
2934
  }
2379
2935
  /**
2380
- * Walk up the directory tree to find the root containing .gkm/secrets/.
2381
- * @internal Exported for testing
2936
+ * Set by `gkm dev --fake` for the apps it starts. Each app's own `gkm dev`
2937
+ * reconciles too, and must answer the way the workspace did.
2382
2938
  */
2383
- function findSecretsRoot(startDir) {
2384
- let dir = startDir;
2385
- while (dir !== "/") {
2386
- if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
2387
- const parent = dirname(dir);
2388
- if (parent === dir) break;
2389
- dir = parent;
2390
- }
2391
- return startDir;
2392
- }
2939
+ const FAKE_ENV = "GKM_FAKE";
2393
2940
  /**
2394
- * Generate the credentials injection code snippet.
2395
- * This is the common logic used by both entry wrapper and exec preload.
2396
- * @internal
2941
+ * Whether external APIs are faked: always for `gkm test`, and for `gkm dev`
2942
+ * only when asked — by `--fake`, or by the workspace that started this app
2943
+ * with it. Never for anything acting on a deployed stage.
2397
2944
  */
2398
- function generateCredentialsInjection(secretsJsonPath) {
2399
- return `import { existsSync, readFileSync } from 'node:fs';
2400
-
2401
- // Inject dev secrets via globalThis and process.env
2402
- // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
2403
- // Object.assign on the Credentials export only mutates one module copy.
2404
- const secretsPath = '${secretsJsonPath}';
2405
- if (existsSync(secretsPath)) {
2406
- const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
2407
- globalThis.__gkm_credentials__ = secrets;
2408
- Object.assign(process.env, secrets);
2409
- }
2410
- `;
2945
+ function fakesApply(stage, fake, env = process.env) {
2946
+ return fake === true || stage === "test" || env["GKM_FAKE"] === "1";
2411
2947
  }
2412
2948
  /**
2413
- * Create a preload script that injects secrets into Credentials.
2414
- * Used by `gkm exec` to inject secrets before running any command.
2415
- * @internal Exported for testing
2949
+ * Reconcile a workspace for one stage.
2950
+ *
2951
+ * Ports are loaded and saved around the call rather than inside it, so the loop
2952
+ * stays a pure-ish function of its inputs and the store keeps its single owner.
2416
2953
  */
2417
- async function createCredentialsPreload(preloadPath, secretsJsonPath) {
2418
- const content = `/**
2419
- * Credentials preload generated by 'gkm exec'
2420
- * This file is loaded via NODE_OPTIONS="--import <path>"
2421
- */
2422
- ${generateCredentialsInjection(secretsJsonPath)}`;
2423
- await writeFile(preloadPath, content);
2954
+ async function reconcileWorkspace(workspace, options) {
2955
+ const runnables = {};
2956
+ const discovered = await discover({
2957
+ patterns: constructGlobs(workspace),
2958
+ cwd: workspace.root,
2959
+ runnables
2960
+ });
2961
+ const manifest = options.manifest ?? discovered;
2962
+ const fakes = fakesApply(options.stage, options.fake) ? await readFakes(workspace.root, manifest) : {};
2963
+ const result = await reconcile({
2964
+ root: workspace.root,
2965
+ project: workspace.name,
2966
+ manifest,
2967
+ stage: options.stage,
2968
+ localStage: workspace.stages.local,
2969
+ ...backendsOf(workspace),
2970
+ saved: await loadPortState(workspace.root),
2971
+ addresses: surfaceAddresses(workspace, manifest),
2972
+ metroPorts: metroPorts(workspace, manifest),
2973
+ fakes,
2974
+ apps: (containers) => appServices(workspace, manifest, containers, runnables, fakes),
2975
+ ...options.start === void 0 ? {} : { start: options.start },
2976
+ ...options.progress ? { progress: options.progress } : {}
2977
+ });
2978
+ await savePortState(workspace.root, { ...result.ports });
2979
+ return result;
2424
2980
  }
2425
2981
  /**
2426
- * Create a wrapper script that injects secrets before importing the entry file.
2427
- * @internal Exported for testing
2982
+ * Where each mobile app's Metro runs: its app's port, which `gkm exec` hands
2983
+ * Expo as `RCT_METRO_PORT`. Kept apart from the surfaces' addresses — a phone
2984
+ * reaches Metro by `exp://`, not through the edge.
2428
2985
  */
2429
- async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
2430
- const credentialsInjection = secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
2431
- ` : "";
2432
- const content = `#!/usr/bin/env node
2433
- /**
2434
- * Entry wrapper generated by 'gkm dev --entry'
2435
- */
2436
- ${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
2437
- await import('${entryPath}');
2438
- `;
2439
- await writeFile(wrapperPath, content);
2986
+ function metroPorts(workspace, manifest) {
2987
+ const ports = {};
2988
+ for (const [id, declaration] of Object.entries(manifest)) {
2989
+ if (declaration.kind !== "mobile-app") continue;
2990
+ const port = workspace.apps[appKey(id)]?.port;
2991
+ if (port) ports[id] = port;
2992
+ }
2993
+ return ports;
2440
2994
  }
2441
2995
  /**
2442
- * Prepare credentials for dev/exec/test modes.
2443
- * Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
2444
- * injects PORT, dependency URLs, and writes credentials JSON.
2996
+ * Where each declared surface and site answers locally.
2445
2997
  *
2446
- * @param options.resolveDockerPorts - How to resolve Docker ports:
2447
- * - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
2448
- * - `'readonly'`: check running containers and saved state only, never probe for new ports. Used by exec.
2449
- * @param options.stages - Secret stages to try, in order. Default: ['dev', 'development'].
2450
- * @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
2451
- * @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
2452
- * @internal Exported for testing
2998
+ * The addresses come from the ports the workspace already assigns; which
2999
+ * construct sits at which one comes from the manifest. That split is the point:
3000
+ * this used to answer "who may call whom" by listing every app the workspace
3001
+ * runs, which is a different question that happened to give the same answer in
3002
+ * a single-repo workspace and no answer at all deployed. Now it answers only
3003
+ * "where does this construct answer", and the graph answers the rest.
3004
+ *
3005
+ * A site is matched to its app by `path`, because that is the one thing a site
3006
+ * declaration and a workspace app both name. Surfaces are matched to the
3007
+ * backend app: every `rest-api` in a process answers on that process's port, so
3008
+ * an app serving both its own API and an auth server publishes one address
3009
+ * twice — which is exactly what it does at runtime.
2453
3010
  */
2454
- async function prepareEntryCredentials(options) {
2455
- const cwd = options.cwd ?? process.cwd();
2456
- const portMode = options.resolveDockerPorts ?? "full";
2457
- let workspaceAppPort;
2458
- let secretsRoot = cwd;
2459
- let appName;
2460
- let appInfo;
2461
- try {
2462
- appInfo = await loadWorkspaceAppInfo(cwd);
2463
- workspaceAppPort = appInfo.app.port;
2464
- secretsRoot = appInfo.workspaceRoot;
2465
- appName = appInfo.appName;
2466
- } catch {
2467
- secretsRoot = findSecretsRoot(cwd);
2468
- appName = getAppNameFromCwd(cwd) ?? void 0;
2469
- }
2470
- const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
2471
- const credentials = await loadSecretsForApp(secretsRoot, appName, options.stages);
2472
- credentials.PORT = String(resolvedPort);
2473
- const declaredKeys = [];
2474
- if (appInfo && usesConstructs(appInfo.workspace)) {
2475
- const { reconcileWorkspace: reconcileWorkspace$1 } = await import("./workspace-k4l3rsFh.mjs");
2476
- const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
2477
- stage: options.reconcileStage ?? "development",
2478
- start: options.startDocker ?? false
2479
- });
2480
- Object.assign(credentials, reconciled.env);
2481
- declaredKeys.push(...Object.keys(reconciled.env));
2482
- if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
2483
- } else {
2484
- const composePath = join(secretsRoot, "docker-compose.yml");
2485
- const mappings = parseComposePortMappings(composePath);
2486
- if (mappings.length > 0) {
2487
- let resolvedPorts;
2488
- if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
2489
- else {
2490
- const savedPorts = await loadPortState(secretsRoot);
2491
- const ports = {};
2492
- for (const mapping of mappings) {
2493
- const containerPort = getContainerHostPort(secretsRoot, mapping.service, mapping.containerPort);
2494
- if (containerPort !== null) ports[mapping.envVar] = containerPort;
2495
- else {
2496
- const saved = savedPorts[mapping.envVar];
2497
- if (saved !== void 0) ports[mapping.envVar] = saved;
2498
- }
2499
- }
2500
- resolvedPorts = {
2501
- dockerEnv: {},
2502
- ports,
2503
- mappings
2504
- };
2505
- }
2506
- if (options.startDocker) if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
2507
- else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
2508
- if (Object.keys(resolvedPorts.ports).length > 0) {
2509
- const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
2510
- Object.assign(credentials, rewritten);
2511
- logger.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
2512
- }
2513
- }
2514
- }
2515
- if (appInfo?.appName) {
2516
- const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
2517
- Object.assign(credentials, depEnv);
2518
- }
2519
- if (!credentials.EVENT_PUBLISHER_CONNECTION_STRING && !credentials.EVENT_SUBSCRIBER_CONNECTION_STRING && credentials.PGBOSS_DB_USER) {
2520
- const pgbossUrl = generatePgBossUrl({
2521
- username: credentials.PGBOSS_DB_USER,
2522
- password: credentials.PGBOSS_DB_PASSWORD ?? "",
2523
- host: credentials.POSTGRES_HOST ?? "localhost",
2524
- port: Number(credentials.POSTGRES_PORT ?? "5432"),
2525
- database: credentials.POSTGRES_DB ?? "app"
2526
- });
2527
- credentials.EVENT_PUBLISHER_CONNECTION_STRING = pgbossUrl;
2528
- credentials.EVENT_SUBSCRIBER_CONNECTION_STRING = pgbossUrl;
3011
+ function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
3012
+ const addresses = {};
3013
+ for (const [id, declaration] of Object.entries(manifest)) {
3014
+ if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
3015
+ const host = appKey(id);
3016
+ const port = workspace.apps[host]?.port;
3017
+ if (port) addresses[id] = at(host, port);
2529
3018
  }
2530
- const secretsDir = join(secretsRoot, ".gkm");
2531
- await mkdir(secretsDir, { recursive: true });
2532
- const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
2533
- const secretsJsonPath = join(secretsDir, secretsFileName);
2534
- await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
2535
- return {
2536
- credentials,
2537
- resolvedPort,
2538
- secretsJsonPath,
2539
- appName,
2540
- secretsRoot,
2541
- appInfo,
2542
- declaredKeys
2543
- };
3019
+ return addresses;
3020
+ }
3021
+ /** Where a local process answers, given the port the workspace gave it. */
3022
+ function localAddress(port) {
3023
+ return `http://localhost:${port}`;
2544
3024
  }
2545
-
2546
3025
  //#endregion
2547
- export { LOCAL_CA_PATH, applyPostgres, cacheBackendOf, constructGlobs, createCredentialsPreload, createEntryWrapper, derivedContainers, emailBackendOf, findAvailablePort, imagePins, isPortAvailable, loadEnvFiles, loadSecretsForApp, prepareEntryCredentials, providerOf, reconcileWorkspace, resolveServicePorts, resourceName, rewriteUrlsWithPorts, startWorkspaceServices, storageBackendOf, usesConstructs };
2548
- //# sourceMappingURL=credentials-BBIRJVp4.mjs.map
3026
+ export { loadEnvFiles as C, isPortAvailable as S, prepareEntryCredentials as T, fakeKey as _, reconcileWorkspace as a, createEntryWrapper as b, LOCAL_CA_PATH as c, postgresDatabaseNames as d, quoteIdentifier as f, describeServices as g, rootSite as h, derivedContainers as i, forgetState as l, resourceName as m, backendsOf as n, workspace_exports as o, pgClient as p, constructGlobs as r, COMPOSE_PATH as s, FAKE_ENV as t, applyPostgres as u, primaryPortKey as v, loadSecretsForApp as w, findAvailablePort as x, createCredentialsPreload as y };
3027
+
3028
+ //# sourceMappingURL=workspace-Dp2zK_Nk.mjs.map