@geekmidas/cli 10.0.0-alpha.2 → 10.0.0-alpha.20

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 (241) hide show
  1. package/README.md +25 -48
  2. package/bin/adjacent-tsconfig.mjs +59 -0
  3. package/bin/gkm.mjs +7 -1
  4. package/dist/{HostingerProvider-Cft7lEYr.mjs → HostingerProvider-BY26bWwB.mjs} +20 -21
  5. package/dist/HostingerProvider-BY26bWwB.mjs.map +1 -0
  6. package/dist/{HostingerProvider-h3EbEp-Z.cjs → HostingerProvider-BZFGYYVA.cjs} +21 -22
  7. package/dist/HostingerProvider-BZFGYYVA.cjs.map +1 -0
  8. package/dist/{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/{Route53Provider-RdpUDJQk.mjs → Route53Provider-Cvj40A8I.mjs} +12 -12
  13. package/dist/Route53Provider-Cvj40A8I.mjs.map +1 -0
  14. package/dist/{Route53Provider-di2fMM5N.cjs → Route53Provider-DVILBsvV.cjs} +20 -21
  15. package/dist/Route53Provider-DVILBsvV.cjs.map +1 -0
  16. package/dist/{SSMStateProvider-BRt2py-A.mjs → SSMStateProvider-B0Hu1MKZ.mjs} +13 -4
  17. package/dist/SSMStateProvider-B0Hu1MKZ.mjs.map +1 -0
  18. package/dist/{SSMStateProvider-DyX8s9HI.cjs → SSMStateProvider-VrQ-yeqd.cjs} +17 -9
  19. package/dist/SSMStateProvider-VrQ-yeqd.cjs.map +1 -0
  20. package/dist/{backup-provisioner-BrsHwYG0.cjs → backup-provisioner-BmhIO_Yr.cjs} +21 -19
  21. package/dist/backup-provisioner-BmhIO_Yr.cjs.map +1 -0
  22. package/dist/{backup-provisioner-Bgj5Vi_L.mjs → backup-provisioner-DIVCbfee.mjs} +10 -7
  23. package/dist/backup-provisioner-DIVCbfee.mjs.map +1 -0
  24. package/dist/{bundler-BY1OmV1e.cjs → bundler-mjqGAvu1.cjs} +10 -28
  25. package/dist/bundler-mjqGAvu1.cjs.map +1 -0
  26. package/dist/{bundler-Be8Zg4Wd.mjs → bundler-mwH7WOb1.mjs} +9 -26
  27. package/dist/bundler-mwH7WOb1.mjs.map +1 -0
  28. package/dist/{config-BholeD-0.cjs → config-DSXI480r.cjs} +80 -148
  29. package/dist/config-DSXI480r.cjs.map +1 -0
  30. package/dist/{config-CzWnfcXM.mjs → config-DdKkoUE1.mjs} +33 -88
  31. package/dist/config-DdKkoUE1.mjs.map +1 -0
  32. package/dist/config.cjs +4 -5
  33. package/dist/config.d.cts +14 -17
  34. package/dist/config.d.cts.map +1 -1
  35. package/dist/config.d.mts +14 -17
  36. package/dist/config.d.mts.map +1 -1
  37. package/dist/config.mjs +3 -5
  38. package/dist/{credentials-DQmbRnr1.cjs → credentials-C6Pfn6GY.cjs} +1502 -1243
  39. package/dist/credentials-C6Pfn6GY.cjs.map +1 -0
  40. package/dist/{credentials-BZcp3CGx.mjs → credentials-lbv-j0Be.mjs} +1370 -1116
  41. package/dist/credentials-lbv-j0Be.mjs.map +1 -0
  42. package/dist/deploy/sniffer-envkit-patch.cjs +18 -10
  43. package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -1
  44. package/dist/deploy/sniffer-envkit-patch.d.cts +0 -2
  45. package/dist/deploy/sniffer-envkit-patch.d.cts.map +1 -1
  46. package/dist/deploy/sniffer-envkit-patch.d.mts +0 -2
  47. package/dist/deploy/sniffer-envkit-patch.d.mts.map +1 -1
  48. package/dist/deploy/sniffer-envkit-patch.mjs +10 -2
  49. package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -1
  50. package/dist/deploy/sniffer-hooks.cjs +13 -7
  51. package/dist/deploy/sniffer-hooks.cjs.map +1 -1
  52. package/dist/deploy/sniffer-hooks.d.cts +1 -2
  53. package/dist/deploy/sniffer-hooks.d.cts.map +1 -1
  54. package/dist/deploy/sniffer-hooks.d.mts +1 -2
  55. package/dist/deploy/sniffer-hooks.d.mts.map +1 -1
  56. package/dist/deploy/sniffer-hooks.mjs +9 -2
  57. package/dist/deploy/sniffer-hooks.mjs.map +1 -1
  58. package/dist/deploy/sniffer-loader.cjs +16 -7
  59. package/dist/deploy/sniffer-loader.cjs.map +1 -1
  60. package/dist/deploy/sniffer-loader.d.cts +1 -1
  61. package/dist/deploy/sniffer-loader.d.mts +1 -1
  62. package/dist/deploy/sniffer-loader.mjs +13 -2
  63. package/dist/deploy/sniffer-loader.mjs.map +1 -1
  64. package/dist/deploy/sniffer-routes-worker.cjs +11 -11
  65. package/dist/deploy/sniffer-routes-worker.cjs.map +1 -1
  66. package/dist/deploy/sniffer-routes-worker.d.cts +1 -1
  67. package/dist/deploy/sniffer-routes-worker.d.mts +1 -1
  68. package/dist/deploy/sniffer-routes-worker.mjs +2 -2
  69. package/dist/deploy/sniffer-routes-worker.mjs.map +1 -1
  70. package/dist/deploy/sniffer-worker.cjs +15 -6
  71. package/dist/deploy/sniffer-worker.cjs.map +1 -1
  72. package/dist/deploy/sniffer-worker.d.cts +1 -3
  73. package/dist/deploy/sniffer-worker.d.cts.map +1 -1
  74. package/dist/deploy/sniffer-worker.d.mts +1 -3
  75. package/dist/deploy/sniffer-worker.d.mts.map +1 -1
  76. package/dist/deploy/sniffer-worker.mjs +15 -4
  77. package/dist/deploy/sniffer-worker.mjs.map +1 -1
  78. package/dist/{discover-DTmFeloL.cjs → discover-BERGFg8v.cjs} +81 -64
  79. package/dist/discover-BERGFg8v.cjs.map +1 -0
  80. package/dist/{discover-CAlFgYec.mjs → discover-CK-n3w6b.mjs} +35 -20
  81. package/dist/discover-CK-n3w6b.mjs.map +1 -0
  82. package/dist/{emit-CbcRolsa.cjs → emit-BdoPFjZf.cjs} +53 -36
  83. package/dist/emit-BdoPFjZf.cjs.map +1 -0
  84. package/dist/{emit-CiZS_5Ub.mjs → emit-Bzq7JsMP.mjs} +24 -6
  85. package/dist/emit-Bzq7JsMP.mjs.map +1 -0
  86. package/dist/{generator-VE2JrFlY.mjs → generator-LfZU7NlU.mjs} +8 -14
  87. package/dist/generator-LfZU7NlU.mjs.map +1 -0
  88. package/dist/{generator-CNzBw7g5.cjs → generator-Wdfp0jCS.cjs} +38 -45
  89. package/dist/generator-Wdfp0jCS.cjs.map +1 -0
  90. package/dist/github-BvfpvBcW.cjs +216 -0
  91. package/dist/github-BvfpvBcW.cjs.map +1 -0
  92. package/dist/github-DW3CP-4D.mjs +216 -0
  93. package/dist/github-DW3CP-4D.mjs.map +1 -0
  94. package/dist/{index-CHB4tSzX.d.cts → index-B4H4XF7A.d.mts} +4 -13
  95. package/dist/index-B4H4XF7A.d.mts.map +1 -0
  96. package/dist/{index-BThUzEIp.d.mts → index-RCUizGxL.d.cts} +4 -13
  97. package/dist/index-RCUizGxL.d.cts.map +1 -0
  98. package/dist/index.cjs +5108 -3381
  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 +5025 -3315
  103. package/dist/index.mjs.map +1 -1
  104. package/dist/{openapi-c9bpieI5.cjs → openapi-B8bu_OXN.cjs} +228 -188
  105. package/dist/openapi-B8bu_OXN.cjs.map +1 -0
  106. package/dist/{openapi-CY9xFkTH.mjs → openapi-DfjbhrsM.mjs} +172 -143
  107. package/dist/openapi-DfjbhrsM.mjs.map +1 -0
  108. package/dist/openapi.cjs +5 -6
  109. package/dist/openapi.d.cts +47 -14
  110. package/dist/openapi.d.cts.map +1 -1
  111. package/dist/openapi.d.mts +47 -14
  112. package/dist/openapi.d.mts.map +1 -1
  113. package/dist/openapi.mjs +2 -6
  114. package/dist/reconcile/public.cjs +4 -4
  115. package/dist/reconcile/public.d.cts +8 -26
  116. package/dist/reconcile/public.d.cts.map +1 -1
  117. package/dist/reconcile/public.d.mts +8 -26
  118. package/dist/reconcile/public.d.mts.map +1 -1
  119. package/dist/reconcile/public.mjs +3 -4
  120. package/dist/{fullstack-secrets-D1o9-G5l.mjs → reconcile-gb1lz1Dk.mjs} +43 -17
  121. package/dist/reconcile-gb1lz1Dk.mjs.map +1 -0
  122. package/dist/{fullstack-secrets-C-GGohke.cjs → reconcile-hx_vBvux.cjs} +63 -38
  123. package/dist/reconcile-hx_vBvux.cjs.map +1 -0
  124. package/dist/rolldown-runtime-BhDjJH2R.mjs +15 -0
  125. package/dist/{chunk-CUT6urMc.cjs → rolldown-runtime-C0BPl7ul.cjs} +23 -10
  126. package/dist/ssm-BKN1Cg4z.mjs +60 -0
  127. package/dist/ssm-BKN1Cg4z.mjs.map +1 -0
  128. package/dist/ssm-FxeSAt35.cjs +60 -0
  129. package/dist/ssm-FxeSAt35.cjs.map +1 -0
  130. package/dist/{storage-BJmzYxt8.cjs → storage-DgypfSD6.cjs} +65 -77
  131. package/dist/storage-DgypfSD6.cjs.map +1 -0
  132. package/dist/{storage-Bi24WfoW.mjs → storage-TP0x118M.mjs} +22 -15
  133. package/dist/storage-TP0x118M.mjs.map +1 -0
  134. package/dist/transfer-DjFZRgub.mjs +122 -0
  135. package/dist/transfer-DjFZRgub.mjs.map +1 -0
  136. package/dist/transfer-SeiSeKEF.cjs +151 -0
  137. package/dist/transfer-SeiSeKEF.cjs.map +1 -0
  138. package/dist/types-7R81hu0i.d.mts +6302 -0
  139. package/dist/types-7R81hu0i.d.mts.map +1 -0
  140. package/dist/types-N7c2O2m7.d.cts +6303 -0
  141. package/dist/types-N7c2O2m7.d.cts.map +1 -0
  142. package/dist/workspace/index.cjs +3 -3
  143. package/dist/workspace/index.d.cts +3 -3
  144. package/dist/workspace/index.d.mts +3 -3
  145. package/dist/workspace/index.mjs +2 -3
  146. package/dist/workspace-Bu3xNCej.mjs +6563 -0
  147. package/dist/workspace-Bu3xNCej.mjs.map +1 -0
  148. package/dist/workspace-CRD9YinV.cjs +6741 -0
  149. package/dist/workspace-CRD9YinV.cjs.map +1 -0
  150. package/package.json +33 -43
  151. package/dist/CachedStateProvider-BLo7zv7S.cjs +0 -3
  152. package/dist/CachedStateProvider-BxoAm-B2.mjs +0 -3
  153. package/dist/CachedStateProvider-CqPdW7CB.cjs +0 -60
  154. package/dist/CachedStateProvider-CqPdW7CB.cjs.map +0 -1
  155. package/dist/CachedStateProvider-DTniehMP.mjs +0 -54
  156. package/dist/CachedStateProvider-DTniehMP.mjs.map +0 -1
  157. package/dist/HostingerProvider-Cft7lEYr.mjs.map +0 -1
  158. package/dist/HostingerProvider-h3EbEp-Z.cjs.map +0 -1
  159. package/dist/LocalStateProvider-Bn6tS-oL.mjs.map +0 -1
  160. package/dist/LocalStateProvider-Db9SEjyH.cjs.map +0 -1
  161. package/dist/Route53Provider-RdpUDJQk.mjs.map +0 -1
  162. package/dist/Route53Provider-di2fMM5N.cjs.map +0 -1
  163. package/dist/SSMStateProvider-BRt2py-A.mjs.map +0 -1
  164. package/dist/SSMStateProvider-DyX8s9HI.cjs.map +0 -1
  165. package/dist/backup-provisioner-Bgj5Vi_L.mjs.map +0 -1
  166. package/dist/backup-provisioner-BrsHwYG0.cjs.map +0 -1
  167. package/dist/bundler-BY1OmV1e.cjs.map +0 -1
  168. package/dist/bundler-Be8Zg4Wd.mjs.map +0 -1
  169. package/dist/chunk-Duj1WY3L.mjs +0 -7
  170. package/dist/config-BholeD-0.cjs.map +0 -1
  171. package/dist/config-CzWnfcXM.mjs.map +0 -1
  172. package/dist/credentials-B5zRHdAl.mjs +0 -141
  173. package/dist/credentials-B5zRHdAl.mjs.map +0 -1
  174. package/dist/credentials-BZcp3CGx.mjs.map +0 -1
  175. package/dist/credentials-BapzjyV5.cjs +0 -195
  176. package/dist/credentials-BapzjyV5.cjs.map +0 -1
  177. package/dist/credentials-DQmbRnr1.cjs.map +0 -1
  178. package/dist/discover-CAlFgYec.mjs.map +0 -1
  179. package/dist/discover-DTmFeloL.cjs.map +0 -1
  180. package/dist/dokploy-api-BS2xnJLZ.mjs +0 -3
  181. package/dist/dokploy-api-BsVH0y_s.cjs +0 -718
  182. package/dist/dokploy-api-BsVH0y_s.cjs.map +0 -1
  183. package/dist/dokploy-api-BuxDRo3l.cjs +0 -3
  184. package/dist/dokploy-api-aTFPz7_4.mjs +0 -706
  185. package/dist/dokploy-api-aTFPz7_4.mjs.map +0 -1
  186. package/dist/emit-CbcRolsa.cjs.map +0 -1
  187. package/dist/emit-CiZS_5Ub.mjs.map +0 -1
  188. package/dist/encryption-C6jaU0hE.cjs +0 -54
  189. package/dist/encryption-C6jaU0hE.cjs.map +0 -1
  190. package/dist/encryption-CHzzeR8a.cjs +0 -4
  191. package/dist/encryption-CQUncu7v.mjs +0 -3
  192. package/dist/encryption-D-abyhZx.mjs +0 -42
  193. package/dist/encryption-D-abyhZx.mjs.map +0 -1
  194. package/dist/fullstack-secrets-C-GGohke.cjs.map +0 -1
  195. package/dist/fullstack-secrets-D1o9-G5l.mjs.map +0 -1
  196. package/dist/generator-CNzBw7g5.cjs.map +0 -1
  197. package/dist/generator-VE2JrFlY.mjs.map +0 -1
  198. package/dist/index-BThUzEIp.d.mts.map +0 -1
  199. package/dist/index-CHB4tSzX.d.cts.map +0 -1
  200. package/dist/openapi-CY9xFkTH.mjs.map +0 -1
  201. package/dist/openapi-c9bpieI5.cjs.map +0 -1
  202. package/dist/openapi-react-query-Byltvm_7.mjs +0 -167
  203. package/dist/openapi-react-query-Byltvm_7.mjs.map +0 -1
  204. package/dist/openapi-react-query-CmJ1xDbn.cjs +0 -173
  205. package/dist/openapi-react-query-CmJ1xDbn.cjs.map +0 -1
  206. package/dist/openapi-react-query.cjs +0 -4
  207. package/dist/openapi-react-query.d.cts +0 -11
  208. package/dist/openapi-react-query.d.cts.map +0 -1
  209. package/dist/openapi-react-query.d.mts +0 -11
  210. package/dist/openapi-react-query.d.mts.map +0 -1
  211. package/dist/openapi-react-query.mjs +0 -4
  212. package/dist/reconcile-CFs8C_1a.mjs +0 -37
  213. package/dist/reconcile-CFs8C_1a.mjs.map +0 -1
  214. package/dist/reconcile-D0pimNiy.cjs +0 -37
  215. package/dist/reconcile-D0pimNiy.cjs.map +0 -1
  216. package/dist/setup-Bc73t0ve.mjs +0 -443
  217. package/dist/setup-Bc73t0ve.mjs.map +0 -1
  218. package/dist/setup-C48C9hto.cjs +0 -473
  219. package/dist/setup-C48C9hto.cjs.map +0 -1
  220. package/dist/setup-DCuudnCl.mjs +0 -11
  221. package/dist/setup-fG_ivuxO.cjs +0 -11
  222. package/dist/storage-BJmzYxt8.cjs.map +0 -1
  223. package/dist/storage-BM9hGpgv.cjs +0 -7
  224. package/dist/storage-Bi24WfoW.mjs.map +0 -1
  225. package/dist/storage-CYlGwWTj.mjs +0 -3
  226. package/dist/sync-CmEuNZsB.mjs +0 -76
  227. package/dist/sync-CmEuNZsB.mjs.map +0 -1
  228. package/dist/sync-D306yOWu.cjs +0 -4
  229. package/dist/sync-SizaChmC.cjs +0 -93
  230. package/dist/sync-SizaChmC.cjs.map +0 -1
  231. package/dist/sync-hOqAWHR6.mjs +0 -3
  232. package/dist/types-C-arnQ1r.d.mts +0 -2184
  233. package/dist/types-C-arnQ1r.d.mts.map +0 -1
  234. package/dist/types-abJQuk7m.d.cts +0 -2184
  235. package/dist/types-abJQuk7m.d.cts.map +0 -1
  236. package/dist/workspace-7FqprxuJ.mjs +0 -4323
  237. package/dist/workspace-7FqprxuJ.mjs.map +0 -1
  238. package/dist/workspace-BO_KWohI.cjs +0 -9
  239. package/dist/workspace-DK4WWJIn.mjs +0 -8
  240. package/dist/workspace-XvqO9jIv.cjs +0 -4472
  241. package/dist/workspace-XvqO9jIv.cjs.map +0 -1
@@ -1,64 +1,38 @@
1
- const require_chunk = require('./chunk-CUT6urMc.cjs');
2
- const require_discover = require('./discover-DTmFeloL.cjs');
3
- const require_config = require('./config-BholeD-0.cjs');
4
- const require_workspace = require('./workspace-XvqO9jIv.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"));
21
-
1
+ const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
2
+ const require_discover = require("./discover-BERGFg8v.cjs");
3
+ const require_config = require("./config-DSXI480r.cjs");
4
+ const require_workspace = require("./workspace-CRD9YinV.cjs");
5
+ const require_generator = require("./generator-Wdfp0jCS.cjs");
6
+ const require_storage = require("./storage-DgypfSD6.cjs");
7
+ let node_fs = require("node:fs");
8
+ let node_fs_promises = require("node:fs/promises");
9
+ let node_path = require("node:path");
10
+ let node_child_process = require("node:child_process");
11
+ let _geekmidas_manifest = require("@geekmidas/manifest");
12
+ let node_net = require("node:net");
13
+ let dotenv = require("dotenv");
14
+ let yaml = require("yaml");
15
+ let node_crypto = require("node:crypto");
16
+ let _geekmidas_db_pg_roles = require("@geekmidas/db/pg/roles");
17
+ let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
18
+ let pg = require("pg");
19
+ let node_util = require("node:util");
20
+ let _geekmidas_cache_postgres = require("@geekmidas/cache/postgres");
22
21
  //#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];
61
- }
22
+ /**
23
+ * Which backend each declared kind resolves to — decided by where the project
24
+ * deploys, and by nothing else.
25
+ *
26
+ * These used to be read out of a `services:` block, where one key answered two
27
+ * questions: *does this exist* and *what backs it*. The first is the manifest's
28
+ * — a declared `Cache` is why there is a cache — and the second is the deploy
29
+ * target's, because the right cache for a Lambda is not the right cache for a
30
+ * box already running Postgres. With both answered elsewhere there was nothing
31
+ * left for the block to say, so it is gone and these read the target.
32
+ *
33
+ * Mail has no answer here at all: every provider speaks SMTP, so the provider
34
+ * is whichever one the stage's mail URL points at.
35
+ */
62
36
  /**
63
37
  * Which family of defaults a workspace's deploy target belongs to.
64
38
  *
@@ -77,17 +51,162 @@ function providerOf(workspace) {
77
51
  return target === "dokploy" || target === "server" ? "server" : "aws";
78
52
  }
79
53
  /**
80
- * Whether a string names a backend, as opposed to being one of the other things
81
- * a `services:` value can be.
54
+ * The cache backend a target implies.
82
55
  *
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.
56
+ * The same answer locally and deployed: a local `gkm dev` for an AWS app still
57
+ * uses the Upstash protocol, because a backend that differed between the two
58
+ * would be worse than a slower one.
86
59
  */
87
- function isBackend(value, backends) {
88
- return typeof value === "string" && backends.includes(value);
60
+ function cacheBackendFor(on) {
61
+ return require_discover.DEFAULT_CACHE[on];
62
+ }
63
+ /** Where a declared bucket lives on a target. */
64
+ function storageBackendFor(on) {
65
+ return require_discover.DEFAULT_STORAGE[on];
66
+ }
67
+ /**
68
+ * Which broker carries declared queues and topics on a target.
69
+ *
70
+ * Only consulted when something declares one; a project with no `Topic` or
71
+ * `Queue` has no events at all, whatever the target.
72
+ */
73
+ function eventsBackendFor(on) {
74
+ return require_discover.DEFAULT_EVENTS[on];
75
+ }
76
+ //#endregion
77
+ //#region src/reconcile/containers.ts
78
+ /**
79
+ * What gkm knows about each container it can derive.
80
+ *
81
+ * Everything provider-specific about the local target lives here: the image, the
82
+ * ports the image listens on, and whether its data is worth keeping. The plan
83
+ * says *which* containers exist and never says any of this, which is the split
84
+ * the design asks for — neutral facts from the declaration, provider detail
85
+ * from here and from config.
86
+ *
87
+ * Shared by allocation and by the compose writer so the two cannot disagree
88
+ * about how many ports a container needs.
89
+ */
90
+ /**
91
+ * The Postgres image for a major version.
92
+ *
93
+ * One place that knows the tag shape, so a declared version reaches the
94
+ * container without anyone restating `-alpine` beside it.
95
+ */
96
+ function postgresImage(version) {
97
+ return `postgres:${version}-alpine`;
98
+ }
99
+ /**
100
+ * The default image for each container.
101
+ *
102
+ * Pinned to a major rather than `latest` where the image publishes one, so a
103
+ * rebuild does not silently move a developer's Postgres a version. Overridable
104
+ * per project: a project needing `postgis/postgis` says so in config.
105
+ */
106
+ const DEFAULT_IMAGES = {
107
+ postgres: postgresImage(_geekmidas_manifest.DEFAULT_POSTGRES_VERSION),
108
+ minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
109
+ mailpit: "axllent/mailpit:latest",
110
+ redis: "redis:8-alpine",
111
+ "redis-http": "hiett/serverless-redis-http:latest",
112
+ rabbitmq: "rabbitmq:4-management-alpine",
113
+ localstack: "floci/floci:latest",
114
+ caddy: "caddy:2-alpine"
115
+ };
116
+ /**
117
+ * The ports each container listens on.
118
+ *
119
+ * The first is always the one an app connects to; the rest are consoles and
120
+ * management UIs, published because a bucket or an inbox you can look at is most
121
+ * of why these are real containers rather than stubs.
122
+ */
123
+ const PORTS = {
124
+ postgres: [{
125
+ key: "postgres",
126
+ inside: 5432,
127
+ label: "postgres"
128
+ }],
129
+ minio: [{
130
+ key: "minio",
131
+ inside: 9e3,
132
+ label: "minio api"
133
+ }, {
134
+ key: "minio-console",
135
+ inside: 9001,
136
+ label: "minio console"
137
+ }],
138
+ mailpit: [{
139
+ key: "mailpit",
140
+ inside: 1025,
141
+ label: "smtp"
142
+ }, {
143
+ key: "mailpit-web",
144
+ inside: 8025,
145
+ label: "mailpit inbox"
146
+ }],
147
+ redis: [{
148
+ key: "redis",
149
+ inside: 6379,
150
+ label: "redis"
151
+ }],
152
+ "redis-http": [{
153
+ key: "redis-http",
154
+ inside: 80,
155
+ label: "cache"
156
+ }],
157
+ rabbitmq: [{
158
+ key: "rabbitmq",
159
+ inside: 5672,
160
+ label: "amqp"
161
+ }, {
162
+ key: "rabbitmq-management",
163
+ inside: 15672,
164
+ label: "rabbitmq console"
165
+ }],
166
+ localstack: [{
167
+ key: "localstack",
168
+ inside: 4566,
169
+ label: "localstack"
170
+ }],
171
+ caddy: [{
172
+ key: "caddy",
173
+ inside: 443,
174
+ label: "https edge"
175
+ }]
176
+ };
177
+ /** The ports a container needs published. Empty for one gkm does not know. */
178
+ function portsOf(container) {
179
+ return PORTS[container] ?? [];
180
+ }
181
+ /** The key a container's primary port is allocated under. */
182
+ function primaryPortKey(container) {
183
+ return portsOf(container)[0]?.key ?? container;
184
+ }
185
+ /**
186
+ * Every port key a set of containers needs, in a stable order.
187
+ *
188
+ * What allocation is handed: it assigns ports to names and knows nothing about
189
+ * what listens on them.
190
+ */
191
+ function portKeys(containers) {
192
+ return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
193
+ }
194
+ /**
195
+ * The named volume a container's data lives in.
196
+ *
197
+ * Mailpit deliberately has none: local mail is disposable, and an inbox that
198
+ * survives days of dev is noise rather than state.
199
+ */
200
+ function volumeOf(container) {
201
+ return {
202
+ postgres: "postgres-data",
203
+ minio: "minio-data",
204
+ redis: "redis-data",
205
+ rabbitmq: "rabbitmq-data",
206
+ localstack: "localstack-data",
207
+ caddy: "caddy-data"
208
+ }[container];
89
209
  }
90
-
91
210
  //#endregion
92
211
  //#region src/reconcile/caddyfile.ts
93
212
  /**
@@ -164,7 +283,7 @@ function portOf(address) {
164
283
  const { port, protocol } = new URL(address);
165
284
  return port || (protocol === "https:" ? "443" : "80");
166
285
  } catch {
167
- return void 0;
286
+ return;
168
287
  }
169
288
  }
170
289
  /**
@@ -247,1139 +366,1194 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
247
366
  # root and point Node at it, so nothing has to be installed to use these URLs
248
367
  # from code; a browser needs the root trusted once.
249
368
  `;
250
-
251
- //#endregion
252
- //#region src/reconcile/clients.ts
253
- /** The credential local containers are brought up with. */
254
- const LOCAL_USER$2 = "geekmidas";
255
- /**
256
- * A Postgres client that opens one connection per database it is asked about.
257
- *
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.
261
- */
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
- } };
279
- }
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
- };
318
- }
319
-
320
369
  //#endregion
