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

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-BfmmB4WD.cjs} +1508 -1243
  39. package/dist/credentials-BfmmB4WD.cjs.map +1 -0
  40. package/dist/{credentials-BZcp3CGx.mjs → credentials-CD8ftUS6.mjs} +1374 -1114
  41. package/dist/credentials-CD8ftUS6.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-Bho-C6Zg.cjs +216 -0
  91. package/dist/github-Bho-C6Zg.cjs.map +1 -0
  92. package/dist/github-De-yMwVt.mjs +216 -0
  93. package/dist/github-De-yMwVt.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-CXoyQZbB.cjs +151 -0
  135. package/dist/transfer-CXoyQZbB.cjs.map +1 -0
  136. package/dist/transfer-Gsxfy6m-.mjs +122 -0
  137. package/dist/transfer-Gsxfy6m-.mjs.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,63 +1,38 @@
1
- import { DEFAULT_CACHE, DEFAULT_EMAIL, DEFAULT_STORAGE, discover } from "./discover-CAlFgYec.mjs";
2
- import { appKey, getAppNameFromCwd, hostOf, loadWorkspaceAppInfo } from "./config-CzWnfcXM.mjs";
3
- import { allConstructGlobs, getDependencyEnvVars } from "./workspace-7FqprxuJ.mjs";
4
- import { generatePgBossUrl } from "./generator-VE2JrFlY.mjs";
5
- import { readStageSecrets, secretsExist, toEmbeddableSecrets } from "./storage-Bi24WfoW.mjs";
1
+ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
+ import { a as DEFAULT_EVENTS$1, i as DEFAULT_CACHE, o as DEFAULT_STORAGE, t as discover } from "./discover-CK-n3w6b.mjs";
3
+ import { a as loadWorkspaceAppInfo, c as appKey, n as getAppNameFromCwd, o as loadWorkspaceConfig } from "./config-DdKkoUE1.mjs";
4
+ import { s as getDependencyEnvVars, t as allConstructGlobs, w as TEST_STAGE } from "./workspace-Bu3xNCej.mjs";
5
+ import { r as generatePgBossUrl } from "./generator-LfZU7NlU.mjs";
6
+ import { n as readStageSecrets, o as toEmbeddableSecrets, r as secretsExist } from "./storage-TP0x118M.mjs";
6
7
  import { existsSync, readFileSync } from "node:fs";
8
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
7
9
  import { dirname, join, resolve } from "node:path";
8
- import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
9
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
10
  import { execFile, execSync } from "node:child_process";
11
+ import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
11
12
  import { createServer } from "node:net";
12
13
  import { config } from "dotenv";
13
14
  import { parse as parse$1, stringify } from "yaml";
15
+ import { createHash } from "node:crypto";
16
+ import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
14
17
  import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
15
18
  import { Client } from "pg";
16
19
  import { promisify } from "node:util";
17
- import { createHash } from "node:crypto";
18
- import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
19
20
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
20
-
21
21
  //#region src/workspace/backends.ts
22
- const CACHE_BACKENDS = [
23
- "upstash",
24
- "elasticache",
25
- "db"
26
- ];
27
- const EMAIL_BACKENDS = [
28
- "resend",
29
- "ses",
30
- "smtp"
31
- ];
32
- const STORAGE_BACKENDS = [
33
- "minio",
34
- "s3",
35
- "r2"
36
- ];
37
- /**
38
- * The cache backend a workspace selected, or the one its target implies.
39
- *
40
- * Defaulted here rather than by every caller, so "what happens when nobody
41
- * said" is answered once and the answer is greppable — and now answered by
42
- * *where it deploys*, because there is no single right answer for both a
43
- * Lambda and a box running its own Postgres.
44
- *
45
- * `on` is the deploy target, not where this code happens to be running. A
46
- * local `gkm dev` for an AWS app still defaults to Upstash, because a backend
47
- * that differed between local and deployed would be worse than a slower one —
48
- * which is the property the type's own documentation asks for.
49
- */
50
- function cacheBackendOf(value, on = "aws") {
51
- return isBackend(value, CACHE_BACKENDS) ? value : DEFAULT_CACHE[on];
52
- }
53
- /** The email backend a workspace selected, defaulting to SES on every target. */
54
- function emailBackendOf(value) {
55
- return isBackend(value, EMAIL_BACKENDS) ? value : DEFAULT_EMAIL;
56
- }
57
- /** The storage backend a workspace selected, or the one its target implies. */
58
- function storageBackendOf(value, on = "aws") {
59
- return isBackend(value, STORAGE_BACKENDS) ? value : DEFAULT_STORAGE[on];
60
- }
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
+ */
61
36
  /**
62
37
  * Which family of defaults a workspace's deploy target belongs to.
63
38
  *
@@ -76,17 +51,162 @@ function providerOf(workspace) {
76
51
  return target === "dokploy" || target === "server" ? "server" : "aws";
77
52
  }
78
53
  /**
79
- * Whether a string names a backend, as opposed to being one of the other things
80
- * a `services:` value can be.
54
+ * The cache backend a target implies.
81
55
  *
82
- * A misspelling falls through to the default rather than throwing, which is the
83
- * wrong trade for something with a cost attached — so callers that can report it
84
- * should use {@link unknownBackend} to say so.
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.
85
59
  */
86
- function isBackend(value, backends) {
87
- return typeof value === "string" && backends.includes(value);
60
+ function cacheBackendFor(on) {
61
+ return DEFAULT_CACHE[on];
62
+ }
63
+ /** Where a declared bucket lives on a target. */
64
+ function storageBackendFor(on) {
65
+ return 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 DEFAULT_EVENTS$1[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(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];
88
209
  }
89
-
90
210
  //#endregion
91
211
  //#region src/reconcile/caddyfile.ts
92
212
  /**
@@ -163,7 +283,7 @@ function portOf(address) {
163
283
  const { port, protocol } = new URL(address);
164
284
  return port || (protocol === "https:" ? "443" : "80");
165
285
  } catch {
166
- return void 0;
286
+ return;
167
287
  }
168
288
  }
169
289
  /**
@@ -246,1139 +366,1200 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
246
366
  # root and point Node at it, so nothing has to be installed to use these URLs
247
367
  # from code; a browser needs the root trusted once.
248
368
  `;
249
-
250
369
  //#endregion
251
- //#region src/reconcile/clients.ts
252
- /** The credential local containers are brought up with. */
253
- const LOCAL_USER$2 = "geekmidas";
370
+ //#region src/reconcile/plan.ts
254
371
  /**
255
- * A Postgres client that opens one connection per database it is asked about.
372
+ * The reconcile plan — what a manifest wants to exist, for one stage.
256
373
  *
257
- * `CREATE DATABASE` cannot run from a connection to the database being created,
258
- * and a schema must be created from inside its own database — so the database is
259
- * part of each query rather than fixed when the client is built.
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.
377
+ *
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.
260
382
  */
261
- function pgClient(port) {
262
- return { async query(database, sql, values) {
263
- const client = new Client({
264
- host: "localhost",
265
- port,
266
- user: LOCAL_USER$2,
267
- password: LOCAL_USER$2,
268
- database: database ?? LOCAL_USER$2
269
- });
270
- await client.connect();
271
- try {
272
- const result = await client.query(sql, values);
273
- return result.rows;
274
- } finally {
275
- await client.end();
276
- }
277
- } };
278
- }
279
- /** A bucket client pointed at the local MinIO. */
280
- function bucketClient(port) {
281
- const s3 = new S3Client({
282
- region: "us-east-1",
283
- endpoint: `http://localhost:${port}`,
284
- forcePathStyle: true,
285
- credentials: {
286
- accessKeyId: LOCAL_USER$2,
287
- secretAccessKey: LOCAL_USER$2
288
- }
289
- });
290
- return {
291
- async exists(bucket) {
292
- try {
293
- await s3.send(new HeadBucketCommand({ Bucket: bucket }));
294
- return true;
295
- } catch {
296
- return false;
297
- }
298
- },
299
- async create(bucket) {
300
- await s3.send(new CreateBucketCommand({ Bucket: bucket }));
301
- },
302
- async policy(bucket) {
303
- try {
304
- const result = await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }));
305
- return result.Policy;
306
- } catch {
307
- return void 0;
308
- }
309
- },
310
- async setPolicy(bucket, policy) {
311
- await s3.send(new PutBucketPolicyCommand({
312
- Bucket: bucket,
313
- Policy: policy
314
- }));
315
- }
316
- };
317
- }
318
-
319
- //#endregion
320
- //#region src/reconcile/containers.ts
321
383
  /**
322
- * What gkm knows about each container it can derive.
323
- *
324
- * Everything provider-specific about the local target lives here: the image, the
325
- * ports the image listens on, and whether its data is worth keeping. The plan
326
- * says *which* containers exist and never says any of this, which is the split
327
- * the design asks for — neutral facts from the declaration, provider detail
328
- * from here and from config.
384
+ * Which container serves a kind.
329
385
  *
330
- * Shared by allocation and by the compose writer so the two cannot disagree
331
- * about how many ports a container needs.
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.
332
389
  */
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
+ };
333
398
  /**
334
- * The Postgres image for a major version.
399
+ * The container a cache backend needs locally.
335
400
  *
336
- * One place that knows the tag shape, so a declared version reaches the
337
- * container without anyone restating `-alpine` beside it.
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.
338
409
  */
339
- function postgresImage(version) {
340
- return `postgres:${version}-alpine`;
341
- }
410
+ const CACHE_CONTAINERS = {
411
+ upstash: "redis-http",
412
+ elasticache: "redis"
413
+ };
342
414
  /**
343
- * The default image for each container.
415
+ * Kinds that resolve a value without anything running.
344
416
  *
345
- * Pinned to a major rather than `latest` where the image publishes one, so a
346
- * rebuild does not silently move a developer's Postgres a version. Overridable
347
- * per project: a project needing `postgis/postgis` says so in config.
348
- */
349
- const DEFAULT_IMAGES = {
350
- postgres: postgresImage(DEFAULT_POSTGRES_VERSION),
351
- minio: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
352
- mailpit: "axllent/mailpit:latest",
353
- redis: "redis:8-alpine",
354
- "redis-http": "hiett/serverless-redis-http:latest",
355
- rabbitmq: "rabbitmq:4-management-alpine",
356
- localstack: "floci/floci:latest",
357
- caddy: "caddy:2-alpine"
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
358
426
  };
359
427
  /**
360
- * The ports each container listens on.
428
+ * Containers that cannot run alone.
361
429
  *
362
- * The first is always the one an app connects to; the rest are consoles and
363
- * management UIs, published because a bucket or an inbox you can look at is most
364
- * of why these are real containers rather than stubs.
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.
365
433
  */
366
- const PORTS = {
367
- postgres: [{
368
- key: "postgres",
369
- inside: 5432,
370
- label: "postgres"
371
- }],
372
- minio: [{
373
- key: "minio",
374
- inside: 9e3,
375
- label: "minio api"
376
- }, {
377
- key: "minio-console",
378
- inside: 9001,
379
- label: "minio console"
380
- }],
381
- mailpit: [{
382
- key: "mailpit",
383
- inside: 1025,
384
- label: "smtp"
385
- }, {
386
- key: "mailpit-web",
387
- inside: 8025,
388
- label: "mailpit inbox"
389
- }],
390
- redis: [{
391
- key: "redis",
392
- inside: 6379,
393
- label: "redis"
394
- }],
395
- "redis-http": [{
396
- key: "redis-http",
397
- inside: 80,
398
- label: "cache"
399
- }],
400
- rabbitmq: [{
401
- key: "rabbitmq",
402
- inside: 5672,
403
- label: "amqp"
404
- }, {
405
- key: "rabbitmq-management",
406
- inside: 15672,
407
- label: "rabbitmq console"
408
- }],
409
- localstack: [{
410
- key: "localstack",
411
- inside: 4566,
412
- label: "localstack"
413
- }],
414
- caddy: [{
415
- key: "caddy",
416
- inside: 443,
417
- label: "https edge"
418
- }]
434
+ const REQUIRES = { "redis-http": ["redis"] };
435
+ /**
436
+ * The role each kind's one key plays, where it is not a URL.
437
+ *
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.
441
+ */
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
419
451
  };
420
- /** The ports a container needs published. Empty for one gkm does not know. */
421
- function portsOf(container) {
422
- return PORTS[container] ?? [];
423
- }
424
- /** The key a container's primary port is allocated under. */
425
- function primaryPortKey(container) {
426
- return portsOf(container)[0]?.key ?? container;
427
- }
428
452
  /**
429
- * Every port key a set of containers needs, in a stable order.
453
+ * Which kinds provision something *inside* their container.
430
454
  *
431
- * What allocation is handed: it assigns ports to names and knows nothing about
432
- * what listens on them.
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".
433
459
  */
