@geekmidas/cli 10.0.0-alpha.4 → 10.0.0-alpha.41

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