321
- //#region src/reconcile/containers.ts
370
+ //#region src/reconcile/plan.ts
322
371
  /**
323
- * What gkm knows about each container it can derive.
372
+ * The reconcile plan — what a manifest wants to exist, for one stage.
324
373
  *
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.
374
+ * Pure. Turning the plan into running containers and provisioned resources is
375
+ * the applier's job; deciding *what* should exist is this, so the decisions can
376
+ * be asserted as data rather than by starting Docker.
330
377
  *
331
- * Shared by allocation and by the compose writer so the two cannot disagree
332
- * about how many ports a container needs.
378
+ * One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
379
+ * reconciles `test`; they differ only in what the resources are called, never in
380
+ * what infrastructure exists. Roles are cluster-scoped in Postgres while grants
381
+ * are per-object, so one container and one role pair serve both.
333
382
  */
334
383
  /**
335
- * The Postgres image for a major version.
384
+ * Which container serves a kind.
336
385
  *
337
- * One place that knows the tag shape, so a declared version reaches the
338
- * container without anyone restating `-alpine` beside it.
386
+ * Derived kinds map to the same container as their parent — a schema tenant
387
+ * lives in its parent's Postgres — so the set is deduplicated rather than
388
+ * counted.
339
389
  */
340
- function postgresImage(version) {
341
- return `postgres:${version}-alpine`;
342
- }
390
+ const CONTAINERS = {
391
+ database: "postgres",
392
+ "database-reader": "postgres",
393
+ "database-schema": "postgres",
394
+ objects: "minio",
395
+ "file-server": "caddy",
396
+ email: "mailpit"
397
+ };
343
398
  /**
344
- * The default image for each container.
399
+ * The container a cache backend needs locally.
345
400
  *
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.
401
+ * Each one matches the protocol its deployed form speaks, which is the property
402
+ * worth having: a cache that behaves differently in the two places is worse than
403
+ * a slower one.
404
+ *
405
+ * `db` is deliberately absent, the same way `pgboss` is absent from
406
+ * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
407
+ * declares, so mapping it here would start a container for a project that needs
408
+ * none.
349
409
  */
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"
410
+ const CACHE_CONTAINERS = {
411
+ upstash: "redis-http",
412
+ elasticache: "redis"
359
413
  };