434
- function portKeys(containers) {
435
- return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
436
- }
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
+ };
437
468
  /**
438
- * The named volume a container's data lives in.
469
+ * The container each events backend needs, where it needs one of its own.
439
470
  *
440
- * Mailpit deliberately has none: local mail is disposable, and an inbox that
441
- * survives days of dev is noise rather than state.
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.
442
478
  */
443
- function volumeOf(container) {
444
- const volumes = {
445
- postgres: "postgres-data",
446
- minio: "minio-data",
447
- redis: "redis-data",
448
- rabbitmq: "rabbitmq-data",
449
- localstack: "localstack-data",
450
- caddy: "caddy-data"
451
- };
452
- return volumes[container];
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}`;
453
509
  }
454
-
455
- //#endregion
456
- //#region src/reconcile/compose.ts
457
- /** The credential every derived container is brought up with locally. */
458
- const LOCAL_USER$1 = "geekmidas";
459
510
  /**
460
- * The token the local cache accepts.
511
+ * The container a kind needs, or `undefined` if it needs none.
461
512
  *
462
- * Fixed rather than generated: it is only ever reachable on a port this project
463
- * published, and the URL that carries it is derived, so nothing is made safer
464
- * by making it unguessable and a stable one keeps `redis-cli` sessions working.
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.
465
516
  */
466
- const LOCAL_TOKEN$1 = "geekmidas";
517
+ function containerFor(kind, events = DEFAULT_EVENTS, cache = 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
+ }
467
526
  /**
468
- * Build the compose document for a plan.
527
+ * Build the plan for a manifest and stage.
469
528
  *
470
- * Every container is published on its assigned port, never on the image's
471
- * default: the point of allocation is that two projects can run at once, and
472
- * one container publishing 5432 puts that back.
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.
473
532
  */
474
- function composeFor(plan, options) {
475
- const services = {};
476
- const volumes = {};
477
- for (const container of [...plan.containers].sort()) {
478
- const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
479
- const image = options.images?.[container] ?? (declared ? postgresImage(declared) : DEFAULT_IMAGES[container]);
480
- if (!image) throw new UnknownContainer(container);
481
- services[container] = define(container, image, publish(container, options.ports));
482
- const volume = volumeOf(container);
483
- if (volume) volumes[volume] = {};
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 ?? 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: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
553
+ provisions: PROVISIONS[declaration.kind] === true,
554
+ ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
555
+ ...declaration.kind === "site" ? { publicEnv: 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
+ }
484
576
  }
485
577
  return {
486
- name: options.project,
487
- services,
488
- ...Object.keys(volumes).length ? { volumes } : {}
578
+ stage,
579
+ events,
580
+ cache,
581
+ containers: [...containers],
582
+ resources
489
583
  };
490
584
  }
491
- /** Render a compose document as the file it becomes. */
492
- function toYaml(file) {
493
- return `# Generated by gkm from the construct manifest — do not edit.
494
- # Containers are derived from what the app declares; images and anything no
495
- # construct implies are yours, in gkm.config.ts.
496
- #
497
- # Use "gkm dev" or "gkm test" to start these. Running "docker compose up"
498
- # directly will not inject secrets.
499
- ${stringify(file, { lineWidth: 0 })}`;
500
- }
501
- /** A container with no image, from config or from the defaults. */
502
- var UnknownContainer = class extends Error {
503
- /** The container that could not be resolved. */
504
- container;
505
- /** The containers that have a default, so the caller can see the set. */
506
- known = Object.keys(DEFAULT_IMAGES).sort();
507
- constructor(container) {
508
- super("No image is known for this container; pin one in config");
509
- this.name = "UnknownContainer";
510
- this.container = container;
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";
511
598
  }
512
599
  };
513
- /** One container's definition, published on the port it was assigned. */
514
- function define(container, image, published) {
515
- switch (container) {
516
- case "postgres": return {
517
- image,
518
- restart: "unless-stopped",
519
- ports: published,
520
- environment: {
521
- POSTGRES_USER: LOCAL_USER$1,
522
- POSTGRES_PASSWORD: LOCAL_USER$1,
523
- POSTGRES_DB: LOCAL_USER$1
524
- },
525
- volumes: ["postgres-data:/var/lib/postgresql"],
526
- healthcheck: {
527
- test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER$1}`],
528
- interval: "5s",
529
- timeout: "3s",
530
- retries: 10,
531
- start_period: "10s"
532
- }
533
- };
534
- case "minio": return {
535
- image,
536
- restart: "unless-stopped",
537
- ports: published,
538
- environment: {
539
- MINIO_ROOT_USER: LOCAL_USER$1,
540
- MINIO_ROOT_PASSWORD: LOCAL_USER$1
541
- },
542
- command: "server --console-address \":9001\" /data",
543
- volumes: ["minio-data:/data"],
544
- healthcheck: {
545
- test: [
546
- "CMD",
547
- "mc",
548
- "ready",
549
- "local"
550
- ],
551
- interval: "10s",
552
- timeout: "5s",
553
- retries: 5,
554
- start_period: "10s"
555
- }
556
- };
557
- case "caddy": return {
558
- image,
559
- restart: "unless-stopped",
560
- ports: published,
561
- volumes: [
562
- "./Caddyfile:/etc/caddy/Caddyfile:ro",
563
- "./caddy-sites:/etc/caddy/sites:ro",
564
- "caddy-data:/data"
565
- ],
566
- extra_hosts: ["host.docker.internal:host-gateway"],
567
- healthcheck: {
568
- test: [
569
- "CMD",
570
- "caddy",
571
- "version"
572
- ],
573
- interval: "10s",
574
- timeout: "5s",
575
- retries: 5
576
- }
577
- };
578
- case "mailpit": return {
579
- image,
580
- restart: "unless-stopped",
581
- ports: published,
582
- environment: {
583
- MP_SMTP_AUTH_ACCEPT_ANY: "1",
584
- MP_SMTP_AUTH_ALLOW_INSECURE: "1"
585
- },
586
- healthcheck: {
587
- test: [
588
- "CMD",
589
- "wget",
590
- "-q",
591
- "--spider",
592
- "http://localhost:8025"
593
- ],
594
- interval: "10s",
595
- timeout: "5s",
596
- retries: 5
597
- }
598
- };
599
- case "redis": return {
600
- image,
601
- restart: "unless-stopped",
602
- ports: published,
603
- volumes: ["redis-data:/data"],
604
- healthcheck: {
605
- test: [
606
- "CMD",
607
- "redis-cli",
608
- "ping"
609
- ],
610
- interval: "5s",
611
- timeout: "3s",
612
- retries: 5
613
- }
614
- };
615
- case "redis-http": return {
616
- image,
617
- restart: "unless-stopped",
618
- ports: published,
619
- environment: {
620
- SRH_MODE: "env",
621
- SRH_TOKEN: LOCAL_TOKEN$1,
622
- SRH_CONNECTION_STRING: "redis://redis:6379"
623
- },
624
- depends_on: ["redis"],
625
- healthcheck: {
626
- test: [
627
- "CMD",
628
- "wget",
629
- "-q",
630
- "-O",
631
- "/dev/null",
632
- "http://127.0.0.1:80/"
633
- ],
634
- interval: "5s",
635
- timeout: "3s",
636
- retries: 10
637
- }
638
- };
639
- case "rabbitmq": return {
640
- image,
641
- restart: "unless-stopped",
642
- ports: published,
643
- environment: {
644
- RABBITMQ_DEFAULT_USER: LOCAL_USER$1,
645
- RABBITMQ_DEFAULT_PASS: LOCAL_USER$1
646
- },
647
- volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
648
- healthcheck: {
649
- test: [
650
- "CMD",
651
- "rabbitmq-diagnostics",
652
- "-q",
653
- "ping"
654
- ],
655
- interval: "10s",
656
- timeout: "5s",
657
- retries: 5
658
- }
659
- };
660
- case "localstack": return {
661
- image,
662
- restart: "unless-stopped",
663
- ports: published,
664
- environment: {
665
- AWS_DEFAULT_REGION: "us-east-1",
666
- AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
667
- AWS_SECRET_ACCESS_KEY: LOCAL_USER$1
668
- },
669
- volumes: ["localstack-data:/var/lib/localstack"],
670
- healthcheck: {
671
- test: [
672
- "CMD",
673
- "curl",
674
- "-f",
675
- "http://localhost:4566/_localstack/health"
676
- ],
677
- interval: "10s",
678
- timeout: "5s",
679
- retries: 5,
680
- start_period: "10s"
681
- }
682
- };
683
- default: throw new UnknownContainer(container);
684
- }
685
- }
686
600
  /**
687
- * 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.
688
603
  *
689
- * Every port is looked up by its own key rather than being derived as an offset
690
- * from the container's first, so MinIO's console cannot land on whatever was
691
- * allocated to the next container.
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.
692
610
  */
693
- function publish(container, ports) {
694
- return portsOf(container).map((port) => {
695
- const assigned = ports[port.key];
696
- if (assigned === void 0) throw new UnassignedPort(port.key);
697
- return `${assigned}:${port.inside}`;
698
- });
699
- }
700
- /** A planned port that allocation never assigned. */
701
- var UnassignedPort = class extends Error {
702
- /** The port key that went unassigned, e.g. `minio-console`. */
703
- key;
704
- constructor(key) {
705
- super("No port was assigned for this container port");
706
- this.name = "UnassignedPort";
707
- this.key = key;
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";
708
619
  }
709
620
  };