360
414
  /**
361
- * The ports each container listens on.
415
+ * Kinds that resolve a value without anything running.
362
416
  *
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
- }]
417
+ * A secret is the case: it has no address, so there is nothing to start and
418
+ * nothing to connect to — but it still appears in the plan, because the target
419
+ * still has to resolve it.
420
+ */
421
+ const CONTAINERLESS = {
422
+ secret: true,
423
+ credential: true,
424
+ "rest-api": true,
425
+ site: true
420
426
  };
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
427
  /**
430
- * Every port key a set of containers needs, in a stable order.
428
+ * Containers that cannot run alone.
431
429
  *
432
- * What allocation is handed: it assigns ports to names and knows nothing about
433
- * what listens on them.
430
+ * The one case is the cache: `serverless-redis-http` is a proxy and needs the
431
+ * Redis it proxies. Kept here rather than in the compose definition so the plan
432
+ * -- which is what decides what must be running -- still names everything.
434
433
  */
435
- function portKeys(containers) {
436
- return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
437
- }
434
+ const REQUIRES = { "redis-http": ["redis"] };
438
435
  /**
439
- * The named volume a container's data lives in.
436
+ * The role each kind's one key plays, where it is not a URL.
440
437
  *
441
- * Mailpit deliberately has none: local mail is disposable, and an inbox that
442
- * survives days of dev is noise rather than state.
438
+ * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
439
+ * construct declares, this is what the local target resolves, and both derive
440
+ * the key from the same `provideKey` so they cannot disagree.
443
441
  */
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];
442
+ const ROLES = {
443
+ queue: "publisherConnectionString",
444
+ topic: "publisherConnectionString",
445
+ credential: "credential"
446
+ };
447
+ /** The kinds whose container is the events backend's rather than their own. */
448
+ const EVENT_KINDS = {
449
+ queue: true,
450
+ topic: true
451
+ };
452
+ /**
453
+ * Which kinds provision something *inside* their container.
454
+ *
455
+ * A database creates a database, a bucket creates a bucket; mail creates
456
+ * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
457
+ * to name. Both still appear in the plan, because both still resolve a URL; the
458
+ * flag is the difference between "must be running" and "must be created".
459
+ */
460
+ const PROVISIONS = {
461
+ database: true,
462
+ "database-reader": true,
463
+ "database-schema": true,
464
+ objects: true,
465
+ cache: true,
466
+ "file-server": true
467
+ };
468
+ /**
469
+ * The container each events backend needs, where it needs one of its own.
470
+ *
471
+ * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
472
+ * schema tenant in a database the manifest already declares, so mapping it here
473
+ * would start a Postgres for a project that declared no database at all.
474
+ *
475
+ * This one comes from config rather than from a declaration because there are
476
+ * no `topic` or `queue` kinds yet. When they land the mapping moves to
477
+ * {@link CONTAINERS} keyed by kind, and this constant goes with them.
478
+ */
479
+ const EVENT_CONTAINERS = {
480
+ sns: "localstack",
481
+ rabbitmq: "rabbitmq"
482
+ };
483
+ /** The backend a project gets when it declares queues or topics and says nothing. */
484
+ const DEFAULT_EVENTS = "pgboss";
485
+ /**
486
+ * How a resource of each kind separates its stage suffix.
487
+ *
488
+ * Postgres identifiers are snake_case and a hyphen would need quoting
489
+ * everywhere; buckets and queues are kebab and disallow underscores in some
490
+ * providers. The suffix is the same word either way.
491
+ */
492
+ const SEPARATORS = {
493
+ database: "_",
494
+ "database-reader": "_",
495
+ "database-schema": "_",
496
+ objects: "-"
497
+ };
498
+ /**
499
+ * The stage-scoped name for a resource.
500
+ *
501
+ * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
502
+ * developer's psql history, saved connections, and muscle memory survive a
503
+ * change of stage name. Every other stage is suffixed.
504
+ */
505
+ function resourceName(id, kind, stage, localStage) {
506
+ const base = id.toLowerCase();
507
+ if (stage === localStage) return base;
508
+ return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
454
509
  }
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
510
  /**
461
- * The token the local cache accepts.
511
+ * The container a kind needs, or `undefined` if it needs none.
462
512
  *
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.
513
+ * Queues and topics have no container of their own — they live in whichever
514
+ * broker the project selected, which is why the backend is an argument here
515
+ * rather than a second pass that adds containers after the fact.
466
516
  */
467
- const LOCAL_TOKEN$1 = "geekmidas";
517
+ function containerFor(kind, events = DEFAULT_EVENTS, cache = require_discover.DEFAULT_CACHE.aws, derived = false, edge = true) {
518
+ if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
519
+ if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
520
+ if (kind === "cache") {
521
+ if (derived) return CONTAINERS.database;
522
+ return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
523
+ }
524
+ return CONTAINERS[kind];
525
+ }
468
526
  /**
469
- * Build the compose document for a plan.
527
+ * Build the plan for a manifest and stage.
470
528
  *
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.
529
+ * `order` is the caller's provisioning order — parents before children — which
530
+ * `provisionOrder` produces from the manifest. Passing it in keeps this
531
+ * function free of the traversal and makes the ordering assertable on its own.
474
532
  */
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] = {};
533
+ function planFor(manifest, stage, order, options = {}) {
534
+ const containers = /* @__PURE__ */ new Set();
535
+ const resources = [];
536
+ const events = options.events ?? "pgboss";
537
+ const cache = options.cache ?? require_discover.DEFAULT_CACHE.aws;
538
+ for (const id of order) {
539
+ const declaration = manifest[id];
540
+ if (!declaration) continue;
541
+ const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
542
+ if (!container && !CONTAINERLESS[declaration.kind]) continue;
543
+ if (container) {
544
+ containers.add(container);
545
+ for (const required of REQUIRES[container] ?? []) containers.add(required);
546
+ }
547
+ resources.push({
548
+ id,
549
+ kind: declaration.kind,
550
+ ...container ? { container } : {},
551
+ name: resourceName(id, declaration.kind, stage, options.localStage),
552
+ envKey: (0, _geekmidas_manifest.providedKeyFor)(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
553
+ provisions: PROVISIONS[declaration.kind] === true,
554
+ ...declaration.kind === "rest-api" ? { callers: (0, _geekmidas_manifest.dependentsOf)(manifest, id) } : {},
555
+ ...declaration.kind === "site" ? { publicEnv: (0, _geekmidas_manifest.publicEnvFor)(declaration, manifest) } : {},
556
+ ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
557
+ ..."of" in declaration ? { of: declaration.of } : {},
558
+ ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
559
+ ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
560
+ ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
561
+ ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
562
+ });
563
+ }
564
+ if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
565
+ for (const extra of options.extraContainers ?? []) containers.add(extra);
566
+ 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));
567
+ if (cache === "db") {
568
+ const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
569
+ if (placeless.length > 0) {
570
+ const databases = resources.filter((r) => r.kind === "database");
571
+ const [only] = databases;
572
+ if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
573
+ if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
574
+ for (const resource of placeless) resource.of = only.id;
575
+ }
485
576
  }
486
577
  return {
487
- name: options.project,
488
- services,
489
- ...Object.keys(volumes).length ? { volumes } : {}
578
+ stage,
579
+ events,
580
+ cache,
581
+ containers: [...containers],
582
+ resources
490
583
  };
491
584
  }
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;
585
+ /**
586
+ * Queues or topics were declared with pg-boss and no database to hold them.
587
+ *
588
+ * Not a reconcile failure but a manifest one: the fix is a line of application
589
+ * code either way — declare a database, or select a backend that brings its own
590
+ * broker.
591
+ */
592
+ var CacheNeedsDatabase = class extends Error {
593
+ ids;
594
+ constructor(ids) {
595
+ 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(", ")}.`);
596
+ this.ids = ids;
597
+ this.name = "CacheNeedsDatabase";
512
598
  }
513
599
  };
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
600
  /**
688
- * The published port mappings for a container.
601
+ * A `'db'` cache — a server target's default — in an app that declares more
602
+ * than one database.
689
603
  *
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.
604
+ * The target says *a* database and the graph offers several, so there is no
605
+ * answer to give — and the wrong kind of failure would be to pick one, since a
606
+ * cache silently landing in the wrong database is a bug that surfaces as
607
+ * missing rows much later. The fix is a stronger statement in application code:
608
+ * `orders.cache('Sessions')` names the database, which is a fact about the
609
+ * application rather than a deployment choice.
693
610
  */
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;
611
+ var CacheIsAmbiguous = class extends Error {
612
+ ids;
613
+ databases;
614
+ constructor(ids, databases) {
615
+ 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(", ")}.`);
616
+ this.ids = ids;
617
+ this.databases = databases;
618
+ this.name = "CacheIsAmbiguous";
709
619
  }
710
620
  };
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
- }
621
+ var PgBossNeedsDatabase = class extends Error {
622
+ ids;
623
+ constructor(ids) {
624
+ 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(", ")}.`);
625
+ this.ids = ids;
626
+ this.name = "PgBossNeedsDatabase";
794
627
  }
795
628
  };
796
-
797
629
  //#endregion
798
- //#region src/reconcile/plan.ts
799
- /** The stage whose resources carry no suffix. */
800
- const DEFAULT_STAGE = "development";
630
+ //#region src/reconcile/env.ts
801
631
  /**
802
- * Which container serves a kind.
632
+ * The URLs a reconciled stage injects.
803
633
  *
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.
634
+ * One `<NAME>_URL` per construct, and the protocol picks the driver. This is the
635
+ * seam between the target that writes a URL and the client that parses it, so
636
+ * every component of it is read off the resource — never inherited from ambient
637
+ * environment. A bucket can live in a different region than the function reading
638
+ * it, and `AWS_REGION` in a Lambda is the *function's* region, so a URL that
639
+ * omits `?region=` breaks cross-region silently at runtime.
640
+ *
641
+ * Locally that self-containment is what lets the port be whatever was free: the
642
+ * app reads `ORDERS_URL` and never sees a host or a port.
807
643
  */
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
- };
816
644
  /**
817
- * The container a cache backend needs locally.
645
+ * The credential local containers are brought up with.
818
646
  *
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.
647
+ * The design's `roles: false` case — both URLs fall back to the cluster's master
648
+ * credential. A deliberate downgrade, not a default: it holds until the role DDL
649
+ * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
650
+ * and the owner URL stops being reachable from any manifest edge.
651
+ */
652
+ const LOCAL_USER$2 = "geekmidas";
653
+ /** The host containers are published on. */
654
+ const LOCAL_HOST = "localhost";
655
+ /** The region local buckets claim, so the client has one to sign with. */
656
+ const LOCAL_REGION = "us-east-1";
657
+ /**
658
+ * The credentials the S3 client resolves for MinIO.
822
659
  *
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.
660
+ * An `s3://` URL deliberately carries none: deployed, the SDK reads them from
661
+ * the execution role, so a URL that embedded a key would be one more thing to
662
+ * rotate and one more thing to leak into a log. Locally there is no role, and
663
+ * the same chain reads these — which is why they are injected beside the URL
664
+ * rather than written into it.
827
665
  */
828
- const CACHE_CONTAINERS = {
829
- upstash: "redis-http",
830
- elasticache: "redis"
666
+ const STORAGE_CREDENTIALS = {
667
+ AWS_ACCESS_KEY_ID: LOCAL_USER$2,
668
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER$2,
669
+ AWS_REGION: LOCAL_REGION
831
670
  };
671
+ /** The token the local cache proxy accepts. Matches the compose definition. */
672
+ const LOCAL_TOKEN$1 = "geekmidas";
673
+ /** The schema pg-boss keeps its tables in, inside the declared database. */
674
+ const PGBOSS_SCHEMA = "pgboss";
675
+ /** The exchange every local topic and queue shares on RabbitMQ. */
676
+ const RABBITMQ_EXCHANGE = "geekmidas.events";
832
677
  /**
833
- * Kinds that resolve a value without anything running.
678
+ * Every env key a reconciled plan resolves, and its value.
834
679
  *
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.
680
+ * Keys come from the declaration rather than being re-derived, so what the build
681
+ * publishes and what the app reads cannot drift.
838
682
  */
839
- const CONTAINERLESS = {
840
- secret: true,
841
- credential: true,
842
- "rest-api": true,
843
- site: true
844
- };
683
+ function envFor(plan, options) {
684
+ const env = {};
685
+ const resolved = {};
686
+ for (const resource of plan.resources) {
687
+ const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
688
+ if (url) resolved[resource.id] = url;
689
+ }
690
+ for (const resource of plan.resources) {
691
+ const url = resolved[resource.id];
692
+ if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
693
+ if (url) env[resource.envKey] = url;
694
+ if (isDatabase(resource.kind) && resource.roles !== false) {
695
+ const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
696
+ if (owner) env[(0, _geekmidas_manifest.provideKey)(resource.id, "ownerUrl")] = owner;
697
+ }
698
+ if (resource.kind === "email" && url) env[(0, _geekmidas_manifest.provideKey)(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
699
+ }
700
+ Object.assign(env, publicEnv(plan, env));
701
+ Object.assign(env, brokerEnv(plan, options.ports));
702
+ if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
703
+ return env;
704
+ }
705
+ /**
706
+ * The values a site's bundler inlines, under the names it inlines them by.
707
+ *
708
+ * A rename and nothing more. `API_URL` was resolved once, by the same code that
709
+ * resolved it for the server; a site reads the same value under `VITE_API_URL`
710
+ * because that prefix is how its bundler is told to ship it. Nothing is derived
711
+ * twice, so a site and its API cannot come to disagree about where the API is.
712
+ *
713
+ * A source that resolved to nothing is skipped rather than written empty: an
714
+ * inlined empty string is a frontend that builds and then fails at runtime
715
+ * against `http:///`, where a missing variable fails at build with the name of
716
+ * the thing that is missing.
717
+ */
718
+ function publicEnv(plan, resolved) {
719
+ const env = {};
720
+ for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
721
+ const value = resolved[source];
722
+ if (value) env[key] = value;
723
+ }
724
+ return env;
725
+ }
726
+ /**
727
+ * What a surface publishes beyond its own address: who may call it, and where
728
+ * a cookie set by it is readable.
729
+ *
730
+ * Both are read off the same list — the constructs that declared an edge to
731
+ * this surface — which is why neither appears in application code. A surface
732
+ * that listed its own callers would be edited every time something new called
733
+ * it, and the thing being edited is already recorded in the graph.
734
+ *
735
+ * An empty origin list is written, and a surface with no address writes nothing
736
+ * at all. The two cases look alike and are not: "nothing depends on this yet" is
737
+ * a real state a target should publish, while "this surface has no address here"
738
+ * means it is not running in this stage, and keys belong with the address they
739
+ * describe.
740
+ */
741
+ function surfaceEnv(resource, url, resolved = {}) {
742
+ if (!url) return {};
743
+ const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
744
+ const domain = (0, _geekmidas_manifest.cookieDomain)([url, ...origins]);
745
+ return {
746
+ [(0, _geekmidas_manifest.provideKey)(resource.id, "trustedOrigins")]: origins.join(","),
747
+ ...domain ? { [(0, _geekmidas_manifest.provideKey)(resource.id, "cookieDomain")]: domain } : {}
748
+ };
749
+ }
750
+ /** An address reduced to the origin a browser compares against. */
751
+ function originOf(address) {
752
+ try {
753
+ return new URL(address).origin;
754
+ } catch {
755
+ return;
756
+ }
757
+ }
845
758
  /**
846
- * Containers that cannot run alone.
759
+ * The runtime role of whatever a derived node hangs off.
847
760
  *
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.
761
+ * A reader's parent may itself be a schema tenant, so this follows `of` by one
762
+ * hop rather than assuming the cluster — the reader of a tenant reads that
763
+ * tenant's schema, not the database's.
851
764
  */
852
- const REQUIRES = { "redis-http": ["redis"] };
765
+ function parentRole(resource, plan) {
766
+ const parent = plan.resources.find((r) => r.id === resource.of);
767
+ return parent ? localRole(parent) : resource.name;
768
+ }
769
+ /** Whether a kind connects to Postgres and therefore has an owner role. */
770
+ function isDatabase(kind) {
771
+ return kind === "database" || kind === "database-schema";
772
+ }
853
773
  /**
854
- * The role each kind's one key plays, where it is not a URL.
774
+ * The URL a migrator connects with — the owner role, which can create, alter
775
+ * and drop.
855
776
  *
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.
777
+ * Never in `provides`, which is the whole point. A handler that could reach this
778
+ * could `DROP TABLE`, and the security property the role split exists for is
779
+ * that it cannot: the value is injected by `gkm exec` for the migrate step and
780
+ * by nothing else.
859
781
  */
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
- };
782
+ function ownerUrl(resource, plan, ports, project) {
783
+ if (!resource.container) return void 0;
784
+ const port = ports[primaryPortKey(resource.container)];
785
+ if (port === void 0) return void 0;
786
+ const owner = (0, _geekmidas_db_pg_roles.ownerRole)(localRole(resource));
787
+ return postgres(port, rootDatabase(resource, plan), {
788
+ user: owner,
789
+ password: localRolePassword(project, plan, owner)
790
+ });
791
+ }
870
792
  /**
871
- * Which kinds provision something *inside* their container.
793
+ * The shared broker keys, when anything is published or consumed.
872
794
  *
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".
795
+ * A queue's own key is the producer's. These two are the *connection* itself,
796
+ * which locally is one broker for every queue and topic in the project: the
797
+ * generated pollers open a single connection and subscribe each worker by name
798
+ * on it. Deployed there is no such thing — a Lambda is handed its own event
799
+ * source — so this pair exists for the local target and says so.
877
800
  */
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
- };
801
+ function brokerEnv(plan, ports) {
802
+ const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
803
+ if (!carrier) return {};
804
+ const publisher = urlFor(carrier, plan, ports, "");
805
+ if (!publisher) return {};
806
+ return {
807
+ EVENT_PUBLISHER_CONNECTION_STRING: publisher,
808
+ EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
809
+ };
810
+ }
886
811
  /**
887
- * The container each events backend needs, where it needs one of its own.
812
+ * The URL for one planned resource.
888
813
  *
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.
814
+ * Every part of it comes from the resource and the port it was published on.
815
+ */
816
+ function urlFor(resource, plan, ports, project, addresses = {}) {
817
+ if (resource.kind === "secret") return localSecret(project, plan, resource);
818
+ if (resource.kind === "credential") return void 0;
819
+ if (resource.kind === "rest-api" || resource.kind === "site") {
820
+ const address = addresses[resource.id];
821
+ const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
822
+ return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
823
+ }
824
+ if (!resource.container) return void 0;
825
+ const port = ports[primaryPortKey(resource.container)];
826
+ if (port === void 0) return void 0;
827
+ switch (resource.kind) {
828
+ case "database":
829
+ case "database-schema":
830
+ case "database-reader": {
831
+ const database = rootDatabase(resource, plan);
832
+ if (resource.roles === false) {
833
+ const schema = schemaOf(resource, plan);
834
+ return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
835
+ }
836
+ const role = resource.kind === "database-reader" ? (0, _geekmidas_db_pg_roles.readerRole)(parentRole(resource, plan)) : localRole(resource);
837
+ return postgres(port, database, {
838
+ user: role,
839
+ password: localRolePassword(project, plan, role)
840
+ });
841
+ }
842
+ case "email": return `smtp://${LOCAL_HOST}:${port}`;
843
+ case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
844
+ case "file-server": {
845
+ const origin = plan.resources.find((r) => r.id === resource.of);
846
+ if (!origin) return void 0;
847
+ return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
848
+ }
849
+ case "cache":
850
+ if (resource.of) {
851
+ const parent = plan.resources.find((r) => r.id === resource.of);
852
+ const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
853
+ return url ? withTable(url, resource.table ?? (0, _geekmidas_manifest.cacheTable)(resource.id)) : void 0;
854
+ }
855
+ switch (plan.cache) {
856
+ case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
857
+ default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
858
+ }
859
+ case "queue":
860
+ case "topic": return broker(plan, port);
861
+ default: return;
862
+ }
863
+ }
864
+ /**
865
+ * The producer's connection string, composed from the backend the plan chose.
892
866
  *
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.
867
+ * The protocol is what picks the transport, so a producer never branches: the
868
+ * same `.publish()` reaches pg-boss here and SQS deployed because the string it
869
+ * was handed said so.
896
870
  */
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";
871
+ function broker(plan, port) {
872
+ switch (plan.events) {
873
+ case "pgboss": {
874
+ const database = plan.resources.find((r) => r.kind === "database");
875
+ if (!database) throw new PgBossNeedsDatabase([]);
876
+ return `pgboss://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
877
+ }
878
+ case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
879
+ case "sns": throw new UnprovisionedEventsBackend("sns");
880
+ }
881
+ }
882
+ /** A local Postgres URL, master credential and all. */
883
+ function postgres(port, database, credential = {
884
+ user: LOCAL_USER$2,
885
+ password: LOCAL_USER$2
886
+ }) {
887
+ return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
888
+ }
903
889
  /**
904
- * How a resource of each kind separates its stage suffix.
890
+ * The role a handler connects as, for one database or tenant.
905
891
  *
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.
892
+ * The construct's id, lowercased and stage-scoped exactly as its database or
893
+ * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
894
+ * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
895
+ * in Postgres, which is why the suffix is not optional here the way it is for a
896
+ * schema.
909
897
  */
910
- const SEPARATORS = {
911
- database: "_",
912
- "database-reader": "_",
913
- "database-schema": "_",
914
- objects: "-"
915
- };
898
+ function localRole(resource) {
899
+ return resource.name;
900
+ }
916
901
  /**
917
- * The stage-scoped name for a resource.
902
+ * The password for a derived local role.
918
903
  *
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.
904
+ * Derived rather than random, for the same reason the local secret is: a
905
+ * password that changed on every `gkm dev` would lock a developer out of the
906
+ * data they had a moment ago. Seeded by the project so two checkouts do not
907
+ * share one, and by the stage so `test` and `development` do not.
908
+ *
909
+ * It is a *local* credential by construction — the cluster is on loopback and
910
+ * the seed is a checkout path — and no deployed target uses this function.
922
911
  */
923
- function resourceName(id, kind, stage) {
924
- const base = id.toLowerCase();
925
- if (stage === DEFAULT_STAGE) return base;
926
- return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
912
+ function localRolePassword(project, plan, role) {
913
+ return (0, node_crypto.createHash)("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
927
914
  }
928
915
  /**
929
- * The container a kind needs, or `undefined` if it needs none.
916
+ * A cache's address: its database's URL, carrying the table it reads.
930
917
  *
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.
918
+ * Appended rather than kept beside the URL as a second key, for the reason the
919
+ * design gives everywhere else — an address and what it opens are one fact, and
920
+ * two keys are one more thing to keep in step.
934
921
  */
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];
922
+ function withTable(url, table) {
923
+ const parsed = new URL(url);
924
+ parsed.searchParams.set("table", table);
925
+ return parsed.toString();
943
926
  }
944
927
  /**
945
- * Build the plan for a manifest and stage.
928
+ * The physical database a derived resource lives in.
946
929
  *
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.
930
+ * Walks `of` to the top rather than reading the immediate parent, because a
931
+ * reader's parent may itself be a schema tenant — two hops from the database
932
+ * that actually exists.
950
933
  */
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
- });
981
- }
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
- }
934
+ function rootDatabase(resource, plan) {
935
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
936
+ let current = resource;
937
+ while (current.of) {
938
+ const parent = byId.get(current.of);
939
+ if (!parent) break;
940
+ current = parent;
941
+ }
942
+ return current.name;
943
+ }
944
+ /** The schema a derived resource puts on its search path, if it has one. */
945
+ function schemaOf(resource, plan) {
946
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
947
+ let current = resource;
948
+ while (current) {
949
+ if (current.kind === "database-schema") return current.schema;
950
+ current = current.of ? byId.get(current.of) : void 0;
994
951
  }
995
- return {
996
- stage,
997
- events,
998
- cache,
999
- containers: [...containers],
1000
- resources
1001
- };
1002
952
  }
1003
953
  /**
1004
- * Queues or topics were declared with pg-boss and no database to hold them.
954
+ * A backend whose local addresses cannot be derived yet.
1005
955
  *
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.
956
+ * SNS and SQS are addressed by ARN, which means the topic and queue have to be
957
+ * created in LocalStack before a URL can name them. Until that provisioning
958
+ * lands, saying so is better than handing out a string that fails on first use.
1009
959
  */
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";
960
+ var UnprovisionedEventsBackend = class extends Error {
961
+ backend;
962
+ constructor(backend) {
963
+ 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.`);
964
+ this.backend = backend;
965
+ this.name = "UnprovisionedEventsBackend";
1015
966
  }
1016
967
  };
1017
968
  /**
1018
- * `services.cache: 'db'` in an app that declares more than one database.
969
+ * The value a secret resolves to locally.
1019
970
  *
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.
971
+ * A hash of what identifies it, so it is stable, distinct per project, stage
972
+ * and construct, and never written to disk as a literal. Deployed this is the
973
+ * one kind whose value does *not* come from here — a secret manager generates
974
+ * it once and the target reads it back.
1026
975
  */
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
-
976
+ function localSecret(project, plan, resource) {
977
+ return (0, node_crypto.createHash)("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
978
+ }
1043
979
  //#endregion
1044
- //#region src/reconcile/env.ts
980
+ //#region src/reconcile/apps.ts
1045
981
  /**
1046
- * The credential local containers are brought up with.
982
+ * The apps, as services beside the containers their constructs derive.
1047
983
  *
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.
984
+ * `gkm docker` used to write these by hand: every backend got
985
+ * `DATABASE_URL=postgresql://…@postgres:5432/app` and `REDIS_URL=redis://redis`,
986
+ * which was right only for a project whose single database happened to be
987
+ * called `Database` — an `Orders` database publishes `ORDERS_URL`, a schema
988
+ * tenant has a URL of its own, and buckets and mail were not wired at all.
989
+ *
990
+ * Now an app's environment is exactly what reconcile hands `gkm dev`: every key
991
+ * the declared constructs provide, from the same derivation. The only
992
+ * difference is where the addresses point — at the containers by service name
993
+ * on the compose network, rather than at `localhost` on the ports published for
994
+ * the host.
995
+ *
996
+ * Always from the *local* stage's plan, whichever stage is being reconciled:
997
+ * `gkm test` rewrites the same file, and running the tests must not quietly
998
+ * point these apps at the test databases.
1052
999
  */
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";
1000
+ /** The profile app services carry, so `gkm dev` starts only the containers. */
1001
+ const APPS_PROFILE = "apps";
1058
1002
  /**
1059
- * The credentials the S3 client resolves for MinIO.
1003
+ * Where an app's image is built from.
1060
1004
  *
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.
1005
+ * An app at the project root is a single-app project, whose `gkm docker`
1006
+ * writes `.gkm/docker/Dockerfile`; a workspace writes one per app.
1066
1007
  */
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";
1008
+ function dockerfileOf(appName, path) {
1009
+ return path === "." ? ".gkm/docker/Dockerfile" : `.gkm/docker/Dockerfile.${appName}`;
1010
+ }
1078
1011
  /**
1079
- * Every env key a reconciled plan resolves, and its value.
1012
+ * Every construct key, addressed across the compose network.
1080
1013
  *
1081
- * Keys come from the declaration rather than being re-derived, so what the build
1082
- * publishes and what the app reads cannot drift.
1014
+ * `envFor` is asked with a placeholder port for every port key, so each
1015
+ * `localhost:<placeholder>` in its answer names exactly one container port —
1016
+ * two containers listening on the same port inside cannot be confused — and is
1017
+ * rewritten to `<container>:<inside>`.
1083
1018
  */
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;
1019
+ function inNetworkEnv(workspace, manifest) {
1020
+ const plan = planFor(manifest, workspace.stages.local, (0, _geekmidas_manifest.provisionOrder)(manifest), {
1021
+ localStage: workspace.stages.local,
1022
+ ...backendsOf(workspace)
1023
+ });
1024
+ const placeholders = {};
1025
+ const targets = /* @__PURE__ */ new Map();
1026
+ let next = 49152;
1027
+ for (const container of [...plan.containers].sort()) for (const port of portsOf(container)) {
1028
+ placeholders[port.key] = next;
1029
+ targets.set(next, `${container}:${port.inside}`);
1030
+ next += 1;
1090
1031
  }
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;
1098
- }
1099
- if (resource.kind === "email" && url) env[(0, __geekmidas_manifest.provideKey)(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
1032
+ for (const key of portKeys(plan.containers)) placeholders[key] ??= next++;
1033
+ const env = envFor(plan, {
1034
+ ports: placeholders,
1035
+ project: workspace.name,
1036
+ addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
1037
+ });
1038
+ const rewrite = (value) => value.replace(/(?:localhost|127\.0\.0\.1):(\d+)/g, (match, port) => {
1039
+ return targets.get(Number(port)) ?? match;
1040
+ });
1041
+ return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
1042
+ }
1043
+ /** One compose service per app the workspace runs, behind the apps profile. */
1044
+ function appServices(workspace, manifest, containers) {
1045
+ const env = inNetworkEnv(workspace, manifest);
1046
+ const services = {};
1047
+ for (const [name, app] of Object.entries(workspace.apps)) {
1048
+ if (app.type === "mobile" || !app.port) continue;
1049
+ const key = require_config.appKey(name);
1050
+ const health = app.type === "web" ? "/" : "/health";
1051
+ services[key] = {
1052
+ image: `${key}:\${TAG:-latest}`,
1053
+ build: {
1054
+ context: ".",
1055
+ dockerfile: dockerfileOf(name, app.path)
1056
+ },
1057
+ profiles: [APPS_PROFILE],
1058
+ ports: [`${app.port}:${app.port}`],
1059
+ environment: {
1060
+ NODE_ENV: "production",
1061
+ PORT: String(app.port),
1062
+ ...env
1063
+ },
1064
+ ...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
1065
+ healthcheck: {
1066
+ test: [
1067
+ "CMD",
1068
+ "wget",
1069
+ "-q",
1070
+ "--spider",
1071
+ `http://localhost:${app.port}${health}`
1072
+ ],
1073
+ interval: "10s",
1074
+ timeout: "5s",
1075
+ retries: 5
1076
+ }
1077
+ };
1100
1078
  }
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;
1079
+ return services;
1105
1080
  }
1081
+ //#endregion
1082
+ //#region src/reconcile/clients.ts
1106
1083
  /**
1107
- * The values a site's bundler inlines, under the names it inlines them by.
1084
+ * The real Postgres and MinIO clients the applier runs against.
1108
1085
  *
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.
1086
+ * Kept apart from `provision.ts` so the rules there — what gets created, in what
1087
+ * order, and never dropping anything — are asserted against fakes, while the
1088
+ * drivers live here where there is nothing to assert but wiring.
1113
1089
  *
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.
1090
+ * Both are constructed from the same local addresses the env writer composes,
1091
+ * so the applier cannot end up talking to a different container than the app.
1118
1092
  */
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
- }
1093
+ /** The credential local containers are brought up with. */
1094
+ const LOCAL_USER$1 = "geekmidas";
1127
1095
  /**
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.
1096
+ * A Postgres client that opens one connection per database it is asked about.
1135
1097
  *
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.
1098
+ * `CREATE DATABASE` cannot run from a connection to the database being created,
1099
+ * and a schema must be created from inside its own database — so the database is
1100
+ * part of each query rather than fixed when the client is built.
1141
1101
  */
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]);
1102
+ function pgClient(port) {
1103
+ return { async query(database, sql, values) {
1104
+ const client = new pg.Client({
1105
+ host: "localhost",
1106
+ port,
1107
+ user: LOCAL_USER$1,
1108
+ password: LOCAL_USER$1,
1109
+ database: database ?? LOCAL_USER$1
1110
+ });
1111
+ await client.connect();
1112
+ try {
1113
+ return (await client.query(sql, values)).rows;
1114
+ } finally {
1115
+ await client.end();
1116
+ }
1117
+ } };
1118
+ }
1119
+ /** A bucket client pointed at the local MinIO. */
1120
+ function bucketClient(port) {
1121
+ const s3 = new _aws_sdk_client_s3.S3Client({
1122
+ region: "us-east-1",
1123
+ endpoint: `http://localhost:${port}`,
1124
+ forcePathStyle: true,
1125
+ credentials: {
1126
+ accessKeyId: LOCAL_USER$1,
1127
+ secretAccessKey: LOCAL_USER$1
1128
+ }
1129
+ });
1146
1130
  return {
1147
- [(0, __geekmidas_manifest.provideKey)(resource.id, "trustedOrigins")]: origins.join(","),
1148
- ...domain ? { [(0, __geekmidas_manifest.provideKey)(resource.id, "cookieDomain")]: domain } : {}
1131
+ async exists(bucket) {
1132
+ try {
1133
+ await s3.send(new _aws_sdk_client_s3.HeadBucketCommand({ Bucket: bucket }));
1134
+ return true;
1135
+ } catch {
1136
+ return false;
1137
+ }
1138
+ },
1139
+ async create(bucket) {
1140
+ await s3.send(new _aws_sdk_client_s3.CreateBucketCommand({ Bucket: bucket }));
1141
+ },
1142
+ async policy(bucket) {
1143
+ try {
1144
+ return (await s3.send(new _aws_sdk_client_s3.GetBucketPolicyCommand({ Bucket: bucket }))).Policy;
1145
+ } catch {
1146
+ return;
1147
+ }
1148
+ },
1149
+ async setPolicy(bucket, policy) {
1150
+ await s3.send(new _aws_sdk_client_s3.PutBucketPolicyCommand({
1151
+ Bucket: bucket,
1152
+ Policy: policy
1153
+ }));
1154
+ }
1149
1155
  };
1150
1156
  }
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
- }
1158
- }
1157
+ //#endregion
1158
+ //#region src/reconcile/compose.ts
1159
1159
  /**
1160
- * The runtime role of whatever a derived node hangs off.
1160
+ * The compose document a plan implies.
1161
1161
  *
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.
1165
- */
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
- }
1174
- /**
1175
- * The URL a migrator connects with — the owner role, which can create, alter
1176
- * and drop.
1162
+ * The third declaration of the same containers disappears here: today a
1163
+ * `services: ['postgres','minio']` block is kept in step with the code by hand,
1164
+ * which is the triplication this design exists to remove, in the one place you
1165
+ * touch every day. A `database` implies Postgres; the list is derived.
1177
1166
  *
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.
1167
+ * Returns an object rather than a string for the same reason `plan` is pure —
1168
+ * what should exist is asserted as data, and turning it into YAML is one line at
1169
+ * the edge.
1170
+ *
1171
+ * What stays config is unchanged: images and versions are pinned explicitly, and
1172
+ * a container no construct implies is still declared by hand. Neutral facts come
1173
+ * from the declaration, provider detail from config.
1182
1174
  */
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
- }
1175
+ /** The credential every derived container is brought up with locally. */
1176
+ const LOCAL_USER = "geekmidas";
1193
1177
  /**
1194
- * The shared broker keys, when anything is published or consumed.
1178
+ * The token the local cache accepts.
1195
1179
  *
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.
1180
+ * Fixed rather than generated: it is only ever reachable on a port this project
1181
+ * published, and the URL that carries it is derived, so nothing is made safer
1182
+ * by making it unguessable and a stable one keeps `redis-cli` sessions working.
1201
1183
  */
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 {};
1207
- return {
1208
- EVENT_PUBLISHER_CONNECTION_STRING: publisher,
1209
- EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
1210
- };
1211
- }
1184
+ const LOCAL_TOKEN = "geekmidas";
1212
1185
  /**
1213
- * The URL for one planned resource.
1186
+ * Build the compose document for a plan.
1214
1187
  *
1215
- * Every part of it comes from the resource and the port it was published on.
1188
+ * Every container is published on its assigned port, never on the image's
1189
+ * default: the point of allocation is that two projects can run at once, and
1190
+ * one container publishing 5432 puts that back.
1216
1191
  */
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;
1192
+ function composeFor(plan, options) {
1193
+ const services = {};
1194
+ const volumes = {};
1195
+ for (const container of [...plan.containers].sort()) {
1196
+ const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
1197
+ const image = declared ? postgresImage(declared) : DEFAULT_IMAGES[container];
1198
+ if (!image) throw new UnknownContainer(container);
1199
+ services[container] = define(container, image, publish(container, options.ports));
1200
+ const volume = volumeOf(container);
1201
+ if (volume) volumes[volume] = {};
1224
1202
  }
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);
1203
+ return {
1204
+ name: options.project,
1205
+ services: {
1206
+ ...services,
1207
+ ...options.apps
1208
+ },
1209
+ ...Object.keys(volumes).length ? { volumes } : {}
1210
+ };
1211
+ }
1212
+ /** Render a compose document as the file it becomes. */
1213
+ function toYaml(file) {
1214
+ return `# Generated by gkm from the construct manifest — do not edit.
1215
+ # Containers are derived from what the app declares. Image pins and anything
1216
+ # no construct implies go in your own docker-compose.yml, which is merged over
1217
+ # this file and wins:
1218
+ #
1219
+ # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
1220
+ #
1221
+ # "gkm dev" and "gkm test" start the containers. The apps sit behind the
1222
+ # "apps" profile: run "gkm setup", then
1223
+ # docker compose -f docker-compose.constructs.yml --profile apps up --build
1224
+ ${(0, yaml.stringify)(file, { lineWidth: 0 })}`;
1225
+ }
1226
+ /** A container with no image, from config or from the defaults. */
1227
+ var UnknownContainer = class extends Error {
1228
+ /** The container that could not be resolved. */
1229
+ container;
1230
+ /** The containers that have a default, so the caller can see the set. */
1231
+ known = Object.keys(DEFAULT_IMAGES).sort();
1232
+ constructor(container) {
1233
+ super("No image is known for this container; pin one in config");
1234
+ this.name = "UnknownContainer";
1235
+ this.container = container;
1236
+ }
1237
+ };
1238
+ /** One container's definition, published on the port it was assigned. */
1239
+ function define(container, image, published) {
1240
+ switch (container) {
1241
+ case "postgres": return {
1242
+ image,
1243
+ restart: "unless-stopped",
1244
+ ports: published,
1245
+ environment: {
1246
+ POSTGRES_USER: LOCAL_USER,
1247
+ POSTGRES_PASSWORD: LOCAL_USER,
1248
+ POSTGRES_DB: LOCAL_USER
1249
+ },
1250
+ volumes: ["postgres-data:/var/lib/postgresql"],
1251
+ healthcheck: {
1252
+ test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
1253
+ interval: "5s",
1254
+ timeout: "3s",
1255
+ retries: 10,
1256
+ start_period: "10s"
1236
1257
  }
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;
1258
+ };
1259
+ case "minio": return {
1260
+ image,
1261
+ restart: "unless-stopped",
1262
+ ports: published,
1263
+ environment: {
1264
+ MINIO_ROOT_USER: LOCAL_USER,
1265
+ MINIO_ROOT_PASSWORD: LOCAL_USER
1266
+ },
1267
+ command: "server --console-address \":9001\" /data",
1268
+ volumes: ["minio-data:/data"],
1269
+ healthcheck: {
1270
+ test: [
1271
+ "CMD",
1272
+ "mc",
1273
+ "ready",
1274
+ "local"
1275
+ ],
1276
+ interval: "10s",
1277
+ timeout: "5s",
1278
+ retries: 5,
1279
+ start_period: "10s"
1255
1280
  }
1256
- switch (plan.cache) {
1257
- case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
1258
- default: return `http://:${LOCAL_TOKEN}@${LOCAL_HOST}:${port}`;
1281
+ };
1282
+ case "caddy": return {
1283
+ image,
1284
+ restart: "unless-stopped",
1285
+ ports: published,
1286
+ volumes: [
1287
+ "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
1288
+ "./.gkm/caddy-sites:/etc/caddy/sites:ro",
1289
+ "caddy-data:/data"
1290
+ ],
1291
+ extra_hosts: ["host.docker.internal:host-gateway"],
1292
+ healthcheck: {
1293
+ test: [
1294
+ "CMD",
1295
+ "caddy",
1296
+ "version"
1297
+ ],
1298
+ interval: "10s",
1299
+ timeout: "5s",
1300
+ retries: 5
1259
1301
  }
1260
- case "queue":
1261
- case "topic": return broker(plan, port);
1262
- default: return void 0;
1263
- }
1264
- }
1265
- /**
1266
- * The producer's connection string, composed from the backend the plan chose.
1267
- *
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.
1271
- */
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");
1302
+ };
1303
+ case "mailpit": return {
1304
+ image,
1305
+ restart: "unless-stopped",
1306
+ ports: published,
1307
+ environment: {
1308
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
1309
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1"
1310
+ },
1311
+ healthcheck: {
1312
+ test: [
1313
+ "CMD",
1314
+ "wget",
1315
+ "-q",
1316
+ "--spider",
1317
+ "http://localhost:8025"
1318
+ ],
1319
+ interval: "10s",
1320
+ timeout: "5s",
1321
+ retries: 5
1322
+ }
1323
+ };
1324
+ case "redis": return {
1325
+ image,
1326
+ restart: "unless-stopped",
1327
+ ports: published,
1328
+ volumes: ["redis-data:/data"],
1329
+ healthcheck: {
1330
+ test: [
1331
+ "CMD",
1332
+ "redis-cli",
1333
+ "ping"
1334
+ ],
1335
+ interval: "5s",
1336
+ timeout: "3s",
1337
+ retries: 5
1338
+ }
1339
+ };
1340
+ case "redis-http": return {
1341
+ image,
1342
+ restart: "unless-stopped",
1343
+ ports: published,
1344
+ environment: {
1345
+ SRH_MODE: "env",
1346
+ SRH_TOKEN: LOCAL_TOKEN,
1347
+ SRH_CONNECTION_STRING: "redis://redis:6379"
1348
+ },
1349
+ depends_on: ["redis"],
1350
+ healthcheck: {
1351
+ test: [
1352
+ "CMD",
1353
+ "wget",
1354
+ "-q",
1355
+ "-O",
1356
+ "/dev/null",
1357
+ "http://127.0.0.1:80/"
1358
+ ],
1359
+ interval: "5s",
1360
+ timeout: "3s",
1361
+ retries: 10
1362
+ }
1363
+ };
1364
+ case "rabbitmq": return {
1365
+ image,
1366
+ restart: "unless-stopped",
1367
+ ports: published,
1368
+ environment: {
1369
+ RABBITMQ_DEFAULT_USER: LOCAL_USER,
1370
+ RABBITMQ_DEFAULT_PASS: LOCAL_USER
1371
+ },
1372
+ volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
1373
+ healthcheck: {
1374
+ test: [
1375
+ "CMD",
1376
+ "rabbitmq-diagnostics",
1377
+ "-q",
1378
+ "ping"
1379
+ ],
1380
+ interval: "10s",
1381
+ timeout: "5s",
1382
+ retries: 5
1383
+ }
1384
+ };
1385
+ case "localstack": return {
1386
+ image,
1387
+ restart: "unless-stopped",
1388
+ ports: published,
1389
+ environment: {
1390
+ AWS_DEFAULT_REGION: "us-east-1",
1391
+ AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
1392
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER
1393
+ },
1394
+ volumes: ["localstack-data:/var/lib/localstack"],
1395
+ healthcheck: {
1396
+ test: [
1397
+ "CMD",
1398
+ "curl",
1399
+ "-f",
1400
+ "http://localhost:4566/_localstack/health"
1401
+ ],
1402
+ interval: "10s",
1403
+ timeout: "5s",
1404
+ retries: 5,
1405
+ start_period: "10s"
1406
+ }
1407
+ };
1408
+ default: throw new UnknownContainer(container);
1281
1409
  }