710
-
711
- //#endregion
712
- //#region src/reconcile/docker.ts
713
- const run = promisify(execFile);
714
- /** How long to wait for containers to pass their health checks. */
715
- const HEALTH_TIMEOUT_MS = 12e4;
716
- const dockerCli = {
717
- async publishedPort(composePath, service, inside) {
718
- try {
719
- const { stdout } = await run("docker", [
720
- "compose",
721
- "-f",
722
- composePath,
723
- "port",
724
- service,
725
- String(inside)
726
- ]);
727
- const port = Number(stdout.trim().split(":").pop());
728
- return Number.isFinite(port) && port > 0 ? port : void 0;
729
- } catch {
730
- return void 0;
731
- }
732
- },
733
- async up(composePath, services) {
734
- await run("docker", [
735
- "compose",
736
- "-f",
737
- composePath,
738
- "up",
739
- "-d",
740
- "--remove-orphans",
741
- "--wait",
742
- `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
743
- ...services
744
- ]);
745
- },
746
- async copyOut(composePath, service, from, to) {
747
- await mkdir(dirname(to), { recursive: true });
748
- await run("docker", [
749
- "compose",
750
- "-f",
751
- composePath,
752
- "cp",
753
- `${service}:${from}`,
754
- to
755
- ]);
756
- },
757
- async reload(composePath, service) {
758
- await run("docker", [
759
- "compose",
760
- "-f",
761
- composePath,
762
- "exec",
763
- "-T",
764
- service,
765
- "caddy",
766
- "reload",
767
- "--config",
768
- "/etc/caddy/Caddyfile"
769
- ]);
770
- },
771
- async healthy(composePath, services) {
772
- if (services.length === 0) return true;
773
- try {
774
- const { stdout } = await run("docker", [
775
- "compose",
776
- "-f",
777
- composePath,
778
- "ps",
779
- "--format",
780
- "json",
781
- ...services
782
- ]);
783
- const running = /* @__PURE__ */ new Set();
784
- for (const line of stdout.trim().split("\n").filter(Boolean)) {
785
- const entry = JSON.parse(line);
786
- const ok = entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy");
787
- if (ok && entry.Service) running.add(entry.Service);
788
- }
789
- return services.every((service) => running.has(service));
790
- } catch {
791
- return false;
792
- }
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";
793
627
  }
794
628
  };
795
-
796
629
  //#endregion
797
- //#region src/reconcile/plan.ts
798
- /** The stage whose resources carry no suffix. */
799
- const DEFAULT_STAGE = "development";
630
+ //#region src/reconcile/env.ts
800
631
  /**
801
- * Which container serves a kind.
632
+ * The URLs a reconciled stage injects.
802
633
  *
803
- * Derived kinds map to the same container as their parent — a schema tenant
804
- * lives in its parent's Postgres — so the set is deduplicated rather than
805
- * counted.
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.
806
643
  */
807
- const CONTAINERS = {
808
- database: "postgres",
809
- "database-reader": "postgres",
810
- "database-schema": "postgres",
811
- objects: "minio",
812
- "file-server": "caddy",
813
- email: "mailpit"
814
- };
815
644
  /**
816
- * The container a cache backend needs locally.
645
+ * The credential local containers are brought up with.
817
646
  *
818
- * Each one matches the protocol its deployed form speaks, which is the property
819
- * worth having: a cache that behaves differently in the two places is worse than
820
- * a slower one.
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.
821
659
  *
822
- * `db` is deliberately absent, the same way `pgboss` is absent from
823
- * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
824
- * declares, so mapping it here would start a container for a project that needs
825
- * none.
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.
826
665
  */
827
- const CACHE_CONTAINERS = {
828
- upstash: "redis-http",
829
- 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
830
670
  };
671
+ /** The token the local cache proxy accepts. Matches the compose definition. */
672
+ const LOCAL_TOKEN$1 = "geekmidas";
673
+ /** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
674
+ const MAILPIT_INBOX_PORT = "mailpit-web";
675
+ /** The schema pg-boss keeps its tables in, inside the declared database. */
676
+ const PGBOSS_SCHEMA = "pgboss";
677
+ /** The exchange every local topic and queue shares on RabbitMQ. */
678
+ const RABBITMQ_EXCHANGE = "geekmidas.events";
831
679
  /**
832
- * Kinds that resolve a value without anything running.
680
+ * Every env key a reconciled plan resolves, and its value.
833
681
  *
834
- * A secret is the case: it has no address, so there is nothing to start and
835
- * nothing to connect to — but it still appears in the plan, because the target
836
- * still has to resolve it.
682
+ * Keys come from the declaration rather than being re-derived, so what the build
683
+ * publishes and what the app reads cannot drift.
837
684
  */
838
- const CONTAINERLESS = {
839
- secret: true,
840
- credential: true,
841
- "rest-api": true,
842
- site: true
843
- };
685
+ function envFor(plan, options) {
686
+ const env = {};
687
+ const resolved = {};
688
+ for (const resource of plan.resources) {
689
+ const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
690
+ if (url) resolved[resource.id] = url;
691
+ }
692
+ for (const resource of plan.resources) {
693
+ const url = resolved[resource.id];
694
+ if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
695
+ if (url) env[resource.envKey] = url;
696
+ if (isDatabase(resource.kind) && resource.roles !== false) {
697
+ const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
698
+ if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
699
+ }
700
+ if (resource.kind === "email" && url) {
701
+ env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
702
+ const inbox = options.ports[MAILPIT_INBOX_PORT];
703
+ if (inbox !== void 0) env[provideKey(resource.id, "inboxUrl")] = `http://${LOCAL_HOST}:${inbox}`;
704
+ }
705
+ }
706
+ Object.assign(env, publicEnv(plan, env));
707
+ Object.assign(env, brokerEnv(plan, options.ports));
708
+ if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
709
+ return env;
710
+ }
844
711
  /**
845
- * Containers that cannot run alone.
712
+ * The values a site's bundler inlines, under the names it inlines them by.
846
713
  *
847
- * The one case is the cache: `serverless-redis-http` is a proxy and needs the
848
- * Redis it proxies. Kept here rather than in the compose definition so the plan
849
- * -- which is what decides what must be running -- still names everything.
714
+ * A rename and nothing more. `API_URL` was resolved once, by the same code that
715
+ * resolved it for the server; a site reads the same value under `VITE_API_URL`
716
+ * because that prefix is how its bundler is told to ship it. Nothing is derived
717
+ * twice, so a site and its API cannot come to disagree about where the API is.
718
+ *
719
+ * A source that resolved to nothing is skipped rather than written empty: an
720
+ * inlined empty string is a frontend that builds and then fails at runtime
721
+ * against `http:///`, where a missing variable fails at build with the name of
722
+ * the thing that is missing.
850
723
  */
851
- const REQUIRES = { "redis-http": ["redis"] };
724
+ function publicEnv(plan, resolved) {
725
+ const env = {};
726
+ for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
727
+ const value = resolved[source];
728
+ if (value) env[key] = value;
729
+ }
730
+ return env;
731
+ }
732
+ /**
733
+ * What a surface publishes beyond its own address: who may call it, and where
734
+ * a cookie set by it is readable.
735
+ *
736
+ * Both are read off the same list — the constructs that declared an edge to
737
+ * this surface — which is why neither appears in application code. A surface
738
+ * that listed its own callers would be edited every time something new called
739
+ * it, and the thing being edited is already recorded in the graph.
740
+ *
741
+ * An empty origin list is written, and a surface with no address writes nothing
742
+ * at all. The two cases look alike and are not: "nothing depends on this yet" is
743
+ * a real state a target should publish, while "this surface has no address here"
744
+ * means it is not running in this stage, and keys belong with the address they
745
+ * describe.
746
+ */
747
+ function surfaceEnv(resource, url, resolved = {}) {
748
+ if (!url) return {};
749
+ const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
750
+ const domain = cookieDomain([url, ...origins]);
751
+ return {
752
+ [provideKey(resource.id, "trustedOrigins")]: origins.join(","),
753
+ ...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {}
754
+ };
755
+ }
756
+ /** An address reduced to the origin a browser compares against. */
757
+ function originOf(address) {
758
+ try {
759
+ return new URL(address).origin;
760
+ } catch {
761
+ return;
762
+ }
763
+ }
852
764
  /**
853
- * The role each kind's one key plays, where it is not a URL.
765
+ * The runtime role of whatever a derived node hangs off.
854
766
  *
855
- * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
856
- * construct declares, this is what the local target resolves, and both derive
857
- * the key from the same `provideKey` so they cannot disagree.
767
+ * A reader's parent may itself be a schema tenant, so this follows `of` by one
768
+ * hop rather than assuming the cluster — the reader of a tenant reads that
769
+ * tenant's schema, not the database's.
858
770
  */
859
- const ROLES = {
860
- queue: "publisherConnectionString",
861
- topic: "publisherConnectionString",
862
- credential: "credential"
863
- };
864
- /** The kinds whose container is the events backend's rather than their own. */
865
- const EVENT_KINDS = {
866
- queue: true,
867
- topic: true
868
- };
771
+ function parentRole(resource, plan) {
772
+ const parent = plan.resources.find((r) => r.id === resource.of);
773
+ return parent ? localRole(parent) : resource.name;
774
+ }
775
+ /** Whether a kind connects to Postgres and therefore has an owner role. */
776
+ function isDatabase(kind) {
777
+ return kind === "database" || kind === "database-schema";
778
+ }
869
779
  /**
870
- * Which kinds provision something *inside* their container.
780
+ * The URL a migrator connects with — the owner role, which can create, alter
781
+ * and drop.
871
782
  *
872
- * A database creates a database, a bucket creates a bucket; mail creates
873
- * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
874
- * to name. Both still appear in the plan, because both still resolve a URL; the
875
- * flag is the difference between "must be running" and "must be created".
783
+ * Never in `provides`, which is the whole point. A handler that could reach this
784
+ * could `DROP TABLE`, and the security property the role split exists for is
785
+ * that it cannot: the value is injected by `gkm exec` for the migrate step and
786
+ * by nothing else.
876
787
  */
877
- const PROVISIONS = {
878
- database: true,
879
- "database-reader": true,
880
- "database-schema": true,
881
- objects: true,
882
- cache: true,
883
- "file-server": true
884
- };
788
+ function ownerUrl(resource, plan, ports, project) {
789
+ if (!resource.container) return void 0;
790
+ const port = ports[primaryPortKey(resource.container)];
791
+ if (port === void 0) return void 0;
792
+ const owner = ownerRole(localRole(resource));
793
+ return postgres(port, rootDatabase(resource, plan), {
794
+ user: owner,
795
+ password: localRolePassword(project, plan, owner)
796
+ });
797
+ }
885
798
  /**
886
- * The container each events backend needs, where it needs one of its own.
887
- *
888
- * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
889
- * schema tenant in a database the manifest already declares, so mapping it here
890
- * would start a Postgres for a project that declared no database at all.
799
+ * The shared broker keys, when anything is published or consumed.
891
800
  *
892
- * This one comes from config rather than from a declaration because there are
893
- * no `topic` or `queue` kinds yet. When they land the mapping moves to
894
- * {@link CONTAINERS} keyed by kind, and this constant goes with them.
801
+ * A queue's own key is the producer's. These two are the *connection* itself,
802
+ * which locally is one broker for every queue and topic in the project: the
803
+ * generated pollers open a single connection and subscribe each worker by name
804
+ * on it. Deployed there is no such thing — a Lambda is handed its own event
805
+ * source — so this pair exists for the local target and says so.
895
806
  */
896
- const EVENT_CONTAINERS = {
897
- sns: "localstack",
898
- rabbitmq: "rabbitmq"
899
- };
900
- /** The backend a project gets when it declares queues or topics and says nothing. */
901
- const DEFAULT_EVENTS = "pgboss";
807
+ function brokerEnv(plan, ports) {
808
+ const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
809
+ if (!carrier) return {};
810
+ const publisher = urlFor(carrier, plan, ports, "");
811
+ if (!publisher) return {};
812
+ return {
813
+ EVENT_PUBLISHER_CONNECTION_STRING: publisher,
814
+ EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
815
+ };
816
+ }
902
817
  /**
903
- * How a resource of each kind separates its stage suffix.
818
+ * The URL for one planned resource.
904
819
  *
905
- * Postgres identifiers are snake_case and a hyphen would need quoting
906
- * everywhere; buckets and queues are kebab and disallow underscores in some
907
- * providers. The suffix is the same word either way.
820
+ * Every part of it comes from the resource and the port it was published on.
908
821
  */
909
- const SEPARATORS = {
910
- database: "_",
911
- "database-reader": "_",
912
- "database-schema": "_",
913
- objects: "-"
914
- };
822
+ function urlFor(resource, plan, ports, project, addresses = {}) {
823
+ if (resource.kind === "secret") return localSecret(project, plan, resource);
824
+ if (resource.kind === "credential") return void 0;
825
+ if (resource.kind === "rest-api" || resource.kind === "site") {
826
+ const address = addresses[resource.id];
827
+ const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
828
+ return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
829
+ }
830
+ if (!resource.container) return void 0;
831
+ const port = ports[primaryPortKey(resource.container)];
832
+ if (port === void 0) return void 0;
833
+ switch (resource.kind) {
834
+ case "database":
835
+ case "database-schema":
836
+ case "database-reader": {
837
+ const database = rootDatabase(resource, plan);
838
+ if (resource.roles === false) {
839
+ const schema = schemaOf(resource, plan);
840
+ return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
841
+ }
842
+ const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
843
+ return postgres(port, database, {
844
+ user: role,
845
+ password: localRolePassword(project, plan, role)
846
+ });
847
+ }
848
+ case "email": return `smtp://${LOCAL_HOST}:${port}`;
849
+ case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
850
+ case "file-server": {
851
+ const origin = plan.resources.find((r) => r.id === resource.of);
852
+ if (!origin) return void 0;
853
+ return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
854
+ }
855
+ case "cache":
856
+ if (resource.of) {
857
+ const parent = plan.resources.find((r) => r.id === resource.of);
858
+ const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
859
+ return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
860
+ }
861
+ switch (plan.cache) {
862
+ case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
863
+ default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
864
+ }
865
+ case "queue":
866
+ case "topic": return broker(plan, port);
867
+ default: return;
868
+ }
869
+ }
915
870
  /**
916
- * The stage-scoped name for a resource.
871
+ * The producer's connection string, composed from the backend the plan chose.
917
872
  *
918
- * The default stage carries no suffix, so `gkm dev` keeps using `orders` and a
919
- * developer's psql history, saved connections, and muscle memory survive this
920
- * change. Every other stage is suffixed.
873
+ * The protocol is what picks the transport, so a producer never branches: the
874
+ * same `.publish()` reaches pg-boss here and SQS deployed because the string it
875
+ * was handed said so.
921
876
  */