1282
1410
  }
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}`;
1289
- }
1290
1411
  /**
1291
- * The role a handler connects as, for one database or tenant.
1412
+ * The published port mappings for a container.
1292
1413
  *
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.
1414
+ * Every port is looked up by its own key rather than being derived as an offset
1415
+ * from the container's first, so MinIO's console cannot land on whatever was
1416
+ * allocated to the next container.
1298
1417
  */
1299
- function localRole(resource) {
1300
- return resource.name;
1418
+ function publish(container, ports) {
1419
+ return portsOf(container).map((port) => {
1420
+ const assigned = ports[port.key];
1421
+ if (assigned === void 0) throw new UnassignedPort(port.key);
1422
+ return `${assigned}:${port.inside}`;
1423
+ });
1301
1424
  }
1425
+ /** A planned port that allocation never assigned. */
1426
+ var UnassignedPort = class extends Error {
1427
+ /** The port key that went unassigned, e.g. `minio-console`. */
1428
+ key;
1429
+ constructor(key) {
1430
+ super("No port was assigned for this container port");
1431
+ this.name = "UnassignedPort";
1432
+ this.key = key;
1433
+ }
1434
+ };
1435
+ //#endregion
1436
+ //#region src/reconcile/docker.ts
1302
1437
  /**
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.
1438
+ * The real Docker, behind the small interface reconcile needs.
1309
1439
  *
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.
1312
- */
1313
- function localRolePassword(project, plan, role) {
1314
- return (0, node_crypto.createHash)("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
1315
- }
1316
- /**
1317
- * A cache's address: its database's URL, carrying the table it reads.
1440
+ * Shells out to `docker compose` rather than talking to the daemon socket: it is
1441
+ * what a developer runs by hand when something is wrong, so the two cannot
1442
+ * disagree, and it keeps Docker out of this package's dependencies.
1318
1443
  *
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.
1444
+ * Every call is scoped to the generated compose file. Nothing here can touch a
1445
+ * container this project did not generate.
1322
1446
  */
1323
- function withTable(url, table) {
1324
- const parsed = new URL(url);
1325
- parsed.searchParams.set("table", table);
1326
- return parsed.toString();
1327
- }
1447
+ const run = (0, node_util.promisify)(node_child_process.execFile);
1328
1448
  /**
1329
- * The physical database a derived resource lives in.
1449
+ * The generated file, then the project's own if it has one.
1330
1450
  *
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.
1451
+ * Merged in that order so the project's wins — an `image:` there pins a
1452
+ * container this file derived, and a service there is added beside them.
1334
1453
  */
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;
1342
- }
1343
- return current.name;
1344
- }
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;
1454
+ function composeFiles(composePath) {
1455
+ const project = (0, node_path.join)((0, node_path.dirname)(composePath), "docker-compose.yml");
1456
+ return [
1457
+ "-f",
1458
+ composePath,
1459
+ ...(0, node_fs.existsSync)(project) ? ["-f", project] : []
1460
+ ];
1354
1461
  }
1355
- /**
1356
- * A backend whose local addresses cannot be derived yet.
1357
- *
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";
1462
+ /** How long to wait for containers to pass their health checks. */
1463
+ const HEALTH_TIMEOUT_MS = 12e4;
1464
+ const dockerCli = {
1465
+ async publishedPort(composePath, service, inside) {
1466
+ try {
1467
+ const { stdout } = await run("docker", [
1468
+ "compose",
1469
+ ...composeFiles(composePath),
1470
+ "port",
1471
+ service,
1472
+ String(inside)
1473
+ ]);
1474
+ const port = Number(stdout.trim().split(":").pop());
1475
+ return Number.isFinite(port) && port > 0 ? port : void 0;
1476
+ } catch {
1477
+ return;
1478
+ }
1479
+ },
1480
+ async up(composePath, services) {
1481
+ await run("docker", [
1482
+ "compose",
1483
+ ...composeFiles(composePath),
1484
+ "up",
1485
+ "-d",
1486
+ "--remove-orphans",
1487
+ "--wait",
1488
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
1489
+ ...services
1490
+ ]);
1491
+ },
1492
+ async copyOut(composePath, service, from, to) {
1493
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(to), { recursive: true });
1494
+ await run("docker", [
1495
+ "compose",
1496
+ ...composeFiles(composePath),
1497
+ "cp",
1498
+ `${service}:${from}`,
1499
+ to
1500
+ ]);
1501
+ },
1502
+ async reload(composePath, service) {
1503
+ await run("docker", [
1504
+ "compose",
1505
+ ...composeFiles(composePath),
1506
+ "exec",
1507
+ "-T",
1508
+ service,
1509
+ "caddy",
1510
+ "reload",
1511
+ "--config",
1512
+ "/etc/caddy/Caddyfile"
1513
+ ]);
1514
+ },
1515
+ async healthy(composePath, services) {
1516
+ if (services.length === 0) return true;
1517
+ try {
1518
+ const { stdout } = await run("docker", [
1519
+ "compose",
1520
+ ...composeFiles(composePath),
1521
+ "ps",
1522
+ "--format",
1523
+ "json",
1524
+ ...services
1525
+ ]);
1526
+ const running = /* @__PURE__ */ new Set();
1527
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
1528
+ const entry = JSON.parse(line);
1529
+ if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
1530
+ }
1531
+ return services.every((service) => running.has(service));
1532
+ } catch {
1533
+ return false;
1534
+ }
1367
1535
  }
1368
1536
  };
1537
+ //#endregion
1538
+ //#region src/reconcile/ports.ts
1369
1539
  /**
1370
- * The value a secret resolves to locally.
1540
+ * Port allocation — why two projects can run at once.
1371
1541
  *
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.
1542
+ * Publishing `5432:5432` means the second project on your machine cannot start.
1543
+ * Nothing solves that except giving each project its own ports, and what makes
1544
+ * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
1545
+ * never sees a host or a port, so reconcile is free to bind whatever is
1546
+ * available and compose the URL from what it bound. An app reading `DB_HOST` and
1547
+ * `DB_PORT` separately could not be moved this way.
1548
+ *
1549
+ * Assignments are **persisted, not recomputed**. Recomputing means adding a
1550
+ * construct renumbers its neighbours, and every running container, saved
1551
+ * connection string, and `psql` history goes stale at once.
1552
+ *
1553
+ * Pure, with the liveness probe injected — a collision with another project's
1554
+ * stack is a fact about the machine, and keeping it a parameter is what lets the
1555
+ * rules be asserted without binding a socket.
1376
1556
  */
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
1557
  /**
1384
1558
  * The window ports are drawn from.
1385
1559
  *
@@ -1478,17 +1652,33 @@ async function nextFree(from, taken, probe) {
1478
1652
  * rather than as a connection failure ten minutes later.
1479
1653
  */
1480
1654
  function isPortFree(port) {
1481
- return new Promise((resolve$1) => {
1655
+ return new Promise((resolve) => {
1482
1656
  const server = (0, node_net.createServer)();
1483
- server.once("error", () => resolve$1(false));
1484
- server.once("listening", () => server.close(() => resolve$1(true)));
1657
+ server.once("error", () => resolve(false));
1658
+ server.once("listening", () => server.close(() => resolve(true)));
1485
1659
  server.listen(port, "0.0.0.0");
1486
1660
  });
1487
1661
  }
1488
-
1489
1662
  //#endregion
1490
1663
  //#region src/reconcile/provision.ts
1491
1664
  /**
1665
+ * Creating what the URLs name.
1666
+ *
1667
+ * Convergent and idempotent: every statement asks whether the thing exists
1668
+ * first, so reconciling repeatedly is free and a half-applied run recovers by
1669
+ * being run again. That property is what lets `gkm dev` do this on every start.
1670
+ *
1671
+ * The line the design draws is at data, not at side effects — this creates
1672
+ * databases, schemas, and buckets, and it never seeds, resets, or drops. A
1673
+ * developer who wants their data gone asks for it explicitly.
1674
+ *
1675
+ * Statement generation is separated from application for the reason the design
1676
+ * gives: the same DDL has to run in-process locally and from a provisioner in a
1677
+ * VPC, and one implementation is what stops "works locally, fails deployed".
1678
+ * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
1679
+ * growing its own.
1680
+ */
1681
+ /**
1492
1682
  * The Postgres work a plan implies.
1493
1683
  *
1494
1684
  * Databases before the schemas inside them, which `provisionOrder` already
@@ -1539,9 +1729,9 @@ function postgresStatements(plan, project = "") {
1539
1729
  function cacheTableDdl(cache, home, plan) {
1540
1730
  const database = rootDatabase(home, plan);
1541
1731
  const schema = home.roles === false ? void 0 : home.schema;
1542
- const name = cache.table ?? (0, __geekmidas_manifest.cacheTable)(cache.id);
1732
+ const name = cache.table ?? (0, _geekmidas_manifest.cacheTable)(cache.id);
1543
1733
  const table = schema ? `${schema}.${name}` : name;
1544
- const created = (0, __geekmidas_cache_postgres.cacheTableStatements)({ table }).map((statement) => ({
1734
+ const created = (0, _geekmidas_cache_postgres.cacheTableStatements)({ table }).map((statement) => ({
1545
1735
  id: cache.id,
1546
1736
  describe: statement.describe,
1547
1737
  database,
@@ -1550,7 +1740,7 @@ function cacheTableDdl(cache, home, plan) {
1550
1740
  }));
1551
1741
  if (!schema) return created;
1552
1742
  const runtime = localRole(home);
1553
- const owner = (0, __geekmidas_db_pg_roles.ownerRole)(runtime);
1743
+ const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
1554
1744
  const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
1555
1745
  return [
1556
1746
  ...created,
@@ -1578,9 +1768,9 @@ function cacheTableDdl(cache, home, plan) {
1578
1768
  function rolesFor(resource, schema, plan, project) {
1579
1769
  if (resource.roles === false) return [];
1580
1770
  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)({
1771
+ const owner = (0, _geekmidas_db_pg_roles.ownerRole)(runtime);
1772
+ const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? (0, _geekmidas_db_pg_roles.readerRole)(runtime) : void 0;
1773
+ return (0, _geekmidas_db_pg_roles.roleStatements)({
1584
1774
  runtime,
1585
1775
  owner,
1586
1776
  ...reader ? { reader } : {},
@@ -1630,8 +1820,7 @@ async function applyPostgres(client, statements) {
1630
1820
  });
1631
1821
  continue;
1632
1822
  }
1633
- const rows = await client.query(statement.database, statement.exists.sql, statement.exists.values);
1634
- if (rows.length > 0) {
1823
+ if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
1635
1824
  applied.push({
1636
1825
  id: statement.id,
1637
1826
  describe: statement.describe,
@@ -1756,9 +1945,20 @@ async function applyBuckets(client, buckets) {
1756
1945
  }
1757
1946
  return applied;
1758
1947
  }
1759
-
1760
1948
  //#endregion
1761
1949
  //#region src/reconcile/state.ts
1950
+ /**
1951
+ * What reconcile records, and the hash that lets it do nothing.
1952
+ *
1953
+ * Reconciling on every `gkm dev` is only acceptable if the converged case is
1954
+ * free, so the desired plan is hashed: matching hash plus healthy containers
1955
+ * means there is nothing to do. Without that, the automatic reconcile the design
1956
+ * asks for would tax every start.
1957
+ *
1958
+ * Almost nothing needs recording — roles are queried, migrations have their own
1959
+ * table, containers are remembered by Docker. Only ports and this hash have no
1960
+ * other owner.
1961
+ */
1762
1962
  const STATE_DIR = ".gkm/reconcile";
1763
1963
  /**
1764
1964
  * The hash of everything a reconcile would act on.
@@ -1787,7 +1987,7 @@ async function loadState(root, stage) {
1787
1987
  const state = JSON.parse(raw);
1788
1988
  return state.stage === stage ? state : void 0;
1789
1989
  } catch {
1790
- return void 0;
1990
+ return;
1791
1991
  }
1792
1992
  }
1793
1993
  /** Record what was applied, so the next run can skip it. */
@@ -1799,11 +1999,36 @@ async function saveState(root, state) {
1799
1999
  function statePath(root, stage) {
1800
2000
  return (0, node_path.join)(root, STATE_DIR, `${stage}.json`);
1801
2001
  }
1802
-
1803
2002
  //#endregion
1804
2003
  //#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";
2004
+ /**
2005
+ * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
2006
+ * all call.
2007
+ *
2008
+ * Three phases, and only this one has side effects: declaring is pure (`gkm
2009
+ * build` and the dev watcher), running reads `<NAME>_URL` and provisions
2010
+ * nothing. Reconcile computes the desired state, compares, and applies the
2011
+ * difference — which is what makes "when does it run" answerable with
2012
+ * *whenever, repeatedly*.
2013
+ *
2014
+ * It is safe to run automatically because its blast radius is entirely local:
2015
+ * this project's containers, this project's `.gkm/`. Nothing it does can reach a
2016
+ * cloud. The line is drawn at data, not at side effects — it allocates ports,
2017
+ * writes compose, starts containers, and creates roles; it never seeds, resets,
2018
+ * or drops.
2019
+ *
2020
+ * Docker is injected rather than imported so the rules above can be asserted
2021
+ * without a daemon. The default implementation is the real one.
2022
+ */
2023
+ /**
2024
+ * Where the generated compose file is written, relative to the project root.
2025
+ *
2026
+ * At the root, beside the project's own `docker-compose.yml`, because the two
2027
+ * are one stack: this file is everything the constructs imply and is never
2028
+ * edited, that one is anything they cannot express — an image pin, an extra
2029
+ * service — and is merged over it, so it wins.
2030
+ */
2031
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
1807
2032
  /**
1808
2033
  * The local edge's config, beside the compose file that mounts it.
1809
2034
  *
@@ -1840,7 +2065,8 @@ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
1840
2065
  */
1841
2066
  async function reconcile(options) {
1842
2067
  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), {
2068
+ const plan = planFor(manifest, stage, (0, _geekmidas_manifest.provisionOrder)(manifest), {
2069
+ localStage: options.localStage,
1844
2070
  events: options.events,
1845
2071
  cache: options.cache,
1846
2072
  extraContainers: options.extraContainers,
@@ -1855,7 +2081,7 @@ async function reconcile(options) {
1855
2081
  const compose = composeFor(plan, {
1856
2082
  project,
1857
2083
  ports,
1858
- ...options.images ? { images: options.images } : {}
2084
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
1859
2085
  });
1860
2086
  const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
1861
2087
  const hash = planHash(plan, compose, { caddyfile });
@@ -1878,13 +2104,16 @@ async function reconcile(options) {
1878
2104
  hash,
1879
2105
  changed: false
1880
2106
  };
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;
2107
+ if ((await loadState(root, stage))?.hash === hash && (0, node_fs.existsSync)(composePath) && (!start || await docker.healthy(composePath, plan.containers))) return result;
1884
2108
  await write(composePath, toYaml(compose));
1885
2109
  if (plan.containers.includes("caddy")) {
1886
2110
  await write((0, node_path.join)(root, CADDYFILE_PATH), caddyfileRoot());
1887
2111
  await write((0, node_path.join)(root, caddySitesPath(stage)), caddyfile);
2112
+ await pruneCaddySites(root, [
2113
+ stage,
2114
+ options.localStage,
2115
+ require_workspace.TEST_STAGE
2116
+ ]);
1888
2117
  }
1889
2118
  if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
1890
2119
  if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
@@ -1950,6 +2179,20 @@ function addressesFor(containers, ports) {
1950
2179
  return addresses;
1951
2180
  }
1952
2181
  /** Write a file only when its content would change, so mtimes stay meaningful. */
2182
+ /**
2183
+ * Remove the routes of stages this project no longer reconciles.
2184
+ *
2185
+ * Only the local stage and `test` are ever written, so any other file is left
2186
+ * over from a stage that was renamed — `development` becoming `dev`. Caddy
2187
+ * imports every file, and the leftover declares the same hosts as its
2188
+ * replacement: it refuses the whole config as ambiguous, and every URL behind
2189
+ * the edge goes with it.
2190
+ */
2191
+ async function pruneCaddySites(root, stages) {
2192
+ const dir = (0, node_path.dirname)((0, node_path.join)(root, caddySitesPath(require_workspace.TEST_STAGE)));
2193
+ const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
2194
+ 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));
2195
+ }
1953
2196
  async function write(path, content) {
1954
2197
  try {
1955
2198
  if (await (0, node_fs_promises.readFile)(path, "utf-8") === content) return;
@@ -1957,10 +2200,28 @@ async function write(path, content) {
1957
2200
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
1958
2201
  await (0, node_fs_promises.writeFile)(path, content);
1959
2202
  }
1960
-
1961
2203
  //#endregion
1962
2204
  //#region src/reconcile/workspace.ts
1963
2205
  /**
2206
+ * Workspace config → reconcile.
2207
+ *
2208
+ * Nothing here is read from a `services:` block — there is none. Which
2209
+ * containers exist comes from the manifest; which backend a cache or a broker
2210
+ * resolves to comes from the deploy target; and an image pin is the project's
2211
+ * own `docker-compose.yml`, merged over the generated file.
2212
+ *
2213
+ * Shared by `gkm setup`, `gkm dev`, and `gkm test` so all three converge on the
2214
+ * same state, differing only in the stage they pass.
2215
+ */
2216
+ var workspace_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
2217
+ backendsOf: () => backendsOf,
2218
+ constructGlobs: () => constructGlobs,
2219
+ derivedContainers: () => derivedContainers,
2220
+ reconcileWorkspace: () => reconcileWorkspace,
2221
+ surfaceAddresses: () => surfaceAddresses,
2222
+ usesConstructs: () => usesConstructs
2223
+ });
2224
+ /**
1964
2225
  * Every constructs glob in the workspace, resolved against its app.
1965
2226
  *
1966
2227
  * Absolute because discovery globs from a single cwd while apps live in
@@ -1973,16 +2234,13 @@ function constructGlobs(workspace) {
1973
2234
  function usesConstructs(workspace) {
1974
2235
  return constructGlobs(workspace).length > 0;
1975
2236
  }
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 };
2237
+ /** The backends a workspace's target implies, as the plan takes them. */
2238
+ function backendsOf(workspace) {
2239
+ const on = providerOf(workspace);
2240
+ return {
2241
+ events: eventsBackendFor(on),
2242
+ cache: cacheBackendFor(on)
2243
+ };
1986
2244
  }
1987
2245
  /**
1988
2246
  * The containers a workspace's declarations imply, without reconciling.
@@ -1999,9 +2257,9 @@ async function derivedContainers(workspace, stage, manifest) {
1999
2257
  patterns: constructGlobs(workspace),
2000
2258
  cwd: workspace.root
2001
2259
  });
2002
- return planFor(found, stage, (0, __geekmidas_manifest.provisionOrder)(found), {
2003
- events: workspace.services.events,
2004
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
2260
+ return planFor(found, stage, (0, _geekmidas_manifest.provisionOrder)(found), {
2261
+ localStage: workspace.stages.local,
2262
+ ...backendsOf(workspace)
2005
2263
  }).containers;
2006
2264
  }
2007
2265
  /**
@@ -2020,11 +2278,11 @@ async function reconcileWorkspace(workspace, options) {
2020
2278
  project: workspace.name,
2021
2279
  manifest,
2022
2280
  stage: options.stage,
2023
- events: workspace.services.events,
2024
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
2025
- images: imagePins(workspace),
2281
+ localStage: workspace.stages.local,
2282
+ ...backendsOf(workspace),
2026
2283
  saved: await loadPortState(workspace.root),
2027
2284
  addresses: surfaceAddresses(workspace, manifest),
2285
+ apps: (containers) => appServices(workspace, manifest, containers),
2028
2286
  ...options.start === void 0 ? {} : { start: options.start }
2029
2287
  });
2030
2288
  await savePortState(workspace.root, { ...result.ports });
@@ -2046,26 +2304,20 @@ async function reconcileWorkspace(workspace, options) {
2046
2304
  * an app serving both its own API and an auth server publishes one address
2047
2305
  * twice — which is exactly what it does at runtime.
2048
2306
  */
2049
- function surfaceAddresses(workspace, manifest) {
2307
+ function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
2050
2308
  const addresses = {};
2051
2309
  for (const [id, declaration] of Object.entries(manifest)) {
2052
2310
  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);
2311
+ const host = require_config.appKey(id);
2312
+ const port = workspace.apps[host]?.port;
2313
+ if (port) addresses[id] = at(host, port);
2056
2314
  }
2057
2315
  return addresses;
2058
2316
  }
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
2317
  /** Where a local process answers, given the port the workspace gave it. */
2065
2318
  function localAddress(port) {
2066
2319
  return `http://localhost:${port}`;
2067
2320
  }
2068
-
2069
2321
  //#endregion
2070
2322
  //#region src/credentials/index.ts
2071
2323
  const logger = console;
@@ -2098,15 +2350,15 @@ function loadEnvFiles(envConfig, cwd = process.cwd()) {
2098
2350
  * @internal Exported for testing
2099
2351
  */
2100
2352
  async function isPortAvailable(port) {
2101
- return new Promise((resolve$1) => {
2353
+ return new Promise((resolve) => {
2102
2354
  const server = (0, node_net.createServer)();
2103
2355
  server.once("error", (err) => {
2104
- if (err.code === "EADDRINUSE") resolve$1(false);
2105
- else resolve$1(false);
2356
+ if (err.code === "EADDRINUSE") resolve(false);
2357
+ else resolve(false);
2106
2358
  });
2107
2359
  server.once("listening", () => {
2108
2360
  server.close();
2109
- resolve$1(true);
2361
+ resolve(true);
2110
2362
  });
2111
2363
  server.listen(port);
2112
2364
  });
@@ -2175,11 +2427,10 @@ async function savePortState(workspaceRoot, ports) {
2175
2427
  */
2176
2428
  function getContainerHostPort(workspaceRoot, service, containerPort) {
2177
2429
  try {
2178
- const result = (0, node_child_process.execSync)(`docker compose port ${service} ${containerPort}`, {
2430
+ const match = (0, node_child_process.execSync)(`docker compose port ${service} ${containerPort}`, {
2179
2431
  cwd: workspaceRoot,
2180
2432
  stdio: "pipe"
2181
- }).toString().trim();
2182
- const match = result.match(/:(\d+)$/);
2433
+ }).toString().trim().match(/:(\d+)$/);
2183
2434
  return match ? Number(match[1]) : null;
2184
2435
  } catch {
2185
2436
  return null;
@@ -2192,8 +2443,7 @@ function getContainerHostPort(workspaceRoot, service, containerPort) {
2192
2443
  * @internal Exported for testing
2193
2444
  */
2194
2445
  async function resolveServicePorts(workspaceRoot) {
2195
- const composePath = (0, node_path.join)(workspaceRoot, "docker-compose.yml");
2196
- const mappings = parseComposePortMappings(composePath);
2446
+ const mappings = parseComposePortMappings((0, node_path.join)(workspaceRoot, "docker-compose.yml"));
2197
2447
  if (mappings.length === 0) return {
2198
2448
  dockerEnv: {},
2199
2449
  ports: {},
@@ -2359,14 +2609,13 @@ async function startWorkspaceServices(workspace, portEnv, secretsEnv) {
2359
2609
  * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
2360
2610
  * @internal Exported for testing
2361
2611
  */
2362
- async function loadSecretsForApp(secretsRoot, appName, stages = ["dev", "development"]) {
2612
+ async function loadSecretsForApp(secretsRoot, stage, appName) {
2363
2613
  let secrets = {};
2364
- for (const stage of stages) if (require_storage.secretsExist(stage, secretsRoot)) {
2614
+ if (require_storage.secretsExist(stage, secretsRoot)) {
2365
2615
  const stageSecrets = await require_storage.readStageSecrets(stage, secretsRoot);
2366
2616
  if (stageSecrets) {
2367
2617
  logger.log(`🔐 Loading secrets from stage: ${stage}`);
2368
2618
  secrets = require_storage.toEmbeddableSecrets(stageSecrets);
2369
- break;
2370
2619
  }
2371
2620
  }
2372
2621
  if (Object.keys(secrets).length === 0) return {};
@@ -2428,13 +2677,12 @@ ${generateCredentialsInjection(secretsJsonPath)}`;
2428
2677
  * @internal Exported for testing
2429
2678
  */
2430
2679
  async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
2431
- const credentialsInjection = secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
2432
- ` : "";
2433
2680
  const content = `#!/usr/bin/env node
2434
2681
  /**
2435
2682
  * Entry wrapper generated by 'gkm dev --entry'
2436
2683
  */
2437
- ${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
2684
+ ${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
2685
+ ` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
2438
2686
  await import('${entryPath}');
2439
2687
  `;
2440
2688
  await (0, node_fs_promises.writeFile)(wrapperPath, content);
@@ -2447,7 +2695,7 @@ await import('${entryPath}');
2447
2695
  * @param options.resolveDockerPorts - How to resolve Docker ports:
2448
2696
  * - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
2449
2697
  * - `'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'].
2698
+ * @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
2451
2699
  * @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
2452
2700
  * @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
2453
2701
  * @internal Exported for testing
@@ -2459,31 +2707,34 @@ async function prepareEntryCredentials(options) {
2459
2707
  let secretsRoot = cwd;
2460
2708
  let appName;
2461
2709
  let appInfo;
2710
+ let workspace;
2462
2711
  try {
2463
2712
  appInfo = await require_config.loadWorkspaceAppInfo(cwd);
2713
+ workspace = appInfo.workspace;
2464
2714
  workspaceAppPort = appInfo.app.port;
2465
2715
  secretsRoot = appInfo.workspaceRoot;
2466
2716
  appName = appInfo.appName;
2467
2717
  } catch {
2468
- secretsRoot = findSecretsRoot(cwd);
2718
+ workspace = await require_config.loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
2719
+ secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
2469
2720
  appName = require_config.getAppNameFromCwd(cwd) ?? void 0;
2470
2721
  }
2471
2722
  const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
2472
- const credentials = await loadSecretsForApp(secretsRoot, appName, options.stages);
2723
+ const stage = options.stage ?? workspace?.stages.local;
2724
+ const credentials = stage ? await loadSecretsForApp(secretsRoot, stage, appName) : {};
2473
2725
  credentials.PORT = String(resolvedPort);
2474
2726
  const declaredKeys = [];
2475
- if (appInfo && usesConstructs(appInfo.workspace)) {
2476
- const { reconcileWorkspace: reconcileWorkspace$1 } = await Promise.resolve().then(() => require("./workspace-BO_KWohI.cjs"));
2477
- const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
2478
- stage: options.reconcileStage ?? "development",
2727
+ if (workspace && usesConstructs(workspace)) {
2728
+ const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
2729
+ const reconciled = await reconcileWorkspace(workspace, {
2730
+ stage: options.reconcileStage ?? workspace.stages.local,
2479
2731
  start: options.startDocker ?? false
2480
2732
  });
2481
2733
  Object.assign(credentials, reconciled.env);
2482
2734
  declaredKeys.push(...Object.keys(reconciled.env));
2483
2735
  if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
2484
2736
  } else {
2485
- const composePath = (0, node_path.join)(secretsRoot, "docker-compose.yml");
2486
- const mappings = parseComposePortMappings(composePath);
2737
+ const mappings = parseComposePortMappings((0, node_path.join)(secretsRoot, "docker-compose.yml"));
2487
2738
  if (mappings.length > 0) {
2488
2739
  let resolvedPorts;
2489
2740
  if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
@@ -2504,8 +2755,10 @@ async function prepareEntryCredentials(options) {
2504
2755
  mappings
2505
2756
  };
2506
2757
  }
2507
- if (options.startDocker) if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
2508
- else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
2758
+ if (options.startDocker) {
2759
+ if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
2760
+ else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
2761
+ }
2509
2762
  if (Object.keys(resolvedPorts.ports).length > 0) {
2510
2763
  const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
2511
2764
  Object.assign(credentials, rewritten);
@@ -2543,138 +2796,144 @@ async function prepareEntryCredentials(options) {
2543
2796
  declaredKeys
2544
2797
  };
2545
2798
  }
2546
-
2547
2799
  //#endregion
2548
- Object.defineProperty(exports, 'LOCAL_CA_PATH', {
2549
- enumerable: true,
2550
- get: function () {
2551
- return LOCAL_CA_PATH;
2552
- }
2800
+ Object.defineProperty(exports, "COMPOSE_PATH", {
2801
+ enumerable: true,
2802
+ get: function() {
2803
+ return COMPOSE_PATH;
2804
+ }
2553
2805
  });
2554
- Object.defineProperty(exports, 'applyPostgres', {
2555
- enumerable: true,
2556
- get: function () {
2557
- return applyPostgres;
2558
- }
2806
+ Object.defineProperty(exports, "LOCAL_CA_PATH", {
2807
+ enumerable: true,
2808
+ get: function() {
2809
+ return LOCAL_CA_PATH;
2810
+ }
2811
+ });
2812
+ Object.defineProperty(exports, "applyPostgres", {
2813
+ enumerable: true,
2814
+ get: function() {
2815
+ return applyPostgres;
2816
+ }
2559
2817
  });
2560
- Object.defineProperty(exports, 'cacheBackendOf', {
2561
- enumerable: true,
2562
- get: function () {
2563
- return cacheBackendOf;
2564
- }
2818
+ Object.defineProperty(exports, "cacheBackendFor", {
2819
+ enumerable: true,
2820
+ get: function() {
2821
+ return cacheBackendFor;
2822
+ }
2565
2823
  });
2566
- Object.defineProperty(exports, 'constructGlobs', {
2567
- enumerable: true,
2568
- get: function () {
2569
- return constructGlobs;
2570
- }
2824
+ Object.defineProperty(exports, "constructGlobs", {
2825
+ enumerable: true,
2826
+ get: function() {
2827
+ return constructGlobs;
2828
+ }
2571
2829
  });
2572
- Object.defineProperty(exports, 'createCredentialsPreload', {
2573
- enumerable: true,
2574
- get: function () {
2575
- return createCredentialsPreload;
2576
- }
2830
+ Object.defineProperty(exports, "createCredentialsPreload", {
2831
+ enumerable: true,
2832
+ get: function() {
2833
+ return createCredentialsPreload;
2834
+ }
2577
2835
  });
2578
- Object.defineProperty(exports, 'createEntryWrapper', {
2579
- enumerable: true,
2580
- get: function () {
2581
- return createEntryWrapper;
2582
- }
2836
+ Object.defineProperty(exports, "createEntryWrapper", {
2837
+ enumerable: true,
2838
+ get: function() {
2839
+ return createEntryWrapper;
2840
+ }
2583
2841
  });
2584
- Object.defineProperty(exports, 'derivedContainers', {
2585
- enumerable: true,
2586
- get: function () {
2587
- return derivedContainers;
2588
- }
2842
+ Object.defineProperty(exports, "derivedContainers", {
2843
+ enumerable: true,
2844
+ get: function() {
2845
+ return derivedContainers;
2846
+ }
2589
2847
  });
2590
- Object.defineProperty(exports, 'emailBackendOf', {
2591
- enumerable: true,
2592
- get: function () {
2593
- return emailBackendOf;
2594
- }
2848
+ Object.defineProperty(exports, "eventsBackendFor", {
2849
+ enumerable: true,
2850
+ get: function() {
2851
+ return eventsBackendFor;
2852
+ }
2595
2853
  });
2596
- Object.defineProperty(exports, 'findAvailablePort', {
2597
- enumerable: true,
2598
- get: function () {
2599
- return findAvailablePort;
2600
- }
2854
+ Object.defineProperty(exports, "findAvailablePort", {
2855
+ enumerable: true,
2856
+ get: function() {
2857
+ return findAvailablePort;
2858
+ }
2601
2859
  });
2602
- Object.defineProperty(exports, 'imagePins', {
2603
- enumerable: true,
2604
- get: function () {
2605
- return imagePins;
2606
- }
2860
+ Object.defineProperty(exports, "isPortAvailable", {
2861
+ enumerable: true,
2862
+ get: function() {
2863
+ return isPortAvailable;
2864
+ }
2607
2865
  });
2608
- Object.defineProperty(exports, 'isPortAvailable', {
2609
- enumerable: true,
2610
- get: function () {
2611
- return isPortAvailable;
2612
- }
2866
+ Object.defineProperty(exports, "loadEnvFiles", {
2867
+ enumerable: true,
2868
+ get: function() {
2869
+ return loadEnvFiles;
2870
+ }
2613
2871
  });
2614
- Object.defineProperty(exports, 'loadEnvFiles', {
2615
- enumerable: true,
2616
- get: function () {
2617
- return loadEnvFiles;
2618
- }
2872
+ Object.defineProperty(exports, "loadSecretsForApp", {
2873
+ enumerable: true,
2874
+ get: function() {
2875
+ return loadSecretsForApp;
2876
+ }
2619
2877
  });
2620
- Object.defineProperty(exports, 'loadSecretsForApp', {
2621
- enumerable: true,
2622
- get: function () {
2623
- return loadSecretsForApp;
2624
- }
2878
+ Object.defineProperty(exports, "prepareEntryCredentials", {
2879
+ enumerable: true,
2880
+ get: function() {
2881
+ return prepareEntryCredentials;
2882
+ }
2625
2883
  });
2626
- Object.defineProperty(exports, 'prepareEntryCredentials', {
2627
- enumerable: true,
2628
- get: function () {
2629
- return prepareEntryCredentials;
2630
- }
2884
+ Object.defineProperty(exports, "providerOf", {
2885
+ enumerable: true,
2886
+ get: function() {
2887
+ return providerOf;
2888
+ }
2631
2889
  });
2632
- Object.defineProperty(exports, 'providerOf', {
2633
- enumerable: true,
2634
- get: function () {
2635
- return providerOf;
2636
- }
2890
+ Object.defineProperty(exports, "reconcileWorkspace", {
2891
+ enumerable: true,
2892
+ get: function() {
2893
+ return reconcileWorkspace;
2894
+ }
2637
2895
  });
2638
- Object.defineProperty(exports, 'reconcileWorkspace', {
2639
- enumerable: true,
2640
- get: function () {
2641
- return reconcileWorkspace;
2642
- }
2896
+ Object.defineProperty(exports, "resolveServicePorts", {
2897
+ enumerable: true,
2898
+ get: function() {
2899
+ return resolveServicePorts;
2900
+ }
2643
2901
  });
2644
- Object.defineProperty(exports, 'resolveServicePorts', {
2645
- enumerable: true,
2646
- get: function () {
2647
- return resolveServicePorts;
2648
- }
2902
+ Object.defineProperty(exports, "resourceName", {
2903
+ enumerable: true,
2904
+ get: function() {
2905
+ return resourceName;
2906
+ }
2649
2907
  });
2650
- Object.defineProperty(exports, 'resourceName', {
2651
- enumerable: true,
2652
- get: function () {
2653
- return resourceName;
2654
- }
2908
+ Object.defineProperty(exports, "rewriteUrlsWithPorts", {
2909
+ enumerable: true,
2910
+ get: function() {
2911
+ return rewriteUrlsWithPorts;
2912
+ }
2655
2913
  });
2656
- Object.defineProperty(exports, 'rewriteUrlsWithPorts', {
2657
- enumerable: true,
2658
- get: function () {
2659
- return rewriteUrlsWithPorts;
2660
- }
2914
+ Object.defineProperty(exports, "startWorkspaceServices", {
2915
+ enumerable: true,
2916
+ get: function() {
2917
+ return startWorkspaceServices;
2918
+ }
2661
2919
  });
2662
- Object.defineProperty(exports, 'startWorkspaceServices', {
2663
- enumerable: true,
2664
- get: function () {
2665
- return startWorkspaceServices;
2666
- }
2920
+ Object.defineProperty(exports, "storageBackendFor", {
2921
+ enumerable: true,
2922
+ get: function() {
2923
+ return storageBackendFor;
2924
+ }
2667
2925
  });
2668
- Object.defineProperty(exports, 'storageBackendOf', {
2669
- enumerable: true,
2670
- get: function () {
2671
- return storageBackendOf;
2672
- }
2926
+ Object.defineProperty(exports, "usesConstructs", {
2927
+ enumerable: true,
2928
+ get: function() {
2929
+ return usesConstructs;
2930
+ }
2673
2931
  });
2674
- Object.defineProperty(exports, 'usesConstructs', {
2675
- enumerable: true,
2676
- get: function () {
2677
- return usesConstructs;
2678
- }
2932
+ Object.defineProperty(exports, "workspace_exports", {
2933
+ enumerable: true,
2934
+ get: function() {
2935
+ return workspace_exports;
2936
+ }
2679
2937
  });
2680
- //# sourceMappingURL=credentials-DQmbRnr1.cjs.map
2938
+
2939
+ //# sourceMappingURL=credentials-C6Pfn6GY.cjs.map