922
- function resourceName(id, kind, stage) {
923
- const base = id.toLowerCase();
924
- if (stage === DEFAULT_STAGE) return base;
925
- return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
877
+ function broker(plan, port) {
878
+ switch (plan.events) {
879
+ case "pgboss": {
880
+ const database = plan.resources.find((r) => r.kind === "database");
881
+ if (!database) throw new PgBossNeedsDatabase([]);
882
+ return `pgboss://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
883
+ }
884
+ case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
885
+ case "sns": throw new UnprovisionedEventsBackend("sns");
886
+ }
887
+ }
888
+ /** A local Postgres URL, master credential and all. */
889
+ function postgres(port, database, credential = {
890
+ user: LOCAL_USER$2,
891
+ password: LOCAL_USER$2
892
+ }) {
893
+ return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
926
894
  }
927
895
  /**
928
- * The container a kind needs, or `undefined` if it needs none.
896
+ * The role a handler connects as, for one database or tenant.
929
897
  *
930
- * Queues and topics have no container of their own — they live in whichever
931
- * broker the project selected, which is why the backend is an argument here
932
- * rather than a second pass that adds containers after the fact.
898
+ * The construct's id, lowercased and stage-scoped exactly as its database or
899
+ * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
900
+ * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
901
+ * in Postgres, which is why the suffix is not optional here the way it is for a
902
+ * schema.
933
903
  */
934
- function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
935
- if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
936
- if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
937
- if (kind === "cache") {
938
- if (derived) return CONTAINERS.database;
939
- return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
940
- }
941
- return CONTAINERS[kind];
904
+ function localRole(resource) {
905
+ return resource.name;
942
906
  }
943
907
  /**
944
- * Build the plan for a manifest and stage.
908
+ * The password for a derived local role.
945
909
  *
946
- * `order` is the caller's provisioning order — parents before children — which
947
- * `provisionOrder` produces from the manifest. Passing it in keeps this
948
- * function free of the traversal and makes the ordering assertable on its own.
910
+ * Derived rather than random, for the same reason the local secret is: a
911
+ * password that changed on every `gkm dev` would lock a developer out of the
912
+ * data they had a moment ago. Seeded by the project so two checkouts do not
913
+ * share one, and by the stage so `test` and `development` do not.
914
+ *
915
+ * It is a *local* credential by construction — the cluster is on loopback and
916
+ * the seed is a checkout path — and no deployed target uses this function.
949
917
  */
950
- function planFor(manifest, stage, order, options = {}) {
951
- const containers = /* @__PURE__ */ new Set();
952
- const resources = [];
953
- const events = options.events ?? DEFAULT_EVENTS;
954
- const cache = options.cache ?? DEFAULT_CACHE.aws;
955
- for (const id of order) {
956
- const declaration = manifest[id];
957
- if (!declaration) continue;
958
- const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
959
- if (!container && !CONTAINERLESS[declaration.kind]) continue;
960
- if (container) {
961
- containers.add(container);
962
- for (const required of REQUIRES[container] ?? []) containers.add(required);
963
- }
964
- resources.push({
965
- id,
966
- kind: declaration.kind,
967
- ...container ? { container } : {},
968
- name: resourceName(id, declaration.kind, stage),
969
- envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
970
- provisions: PROVISIONS[declaration.kind] === true,
971
- ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
972
- ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
973
- ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
974
- ..."of" in declaration ? { of: declaration.of } : {},
975
- ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
976
- ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
977
- ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
978
- ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
979
- });
980
- }
981
- if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
982
- for (const extra of options.extraContainers ?? []) containers.add(extra);
983
- if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
984
- if (cache === "db") {
985
- const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
986
- if (placeless.length > 0) {
987
- const databases = resources.filter((r) => r.kind === "database");
988
- const [only] = databases;
989
- if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
990
- if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
991
- for (const resource of placeless) resource.of = only.id;
992
- }
993
- }
994
- return {
995
- stage,
996
- events,
997
- cache,
998
- containers: [...containers],
999
- resources
1000
- };
918
+ function localRolePassword(project, plan, role) {
919
+ return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
920
+ }
921
+ /**
922
+ * A cache's address: its database's URL, carrying the table it reads.
923
+ *
924
+ * Appended rather than kept beside the URL as a second key, for the reason the
925
+ * design gives everywhere else — an address and what it opens are one fact, and
926
+ * two keys are one more thing to keep in step.
927
+ */
928
+ function withTable(url, table) {
929
+ const parsed = new URL(url);
930
+ parsed.searchParams.set("table", table);
931
+ return parsed.toString();
1001
932
  }
1002
933
  /**
1003
- * Queues or topics were declared with pg-boss and no database to hold them.
934
+ * The physical database a derived resource lives in.
1004
935
  *
1005
- * Not a reconcile failure but a manifest one: the fix is a line of application
1006
- * code either way — declare a database, or select a backend that brings its own
1007
- * broker.
936
+ * Walks `of` to the top rather than reading the immediate parent, because a
937
+ * reader's parent may itself be a schema tenant — two hops from the database
938
+ * that actually exists.
1008
939
  */
1009
- var CacheNeedsDatabase = class extends Error {
1010
- constructor(ids) {
1011
- super(`A cache backed by the database needs a declared database, and none was declared. Declare one, or set services.cache to 'upstash' or 'elasticache'. Caches affected: ${ids.join(", ")}.`);
1012
- this.ids = ids;
1013
- this.name = "CacheNeedsDatabase";
940
+ function rootDatabase(resource, plan) {
941
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
942
+ let current = resource;
943
+ while (current.of) {
944
+ const parent = byId.get(current.of);
945
+ if (!parent) break;
946
+ current = parent;
1014
947
  }
1015
- };
948
+ return current.name;
949
+ }
950
+ /** The schema a derived resource puts on its search path, if it has one. */
951
+ function schemaOf(resource, plan) {
952
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
953
+ let current = resource;
954
+ while (current) {
955
+ if (current.kind === "database-schema") return current.schema;
956
+ current = current.of ? byId.get(current.of) : void 0;
957
+ }
958
+ }
1016
959
  /**
1017
- * `services.cache: 'db'` in an app that declares more than one database.
960
+ * A backend whose local addresses cannot be derived yet.
1018
961
  *
1019
- * The config says *a* database and the graph offers several, so there is no
1020
- * answer to give — and the wrong kind of failure would be to pick one, since a
1021
- * cache silently landing in the wrong database is a bug that surfaces as
1022
- * missing rows much later. The fix is a stronger statement in application code:
1023
- * `orders.cache('Sessions')` names the database, which is a fact about the
1024
- * application rather than a deployment choice.
962
+ * SNS and SQS are addressed by ARN, which means the topic and queue have to be
963
+ * created in LocalStack before a URL can name them. Until that provisioning
964
+ * lands, saying so is better than handing out a string that fails on first use.
1025
965
  */
1026
- var CacheIsAmbiguous = class extends Error {
1027
- constructor(ids, databases) {
1028
- super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than with services.cache. Caches affected: ${ids.join(", ")}.`);
1029
- this.ids = ids;
1030
- this.databases = databases;
1031
- this.name = "CacheIsAmbiguous";
1032
- }
1033
- };
1034
- var PgBossNeedsDatabase = class extends Error {
1035
- constructor(ids) {
1036
- super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one, or set services.events to 'rabbitmq' or 'sns'. Queues and topics affected: ${ids.join(", ")}.`);
1037
- this.ids = ids;
1038
- this.name = "PgBossNeedsDatabase";
966
+ var UnprovisionedEventsBackend = class extends Error {
967
+ backend;
968
+ constructor(backend) {
969
+ 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.`);
970
+ this.backend = backend;
971
+ this.name = "UnprovisionedEventsBackend";
1039
972
  }
1040
973
  };
1041
-
1042
- //#endregion
1043
- //#region src/reconcile/env.ts
1044
974
  /**
1045
- * The credential local containers are brought up with.
975
+ * The value a secret resolves to locally.
1046
976
  *
1047
- * The design's `roles: false` case — both URLs fall back to the cluster's master
1048
- * credential. A deliberate downgrade, not a default: it holds until the role DDL
1049
- * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
1050
- * and the owner URL stops being reachable from any manifest edge.
977
+ * A hash of what identifies it, so it is stable, distinct per project, stage
978
+ * and construct, and never written to disk as a literal. Deployed this is the
979
+ * one kind whose value does *not* come from here — a secret manager generates
980
+ * it once and the target reads it back.
1051
981
  */
1052
- const LOCAL_USER = "geekmidas";
1053
- /** The host containers are published on. */
1054
- const LOCAL_HOST = "localhost";
1055
- /** The region local buckets claim, so the client has one to sign with. */
1056
- const LOCAL_REGION = "us-east-1";
982
+ function localSecret(project, plan, resource) {
983
+ return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
984
+ }
985
+ //#endregion
986
+ //#region src/reconcile/apps.ts
1057
987
  /**
1058
- * The credentials the S3 client resolves for MinIO.
988
+ * The apps, as services beside the containers their constructs derive.
1059
989
  *
1060
- * An `s3://` URL deliberately carries none: deployed, the SDK reads them from
1061
- * the execution role, so a URL that embedded a key would be one more thing to
1062
- * rotate and one more thing to leak into a log. Locally there is no role, and
1063
- * the same chain reads these — which is why they are injected beside the URL
1064
- * rather than written into it.
990
+ * `gkm docker` used to write these by hand: every backend got
991
+ * `DATABASE_URL=postgresql://…@postgres:5432/app` and `REDIS_URL=redis://redis`,
992
+ * which was right only for a project whose single database happened to be
993
+ * called `Database` — an `Orders` database publishes `ORDERS_URL`, a schema
994
+ * tenant has a URL of its own, and buckets and mail were not wired at all.
995
+ *
996
+ * Now an app's environment is exactly what reconcile hands `gkm dev`: every key
997
+ * the declared constructs provide, from the same derivation. The only
998
+ * difference is where the addresses point — at the containers by service name
999
+ * on the compose network, rather than at `localhost` on the ports published for
1000
+ * the host.
1001
+ *
1002
+ * Always from the *local* stage's plan, whichever stage is being reconciled:
1003
+ * `gkm test` rewrites the same file, and running the tests must not quietly
1004
+ * point these apps at the test databases.
1065
1005
  */
1066
- const STORAGE_CREDENTIALS = {
1067
- AWS_ACCESS_KEY_ID: LOCAL_USER,
1068
- AWS_SECRET_ACCESS_KEY: LOCAL_USER,
1069
- AWS_REGION: LOCAL_REGION
1070
- };
1071
- /** The token the local cache proxy accepts. Matches the compose definition. */
1072
- const LOCAL_TOKEN = "geekmidas";
1073
- /** The schema pg-boss keeps its tables in, inside the declared database. */
1074
- const PGBOSS_SCHEMA = "pgboss";
1075
- /** The exchange every local topic and queue shares on RabbitMQ. */
1076
- const RABBITMQ_EXCHANGE = "geekmidas.events";
1006
+ /** The profile app services carry, so `gkm dev` starts only the containers. */
1007
+ const APPS_PROFILE = "apps";
1077
1008
  /**
1078
- * Every env key a reconciled plan resolves, and its value.
1009
+ * Where an app's image is built from.
1079
1010
  *
1080
- * Keys come from the declaration rather than being re-derived, so what the build
1081
- * publishes and what the app reads cannot drift.
1011
+ * An app at the project root is a single-app project, whose `gkm docker`
1012
+ * writes `.gkm/docker/Dockerfile`; a workspace writes one per app.
1082
1013
  */
1083
- function envFor(plan, options) {
1084
- const env = {};
1085
- const resolved = {};
1086
- for (const resource of plan.resources) {
1087
- const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
1088
- if (url) resolved[resource.id] = url;
1089
- }
1090
- for (const resource of plan.resources) {
1091
- const url = resolved[resource.id];
1092
- if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
1093
- if (url) env[resource.envKey] = url;
1094
- if (isDatabase(resource.kind) && resource.roles !== false) {
1095
- const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
1096
- if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
1097
- }
1098
- if (resource.kind === "email" && url) env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
1099
- }
1100
- Object.assign(env, publicEnv(plan, env));
1101
- Object.assign(env, brokerEnv(plan, options.ports));
1102
- if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
1103
- return env;
1014
+ function dockerfileOf(appName, path) {
1015
+ return path === "." ? ".gkm/docker/Dockerfile" : `.gkm/docker/Dockerfile.${appName}`;
1104
1016
  }
1105
1017
  /**
1106
- * The values a site's bundler inlines, under the names it inlines them by.
1107
- *
1108
- * A rename and nothing more. `API_URL` was resolved once, by the same code that
1109
- * resolved it for the server; a site reads the same value under `VITE_API_URL`
1110
- * because that prefix is how its bundler is told to ship it. Nothing is derived
1111
- * twice, so a site and its API cannot come to disagree about where the API is.
1018
+ * Every construct key, addressed across the compose network.
1112
1019
  *
1113
- * A source that resolved to nothing is skipped rather than written empty: an
1114
- * inlined empty string is a frontend that builds and then fails at runtime
1115
- * against `http:///`, where a missing variable fails at build with the name of
1116
- * the thing that is missing.
1020
+ * `envFor` is asked with a placeholder port for every port key, so each
1021
+ * `localhost:<placeholder>` in its answer names exactly one container port —
1022
+ * two containers listening on the same port inside cannot be confused — and is
1023
+ * rewritten to `<container>:<inside>`.
1117
1024
  */
1118
- function publicEnv(plan, resolved) {
1119
- const env = {};
1120
- for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
1121
- const value = resolved[source];
1122
- if (value) env[key] = value;
1025
+ function inNetworkEnv(workspace, manifest) {
1026
+ const plan = planFor(manifest, workspace.stages.local, provisionOrder(manifest), {
1027
+ localStage: workspace.stages.local,
1028
+ ...backendsOf(workspace)
1029
+ });
1030
+ const placeholders = {};
1031
+ const targets = /* @__PURE__ */ new Map();
1032
+ let next = 49152;
1033
+ for (const container of [...plan.containers].sort()) for (const port of portsOf(container)) {
1034
+ placeholders[port.key] = next;
1035
+ targets.set(next, `${container}:${port.inside}`);
1036
+ next += 1;
1123
1037
  }
1124
- return env;
1038
+ for (const key of portKeys(plan.containers)) placeholders[key] ??= next++;
1039
+ const env = envFor(plan, {
1040
+ ports: placeholders,
1041
+ project: workspace.name,
1042
+ addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
1043
+ });
1044
+ const rewrite = (value) => value.replace(/(?:localhost|127\.0\.0\.1):(\d+)/g, (match, port) => {
1045
+ return targets.get(Number(port)) ?? match;
1046
+ });
1047
+ return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
1048
+ }
1049
+ /** One compose service per app the workspace runs, behind the apps profile. */
1050
+ function appServices(workspace, manifest, containers) {
1051
+ const env = inNetworkEnv(workspace, manifest);
1052
+ const services = {};
1053
+ for (const [name, app] of Object.entries(workspace.apps)) {
1054
+ if (app.type === "mobile" || !app.port) continue;
1055
+ const key = appKey(name);
1056
+ const health = app.type === "web" ? "/" : "/health";
1057
+ services[key] = {
1058
+ image: `${key}:\${TAG:-latest}`,
1059
+ build: {
1060
+ context: ".",
1061
+ dockerfile: dockerfileOf(name, app.path)
1062
+ },
1063
+ profiles: [APPS_PROFILE],
1064
+ ports: [`${app.port}:${app.port}`],
1065
+ environment: {
1066
+ NODE_ENV: "production",
1067
+ PORT: String(app.port),
1068
+ ...env
1069
+ },
1070
+ ...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
1071
+ healthcheck: {
1072
+ test: [
1073
+ "CMD",
1074
+ "wget",
1075
+ "-q",
1076
+ "--spider",
1077
+ `http://localhost:${app.port}${health}`
1078
+ ],
1079
+ interval: "10s",
1080
+ timeout: "5s",
1081
+ retries: 5
1082
+ }
1083
+ };
1084
+ }
1085
+ return services;
1125
1086
  }
1087
+ //#endregion
1088
+ //#region src/reconcile/clients.ts
1126
1089
  /**
1127
- * What a surface publishes beyond its own address: who may call it, and where
1128
- * a cookie set by it is readable.
1090
+ * The real Postgres and MinIO clients the applier runs against.
1129
1091
  *
1130
- * Both are read off the same list — the constructs that declared an edge to
1131
- * this surface — which is why neither appears in application code. A surface
1132
- * that listed its own callers would be edited every time something new called
1133
- * it, and the thing being edited is already recorded in the graph.
1092
+ * Kept apart from `provision.ts` so the rules there — what gets created, in what
1093
+ * order, and never dropping anything — are asserted against fakes, while the
1094
+ * drivers live here where there is nothing to assert but wiring.
1134
1095
  *
1135
- * An empty origin list is written, and a surface with no address writes nothing
1136
- * at all. The two cases look alike and are not: "nothing depends on this yet" is
1137
- * a real state a target should publish, while "this surface has no address here"
1138
- * means it is not running in this stage, and keys belong with the address they
1139
- * describe.
1096
+ * Both are constructed from the same local addresses the env writer composes,
1097
+ * so the applier cannot end up talking to a different container than the app.
1140
1098
  */
1141
- function surfaceEnv(resource, url, resolved = {}) {
1142
- if (!url) return {};
1143
- const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
1144
- const domain = cookieDomain([url, ...origins]);
1145
- return {
1146
- [provideKey(resource.id, "trustedOrigins")]: origins.join(","),
1147
- ...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {}
1148
- };
1149
- }
1150
- /** An address reduced to the origin a browser compares against. */
1151
- function originOf(address) {
1152
- try {
1153
- return new URL(address).origin;
1154
- } catch {
1155
- return void 0;
1156
- }
1157
- }
1099
+ /** The credential local containers are brought up with. */
1100
+ const LOCAL_USER$1 = "geekmidas";
1158
1101
  /**
1159
- * The runtime role of whatever a derived node hangs off.
1102
+ * A Postgres client that opens one connection per database it is asked about.
1160
1103
  *
1161
- * A reader's parent may itself be a schema tenant, so this follows `of` by one
1162
- * hop rather than assuming the cluster — the reader of a tenant reads that
1163
- * tenant's schema, not the database's.
1104
+ * `CREATE DATABASE` cannot run from a connection to the database being created,
1105
+ * and a schema must be created from inside its own database — so the database is
1106
+ * part of each query rather than fixed when the client is built.
1164
1107
  */
1165
- function parentRole(resource, plan) {
1166
- const parent = plan.resources.find((r) => r.id === resource.of);
1167
- return parent ? localRole(parent) : resource.name;
1108
+ function pgClient(port) {
1109
+ return { async query(database, sql, values) {
1110
+ const client = new Client({
1111
+ host: "localhost",
1112
+ port,
1113
+ user: LOCAL_USER$1,
1114
+ password: LOCAL_USER$1,
1115
+ database: database ?? LOCAL_USER$1
1116
+ });
1117
+ await client.connect();
1118
+ try {
1119
+ return (await client.query(sql, values)).rows;
1120
+ } finally {
1121
+ await client.end();
1122
+ }
1123
+ } };
1168
1124
  }
1169
- /** Whether a kind connects to Postgres and therefore has an owner role. */
1170
- function isDatabase(kind) {
1171
- return kind === "database" || kind === "database-schema";
1125
+ /** A bucket client pointed at the local MinIO. */
1126
+ function bucketClient(port) {
1127
+ const s3 = new S3Client({
1128
+ region: "us-east-1",
1129
+ endpoint: `http://localhost:${port}`,
1130
+ forcePathStyle: true,
1131
+ credentials: {
1132
+ accessKeyId: LOCAL_USER$1,
1133
+ secretAccessKey: LOCAL_USER$1
1134
+ }
1135
+ });
1136
+ return {
1137
+ async exists(bucket) {
1138
+ try {
1139
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
1140
+ return true;
1141
+ } catch {
1142
+ return false;
1143
+ }
1144
+ },
1145
+ async create(bucket) {
1146
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
1147
+ },
1148
+ async policy(bucket) {
1149
+ try {
1150
+ return (await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }))).Policy;
1151
+ } catch {
1152
+ return;
1153
+ }
1154
+ },
1155
+ async setPolicy(bucket, policy) {
1156
+ await s3.send(new PutBucketPolicyCommand({
1157
+ Bucket: bucket,
1158
+ Policy: policy
1159
+ }));
1160
+ }
1161
+ };
1172
1162
  }
1163
+ //#endregion
1164
+ //#region src/reconcile/compose.ts
1173
1165
  /**
1174
- * The URL a migrator connects with — the owner role, which can create, alter
1175
- * and drop.
1166
+ * The compose document a plan implies.
1176
1167
  *
1177
- * Never in `provides`, which is the whole point. A handler that could reach this
1178
- * could `DROP TABLE`, and the security property the role split exists for is
1179
- * that it cannot: the value is injected by `gkm exec` for the migrate step and
1180
- * by nothing else.
1168
+ * The third declaration of the same containers disappears here: today a
1169
+ * `services: ['postgres','minio']` block is kept in step with the code by hand,
1170
+ * which is the triplication this design exists to remove, in the one place you
1171
+ * touch every day. A `database` implies Postgres; the list is derived.
1172
+ *
1173
+ * Returns an object rather than a string for the same reason `plan` is pure —
1174
+ * what should exist is asserted as data, and turning it into YAML is one line at
1175
+ * the edge.
1176
+ *
1177
+ * What stays config is unchanged: images and versions are pinned explicitly, and
1178
+ * a container no construct implies is still declared by hand. Neutral facts come
1179
+ * from the declaration, provider detail from config.
1181
1180
  */
1182
- function ownerUrl(resource, plan, ports, project) {
1183
- if (!resource.container) return void 0;
1184
- const port = ports[primaryPortKey(resource.container)];
1185
- if (port === void 0) return void 0;
1186
- const owner = ownerRole(localRole(resource));
1187
- return postgres(port, rootDatabase(resource, plan), {
1188
- user: owner,
1189
- password: localRolePassword(project, plan, owner)
1190
- });
1191
- }
1181
+ /** The credential every derived container is brought up with locally. */
1182
+ const LOCAL_USER = "geekmidas";
1192
1183
  /**
1193
- * The shared broker keys, when anything is published or consumed.
1184
+ * The token the local cache accepts.
1194
1185
  *
1195
- * A queue's own key is the producer's. These two are the *connection* itself,
1196
- * which locally is one broker for every queue and topic in the project: the
1197
- * generated pollers open a single connection and subscribe each worker by name
1198
- * on it. Deployed there is no such thing — a Lambda is handed its own event
1199
- * source — so this pair exists for the local target and says so.
1186
+ * Fixed rather than generated: it is only ever reachable on a port this project
1187
+ * published, and the URL that carries it is derived, so nothing is made safer
1188
+ * by making it unguessable and a stable one keeps `redis-cli` sessions working.
1200
1189
  */
1201
- function brokerEnv(plan, ports) {
1202
- const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
1203
- if (!carrier) return {};
1204
- const publisher = urlFor(carrier, plan, ports, "");
1205
- if (!publisher) return {};
1206
- return {
1207
- EVENT_PUBLISHER_CONNECTION_STRING: publisher,
1208
- EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
1209
- };
1210
- }
1190
+ const LOCAL_TOKEN = "geekmidas";
1211
1191
  /**
1212
- * The URL for one planned resource.
1192
+ * Build the compose document for a plan.
1213
1193
  *
1214
- * Every part of it comes from the resource and the port it was published on.
1194
+ * Every container is published on its assigned port, never on the image's
1195
+ * default: the point of allocation is that two projects can run at once, and
1196
+ * one container publishing 5432 puts that back.
1215
1197
  */
1216
- function urlFor(resource, plan, ports, project, addresses = {}) {
1217
- if (resource.kind === "secret") return localSecret(project, plan, resource);
1218
- if (resource.kind === "credential") return void 0;
1219
- if (resource.kind === "rest-api" || resource.kind === "site") {
1220
- const address = addresses[resource.id];
1221
- const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
1222
- return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
1198
+ function composeFor(plan, options) {
1199
+ const services = {};
1200
+ const volumes = {};
1201
+ for (const container of [...plan.containers].sort()) {
1202
+ const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
1203
+ const image = declared ? postgresImage(declared) : DEFAULT_IMAGES[container];
1204
+ if (!image) throw new UnknownContainer(container);
1205
+ services[container] = define(container, image, publish(container, options.ports));
1206
+ const volume = volumeOf(container);
1207
+ if (volume) volumes[volume] = {};
1223
1208
  }
1224
- if (!resource.container) return void 0;
1225
- const port = ports[primaryPortKey(resource.container)];
1226
- if (port === void 0) return void 0;
1227
- switch (resource.kind) {
1228
- case "database":
1229
- case "database-schema":
1230
- case "database-reader": {
1231
- const database = rootDatabase(resource, plan);
1232
- if (resource.roles === false) {
1233
- const schema = schemaOf(resource, plan);
1234
- return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
1209
+ return {
1210
+ name: options.project,
1211
+ services: {
1212
+ ...services,
1213
+ ...options.apps
1214
+ },
1215
+ ...Object.keys(volumes).length ? { volumes } : {}
1216
+ };
1217
+ }
1218
+ /** Render a compose document as the file it becomes. */
1219
+ function toYaml(file) {
1220
+ return `# Generated by gkm from the construct manifest — do not edit.
1221
+ # Containers are derived from what the app declares. Image pins and anything
1222
+ # no construct implies go in your own docker-compose.yml, which is merged over
1223
+ # this file and wins:
1224
+ #
1225
+ # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
1226
+ #
1227
+ # "gkm dev" and "gkm test" start the containers. The apps sit behind the
1228
+ # "apps" profile: run "gkm setup", then
1229
+ # docker compose -f docker-compose.constructs.yml --profile apps up --build
1230
+ ${stringify(file, { lineWidth: 0 })}`;
1231
+ }
1232
+ /** A container with no image, from config or from the defaults. */
1233
+ var UnknownContainer = class extends Error {
1234
+ /** The container that could not be resolved. */
1235
+ container;
1236
+ /** The containers that have a default, so the caller can see the set. */
1237
+ known = Object.keys(DEFAULT_IMAGES).sort();
1238
+ constructor(container) {
1239
+ super("No image is known for this container; pin one in config");
1240
+ this.name = "UnknownContainer";
1241
+ this.container = container;
1242
+ }
1243
+ };
1244
+ /** One container's definition, published on the port it was assigned. */
1245
+ function define(container, image, published) {
1246
+ switch (container) {
1247
+ case "postgres": return {
1248
+ image,
1249
+ restart: "unless-stopped",
1250
+ ports: published,
1251
+ environment: {
1252
+ POSTGRES_USER: LOCAL_USER,
1253
+ POSTGRES_PASSWORD: LOCAL_USER,
1254
+ POSTGRES_DB: LOCAL_USER
1255
+ },
1256
+ volumes: ["postgres-data:/var/lib/postgresql"],
1257
+ healthcheck: {
1258
+ test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
1259
+ interval: "5s",
1260
+ timeout: "3s",
1261
+ retries: 10,
1262
+ start_period: "10s"
1235
1263
  }
1236
- const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
1237
- return postgres(port, database, {
1238
- user: role,
1239
- password: localRolePassword(project, plan, role)
1240
- });
1241
- }
1242
- case "email": return `smtp://${LOCAL_HOST}:${port}`;
1243
- case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
1244
- case "file-server": {
1245
- const origin = plan.resources.find((r) => r.id === resource.of);
1246
- if (!origin) return void 0;
1247
- return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
1248
- }
1249
- case "cache":
1250
- if (resource.of) {
1251
- const parent = plan.resources.find((r) => r.id === resource.of);
1252
- const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
1253
- return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
1264
+ };
1265
+ case "minio": return {
1266
+ image,
1267
+ restart: "unless-stopped",
1268
+ ports: published,
1269
+ environment: {
1270
+ MINIO_ROOT_USER: LOCAL_USER,
1271
+ MINIO_ROOT_PASSWORD: LOCAL_USER
1272
+ },
1273
+ command: "server --console-address \":9001\" /data",
1274
+ volumes: ["minio-data:/data"],
1275
+ healthcheck: {
1276
+ test: [
1277
+ "CMD",
1278
+ "mc",
1279
+ "ready",
1280
+ "local"
1281
+ ],
1282
+ interval: "10s",
1283
+ timeout: "5s",
1284
+ retries: 5,
1285
+ start_period: "10s"
1254
1286
  }
1255
- switch (plan.cache) {
1256
- case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
1257
- default: return `http://:${LOCAL_TOKEN}@${LOCAL_HOST}:${port}`;
1287
+ };
1288
+ case "caddy": return {
1289
+ image,
1290
+ restart: "unless-stopped",
1291
+ ports: published,
1292
+ volumes: [
1293
+ "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
1294
+ "./.gkm/caddy-sites:/etc/caddy/sites:ro",
1295
+ "caddy-data:/data"
1296
+ ],
1297
+ extra_hosts: ["host.docker.internal:host-gateway"],
1298
+ healthcheck: {
1299
+ test: [
1300
+ "CMD",
1301
+ "caddy",
1302
+ "version"
1303
+ ],
1304
+ interval: "10s",
1305
+ timeout: "5s",
1306
+ retries: 5
1307
+ }
1308
+ };
1309
+ case "mailpit": return {
1310
+ image,
1311
+ restart: "unless-stopped",
1312
+ ports: published,
1313
+ environment: {
1314
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
1315
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1"
1316
+ },
1317
+ healthcheck: {
1318
+ test: [
1319
+ "CMD",
1320
+ "wget",
1321
+ "-q",
1322
+ "--spider",
1323
+ "http://localhost:8025"
1324
+ ],
1325
+ interval: "10s",
1326
+ timeout: "5s",
1327
+ retries: 5
1328
+ }
1329
+ };
1330
+ case "redis": return {
1331
+ image,
1332
+ restart: "unless-stopped",
1333
+ ports: published,
1334
+ volumes: ["redis-data:/data"],
1335
+ healthcheck: {
1336
+ test: [
1337
+ "CMD",
1338
+ "redis-cli",
1339
+ "ping"
1340
+ ],
1341
+ interval: "5s",
1342
+ timeout: "3s",
1343
+ retries: 5
1344
+ }
1345
+ };
1346
+ case "redis-http": return {
1347
+ image,
1348
+ restart: "unless-stopped",
1349
+ ports: published,
1350
+ environment: {
1351
+ SRH_MODE: "env",
1352
+ SRH_TOKEN: LOCAL_TOKEN,
1353
+ SRH_CONNECTION_STRING: "redis://redis:6379"
1354
+ },
1355
+ depends_on: ["redis"],
1356
+ healthcheck: {
1357
+ test: [
1358
+ "CMD",
1359
+ "wget",
1360
+ "-q",
1361
+ "-O",
1362
+ "/dev/null",
1363
+ "http://127.0.0.1:80/"
1364
+ ],
1365
+ interval: "5s",
1366
+ timeout: "3s",
1367
+ retries: 10
1368
+ }
1369
+ };
1370
+ case "rabbitmq": return {
1371
+ image,
1372
+ restart: "unless-stopped",
1373
+ ports: published,
1374
+ environment: {
1375
+ RABBITMQ_DEFAULT_USER: LOCAL_USER,
1376
+ RABBITMQ_DEFAULT_PASS: LOCAL_USER
1377
+ },
1378
+ volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
1379
+ healthcheck: {
1380
+ test: [
1381
+ "CMD",
1382
+ "rabbitmq-diagnostics",
1383
+ "-q",
1384
+ "ping"
1385
+ ],
1386
+ interval: "10s",
1387
+ timeout: "5s",
1388
+ retries: 5
1389
+ }
1390
+ };
1391
+ case "localstack": return {
1392
+ image,
1393
+ restart: "unless-stopped",
1394
+ ports: published,
1395
+ environment: {
1396
+ AWS_DEFAULT_REGION: "us-east-1",
1397
+ AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
1398
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER
1399
+ },
1400
+ volumes: ["localstack-data:/var/lib/localstack"],
1401
+ healthcheck: {
1402
+ test: [
1403
+ "CMD",
1404
+ "curl",
1405
+ "-f",
1406
+ "http://localhost:4566/_localstack/health"
1407
+ ],
1408
+ interval: "10s",
1409
+ timeout: "5s",
1410
+ retries: 5,
1411
+ start_period: "10s"
1258
1412
  }
1259
- case "queue":
1260
- case "topic": return broker(plan, port);
1261
- default: return void 0;
1262
- }
1263
- }
1264
- /**
1265
- * The producer's connection string, composed from the backend the plan chose.
1266
- *
1267
- * The protocol is what picks the transport, so a producer never branches: the
1268
- * same `.publish()` reaches pg-boss here and SQS deployed because the string it
1269
- * was handed said so.
1270
- */
1271
- function broker(plan, port) {
1272
- switch (plan.events) {
1273
- case "pgboss": {
1274
- const database = plan.resources.find((r) => r.kind === "database");
1275
- if (!database) throw new PgBossNeedsDatabase([]);
1276
- return `pgboss://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
1277
- }
1278
- case "rabbitmq": return `rabbitmq://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
1279
- case "sns": throw new UnprovisionedEventsBackend("sns");
1413
+ };
1414
+ default: throw new UnknownContainer(container);
1280
1415
  }
1281
1416
  }
1282
- /** A local Postgres URL, master credential and all. */
1283
- function postgres(port, database, credential = {
1284
- user: LOCAL_USER,
1285
- password: LOCAL_USER
1286
- }) {
1287
- return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
1288
- }
1289
1417
  /**
1290
- * The role a handler connects as, for one database or tenant.
1418
+ * The published port mappings for a container.
1291
1419
  *
1292
- * The construct's id, lowercased and stage-scoped exactly as its database or
1293
- * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
1294
- * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
1295
- * in Postgres, which is why the suffix is not optional here the way it is for a
1296
- * schema.
1420
+ * Every port is looked up by its own key rather than being derived as an offset
1421
+ * from the container's first, so MinIO's console cannot land on whatever was
1422
+ * allocated to the next container.
1297
1423
  */
1298
- function localRole(resource) {
1299
- return resource.name;
1424
+ function publish(container, ports) {
1425
+ return portsOf(container).map((port) => {
1426
+ const assigned = ports[port.key];
1427
+ if (assigned === void 0) throw new UnassignedPort(port.key);
1428
+ return `${assigned}:${port.inside}`;
1429
+ });
1300
1430
  }
1431
+ /** A planned port that allocation never assigned. */
1432
+ var UnassignedPort = class extends Error {
1433
+ /** The port key that went unassigned, e.g. `minio-console`. */
1434
+ key;
1435
+ constructor(key) {
1436
+ super("No port was assigned for this container port");
1437
+ this.name = "UnassignedPort";
1438
+ this.key = key;
1439
+ }
1440
+ };
1441
+ //#endregion
1442
+ //#region src/reconcile/docker.ts
1301
1443
  /**
1302
- * The password for a derived local role.
1303
- *
1304
- * Derived rather than random, for the same reason the local secret is: a
1305
- * password that changed on every `gkm dev` would lock a developer out of the
1306
- * data they had a moment ago. Seeded by the project so two checkouts do not
1307
- * share one, and by the stage so `test` and `development` do not.
1444
+ * The real Docker, behind the small interface reconcile needs.
1308
1445
  *
1309
- * It is a *local* credential by construction — the cluster is on loopback and
1310
- * the seed is a checkout path — and no deployed target uses this function.
1311
- */
1312
- function localRolePassword(project, plan, role) {
1313
- return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
1314
- }
1315
- /**
1316
- * A cache's address: its database's URL, carrying the table it reads.
1446
+ * Shells out to `docker compose` rather than talking to the daemon socket: it is
1447
+ * what a developer runs by hand when something is wrong, so the two cannot
1448
+ * disagree, and it keeps Docker out of this package's dependencies.
1317
1449
  *
1318
- * Appended rather than kept beside the URL as a second key, for the reason the
1319
- * design gives everywhere else — an address and what it opens are one fact, and
1320
- * two keys are one more thing to keep in step.
1450
+ * Every call is scoped to the generated compose file. Nothing here can touch a
1451
+ * container this project did not generate.
1321
1452
  */
1322
- function withTable(url, table) {
1323
- const parsed = new URL(url);
1324
- parsed.searchParams.set("table", table);
1325
- return parsed.toString();
1326
- }
1453
+ const run = promisify(execFile);
1327
1454
  /**
1328
- * The physical database a derived resource lives in.
1455
+ * The generated file, then the project's own if it has one.
1329
1456
  *
1330
- * Walks `of` to the top rather than reading the immediate parent, because a
1331
- * reader's parent may itself be a schema tenant — two hops from the database
1332
- * that actually exists.
1457
+ * Merged in that order so the project's wins — an `image:` there pins a
1458
+ * container this file derived, and a service there is added beside them.
1333
1459
  */
1334
- function rootDatabase(resource, plan) {
1335
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1336
- let current = resource;
1337
- while (current.of) {
1338
- const parent = byId.get(current.of);
1339
- if (!parent) break;
1340
- current = parent;
1341
- }
1342
- return current.name;
1343
- }
1344
- /** The schema a derived resource puts on its search path, if it has one. */
1345
- function schemaOf(resource, plan) {
1346
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1347
- let current = resource;
1348
- while (current) {
1349
- if (current.kind === "database-schema") return current.schema;
1350
- current = current.of ? byId.get(current.of) : void 0;
1351
- }
1352
- return void 0;
1460
+ function composeFiles(composePath) {
1461
+ const project = join(dirname(composePath), "docker-compose.yml");
1462
+ return [
1463
+ "-f",
1464
+ composePath,
1465
+ ...existsSync(project) ? ["-f", project] : []
1466
+ ];
1353
1467
  }
1354
- /**
1355
- * A backend whose local addresses cannot be derived yet.
1356
- *
1357
- * SNS and SQS are addressed by ARN, which means the topic and queue have to be
1358
- * created in LocalStack before a URL can name them. Until that provisioning
1359
- * lands, saying so is better than handing out a string that fails on first use.
1360
- */
1361
- var UnprovisionedEventsBackend = class extends Error {
1362
- constructor(backend) {
1363
- super(`The local target cannot derive addresses for '${backend}' yet — its topics and queues are named by ARN, which nothing creates locally. Use 'pgboss' (the default) or 'rabbitmq' for local development.`);
1364
- this.backend = backend;
1365
- this.name = "UnprovisionedEventsBackend";
1468
+ /** How long to wait for containers to pass their health checks. */
1469
+ const HEALTH_TIMEOUT_MS = 12e4;
1470
+ const dockerCli = {
1471
+ async publishedPort(composePath, service, inside) {
1472
+ try {
1473
+ const { stdout } = await run("docker", [
1474
+ "compose",
1475
+ ...composeFiles(composePath),
1476
+ "port",
1477
+ service,
1478
+ String(inside)
1479
+ ]);
1480
+ const port = Number(stdout.trim().split(":").pop());
1481
+ return Number.isFinite(port) && port > 0 ? port : void 0;
1482
+ } catch {
1483
+ return;
1484
+ }
1485
+ },
1486
+ async up(composePath, services) {
1487
+ await run("docker", [
1488
+ "compose",
1489
+ ...composeFiles(composePath),
1490
+ "up",
1491
+ "-d",
1492
+ "--remove-orphans",
1493
+ "--wait",
1494
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
1495
+ ...services
1496
+ ]);
1497
+ },
1498
+ async copyOut(composePath, service, from, to) {
1499
+ await mkdir(dirname(to), { recursive: true });
1500
+ await run("docker", [
1501
+ "compose",
1502
+ ...composeFiles(composePath),
1503
+ "cp",
1504
+ `${service}:${from}`,
1505
+ to
1506
+ ]);
1507
+ },
1508
+ async reload(composePath, service) {
1509
+ await run("docker", [
1510
+ "compose",
1511
+ ...composeFiles(composePath),
1512
+ "exec",
1513
+ "-T",
1514
+ service,
1515
+ "caddy",
1516
+ "reload",
1517
+ "--config",
1518
+ "/etc/caddy/Caddyfile"
1519
+ ]);
1520
+ },
1521
+ async healthy(composePath, services) {
1522
+ if (services.length === 0) return true;
1523
+ try {
1524
+ const { stdout } = await run("docker", [
1525
+ "compose",
1526
+ ...composeFiles(composePath),
1527
+ "ps",
1528
+ "--format",
1529
+ "json",
1530
+ ...services
1531
+ ]);
1532
+ const running = /* @__PURE__ */ new Set();
1533
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
1534
+ const entry = JSON.parse(line);
1535
+ if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
1536
+ }
1537
+ return services.every((service) => running.has(service));
1538
+ } catch {
1539
+ return false;
1540
+ }
1366
1541
  }
1367
1542
  };
1543
+ //#endregion
1544
+ //#region src/reconcile/ports.ts
1368
1545
  /**
1369
- * The value a secret resolves to locally.
1546
+ * Port allocation — why two projects can run at once.
1370
1547
  *
1371
- * A hash of what identifies it, so it is stable, distinct per project, stage
1372
- * and construct, and never written to disk as a literal. Deployed this is the
1373
- * one kind whose value does *not* come from here — a secret manager generates
1374
- * it once and the target reads it back.
1548
+ * Publishing `5432:5432` means the second project on your machine cannot start.
1549
+ * Nothing solves that except giving each project its own ports, and what makes
1550
+ * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
1551
+ * never sees a host or a port, so reconcile is free to bind whatever is
1552
+ * available and compose the URL from what it bound. An app reading `DB_HOST` and
1553
+ * `DB_PORT` separately could not be moved this way.
1554
+ *
1555
+ * Assignments are **persisted, not recomputed**. Recomputing means adding a
1556
+ * construct renumbers its neighbours, and every running container, saved
1557
+ * connection string, and `psql` history goes stale at once.
1558
+ *
1559
+ * Pure, with the liveness probe injected — a collision with another project's
1560
+ * stack is a fact about the machine, and keeping it a parameter is what lets the
1561
+ * rules be asserted without binding a socket.
1375
1562
  */
1376
- function localSecret(project, plan, resource) {
1377
- return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
1378
- }
1379
-
1380
- //#endregion
1381
- //#region src/reconcile/ports.ts
1382
1563
  /**
1383
1564
  * The window ports are drawn from.
1384
1565
  *
@@ -1477,17 +1658,33 @@ async function nextFree(from, taken, probe) {
1477
1658
  * rather than as a connection failure ten minutes later.
1478
1659
  */
1479
1660
  function isPortFree(port) {
1480
- return new Promise((resolve$1) => {
1661
+ return new Promise((resolve) => {
1481
1662
  const server = createServer();
1482
- server.once("error", () => resolve$1(false));
1483
- server.once("listening", () => server.close(() => resolve$1(true)));
1663
+ server.once("error", () => resolve(false));
1664
+ server.once("listening", () => server.close(() => resolve(true)));
1484
1665
  server.listen(port, "0.0.0.0");
1485
1666
  });
1486
1667
  }
1487
-
1488
1668
  //#endregion
1489
1669
  //#region src/reconcile/provision.ts
1490
1670
  /**
1671
+ * Creating what the URLs name.
1672
+ *
1673
+ * Convergent and idempotent: every statement asks whether the thing exists
1674
+ * first, so reconciling repeatedly is free and a half-applied run recovers by
1675
+ * being run again. That property is what lets `gkm dev` do this on every start.
1676
+ *
1677
+ * The line the design draws is at data, not at side effects — this creates
1678
+ * databases, schemas, and buckets, and it never seeds, resets, or drops. A
1679
+ * developer who wants their data gone asks for it explicitly.
1680
+ *
1681
+ * Statement generation is separated from application for the reason the design
1682
+ * gives: the same DDL has to run in-process locally and from a provisioner in a
1683
+ * VPC, and one implementation is what stops "works locally, fails deployed".
1684
+ * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
1685
+ * growing its own.
1686
+ */
1687
+ /**
1491
1688
  * The Postgres work a plan implies.
1492
1689
  *
1493
1690
  * Databases before the schemas inside them, which `provisionOrder` already
@@ -1629,8 +1826,7 @@ async function applyPostgres(client, statements) {
1629
1826
  });
1630
1827
  continue;
1631
1828
  }
1632
- const rows = await client.query(statement.database, statement.exists.sql, statement.exists.values);
1633
- if (rows.length > 0) {
1829
+ if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
1634
1830
  applied.push({
1635
1831
  id: statement.id,
1636
1832
  describe: statement.describe,
@@ -1755,9 +1951,20 @@ async function applyBuckets(client, buckets) {
1755
1951
  }
1756
1952
  return applied;
1757
1953
  }
1758
-
1759
1954
  //#endregion
1760
1955
  //#region src/reconcile/state.ts
1956
+ /**
1957
+ * What reconcile records, and the hash that lets it do nothing.
1958
+ *
1959
+ * Reconciling on every `gkm dev` is only acceptable if the converged case is
1960
+ * free, so the desired plan is hashed: matching hash plus healthy containers
1961
+ * means there is nothing to do. Without that, the automatic reconcile the design
1962
+ * asks for would tax every start.
1963
+ *
1964
+ * Almost nothing needs recording — roles are queried, migrations have their own
1965
+ * table, containers are remembered by Docker. Only ports and this hash have no
1966
+ * other owner.
1967
+ */
1761
1968
  const STATE_DIR = ".gkm/reconcile";
1762
1969
  /**
1763
1970
  * The hash of everything a reconcile would act on.
@@ -1786,7 +1993,7 @@ async function loadState(root, stage) {
1786
1993
  const state = JSON.parse(raw);
1787
1994
  return state.stage === stage ? state : void 0;
1788
1995
  } catch {
1789
- return void 0;
1996
+ return;
1790
1997
  }
1791
1998
  }
1792
1999
  /** Record what was applied, so the next run can skip it. */
@@ -1798,11 +2005,36 @@ async function saveState(root, state) {
1798
2005
  function statePath(root, stage) {
1799
2006
  return join(root, STATE_DIR, `${stage}.json`);
1800
2007
  }
1801
-
1802
2008
  //#endregion
1803
2009
  //#region src/reconcile/index.ts
1804
- /** Where the generated compose file is written, relative to the project root. */
1805
- const COMPOSE_PATH = ".gkm/docker-compose.yml";
2010
+ /**
2011
+ * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
2012
+ * all call.
2013
+ *
2014
+ * Three phases, and only this one has side effects: declaring is pure (`gkm
2015
+ * build` and the dev watcher), running reads `<NAME>_URL` and provisions
2016
+ * nothing. Reconcile computes the desired state, compares, and applies the
2017
+ * difference — which is what makes "when does it run" answerable with
2018
+ * *whenever, repeatedly*.
2019
+ *
2020
+ * It is safe to run automatically because its blast radius is entirely local:
2021
+ * this project's containers, this project's `.gkm/`. Nothing it does can reach a
2022
+ * cloud. The line is drawn at data, not at side effects — it allocates ports,
2023
+ * writes compose, starts containers, and creates roles; it never seeds, resets,
2024
+ * or drops.
2025
+ *
2026
+ * Docker is injected rather than imported so the rules above can be asserted
2027
+ * without a daemon. The default implementation is the real one.
2028
+ */
2029
+ /**
2030
+ * Where the generated compose file is written, relative to the project root.
2031
+ *
2032
+ * At the root, beside the project's own `docker-compose.yml`, because the two
2033
+ * are one stack: this file is everything the constructs imply and is never
2034
+ * edited, that one is anything they cannot express — an image pin, an extra
2035
+ * service — and is merged over it, so it wins.
2036
+ */
2037
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
1806
2038
  /**
1807
2039
  * The local edge's config, beside the compose file that mounts it.
1808
2040
  *
@@ -1840,6 +2072,7 @@ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
1840
2072
  async function reconcile(options) {
1841
2073
  const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient } = options;
1842
2074
  const plan = planFor(manifest, stage, provisionOrder(manifest), {
2075
+ localStage: options.localStage,
1843
2076
  events: options.events,
1844
2077
  cache: options.cache,
1845
2078
  extraContainers: options.extraContainers,
@@ -1854,7 +2087,7 @@ async function reconcile(options) {
1854
2087
  const compose = composeFor(plan, {
1855
2088
  project,
1856
2089
  ports,
1857
- ...options.images ? { images: options.images } : {}
2090
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
1858
2091
  });
1859
2092
  const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
1860
2093
  const hash = planHash(plan, compose, { caddyfile });
@@ -1877,13 +2110,16 @@ async function reconcile(options) {
1877
2110
  hash,
1878
2111
  changed: false
1879
2112
  };
1880
- const recorded = await loadState(root, stage);
1881
- const converged = recorded?.hash === hash && (!start || await docker.healthy(composePath, plan.containers));
1882
- if (converged) return result;
2113
+ if ((await loadState(root, stage))?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers))) return result;
1883
2114
  await write(composePath, toYaml(compose));
1884
2115
  if (plan.containers.includes("caddy")) {
1885
2116
  await write(join(root, CADDYFILE_PATH), caddyfileRoot());
1886
2117
  await write(join(root, caddySitesPath(stage)), caddyfile);
2118
+ await pruneCaddySites(root, [
2119
+ stage,
2120
+ options.localStage,
2121
+ TEST_STAGE
2122
+ ]);
1887
2123
  }
1888
2124
  if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
1889
2125
  if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
@@ -1949,6 +2185,20 @@ function addressesFor(containers, ports) {
1949
2185
  return addresses;
1950
2186
  }
1951
2187
  /** Write a file only when its content would change, so mtimes stay meaningful. */
2188
+ /**
2189
+ * Remove the routes of stages this project no longer reconciles.
2190
+ *
2191
+ * Only the local stage and `test` are ever written, so any other file is left
2192
+ * over from a stage that was renamed — `development` becoming `dev`. Caddy
2193
+ * imports every file, and the leftover declares the same hosts as its
2194
+ * replacement: it refuses the whole config as ambiguous, and every URL behind
2195
+ * the edge goes with it.
2196
+ */
2197
+ async function pruneCaddySites(root, stages) {
2198
+ const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
2199
+ const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
2200
+ for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
2201
+ }
1952
2202
  async function write(path, content) {
1953
2203
  try {
1954
2204
  if (await readFile(path, "utf-8") === content) return;
@@ -1956,10 +2206,28 @@ async function write(path, content) {
1956
2206
  await mkdir(dirname(path), { recursive: true });
1957
2207
  await writeFile(path, content);
1958
2208
  }
1959
-
1960
2209
  //#endregion
1961
2210
  //#region src/reconcile/workspace.ts
1962
2211
  /**
2212
+ * Workspace config → reconcile.
2213
+ *
2214
+ * Nothing here is read from a `services:` block — there is none. Which
2215
+ * containers exist comes from the manifest; which backend a cache or a broker
2216
+ * resolves to comes from the deploy target; and an image pin is the project's
2217
+ * own `docker-compose.yml`, merged over the generated file.
2218
+ *
2219
+ * Shared by `gkm setup`, `gkm dev`, and `gkm test` so all three converge on the
2220
+ * same state, differing only in the stage they pass.
2221
+ */
2222
+ var workspace_exports = /* @__PURE__ */ __exportAll({
2223
+ backendsOf: () => backendsOf,
2224
+ constructGlobs: () => constructGlobs,
2225
+ derivedContainers: () => derivedContainers,
2226
+ reconcileWorkspace: () => reconcileWorkspace,
2227
+ surfaceAddresses: () => surfaceAddresses,
2228
+ usesConstructs: () => usesConstructs
2229
+ });
2230
+ /**
1963
2231
  * Every constructs glob in the workspace, resolved against its app.
1964
2232
  *
1965
2233
  * Absolute because discovery globs from a single cwd while apps live in
@@ -1972,16 +2240,13 @@ function constructGlobs(workspace) {
1972
2240
  function usesConstructs(workspace) {
1973
2241
  return constructGlobs(workspace).length > 0;
1974
2242
  }
1975
- /**
1976
- * Image pins, by container. The config half of the derived/config split.
1977
- *
1978
- * Read from `services.images` rather than from the backend keys beside it.
1979
- * They used to share one key, which is how `cache: true` came to mean "start a
1980
- * Redis" — the last way a container could exist because config asked for one
1981
- * instead of because something declared it.
1982
- */
1983
- function imagePins(workspace) {
1984
- return { ...workspace.services.images };
2243
+ /** The backends a workspace's target implies, as the plan takes them. */
2244
+ function backendsOf(workspace) {
2245
+ const on = providerOf(workspace);
2246
+ return {
2247
+ events: eventsBackendFor(on),
2248
+ cache: cacheBackendFor(on)
2249
+ };
1985
2250
  }
1986
2251
  /**
1987
2252
  * The containers a workspace's declarations imply, without reconciling.
@@ -1999,8 +2264,8 @@ async function derivedContainers(workspace, stage, manifest) {
1999
2264
  cwd: workspace.root
2000
2265
  });
2001
2266
  return planFor(found, stage, provisionOrder(found), {
2002
- events: workspace.services.events,
2003
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
2267
+ localStage: workspace.stages.local,
2268
+ ...backendsOf(workspace)
2004
2269
  }).containers;
2005
2270
  }
2006
2271
  /**
@@ -2019,11 +2284,11 @@ async function reconcileWorkspace(workspace, options) {
2019
2284
  project: workspace.name,
2020
2285
  manifest,
2021
2286
  stage: options.stage,
2022
- events: workspace.services.events,
2023
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
2024
- images: imagePins(workspace),
2287
+ localStage: workspace.stages.local,
2288
+ ...backendsOf(workspace),
2025
2289
  saved: await loadPortState(workspace.root),
2026
2290
  addresses: surfaceAddresses(workspace, manifest),
2291
+ apps: (containers) => appServices(workspace, manifest, containers),
2027
2292
  ...options.start === void 0 ? {} : { start: options.start }
2028
2293
  });
2029
2294
  await savePortState(workspace.root, { ...result.ports });
@@ -2045,26 +2310,20 @@ async function reconcileWorkspace(workspace, options) {
2045
2310
  * an app serving both its own API and an auth server publishes one address
2046
2311
  * twice — which is exactly what it does at runtime.
2047
2312
  */
2048
- function surfaceAddresses(workspace, manifest) {
2313
+ function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
2049
2314
  const addresses = {};
2050
2315
  for (const [id, declaration] of Object.entries(manifest)) {
2051
2316
  if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
2052
- const host = declaration.kind === "site" ? appKey(id) : appKeyOfHost(manifest, id);
2053
- const app = host ? workspace.apps[host] : void 0;
2054
- if (app?.port) addresses[id] = localAddress(app.port);
2317
+ const host = appKey(id);
2318
+ const port = workspace.apps[host]?.port;
2319
+ if (port) addresses[id] = at(host, port);
2055
2320
  }
2056
2321
  return addresses;
2057
2322
  }
2058
- /** The app key serving a surface, following a mount to its host. */
2059
- function appKeyOfHost(manifest, id) {
2060
- const host = hostOf(manifest, id);
2061
- return host ? appKey(host) : void 0;
2062
- }
2063
2323
  /** Where a local process answers, given the port the workspace gave it. */
2064
2324
  function localAddress(port) {
2065
2325
  return `http://localhost:${port}`;
2066
2326
  }
2067
-
2068
2327
  //#endregion
2069
2328
  //#region src/credentials/index.ts
2070
2329
  const logger = console;
@@ -2097,15 +2356,15 @@ function loadEnvFiles(envConfig, cwd = process.cwd()) {
2097
2356
  * @internal Exported for testing
2098
2357
  */
2099
2358
  async function isPortAvailable(port) {
2100
- return new Promise((resolve$1) => {
2359
+ return new Promise((resolve) => {
2101
2360
  const server = createServer();
2102
2361
  server.once("error", (err) => {
2103
- if (err.code === "EADDRINUSE") resolve$1(false);
2104
- else resolve$1(false);
2362
+ if (err.code === "EADDRINUSE") resolve(false);
2363
+ else resolve(false);
2105
2364
  });
2106
2365
  server.once("listening", () => {
2107
2366
  server.close();
2108
- resolve$1(true);
2367
+ resolve(true);
2109
2368
  });
2110
2369
  server.listen(port);
2111
2370
  });
@@ -2174,11 +2433,10 @@ async function savePortState(workspaceRoot, ports) {
2174
2433
  */
2175
2434
  function getContainerHostPort(workspaceRoot, service, containerPort) {
2176
2435
  try {
2177
- const result = execSync(`docker compose port ${service} ${containerPort}`, {
2436
+ const match = execSync(`docker compose port ${service} ${containerPort}`, {
2178
2437
  cwd: workspaceRoot,
2179
2438
  stdio: "pipe"
2180
- }).toString().trim();
2181
- const match = result.match(/:(\d+)$/);
2439
+ }).toString().trim().match(/:(\d+)$/);
2182
2440
  return match ? Number(match[1]) : null;
2183
2441
  } catch {
2184
2442
  return null;
@@ -2191,8 +2449,7 @@ function getContainerHostPort(workspaceRoot, service, containerPort) {
2191
2449
  * @internal Exported for testing
2192
2450
  */
2193
2451
  async function resolveServicePorts(workspaceRoot) {
2194
- const composePath = join(workspaceRoot, "docker-compose.yml");
2195
- const mappings = parseComposePortMappings(composePath);
2452
+ const mappings = parseComposePortMappings(join(workspaceRoot, "docker-compose.yml"));
2196
2453
  if (mappings.length === 0) return {
2197
2454
  dockerEnv: {},
2198
2455
  ports: {},
@@ -2358,14 +2615,13 @@ async function startWorkspaceServices(workspace, portEnv, secretsEnv) {
2358
2615
  * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
2359
2616
  * @internal Exported for testing
2360
2617
  */
2361
- async function loadSecretsForApp(secretsRoot, appName, stages = ["dev", "development"]) {
2618
+ async function loadSecretsForApp(secretsRoot, stage, appName) {
2362
2619
  let secrets = {};
2363
- for (const stage of stages) if (secretsExist(stage, secretsRoot)) {
2620
+ if (secretsExist(stage, secretsRoot)) {
2364
2621
  const stageSecrets = await readStageSecrets(stage, secretsRoot);
2365
2622
  if (stageSecrets) {
2366
2623
  logger.log(`🔐 Loading secrets from stage: ${stage}`);
2367
2624
  secrets = toEmbeddableSecrets(stageSecrets);
2368
- break;
2369
2625
  }
2370
2626
  }
2371
2627
  if (Object.keys(secrets).length === 0) return {};
@@ -2427,13 +2683,12 @@ ${generateCredentialsInjection(secretsJsonPath)}`;
2427
2683
  * @internal Exported for testing
2428
2684
  */
2429
2685
  async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
2430
- const credentialsInjection = secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
2431
- ` : "";
2432
2686
  const content = `#!/usr/bin/env node
2433
2687
  /**
2434
2688
  * Entry wrapper generated by 'gkm dev --entry'
2435
2689
  */
2436
- ${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
2690
+ ${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
2691
+ ` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
2437
2692
  await import('${entryPath}');
2438
2693
  `;
2439
2694
  await writeFile(wrapperPath, content);
@@ -2446,7 +2701,7 @@ await import('${entryPath}');
2446
2701
  * @param options.resolveDockerPorts - How to resolve Docker ports:
2447
2702
  * - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
2448
2703
  * - `'readonly'`: check running containers and saved state only, never probe for new ports. Used by exec.
2449
- * @param options.stages - Secret stages to try, in order. Default: ['dev', 'development'].
2704
+ * @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
2450
2705
  * @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
2451
2706
  * @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
2452
2707
  * @internal Exported for testing
@@ -2458,31 +2713,34 @@ async function prepareEntryCredentials(options) {
2458
2713
  let secretsRoot = cwd;
2459
2714
  let appName;
2460
2715
  let appInfo;
2716
+ let workspace;
2461
2717
  try {
2462
2718
  appInfo = await loadWorkspaceAppInfo(cwd);
2719
+ workspace = appInfo.workspace;
2463
2720
  workspaceAppPort = appInfo.app.port;
2464
2721
  secretsRoot = appInfo.workspaceRoot;
2465
2722
  appName = appInfo.appName;
2466
2723
  } catch {
2467
- secretsRoot = findSecretsRoot(cwd);
2724
+ workspace = await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
2725
+ secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
2468
2726
  appName = getAppNameFromCwd(cwd) ?? void 0;
2469
2727
  }
2470
2728
  const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
2471
- const credentials = await loadSecretsForApp(secretsRoot, appName, options.stages);
2729
+ const stage = options.stage ?? workspace?.stages.local;
2730
+ const credentials = stage ? await loadSecretsForApp(secretsRoot, stage, appName) : {};
2472
2731
  credentials.PORT = String(resolvedPort);
2473
2732
  const declaredKeys = [];
2474
- if (appInfo && usesConstructs(appInfo.workspace)) {
2475
- const { reconcileWorkspace: reconcileWorkspace$1 } = await import("./workspace-DK4WWJIn.mjs");
2476
- const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
2477
- stage: options.reconcileStage ?? "development",
2733
+ if (workspace && usesConstructs(workspace)) {
2734
+ const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
2735
+ const reconciled = await reconcileWorkspace(workspace, {
2736
+ stage: options.reconcileStage ?? workspace.stages.local,
2478
2737
  start: options.startDocker ?? false
2479
2738
  });
2480
2739
  Object.assign(credentials, reconciled.env);
2481
2740
  declaredKeys.push(...Object.keys(reconciled.env));
2482
2741
  if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
2483
2742
  } else {
2484
- const composePath = join(secretsRoot, "docker-compose.yml");
2485
- const mappings = parseComposePortMappings(composePath);
2743
+ const mappings = parseComposePortMappings(join(secretsRoot, "docker-compose.yml"));
2486
2744
  if (mappings.length > 0) {
2487
2745
  let resolvedPorts;
2488
2746
  if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
@@ -2503,8 +2761,10 @@ async function prepareEntryCredentials(options) {
2503
2761
  mappings
2504
2762
  };
2505
2763
  }
2506
- if (options.startDocker) if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
2507
- else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
2764
+ if (options.startDocker) {
2765
+ if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
2766
+ else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
2767
+ }
2508
2768
  if (Object.keys(resolvedPorts.ports).length > 0) {
2509
2769
  const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
2510
2770
  Object.assign(credentials, rewritten);
@@ -2542,7 +2802,7 @@ async function prepareEntryCredentials(options) {
2542
2802
  declaredKeys
2543
2803
  };
2544
2804
  }
2545
-
2546
2805
  //#endregion
2547
- export { LOCAL_CA_PATH, applyPostgres, cacheBackendOf, constructGlobs, createCredentialsPreload, createEntryWrapper, derivedContainers, emailBackendOf, findAvailablePort, imagePins, isPortAvailable, loadEnvFiles, loadSecretsForApp, prepareEntryCredentials, providerOf, reconcileWorkspace, resolveServicePorts, resourceName, rewriteUrlsWithPorts, startWorkspaceServices, storageBackendOf, usesConstructs };
2548
- //# sourceMappingURL=credentials-BZcp3CGx.mjs.map
2806
+ export { storageBackendFor as C, providerOf as S, LOCAL_CA_PATH as _, loadEnvFiles as a, cacheBackendFor as b, resolveServicePorts as c, constructGlobs as d, derivedContainers as f, COMPOSE_PATH as g, workspace_exports as h, isPortAvailable as i, rewriteUrlsWithPorts as l, usesConstructs as m, createEntryWrapper as n, loadSecretsForApp as o, reconcileWorkspace as p, findAvailablePort as r, prepareEntryCredentials as s, createCredentialsPreload as t, startWorkspaceServices as u, applyPostgres as v, eventsBackendFor as x, resourceName as y };
2807
+
2808
+ //# sourceMappingURL=credentials-CD8ftUS6.mjs.map