@geekmidas/cli 10.0.0-alpha.3 → 10.0.0-alpha.30

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 (255) hide show
  1. package/README.md +25 -48
  2. package/bin/adjacent-tsconfig.mjs +97 -0
  3. package/bin/gkm.mjs +7 -1
  4. package/dist/{HostingerProvider-h3EbEp-Z.cjs → HostingerProvider-BZFGYYVA.cjs} +21 -22
  5. package/dist/HostingerProvider-BZFGYYVA.cjs.map +1 -0
  6. package/dist/{HostingerProvider-Cft7lEYr.mjs → HostingerProvider-GF6NAZAK.mjs} +20 -21
  7. package/dist/HostingerProvider-GF6NAZAK.mjs.map +1 -0
  8. package/dist/{LocalStateProvider-Db9SEjyH.cjs → LocalStateProvider-7HR6SS2t.cjs} +11 -6
  9. package/dist/LocalStateProvider-7HR6SS2t.cjs.map +1 -0
  10. package/dist/{LocalStateProvider-Bn6tS-oL.mjs → LocalStateProvider-D7QyJoLM.mjs} +10 -4
  11. package/dist/LocalStateProvider-D7QyJoLM.mjs.map +1 -0
  12. package/dist/{openapi-c9bpieI5.cjs → OpenApiTsGenerator-D1nhN98m.cjs} +135 -298
  13. package/dist/OpenApiTsGenerator-D1nhN98m.cjs.map +1 -0
  14. package/dist/{openapi-CY9xFkTH.mjs → OpenApiTsGenerator-hDINScnz.mjs} +106 -250
  15. package/dist/OpenApiTsGenerator-hDINScnz.mjs.map +1 -0
  16. package/dist/{Route53Provider-RdpUDJQk.mjs → Route53Provider-Cvj40A8I.mjs} +12 -12
  17. package/dist/Route53Provider-Cvj40A8I.mjs.map +1 -0
  18. package/dist/{Route53Provider-di2fMM5N.cjs → Route53Provider-DVILBsvV.cjs} +20 -21
  19. package/dist/Route53Provider-DVILBsvV.cjs.map +1 -0
  20. package/dist/{SSMStateProvider-BRt2py-A.mjs → SSMStateProvider-B0Hu1MKZ.mjs} +13 -4
  21. package/dist/SSMStateProvider-B0Hu1MKZ.mjs.map +1 -0
  22. package/dist/{SSMStateProvider-DyX8s9HI.cjs → SSMStateProvider-VrQ-yeqd.cjs} +17 -9
  23. package/dist/SSMStateProvider-VrQ-yeqd.cjs.map +1 -0
  24. package/dist/{backup-provisioner-BrsHwYG0.cjs → backup-provisioner-BmhIO_Yr.cjs} +21 -19
  25. package/dist/backup-provisioner-BmhIO_Yr.cjs.map +1 -0
  26. package/dist/{backup-provisioner-Bgj5Vi_L.mjs → backup-provisioner-DIVCbfee.mjs} +10 -7
  27. package/dist/backup-provisioner-DIVCbfee.mjs.map +1 -0
  28. package/dist/{bundler-BY1OmV1e.cjs → bundler-CYF5mWi9.cjs} +10 -28
  29. package/dist/bundler-CYF5mWi9.cjs.map +1 -0
  30. package/dist/{bundler-Be8Zg4Wd.mjs → bundler-D9IPNvDt.mjs} +9 -26
  31. package/dist/bundler-D9IPNvDt.mjs.map +1 -0
  32. package/dist/{config-CzWnfcXM.mjs → config-BH3Ki69F.mjs} +49 -89
  33. package/dist/config-BH3Ki69F.mjs.map +1 -0
  34. package/dist/{config-BholeD-0.cjs → config-URVD4bTj.cjs} +101 -148
  35. package/dist/config-URVD4bTj.cjs.map +1 -0
  36. package/dist/config.cjs +5 -5
  37. package/dist/config.d.cts +25 -17
  38. package/dist/config.d.cts.map +1 -1
  39. package/dist/config.d.mts +25 -17
  40. package/dist/config.d.mts.map +1 -1
  41. package/dist/config.mjs +3 -5
  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 +3 -3
  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-CAlFgYec.mjs → discover-CMxesskG.mjs} +66 -22
  79. package/dist/discover-CMxesskG.mjs.map +1 -0
  80. package/dist/{discover-DTmFeloL.cjs → discover-CroTIm0K.cjs} +117 -66
  81. package/dist/discover-CroTIm0K.cjs.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/github-B2fis34P.cjs +216 -0
  87. package/dist/github-B2fis34P.cjs.map +1 -0
  88. package/dist/github-BrDmlm1x.mjs +216 -0
  89. package/dist/github-BrDmlm1x.mjs.map +1 -0
  90. package/dist/{index-CHB4tSzX.d.cts → index-p89EzILa.d.mts} +4 -23
  91. package/dist/index-p89EzILa.d.mts.map +1 -0
  92. package/dist/{index-BThUzEIp.d.mts → index-rU9u761a.d.cts} +4 -23
  93. package/dist/index-rU9u761a.d.cts.map +1 -0
  94. package/dist/index.cjs +6001 -4452
  95. package/dist/index.cjs.map +1 -1
  96. package/dist/index.d.cts +1 -1
  97. package/dist/index.d.mts +1 -1
  98. package/dist/index.mjs +5895 -4367
  99. package/dist/index.mjs.map +1 -1
  100. package/dist/migrate-BuCx7tIv.cjs +498 -0
  101. package/dist/migrate-BuCx7tIv.cjs.map +1 -0
  102. package/dist/migrate-CBICVMAi.mjs +493 -0
  103. package/dist/migrate-CBICVMAi.mjs.map +1 -0
  104. package/dist/openapi.cjs +197 -8
  105. package/dist/openapi.cjs.map +1 -0
  106. package/dist/openapi.d.cts +47 -14
  107. package/dist/openapi.d.cts.map +1 -1
  108. package/dist/openapi.d.mts +47 -14
  109. package/dist/openapi.d.mts.map +1 -1
  110. package/dist/openapi.mjs +191 -5
  111. package/dist/openapi.mjs.map +1 -0
  112. package/dist/ready-CvP3JVEw.mjs +355 -0
  113. package/dist/ready-CvP3JVEw.mjs.map +1 -0
  114. package/dist/ready-DXtNfk7v.cjs +390 -0
  115. package/dist/ready-DXtNfk7v.cjs.map +1 -0
  116. package/dist/reconcile/public.cjs +4 -4
  117. package/dist/reconcile/public.d.cts +27 -26
  118. package/dist/reconcile/public.d.cts.map +1 -1
  119. package/dist/reconcile/public.d.mts +27 -26
  120. package/dist/reconcile/public.d.mts.map +1 -1
  121. package/dist/reconcile/public.mjs +3 -4
  122. package/dist/reconcile-BxZTV2vl.cjs +332 -0
  123. package/dist/reconcile-BxZTV2vl.cjs.map +1 -0
  124. package/dist/reconcile-yNq6Shau.mjs +279 -0
  125. package/dist/reconcile-yNq6Shau.mjs.map +1 -0
  126. package/dist/rolldown-runtime-BhDjJH2R.mjs +15 -0
  127. package/dist/{chunk-CUT6urMc.cjs → rolldown-runtime-C0BPl7ul.cjs} +23 -10
  128. package/dist/ssm-BKN1Cg4z.mjs +60 -0
  129. package/dist/ssm-BKN1Cg4z.mjs.map +1 -0
  130. package/dist/ssm-FxeSAt35.cjs +60 -0
  131. package/dist/ssm-FxeSAt35.cjs.map +1 -0
  132. package/dist/{storage-Bi24WfoW.mjs → storage-BjTLjDDp.mjs} +22 -22
  133. package/dist/storage-BjTLjDDp.mjs.map +1 -0
  134. package/dist/{storage-BJmzYxt8.cjs → storage-C2JC31V9.cjs} +65 -84
  135. package/dist/storage-C2JC31V9.cjs.map +1 -0
  136. package/dist/types-CzK6hsXP.d.mts +6286 -0
  137. package/dist/types-CzK6hsXP.d.mts.map +1 -0
  138. package/dist/types-DOOAevjG.d.cts +6287 -0
  139. package/dist/types-DOOAevjG.d.cts.map +1 -0
  140. package/dist/vitest.cjs +61 -0
  141. package/dist/vitest.cjs.map +1 -0
  142. package/dist/vitest.d.cts +28 -0
  143. package/dist/vitest.d.cts.map +1 -0
  144. package/dist/vitest.d.mts +28 -0
  145. package/dist/vitest.d.mts.map +1 -0
  146. package/dist/vitest.mjs +61 -0
  147. package/dist/vitest.mjs.map +1 -0
  148. package/dist/workspace/index.cjs +3 -4
  149. package/dist/workspace/index.d.cts +3 -3
  150. package/dist/workspace/index.d.mts +3 -3
  151. package/dist/workspace/index.mjs +2 -3
  152. package/dist/workspace-Bav3TVrP.cjs +6710 -0
  153. package/dist/workspace-Bav3TVrP.cjs.map +1 -0
  154. package/dist/{credentials-DQmbRnr1.cjs → workspace-BhiMkRuA.cjs} +2076 -1963
  155. package/dist/workspace-BhiMkRuA.cjs.map +1 -0
  156. package/dist/{credentials-BZcp3CGx.mjs → workspace-C97QUCIf.mjs} +1957 -1837
  157. package/dist/workspace-C97QUCIf.mjs.map +1 -0
  158. package/dist/workspace-T4IVGSoB.mjs +6538 -0
  159. package/dist/workspace-T4IVGSoB.mjs.map +1 -0
  160. package/package.json +45 -38
  161. package/dist/CachedStateProvider-BLo7zv7S.cjs +0 -3
  162. package/dist/CachedStateProvider-BxoAm-B2.mjs +0 -3
  163. package/dist/CachedStateProvider-CqPdW7CB.cjs +0 -60
  164. package/dist/CachedStateProvider-CqPdW7CB.cjs.map +0 -1
  165. package/dist/CachedStateProvider-DTniehMP.mjs +0 -54
  166. package/dist/CachedStateProvider-DTniehMP.mjs.map +0 -1
  167. package/dist/HostingerProvider-Cft7lEYr.mjs.map +0 -1
  168. package/dist/HostingerProvider-h3EbEp-Z.cjs.map +0 -1
  169. package/dist/LocalStateProvider-Bn6tS-oL.mjs.map +0 -1
  170. package/dist/LocalStateProvider-Db9SEjyH.cjs.map +0 -1
  171. package/dist/Route53Provider-RdpUDJQk.mjs.map +0 -1
  172. package/dist/Route53Provider-di2fMM5N.cjs.map +0 -1
  173. package/dist/SSMStateProvider-BRt2py-A.mjs.map +0 -1
  174. package/dist/SSMStateProvider-DyX8s9HI.cjs.map +0 -1
  175. package/dist/backup-provisioner-Bgj5Vi_L.mjs.map +0 -1
  176. package/dist/backup-provisioner-BrsHwYG0.cjs.map +0 -1
  177. package/dist/bundler-BY1OmV1e.cjs.map +0 -1
  178. package/dist/bundler-Be8Zg4Wd.mjs.map +0 -1
  179. package/dist/chunk-Duj1WY3L.mjs +0 -7
  180. package/dist/config-BholeD-0.cjs.map +0 -1
  181. package/dist/config-CzWnfcXM.mjs.map +0 -1
  182. package/dist/credentials-B5zRHdAl.mjs +0 -141
  183. package/dist/credentials-B5zRHdAl.mjs.map +0 -1
  184. package/dist/credentials-BZcp3CGx.mjs.map +0 -1
  185. package/dist/credentials-BapzjyV5.cjs +0 -195
  186. package/dist/credentials-BapzjyV5.cjs.map +0 -1
  187. package/dist/credentials-DQmbRnr1.cjs.map +0 -1
  188. package/dist/discover-CAlFgYec.mjs.map +0 -1
  189. package/dist/discover-DTmFeloL.cjs.map +0 -1
  190. package/dist/dokploy-api-BS2xnJLZ.mjs +0 -3
  191. package/dist/dokploy-api-BsVH0y_s.cjs +0 -718
  192. package/dist/dokploy-api-BsVH0y_s.cjs.map +0 -1
  193. package/dist/dokploy-api-BuxDRo3l.cjs +0 -3
  194. package/dist/dokploy-api-aTFPz7_4.mjs +0 -706
  195. package/dist/dokploy-api-aTFPz7_4.mjs.map +0 -1
  196. package/dist/emit-CbcRolsa.cjs.map +0 -1
  197. package/dist/emit-CiZS_5Ub.mjs.map +0 -1
  198. package/dist/encryption-C6jaU0hE.cjs +0 -54
  199. package/dist/encryption-C6jaU0hE.cjs.map +0 -1
  200. package/dist/encryption-CHzzeR8a.cjs +0 -4
  201. package/dist/encryption-CQUncu7v.mjs +0 -3
  202. package/dist/encryption-D-abyhZx.mjs +0 -42
  203. package/dist/encryption-D-abyhZx.mjs.map +0 -1
  204. package/dist/fullstack-secrets-C-GGohke.cjs +0 -111
  205. package/dist/fullstack-secrets-C-GGohke.cjs.map +0 -1
  206. package/dist/fullstack-secrets-D1o9-G5l.mjs +0 -87
  207. package/dist/fullstack-secrets-D1o9-G5l.mjs.map +0 -1
  208. package/dist/generator-CNzBw7g5.cjs +0 -289
  209. package/dist/generator-CNzBw7g5.cjs.map +0 -1
  210. package/dist/generator-VE2JrFlY.mjs +0 -253
  211. package/dist/generator-VE2JrFlY.mjs.map +0 -1
  212. package/dist/index-BThUzEIp.d.mts.map +0 -1
  213. package/dist/index-CHB4tSzX.d.cts.map +0 -1
  214. package/dist/openapi-CY9xFkTH.mjs.map +0 -1
  215. package/dist/openapi-c9bpieI5.cjs.map +0 -1
  216. package/dist/openapi-react-query-Byltvm_7.mjs +0 -167
  217. package/dist/openapi-react-query-Byltvm_7.mjs.map +0 -1
  218. package/dist/openapi-react-query-CmJ1xDbn.cjs +0 -173
  219. package/dist/openapi-react-query-CmJ1xDbn.cjs.map +0 -1
  220. package/dist/openapi-react-query.cjs +0 -4
  221. package/dist/openapi-react-query.d.cts +0 -11
  222. package/dist/openapi-react-query.d.cts.map +0 -1
  223. package/dist/openapi-react-query.d.mts +0 -11
  224. package/dist/openapi-react-query.d.mts.map +0 -1
  225. package/dist/openapi-react-query.mjs +0 -4
  226. package/dist/reconcile-CFs8C_1a.mjs +0 -37
  227. package/dist/reconcile-CFs8C_1a.mjs.map +0 -1
  228. package/dist/reconcile-D0pimNiy.cjs +0 -37
  229. package/dist/reconcile-D0pimNiy.cjs.map +0 -1
  230. package/dist/setup-Bc73t0ve.mjs +0 -443
  231. package/dist/setup-Bc73t0ve.mjs.map +0 -1
  232. package/dist/setup-C48C9hto.cjs +0 -473
  233. package/dist/setup-C48C9hto.cjs.map +0 -1
  234. package/dist/setup-DCuudnCl.mjs +0 -11
  235. package/dist/setup-fG_ivuxO.cjs +0 -11
  236. package/dist/storage-BJmzYxt8.cjs.map +0 -1
  237. package/dist/storage-BM9hGpgv.cjs +0 -7
  238. package/dist/storage-Bi24WfoW.mjs.map +0 -1
  239. package/dist/storage-CYlGwWTj.mjs +0 -3
  240. package/dist/sync-CmEuNZsB.mjs +0 -76
  241. package/dist/sync-CmEuNZsB.mjs.map +0 -1
  242. package/dist/sync-D306yOWu.cjs +0 -4
  243. package/dist/sync-SizaChmC.cjs +0 -93
  244. package/dist/sync-SizaChmC.cjs.map +0 -1
  245. package/dist/sync-hOqAWHR6.mjs +0 -3
  246. package/dist/types-C-arnQ1r.d.mts +0 -2184
  247. package/dist/types-C-arnQ1r.d.mts.map +0 -1
  248. package/dist/types-abJQuk7m.d.cts +0 -2184
  249. package/dist/types-abJQuk7m.d.cts.map +0 -1
  250. package/dist/workspace-7FqprxuJ.mjs +0 -4323
  251. package/dist/workspace-7FqprxuJ.mjs.map +0 -1
  252. package/dist/workspace-BO_KWohI.cjs +0 -9
  253. package/dist/workspace-DK4WWJIn.mjs +0 -8
  254. package/dist/workspace-XvqO9jIv.cjs +0 -4472
  255. package/dist/workspace-XvqO9jIv.cjs.map +0 -1
@@ -1,63 +1,257 @@
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";
6
- import { existsSync, readFileSync } from "node:fs";
1
+ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
+ import { a as DEFAULT_CACHE, o as DEFAULT_EVENTS$1, s as DEFAULT_STORAGE, t as discover } from "./discover-CMxesskG.mjs";
3
+ import { l as appKey, o as loadWorkspaceAppInfo, r as getAppNameFromCwd, s as loadWorkspaceConfig } from "./config-BH3Ki69F.mjs";
4
+ import { E as TEST_STAGE, t as allConstructGlobs } from "./workspace-T4IVGSoB.mjs";
5
+ import { n as readStageSecrets, o as toEmbeddableSecrets, r as secretsExist } from "./storage-BjTLjDDp.mjs";
6
+ import { existsSync } from "node:fs";
7
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
7
8
  import { dirname, join, resolve } from "node:path";
8
- import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
9
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
- import { execFile, execSync } from "node:child_process";
9
+ import { execFile } from "node:child_process";
10
+ import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
11
11
  import { createServer } from "node:net";
12
12
  import { config } from "dotenv";
13
- import { parse as parse$1, stringify } from "yaml";
13
+ import { createHash } from "node:crypto";
14
+ import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
14
15
  import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
15
16
  import { Client } from "pg";
17
+ import { stringify } from "yaml";
16
18
  import { promisify } from "node:util";
17
- import { createHash } from "node:crypto";
18
- import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
19
19
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
20
+ //#region src/credentials/index.ts
21
+ const logger = console;
22
+ /**
23
+ * Load environment files
24
+ * @internal Exported for testing
25
+ */
26
+ function loadEnvFiles(envConfig, cwd = process.cwd()) {
27
+ const loaded = [];
28
+ const missing = [];
29
+ const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
30
+ for (const envFile of envFiles) {
31
+ const envPath = resolve(cwd, envFile);
32
+ if (existsSync(envPath)) {
33
+ config({
34
+ path: envPath,
35
+ override: true,
36
+ quiet: true
37
+ });
38
+ loaded.push(envFile);
39
+ } else if (envConfig) missing.push(envFile);
40
+ }
41
+ return {
42
+ loaded,
43
+ missing
44
+ };
45
+ }
46
+ /**
47
+ * Check if a port is available
48
+ * @internal Exported for testing
49
+ */
50
+ async function isPortAvailable(port) {
51
+ return new Promise((resolve) => {
52
+ const server = createServer();
53
+ server.once("error", (err) => {
54
+ if (err.code === "EADDRINUSE") resolve(false);
55
+ else resolve(false);
56
+ });
57
+ server.once("listening", () => {
58
+ server.close();
59
+ resolve(true);
60
+ });
61
+ server.listen(port);
62
+ });
63
+ }
64
+ /**
65
+ * Find an available port starting from the preferred port
66
+ * @internal Exported for testing
67
+ */
68
+ async function findAvailablePort(preferredPort, maxAttempts = 10) {
69
+ for (let i = 0; i < maxAttempts; i++) {
70
+ const port = preferredPort + i;
71
+ if (await isPortAvailable(port)) return port;
72
+ logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
73
+ }
74
+ throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
75
+ }
76
+ const PORT_STATE_PATH = ".gkm/ports.json";
77
+ /**
78
+ * Load saved port state from .gkm/ports.json.
79
+ * @internal Exported for testing
80
+ */
81
+ async function loadPortState(workspaceRoot) {
82
+ try {
83
+ const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
84
+ return JSON.parse(raw);
85
+ } catch {
86
+ return {};
87
+ }
88
+ }
89
+ /**
90
+ * Save port state to .gkm/ports.json.
91
+ * @internal Exported for testing
92
+ */
93
+ async function savePortState(workspaceRoot, ports) {
94
+ const dir = join(workspaceRoot, ".gkm");
95
+ await mkdir(dir, { recursive: true });
96
+ await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
97
+ }
98
+ /**
99
+ * Load and flatten secrets for an app from encrypted storage.
100
+ * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
101
+ * @internal Exported for testing
102
+ */
103
+ async function loadSecretsForApp(secretsRoot, stage, appName) {
104
+ let secrets = {};
105
+ if (secretsExist(stage, secretsRoot)) {
106
+ const stageSecrets = await readStageSecrets(stage, secretsRoot);
107
+ if (stageSecrets) {
108
+ logger.log(`🔐 Loading secrets from stage: ${stage}`);
109
+ secrets = toEmbeddableSecrets(stageSecrets);
110
+ }
111
+ }
112
+ if (Object.keys(secrets).length === 0) return {};
113
+ if (!appName) return secrets;
114
+ const prefix = appName.toUpperCase();
115
+ const mapped = { ...secrets };
116
+ const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
117
+ if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
118
+ return mapped;
119
+ }
120
+ /**
121
+ * Walk up the directory tree to find the root containing .gkm/secrets/.
122
+ * @internal Exported for testing
123
+ */
124
+ function findSecretsRoot(startDir) {
125
+ let dir = startDir;
126
+ while (dir !== "/") {
127
+ if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
128
+ const parent = dirname(dir);
129
+ if (parent === dir) break;
130
+ dir = parent;
131
+ }
132
+ return startDir;
133
+ }
134
+ /**
135
+ * Generate the credentials injection code snippet.
136
+ * This is the common logic used by both entry wrapper and exec preload.
137
+ * @internal
138
+ */
139
+ function generateCredentialsInjection(secretsJsonPath) {
140
+ return `import { existsSync, readFileSync } from 'node:fs';
20
141
 
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];
142
+ // Inject dev secrets via globalThis and process.env
143
+ // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
144
+ // Object.assign on the Credentials export only mutates one module copy.
145
+ const secretsPath = '${secretsJsonPath}';
146
+ if (existsSync(secretsPath)) {
147
+ const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
148
+ globalThis.__gkm_credentials__ = secrets;
149
+ Object.assign(process.env, secrets);
60
150
  }
151
+ `;
152
+ }
153
+ /**
154
+ * Create a preload script that injects secrets into Credentials.
155
+ * Used by `gkm exec` to inject secrets before running any command.
156
+ * @internal Exported for testing
157
+ */
158
+ async function createCredentialsPreload(preloadPath, secretsJsonPath) {
159
+ const content = `/**
160
+ * Credentials preload generated by 'gkm exec'
161
+ * This file is loaded via NODE_OPTIONS="--import <path>"
162
+ */
163
+ ${generateCredentialsInjection(secretsJsonPath)}`;
164
+ await writeFile(preloadPath, content);
165
+ }
166
+ /**
167
+ * Create a wrapper script that injects secrets before importing the entry file.
168
+ * @internal Exported for testing
169
+ */
170
+ async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
171
+ const content = `#!/usr/bin/env node
172
+ /**
173
+ * Entry wrapper generated by 'gkm dev --entry'
174
+ */
175
+ ${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
176
+ ` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
177
+ await import('${entryPath}');
178
+ `;
179
+ await writeFile(wrapperPath, content);
180
+ }
181
+ /**
182
+ * Prepare credentials for dev/exec/test modes.
183
+ * Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
184
+ * injects PORT, dependency URLs, and writes credentials JSON.
185
+ *
186
+ * @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
187
+ * @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
188
+ * @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
189
+ * @internal Exported for testing
190
+ */
191
+ async function prepareEntryCredentials(options) {
192
+ const cwd = options.cwd ?? process.cwd();
193
+ let workspaceAppPort;
194
+ let secretsRoot = cwd;
195
+ let appName;
196
+ let appInfo;
197
+ let workspace;
198
+ try {
199
+ appInfo = await loadWorkspaceAppInfo(cwd);
200
+ workspace = appInfo.workspace;
201
+ workspaceAppPort = appInfo.app.port;
202
+ secretsRoot = appInfo.workspaceRoot;
203
+ appName = appInfo.appName;
204
+ } catch {
205
+ workspace = await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
206
+ secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
207
+ appName = getAppNameFromCwd(cwd) ?? void 0;
208
+ }
209
+ const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
210
+ const stage = options.stage ?? workspace?.stages.local;
211
+ const credentials = stage ? await loadSecretsForApp(secretsRoot, stage, appName) : {};
212
+ credentials.PORT = String(resolvedPort);
213
+ const declaredKeys = [];
214
+ if (workspace) {
215
+ const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
216
+ const reconciled = await reconcileWorkspace(workspace, {
217
+ stage: options.reconcileStage ?? workspace.stages.local,
218
+ start: options.startDocker ?? false
219
+ });
220
+ Object.assign(credentials, reconciled.env);
221
+ declaredKeys.push(...Object.keys(reconciled.env));
222
+ if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
223
+ }
224
+ const secretsDir = join(secretsRoot, ".gkm");
225
+ await mkdir(secretsDir, { recursive: true });
226
+ const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
227
+ const secretsJsonPath = join(secretsDir, secretsFileName);
228
+ await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
229
+ return {
230
+ credentials,
231
+ resolvedPort,
232
+ secretsJsonPath,
233
+ appName,
234
+ secretsRoot,
235
+ appInfo,
236
+ declaredKeys
237
+ };
238
+ }
239
+ //#endregion
240
+ //#region src/workspace/backends.ts
241
+ /**
242
+ * Which backend each declared kind resolves to — decided by where the project
243
+ * deploys, and by nothing else.
244
+ *
245
+ * These used to be read out of a `services:` block, where one key answered two
246
+ * questions: *does this exist* and *what backs it*. The first is the manifest's
247
+ * — a declared `Cache` is why there is a cache — and the second is the deploy
248
+ * target's, because the right cache for a Lambda is not the right cache for a
249
+ * box already running Postgres. With both answered elsewhere there was nothing
250
+ * left for the block to say, so it is gone and these read the target.
251
+ *
252
+ * Mail has no answer here at all: every provider speaks SMTP, so the provider
253
+ * is whichever one the stage's mail URL points at.
254
+ */
61
255
  /**
62
256
  * Which family of defaults a workspace's deploy target belongs to.
63
257
  *
@@ -76,54 +270,199 @@ function providerOf(workspace) {
76
270
  return target === "dokploy" || target === "server" ? "server" : "aws";
77
271
  }
78
272
  /**
79
- * Whether a string names a backend, as opposed to being one of the other things
80
- * a `services:` value can be.
273
+ * The cache backend a target implies.
81
274
  *
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.
275
+ * The same answer locally and deployed: a local `gkm dev` for an AWS app still
276
+ * uses the Upstash protocol, because a backend that differed between the two
277
+ * would be worse than a slower one.
85
278
  */
86
- function isBackend(value, backends) {
87
- return typeof value === "string" && backends.includes(value);
279
+ function cacheBackendFor(on) {
280
+ return DEFAULT_CACHE[on];
281
+ }
282
+ /** Where a declared bucket lives on a target. */
283
+ function storageBackendFor(on) {
284
+ return DEFAULT_STORAGE[on];
88
285
  }
89
-
90
- //#endregion
91
- //#region src/reconcile/caddyfile.ts
92
286
  /**
93
- * The kinds the edge serves.
94
- *
95
- * Everything that owns a *public address*. Deployed, each of these has a real
96
- * hostname and a certificate, so this is what makes the local shape the same
97
- * one rather than a near-enough one — and the application is indifferent
98
- * either way, because it reads whichever address was injected and never
99
- * composes one.
287
+ * Which broker carries declared queues and topics on a target.
100
288
  *
101
- * Adding a kind here is the whole of adding it to the edge.
289
+ * Only consulted when something declares one; a project with no `Topic` or
290
+ * `Queue` has no events at all, whatever the target.
102
291
  */
103
- const EDGE_KINDS = {
104
- "file-server": true,
105
- "rest-api": true,
106
- site: true
107
- };
292
+ function eventsBackendFor(on) {
293
+ return DEFAULT_EVENTS$1[on];
294
+ }
295
+ //#endregion
296
+ //#region src/reconcile/containers.ts
108
297
  /**
109
- * How the edge reaches the host, from inside its container.
298
+ * What gkm knows about each container it can derive.
110
299
  *
111
- * A surface and a site are served by processes `gkm dev` starts on the host,
112
- * not by containers — so the edge has to leave Docker's network to reach them.
113
- * Docker Desktop resolves this name already; on Linux the compose definition
114
- * maps it to the host gateway.
300
+ * Everything provider-specific about the local target lives here: the image, the
301
+ * ports the image listens on, and whether its data is worth keeping. The plan
302
+ * says *which* containers exist and never says any of this, which is the split
303
+ * the design asks for — neutral facts from the declaration, provider detail
304
+ * from here and from config.
305
+ *
306
+ * Shared by allocation and by the compose writer so the two cannot disagree
307
+ * about how many ports a container needs.
115
308
  */
116
- const HOST_GATEWAY = "host.docker.internal";
117
309
  /**
118
- * The suffix every local host shares.
310
+ * The Postgres image for a major version.
119
311
  *
120
- * `.localhost` rather than a made-up TLD because it resolves to loopback
121
- * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
122
- * subdomains of it, and Caddy issues internal certificates for it by default.
312
+ * One place that knows the tag shape, so a declared version reaches the
313
+ * container without anyone restating `-alpine` beside it.
123
314
  */
124
- const LOCAL_TLD = "localhost";
315
+ function postgresImage(version) {
316
+ return `postgres:${version}-alpine`;
317
+ }
125
318
  /**
126
- * The sites a plan implies, one per file server.
319
+ * The default image for each container.
320
+ *
321
+ * Pinned to a major rather than `latest` where the image publishes one, so a
322
+ * rebuild does not silently move a developer's Postgres a version. Overridable
323
+ * per project: a project needing `postgis/postgis` says so in config.
324
+ */
325
+ const DEFAULT_IMAGES = {
326
+ postgres: postgresImage(DEFAULT_POSTGRES_VERSION),
327
+ minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
328
+ mailpit: "axllent/mailpit:latest",
329
+ redis: "redis:8-alpine",
330
+ "redis-http": "hiett/serverless-redis-http:latest",
331
+ rabbitmq: "rabbitmq:4-management-alpine",
332
+ localstack: "floci/floci:latest",
333
+ caddy: "caddy:2-alpine"
334
+ };
335
+ /**
336
+ * The ports each container listens on.
337
+ *
338
+ * The first is always the one an app connects to; the rest are consoles and
339
+ * management UIs, published because a bucket or an inbox you can look at is most
340
+ * of why these are real containers rather than stubs.
341
+ */
342
+ const PORTS = {
343
+ postgres: [{
344
+ key: "postgres",
345
+ inside: 5432,
346
+ label: "postgres"
347
+ }],
348
+ minio: [{
349
+ key: "minio",
350
+ inside: 9e3,
351
+ label: "minio api"
352
+ }, {
353
+ key: "minio-console",
354
+ inside: 9001,
355
+ label: "minio console"
356
+ }],
357
+ mailpit: [{
358
+ key: "mailpit",
359
+ inside: 1025,
360
+ label: "smtp"
361
+ }, {
362
+ key: "mailpit-web",
363
+ inside: 8025,
364
+ label: "mailpit inbox"
365
+ }],
366
+ redis: [{
367
+ key: "redis",
368
+ inside: 6379,
369
+ label: "redis"
370
+ }],
371
+ "redis-http": [{
372
+ key: "redis-http",
373
+ inside: 80,
374
+ label: "cache"
375
+ }],
376
+ rabbitmq: [{
377
+ key: "rabbitmq",
378
+ inside: 5672,
379
+ label: "amqp"
380
+ }, {
381
+ key: "rabbitmq-management",
382
+ inside: 15672,
383
+ label: "rabbitmq console"
384
+ }],
385
+ localstack: [{
386
+ key: "localstack",
387
+ inside: 4566,
388
+ label: "localstack"
389
+ }],
390
+ caddy: [{
391
+ key: "caddy",
392
+ inside: 443,
393
+ label: "https edge"
394
+ }]
395
+ };
396
+ /** The ports a container needs published. Empty for one gkm does not know. */
397
+ function portsOf(container) {
398
+ return PORTS[container] ?? [];
399
+ }
400
+ /** The key a container's primary port is allocated under. */
401
+ function primaryPortKey(container) {
402
+ return portsOf(container)[0]?.key ?? container;
403
+ }
404
+ /**
405
+ * Every port key a set of containers needs, in a stable order.
406
+ *
407
+ * What allocation is handed: it assigns ports to names and knows nothing about
408
+ * what listens on them.
409
+ */
410
+ function portKeys(containers) {
411
+ return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
412
+ }
413
+ /**
414
+ * The named volume a container's data lives in.
415
+ *
416
+ * Mailpit deliberately has none: local mail is disposable, and an inbox that
417
+ * survives days of dev is noise rather than state.
418
+ */
419
+ function volumeOf(container) {
420
+ return {
421
+ postgres: "postgres-data",
422
+ minio: "minio-data",
423
+ redis: "redis-data",
424
+ rabbitmq: "rabbitmq-data",
425
+ localstack: "localstack-data",
426
+ caddy: "caddy-data"
427
+ }[container];
428
+ }
429
+ //#endregion
430
+ //#region src/reconcile/caddyfile.ts
431
+ /**
432
+ * The kinds the edge serves.
433
+ *
434
+ * Everything that owns a *public address*. Deployed, each of these has a real
435
+ * hostname and a certificate, so this is what makes the local shape the same
436
+ * one rather than a near-enough one — and the application is indifferent
437
+ * either way, because it reads whichever address was injected and never
438
+ * composes one.
439
+ *
440
+ * Adding a kind here is the whole of adding it to the edge.
441
+ */
442
+ const EDGE_KINDS = {
443
+ "file-server": true,
444
+ "rest-api": true,
445
+ site: true
446
+ };
447
+ /**
448
+ * How the edge reaches the host, from inside its container.
449
+ *
450
+ * A surface and a site are served by processes `gkm dev` starts on the host,
451
+ * not by containers — so the edge has to leave Docker's network to reach them.
452
+ * Docker Desktop resolves this name already; on Linux the compose definition
453
+ * maps it to the host gateway.
454
+ */
455
+ const HOST_GATEWAY = "host.docker.internal";
456
+ /**
457
+ * The suffix every local host shares.
458
+ *
459
+ * `.localhost` rather than a made-up TLD because it resolves to loopback
460
+ * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
461
+ * subdomains of it, and Caddy issues internal certificates for it by default.
462
+ */
463
+ const LOCAL_TLD = "localhost";
464
+ /**
465
+ * The sites a plan implies, one per file server.
127
466
  *
128
467
  * The leading label is the *server's* stage-scoped name rather than the
129
468
  * bucket's, which is the difference that matters: two servers over one bucket
@@ -163,7 +502,7 @@ function portOf(address) {
163
502
  const { port, protocol } = new URL(address);
164
503
  return port || (protocol === "https:" ? "443" : "80");
165
504
  } catch {
166
- return void 0;
505
+ return;
167
506
  }
168
507
  }
169
508
  /**
@@ -176,11 +515,16 @@ function portOf(address) {
176
515
  */
177
516
  function hostFor(resource, project) {
178
517
  return [
179
- resource.name,
518
+ resource.root && project ? stageOf(resource) : resource.name,
180
519
  project,
181
520
  LOCAL_TLD
182
521
  ].filter(Boolean).join(".");
183
522
  }
523
+ /** The stage a resource's name carries, or nothing for the local stage's. */
524
+ function stageOf(resource) {
525
+ const base = resource.id.toLowerCase();
526
+ return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
527
+ }
184
528
  /**
185
529
  * The static root config, which only ever imports.
186
530
  *
@@ -246,757 +590,274 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
246
590
  # root and point Node at it, so nothing has to be installed to use these URLs
247
591
  # from code; a browser needs the root trusted once.
248
592
  `;
249
-
250
593
  //#endregion
251
- //#region src/reconcile/clients.ts
252
- /** The credential local containers are brought up with. */
253
- const LOCAL_USER$2 = "geekmidas";
254
- /**
255
- * A Postgres client that opens one connection per database it is asked about.
256
- *
257
- * `CREATE DATABASE` cannot run from a connection to the database being created,
258
- * and a schema must be created from inside its own database — so the database is
259
- * part of each query rather than fixed when the client is built.
260
- */
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
-
594
+ //#region src/workspace/rootSite.ts
595
+ /**
596
+ * Which site the base domain points at — deployed and locally alike.
597
+ *
598
+ * Three rules, in order, and the last one is the point:
599
+ *
600
+ * 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
601
+ * 2. **Named `web`** — the convention wins, because people already rely on it.
602
+ * 3. **Declared `root: true`** — the site says so itself.
603
+ *
604
+ * Anything else throws. It used to take the *first* site it iterated, and that
605
+ * order came from the manifest, which is built in glob traversal order — so two
606
+ * sites with neither named `web` meant renaming a file could move the root
607
+ * domain, silently.
608
+ *
609
+ * One rule for both targets: deployed the root site gets the base domain, and
610
+ * locally it gets the project's bare host behind the edge. Before this was
611
+ * shared, the local edge put every site on a subdomain, so what a developer
612
+ * browsed was not the shape production served.
613
+ *
614
+ * @returns the root site's name, or `undefined` when there are no sites
615
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
616
+ */
617
+ function rootSite(sites) {
618
+ if (sites.length === 0) return void 0;
619
+ if (sites.length === 1) return sites[0].name;
620
+ if (sites.some(({ name }) => name === "web")) return "web";
621
+ const declared = sites.filter(({ root }) => root === true);
622
+ if (declared.length === 1) return declared[0].name;
623
+ throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
624
+ }
625
+ /** Several sites could hold the base domain, and none of them says it does. */
626
+ var AmbiguousRootSite = class extends Error {
627
+ sites;
628
+ tooMany;
629
+ constructor(sites, tooMany = false) {
630
+ super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
631
+ this.sites = sites;
632
+ this.tooMany = tooMany;
633
+ this.name = "AmbiguousRootSite";
634
+ }
635
+ };
319
636
  //#endregion
320
- //#region src/reconcile/containers.ts
637
+ //#region src/reconcile/plan.ts
321
638
  /**
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.
639
+ * The reconcile plan — what a manifest wants to exist, for one stage.
329
640
  *
330
- * Shared by allocation and by the compose writer so the two cannot disagree
331
- * about how many ports a container needs.
332
- */
333
- /**
334
- * The Postgres image for a major version.
641
+ * Pure. Turning the plan into running containers and provisioned resources is
642
+ * the applier's job; deciding *what* should exist is this, so the decisions can
643
+ * be asserted as data rather than by starting Docker.
335
644
  *
336
- * One place that knows the tag shape, so a declared version reaches the
337
- * container without anyone restating `-alpine` beside it.
645
+ * One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
646
+ * reconciles `test`; they differ only in what the resources are called, never in
647
+ * what infrastructure exists. Roles are cluster-scoped in Postgres while grants
648
+ * are per-object, so one container and one role pair serve both.
338
649
  */
339
- function postgresImage(version) {
340
- return `postgres:${version}-alpine`;
341
- }
342
650
  /**
343
- * The default image for each container.
651
+ * Which container serves a kind.
344
652
  *
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.
653
+ * Derived kinds map to the same container as their parent — a schema tenant
654
+ * lives in its parent's Postgres — so the set is deduplicated rather than
655
+ * counted.
348
656
  */
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"
657
+ const CONTAINERS = {
658
+ database: "postgres",
659
+ "database-reader": "postgres",
660
+ "database-schema": "postgres",
661
+ objects: "minio",
662
+ "file-server": "caddy",
663
+ email: "mailpit"
358
664
  };
359
665
  /**
360
- * The ports each container listens on.
666
+ * The container a cache backend needs locally.
361
667
  *
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.
365
- */
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
- }]
668
+ * Each one matches the protocol its deployed form speaks, which is the property
669
+ * worth having: a cache that behaves differently in the two places is worse than
670
+ * a slower one.
671
+ *
672
+ * `db` is deliberately absent, the same way `pgboss` is absent from
673
+ * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
674
+ * declares, so mapping it here would start a container for a project that needs
675
+ * none.
676
+ */
677
+ const CACHE_CONTAINERS = {
678
+ upstash: "redis-http",
679
+ elasticache: "redis"
419
680
  };
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
681
  /**
429
- * Every port key a set of containers needs, in a stable order.
682
+ * Kinds that resolve a value without anything running.
430
683
  *
431
- * What allocation is handed: it assigns ports to names and knows nothing about
432
- * what listens on them.
684
+ * A secret is the case: it has no address, so there is nothing to start and
685
+ * nothing to connect to — but it still appears in the plan, because the target
686
+ * still has to resolve it.
433
687
  */
434
- function portKeys(containers) {
435
- return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
436
- }
688
+ const CONTAINERLESS = {
689
+ secret: true,
690
+ credential: true,
691
+ "rest-api": true,
692
+ site: true
693
+ };
437
694
  /**
438
- * The named volume a container's data lives in.
695
+ * Containers that cannot run alone.
439
696
  *
440
- * Mailpit deliberately has none: local mail is disposable, and an inbox that
441
- * survives days of dev is noise rather than state.
697
+ * The one case is the cache: `serverless-redis-http` is a proxy and needs the
698
+ * Redis it proxies. Kept here rather than in the compose definition so the plan
699
+ * -- which is what decides what must be running -- still names everything.
442
700
  */
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];
701
+ const REQUIRES = { "redis-http": ["redis"] };
702
+ /**
703
+ * The role each kind's one key plays, where it is not a URL.
704
+ *
705
+ * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
706
+ * construct declares, this is what the local target resolves, and both derive
707
+ * the key from the same `provideKey` so they cannot disagree.
708
+ */
709
+ const ROLES = {
710
+ queue: "publisherConnectionString",
711
+ topic: "publisherConnectionString",
712
+ credential: "credential"
713
+ };
714
+ /** The kinds whose container is the events backend's rather than their own. */
715
+ const EVENT_KINDS = {
716
+ queue: true,
717
+ topic: true
718
+ };
719
+ /**
720
+ * Which kinds provision something *inside* their container.
721
+ *
722
+ * A database creates a database, a bucket creates a bucket; mail creates
723
+ * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
724
+ * to name. Both still appear in the plan, because both still resolve a URL; the
725
+ * flag is the difference between "must be running" and "must be created".
726
+ */
727
+ const PROVISIONS = {
728
+ database: true,
729
+ "database-reader": true,
730
+ "database-schema": true,
731
+ objects: true,
732
+ cache: true,
733
+ "file-server": true
734
+ };
735
+ /**
736
+ * The container each events backend needs, where it needs one of its own.
737
+ *
738
+ * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
739
+ * schema tenant in a database the manifest already declares, so mapping it here
740
+ * would start a Postgres for a project that declared no database at all.
741
+ *
742
+ * This one comes from config rather than from a declaration because there are
743
+ * no `topic` or `queue` kinds yet. When they land the mapping moves to
744
+ * {@link CONTAINERS} keyed by kind, and this constant goes with them.
745
+ */
746
+ const EVENT_CONTAINERS = {
747
+ sns: "localstack",
748
+ rabbitmq: "rabbitmq"
749
+ };
750
+ /** The backend a project gets when it declares queues or topics and says nothing. */
751
+ const DEFAULT_EVENTS = "pgboss";
752
+ /**
753
+ * How a resource of each kind separates its stage suffix.
754
+ *
755
+ * Postgres identifiers are snake_case and a hyphen would need quoting
756
+ * everywhere; buckets and queues are kebab and disallow underscores in some
757
+ * providers. The suffix is the same word either way.
758
+ */
759
+ const SEPARATORS = {
760
+ database: "_",
761
+ "database-reader": "_",
762
+ "database-schema": "_",
763
+ objects: "-"
764
+ };
765
+ /**
766
+ * The stage-scoped name for a resource.
767
+ *
768
+ * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
769
+ * developer's psql history, saved connections, and muscle memory survive a
770
+ * change of stage name. Every other stage is suffixed.
771
+ */
772
+ function resourceName(id, kind, stage, localStage) {
773
+ const base = id.toLowerCase();
774
+ if (stage === localStage) return base;
775
+ return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
453
776
  }
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
777
  /**
460
- * The token the local cache accepts.
778
+ * The container a kind needs, or `undefined` if it needs none.
461
779
  *
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.
780
+ * Queues and topics have no container of their own — they live in whichever
781
+ * broker the project selected, which is why the backend is an argument here
782
+ * rather than a second pass that adds containers after the fact.
465
783
  */
466
- const LOCAL_TOKEN$1 = "geekmidas";
784
+ function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
785
+ if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
786
+ if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
787
+ if (kind === "cache") {
788
+ if (derived) return CONTAINERS.database;
789
+ return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
790
+ }
791
+ return CONTAINERS[kind];
792
+ }
467
793
  /**
468
- * Build the compose document for a plan.
794
+ * Build the plan for a manifest and stage.
469
795
  *
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.
796
+ * `order` is the caller's provisioning order — parents before children — which
797
+ * `provisionOrder` produces from the manifest. Passing it in keeps this
798
+ * function free of the traversal and makes the ordering assertable on its own.
473
799
  */
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] = {};
484
- }
485
- return {
486
- name: options.project,
487
- services,
488
- ...Object.keys(volumes).length ? { volumes } : {}
489
- };
490
- }
491
- /** Render a compose document as the file it becomes. */
492
- function toYaml(file) {
493
- return `# Generated by gkm from the construct manifest — do not edit.
494
- # Containers are derived from what the app declares; images and anything no
495
- # construct implies are yours, in gkm.config.ts.
496
- #
497
- # Use "gkm dev" or "gkm test" to start these. Running "docker compose up"
498
- # directly will not inject secrets.
499
- ${stringify(file, { lineWidth: 0 })}`;
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;
511
- }
512
- };
513
- /** One container's definition, published on the port it was assigned. */
514
- function define(container, image, published) {
515
- switch (container) {
516
- case "postgres": return {
517
- image,
518
- restart: "unless-stopped",
519
- ports: published,
520
- environment: {
521
- POSTGRES_USER: LOCAL_USER$1,
522
- POSTGRES_PASSWORD: LOCAL_USER$1,
523
- POSTGRES_DB: LOCAL_USER$1
524
- },
525
- volumes: ["postgres-data:/var/lib/postgresql"],
526
- healthcheck: {
527
- test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER$1}`],
528
- interval: "5s",
529
- timeout: "3s",
530
- retries: 10,
531
- start_period: "10s"
532
- }
533
- };
534
- case "minio": return {
535
- image,
536
- restart: "unless-stopped",
537
- ports: published,
538
- environment: {
539
- MINIO_ROOT_USER: LOCAL_USER$1,
540
- MINIO_ROOT_PASSWORD: LOCAL_USER$1
541
- },
542
- command: "server --console-address \":9001\" /data",
543
- volumes: ["minio-data:/data"],
544
- healthcheck: {
545
- test: [
546
- "CMD",
547
- "mc",
548
- "ready",
549
- "local"
550
- ],
551
- interval: "10s",
552
- timeout: "5s",
553
- retries: 5,
554
- start_period: "10s"
555
- }
556
- };
557
- case "caddy": return {
558
- image,
559
- restart: "unless-stopped",
560
- ports: published,
561
- volumes: [
562
- "./Caddyfile:/etc/caddy/Caddyfile:ro",
563
- "./caddy-sites:/etc/caddy/sites:ro",
564
- "caddy-data:/data"
565
- ],
566
- extra_hosts: ["host.docker.internal:host-gateway"],
567
- healthcheck: {
568
- test: [
569
- "CMD",
570
- "caddy",
571
- "version"
572
- ],
573
- interval: "10s",
574
- timeout: "5s",
575
- retries: 5
576
- }
577
- };
578
- case "mailpit": return {
579
- image,
580
- restart: "unless-stopped",
581
- ports: published,
582
- environment: {
583
- MP_SMTP_AUTH_ACCEPT_ANY: "1",
584
- MP_SMTP_AUTH_ALLOW_INSECURE: "1"
585
- },
586
- healthcheck: {
587
- test: [
588
- "CMD",
589
- "wget",
590
- "-q",
591
- "--spider",
592
- "http://localhost:8025"
593
- ],
594
- interval: "10s",
595
- timeout: "5s",
596
- retries: 5
597
- }
598
- };
599
- case "redis": return {
600
- image,
601
- restart: "unless-stopped",
602
- ports: published,
603
- volumes: ["redis-data:/data"],
604
- healthcheck: {
605
- test: [
606
- "CMD",
607
- "redis-cli",
608
- "ping"
609
- ],
610
- interval: "5s",
611
- timeout: "3s",
612
- retries: 5
613
- }
614
- };
615
- case "redis-http": return {
616
- image,
617
- restart: "unless-stopped",
618
- ports: published,
619
- environment: {
620
- SRH_MODE: "env",
621
- SRH_TOKEN: LOCAL_TOKEN$1,
622
- SRH_CONNECTION_STRING: "redis://redis:6379"
623
- },
624
- depends_on: ["redis"],
625
- healthcheck: {
626
- test: [
627
- "CMD",
628
- "wget",
629
- "-q",
630
- "-O",
631
- "/dev/null",
632
- "http://127.0.0.1:80/"
633
- ],
634
- interval: "5s",
635
- timeout: "3s",
636
- retries: 10
637
- }
638
- };
639
- case "rabbitmq": return {
640
- image,
641
- restart: "unless-stopped",
642
- ports: published,
643
- environment: {
644
- RABBITMQ_DEFAULT_USER: LOCAL_USER$1,
645
- RABBITMQ_DEFAULT_PASS: LOCAL_USER$1
646
- },
647
- volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
648
- healthcheck: {
649
- test: [
650
- "CMD",
651
- "rabbitmq-diagnostics",
652
- "-q",
653
- "ping"
654
- ],
655
- interval: "10s",
656
- timeout: "5s",
657
- retries: 5
658
- }
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
- /**
687
- * The published port mappings for a container.
688
- *
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.
692
- */
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;
708
- }
709
- };
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
- }
793
- }
794
- };
795
-
796
- //#endregion
797
- //#region src/reconcile/plan.ts
798
- /** The stage whose resources carry no suffix. */
799
- const DEFAULT_STAGE = "development";
800
- /**
801
- * Which container serves a kind.
802
- *
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.
806
- */
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
- /**
816
- * The container a cache backend needs locally.
817
- *
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.
821
- *
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.
826
- */
827
- const CACHE_CONTAINERS = {
828
- upstash: "redis-http",
829
- elasticache: "redis"
830
- };
831
- /**
832
- * Kinds that resolve a value without anything running.
833
- *
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.
837
- */
838
- const CONTAINERLESS = {
839
- secret: true,
840
- credential: true,
841
- "rest-api": true,
842
- site: true
843
- };
844
- /**
845
- * Containers that cannot run alone.
846
- *
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.
850
- */
851
- const REQUIRES = { "redis-http": ["redis"] };
852
- /**
853
- * The role each kind's one key plays, where it is not a URL.
854
- *
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.
858
- */
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
- };
869
- /**
870
- * Which kinds provision something *inside* their container.
871
- *
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".
876
- */
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
- };
885
- /**
886
- * The container each events backend needs, where it needs one of its own.
887
- *
888
- * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
889
- * schema tenant in a database the manifest already declares, so mapping it here
890
- * would start a Postgres for a project that declared no database at all.
891
- *
892
- * This one comes from config rather than from a declaration because there are
893
- * no `topic` or `queue` kinds yet. When they land the mapping moves to
894
- * {@link CONTAINERS} keyed by kind, and this constant goes with them.
895
- */
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";
902
- /**
903
- * How a resource of each kind separates its stage suffix.
904
- *
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.
908
- */
909
- const SEPARATORS = {
910
- database: "_",
911
- "database-reader": "_",
912
- "database-schema": "_",
913
- objects: "-"
914
- };
915
- /**
916
- * The stage-scoped name for a resource.
917
- *
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.
921
- */
922
- function resourceName(id, kind, stage) {
923
- const base = id.toLowerCase();
924
- if (stage === DEFAULT_STAGE) return base;
925
- return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
926
- }
927
- /**
928
- * The container a kind needs, or `undefined` if it needs none.
929
- *
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.
933
- */
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];
942
- }
943
- /**
944
- * Build the plan for a manifest and stage.
945
- *
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.
949
- */
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
- }
800
+ function planFor(manifest, stage, order, options = {}) {
801
+ const containers = /* @__PURE__ */ new Set();
802
+ const resources = [];
803
+ const events = options.events ?? "pgboss";
804
+ const cache = options.cache ?? DEFAULT_CACHE.aws;
805
+ for (const id of order) {
806
+ const declaration = manifest[id];
807
+ if (!declaration) continue;
808
+ const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
809
+ if (!container && !CONTAINERLESS[declaration.kind]) continue;
810
+ if (container) {
811
+ containers.add(container);
812
+ for (const required of REQUIRES[container] ?? []) containers.add(required);
813
+ }
814
+ resources.push({
815
+ id,
816
+ kind: declaration.kind,
817
+ ...container ? { container } : {},
818
+ name: resourceName(id, declaration.kind, stage, options.localStage),
819
+ envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
820
+ provisions: PROVISIONS[declaration.kind] === true,
821
+ ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
822
+ ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
823
+ ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
824
+ ..."of" in declaration ? { of: declaration.of } : {},
825
+ ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
826
+ ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
827
+ ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
828
+ ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
829
+ });
830
+ }
831
+ if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
832
+ for (const extra of options.extraContainers ?? []) containers.add(extra);
833
+ 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));
834
+ if (cache === "db") {
835
+ const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
836
+ if (placeless.length > 0) {
837
+ const databases = resources.filter((r) => r.kind === "database");
838
+ const [only] = databases;
839
+ if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
840
+ if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
841
+ for (const resource of placeless) resource.of = only.id;
842
+ }
993
843
  }
844
+ const sites = resources.filter((r) => r.kind === "site");
845
+ const root = rootSite(sites.map((r) => {
846
+ const declared = manifest[r.id];
847
+ return {
848
+ name: appKey(r.id),
849
+ ...declared?.kind === "site" && declared.root ? { root: true } : {}
850
+ };
851
+ }));
852
+ for (const site of sites) if (appKey(site.id) === root) site.root = true;
853
+ const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
994
854
  return {
995
855
  stage,
996
856
  events,
997
857
  cache,
998
858
  containers: [...containers],
999
- resources
859
+ resources,
860
+ ...workerBroker ? { workerBroker: true } : {}
1000
861
  };
1001
862
  }
1002
863
  /**
@@ -1007,16 +868,18 @@ function planFor(manifest, stage, order, options = {}) {
1007
868
  * broker.
1008
869
  */
1009
870
  var CacheNeedsDatabase = class extends Error {
871
+ ids;
1010
872
  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(", ")}.`);
873
+ 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(", ")}.`);
1012
874
  this.ids = ids;
1013
875
  this.name = "CacheNeedsDatabase";
1014
876
  }
1015
877
  };
1016
878
  /**
1017
- * `services.cache: 'db'` in an app that declares more than one database.
879
+ * A `'db'` cache — a server target's default — in an app that declares more
880
+ * than one database.
1018
881
  *
1019
- * The config says *a* database and the graph offers several, so there is no
882
+ * The target says *a* database and the graph offers several, so there is no
1020
883
  * answer to give — and the wrong kind of failure would be to pick one, since a
1021
884
  * cache silently landing in the wrong database is a bug that surfaces as
1022
885
  * missing rows much later. The fix is a stronger statement in application code:
@@ -1024,24 +887,39 @@ var CacheNeedsDatabase = class extends Error {
1024
887
  * application rather than a deployment choice.
1025
888
  */
1026
889
  var CacheIsAmbiguous = class extends Error {
890
+ ids;
891
+ databases;
1027
892
  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(", ")}.`);
893
+ 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(", ")}.`);
1029
894
  this.ids = ids;
1030
895
  this.databases = databases;
1031
896
  this.name = "CacheIsAmbiguous";
1032
897
  }
1033
898
  };
1034
899
  var PgBossNeedsDatabase = class extends Error {
900
+ ids;
1035
901
  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(", ")}.`);
902
+ 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(", ")}.`);
1037
903
  this.ids = ids;
1038
904
  this.name = "PgBossNeedsDatabase";
1039
905
  }
1040
906
  };
1041
-
1042
907
  //#endregion
1043
908
  //#region src/reconcile/env.ts
1044
909
  /**
910
+ * The URLs a reconciled stage injects.
911
+ *
912
+ * One `<NAME>_URL` per construct, and the protocol picks the driver. This is the
913
+ * seam between the target that writes a URL and the client that parses it, so
914
+ * every component of it is read off the resource — never inherited from ambient
915
+ * environment. A bucket can live in a different region than the function reading
916
+ * it, and `AWS_REGION` in a Lambda is the *function's* region, so a URL that
917
+ * omits `?region=` breaks cross-region silently at runtime.
918
+ *
919
+ * Locally that self-containment is what lets the port be whatever was free: the
920
+ * app reads `ORDERS_URL` and never sees a host or a port.
921
+ */
922
+ /**
1045
923
  * The credential local containers are brought up with.
1046
924
  *
1047
925
  * The design's `roles: false` case — both URLs fall back to the cluster's master
@@ -1049,7 +927,7 @@ var PgBossNeedsDatabase = class extends Error {
1049
927
  * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
1050
928
  * and the owner URL stops being reachable from any manifest edge.
1051
929
  */
1052
- const LOCAL_USER = "geekmidas";
930
+ const LOCAL_USER$2 = "geekmidas";
1053
931
  /** The host containers are published on. */
1054
932
  const LOCAL_HOST = "localhost";
1055
933
  /** The region local buckets claim, so the client has one to sign with. */
@@ -1064,12 +942,14 @@ const LOCAL_REGION = "us-east-1";
1064
942
  * rather than written into it.
1065
943
  */
1066
944
  const STORAGE_CREDENTIALS = {
1067
- AWS_ACCESS_KEY_ID: LOCAL_USER,
1068
- AWS_SECRET_ACCESS_KEY: LOCAL_USER,
945
+ AWS_ACCESS_KEY_ID: LOCAL_USER$2,
946
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER$2,
1069
947
  AWS_REGION: LOCAL_REGION
1070
948
  };
1071
949
  /** The token the local cache proxy accepts. Matches the compose definition. */
1072
- const LOCAL_TOKEN = "geekmidas";
950
+ const LOCAL_TOKEN$1 = "geekmidas";
951
+ /** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
952
+ const MAILPIT_INBOX_PORT = "mailpit-web";
1073
953
  /** The schema pg-boss keeps its tables in, inside the declared database. */
1074
954
  const PGBOSS_SCHEMA = "pgboss";
1075
955
  /** The exchange every local topic and queue shares on RabbitMQ. */
@@ -1095,7 +975,11 @@ function envFor(plan, options) {
1095
975
  const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
1096
976
  if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
1097
977
  }
1098
- if (resource.kind === "email" && url) env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
978
+ if (resource.kind === "email" && url) {
979
+ env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
980
+ const inbox = options.ports[MAILPIT_INBOX_PORT];
981
+ if (inbox !== void 0) env[provideKey(resource.id, "inboxUrl")] = `http://${LOCAL_HOST}:${inbox}`;
982
+ }
1099
983
  }
1100
984
  Object.assign(env, publicEnv(plan, env));
1101
985
  Object.assign(env, brokerEnv(plan, options.ports));
@@ -1152,7 +1036,7 @@ function originOf(address) {
1152
1036
  try {
1153
1037
  return new URL(address).origin;
1154
1038
  } catch {
1155
- return void 0;
1039
+ return;
1156
1040
  }
1157
1041
  }
1158
1042
  /**
@@ -1200,8 +1084,7 @@ function ownerUrl(resource, plan, ports, project) {
1200
1084
  */
1201
1085
  function brokerEnv(plan, ports) {
1202
1086
  const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
1203
- if (!carrier) return {};
1204
- const publisher = urlFor(carrier, plan, ports, "");
1087
+ const publisher = carrier ? urlFor(carrier, plan, ports, "") : plan.workerBroker ? workerBroker(plan, ports) : void 0;
1205
1088
  if (!publisher) return {};
1206
1089
  return {
1207
1090
  EVENT_PUBLISHER_CONNECTION_STRING: publisher,
@@ -1209,6 +1092,16 @@ function brokerEnv(plan, ports) {
1209
1092
  };
1210
1093
  }
1211
1094
  /**
1095
+ * The broker a worker schedules its crons through, when nothing else declared
1096
+ * one — pg-boss in the declared database, on that database's port.
1097
+ */
1098
+ function workerBroker(plan, ports) {
1099
+ const database = plan.resources.find((r) => r.kind === "database");
1100
+ if (!database?.container) return void 0;
1101
+ const port = ports[primaryPortKey(database.container)];
1102
+ return port === void 0 ? void 0 : broker(plan, port);
1103
+ }
1104
+ /**
1212
1105
  * The URL for one planned resource.
1213
1106
  *
1214
1107
  * Every part of it comes from the resource and the port it was published on.
@@ -1254,11 +1147,11 @@ function urlFor(resource, plan, ports, project, addresses = {}) {
1254
1147
  }
1255
1148
  switch (plan.cache) {
1256
1149
  case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
1257
- default: return `http://:${LOCAL_TOKEN}@${LOCAL_HOST}:${port}`;
1150
+ default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
1258
1151
  }
1259
1152
  case "queue":
1260
1153
  case "topic": return broker(plan, port);
1261
- default: return void 0;
1154
+ default: return;
1262
1155
  }
1263
1156
  }
1264
1157
  /**
@@ -1273,16 +1166,16 @@ function broker(plan, port) {
1273
1166
  case "pgboss": {
1274
1167
  const database = plan.resources.find((r) => r.kind === "database");
1275
1168
  if (!database) throw new PgBossNeedsDatabase([]);
1276
- return `pgboss://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
1169
+ return `pgboss://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
1277
1170
  }
1278
- case "rabbitmq": return `rabbitmq://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
1171
+ case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
1279
1172
  case "sns": throw new UnprovisionedEventsBackend("sns");
1280
1173
  }
1281
1174
  }
1282
1175
  /** A local Postgres URL, master credential and all. */
1283
1176
  function postgres(port, database, credential = {
1284
- user: LOCAL_USER,
1285
- password: LOCAL_USER
1177
+ user: LOCAL_USER$2,
1178
+ password: LOCAL_USER$2
1286
1179
  }) {
1287
1180
  return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
1288
1181
  }
@@ -1349,7 +1242,6 @@ function schemaOf(resource, plan) {
1349
1242
  if (current.kind === "database-schema") return current.schema;
1350
1243
  current = current.of ? byId.get(current.of) : void 0;
1351
1244
  }
1352
- return void 0;
1353
1245
  }
1354
1246
  /**
1355
1247
  * A backend whose local addresses cannot be derived yet.
@@ -1359,6 +1251,7 @@ function schemaOf(resource, plan) {
1359
1251
  * lands, saying so is better than handing out a string that fails on first use.
1360
1252
  */
1361
1253
  var UnprovisionedEventsBackend = class extends Error {
1254
+ backend;
1362
1255
  constructor(backend) {
1363
1256
  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
1257
  this.backend = backend;
@@ -1376,1173 +1269,1400 @@ var UnprovisionedEventsBackend = class extends Error {
1376
1269
  function localSecret(project, plan, resource) {
1377
1270
  return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
1378
1271
  }
1379
-
1380
1272
  //#endregion
1381
- //#region src/reconcile/ports.ts
1382
- /**
1383
- * The window ports are drawn from.
1384
- *
1385
- * Above the registered range so nothing here needs privileges, and below the
1386
- * ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
1387
- * the kernel might hand to an outgoing connection is one that is free when
1388
- * checked and taken by the time it is bound.
1389
- */
1390
- const WINDOW = {
1391
- min: 2e4,
1392
- max: 32e3
1393
- };
1273
+ //#region src/reconcile/apps.ts
1394
1274
  /**
1395
- * How far apart two projects start.
1275
+ * The apps, as services beside the containers their constructs derive.
1396
1276
  *
1397
- * Wide enough that a project can add containers without walking into its
1398
- * neighbour's block, narrow enough that the window holds many projects.
1399
- */
1400
- const BLOCK = 50;
1401
- /**
1402
- * Where a project's ports start.
1277
+ * `gkm docker` used to write these by hand: every backend got
1278
+ * `DATABASE_URL=postgresql://…@postgres:5432/app` and `REDIS_URL=redis://redis`,
1279
+ * which was right only for a project whose single database happened to be
1280
+ * called `Database` — an `Orders` database publishes `ORDERS_URL`, a schema
1281
+ * tenant has a URL of its own, and buckets and mail were not wired at all.
1403
1282
  *
1404
- * Derived from the name rather than assigned sequentially so that a fresh clone
1405
- * of the same project on the same machine lands on the same block — the first
1406
- * run has nothing persisted to read, and hashing is what keeps that first run
1407
- * from colliding with whatever else is already allocated.
1283
+ * Now an app's environment is exactly what reconcile hands `gkm dev`: every key
1284
+ * the declared constructs provide, from the same derivation. The only
1285
+ * difference is where the addresses point — at the containers by service name
1286
+ * on the compose network, rather than at `localhost` on the ports published for
1287
+ * the host.
1408
1288
  *
1409
- * @example startingPort('toolbox') // stable for a given name
1289
+ * Always from the *local* stage's plan, whichever stage is being reconciled:
1290
+ * `gkm test` rewrites the same file, and running the tests must not quietly
1291
+ * point these apps at the test databases.
1410
1292
  */
1411
- function startingPort(project) {
1412
- const digest = createHash("sha256").update(project).digest();
1413
- const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
1414
- return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
1415
- }
1293
+ /** The profile app services carry, so `gkm dev` starts only the containers. */
1294
+ const APPS_PROFILE = "apps";
1416
1295
  /**
1417
- * Assign a port to every container that has none, keeping the ones that do.
1418
- *
1419
- * Existing assignments are returned untouched even when the container is no
1420
- * longer in the plan: removing a construct must not renumber what is left, and
1421
- * keeping the entry means re-adding it later restores the same port rather than
1422
- * inventing a new one.
1296
+ * Where an app's image is built from.
1423
1297
  *
1424
- * @param probe - reports what is actually listening. A port assigned to this
1425
- * project is skipped without probing, because on a converged reconcile our own
1426
- * container is the thing listening on it.
1427
- * @throws {NoPortAvailable} when the window is exhausted.
1298
+ * An app at the project root is a single-app project, whose `gkm docker`
1299
+ * writes `.gkm/docker/Dockerfile`; a workspace writes one per app.
1428
1300
  */
1429
- async function allocate(project, containers, existing = {}, probe = isPortFree) {
1430
- const assignments = { ...existing };
1431
- const taken = new Set(Object.values(assignments));
1432
- for (const container of [...containers].sort()) {
1433
- if (assignments[container] !== void 0) continue;
1434
- const port = await nextFree(startingPort(project), taken, probe);
1435
- assignments[container] = port;
1436
- taken.add(port);
1437
- }
1438
- return assignments;
1301
+ function dockerfileOf(appName, path) {
1302
+ return path === "." ? ".gkm/docker/Dockerfile" : `.gkm/docker/Dockerfile.${appName}`;
1439
1303
  }
1440
- /** The window was exhausted. */
1441
- var NoPortAvailable = class extends Error {
1442
- /** Where the search started. */
1443
- from;
1444
- /** The window searched, so the caller can report what was full. */
1445
- window = WINDOW;
1446
- constructor(from) {
1447
- super("No free port is available in the local port range");
1448
- this.name = "NoPortAvailable";
1449
- this.from = from;
1450
- }
1451
- };
1452
1304
  /**
1453
- * The first port at or after `from` that is neither ours nor in use.
1305
+ * Every construct key, addressed across the compose network.
1454
1306
  *
1455
- * Wraps at the top of the window rather than stopping, so a project whose block
1456
- * sits near the end is not the one that fails.
1307
+ * `envFor` is asked with a placeholder port for every port key, so each
1308
+ * `localhost:<placeholder>` in its answer names exactly one container port —
1309
+ * two containers listening on the same port inside cannot be confused — and is
1310
+ * rewritten to `<container>:<inside>`.
1457
1311
  */
1458
- async function nextFree(from, taken, probe) {
1459
- const span = WINDOW.max - WINDOW.min;
1460
- for (let offset = 0; offset < span; offset++) {
1461
- const port = WINDOW.min + (from - WINDOW.min + offset) % span;
1462
- if (taken.has(port)) continue;
1463
- if (await probe(port)) return port;
1312
+ function inNetworkEnv(workspace, manifest) {
1313
+ const plan = planFor(manifest, workspace.stages.local, provisionOrder(manifest), {
1314
+ localStage: workspace.stages.local,
1315
+ ...backendsOf(workspace)
1316
+ });
1317
+ const placeholders = {};
1318
+ const targets = /* @__PURE__ */ new Map();
1319
+ let next = 49152;
1320
+ for (const container of [...plan.containers].sort()) for (const port of portsOf(container)) {
1321
+ placeholders[port.key] = next;
1322
+ targets.set(next, `${container}:${port.inside}`);
1323
+ next += 1;
1464
1324
  }
1465
- throw new NoPortAvailable(from);
1466
- }
1467
- /**
1468
- * Whether nothing is listening on a port.
1469
- *
1470
- * Binds rather than connects: a refused connection says nothing about whether
1471
- * the port can be *published*, which is the question Docker will ask. Binds on
1472
- * `0.0.0.0` for the same reason — a port free on loopback can still be taken on
1473
- * the address Docker publishes to.
1474
- *
1475
- * Inherently racy, and that is acceptable: Docker failing to bind stays the real
1476
- * check. This exists so the common case surfaces as a message at reconcile
1477
- * rather than as a connection failure ten minutes later.
1478
- */
1479
- function isPortFree(port) {
1480
- return new Promise((resolve$1) => {
1481
- const server = createServer();
1482
- server.once("error", () => resolve$1(false));
1483
- server.once("listening", () => server.close(() => resolve$1(true)));
1484
- server.listen(port, "0.0.0.0");
1325
+ for (const key of portKeys(plan.containers)) placeholders[key] ??= next++;
1326
+ const env = envFor(plan, {
1327
+ ports: placeholders,
1328
+ project: workspace.name,
1329
+ addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
1485
1330
  });
1486
- }
1487
-
1488
- //#endregion
1489
- //#region src/reconcile/provision.ts
1490
- /**
1491
- * The Postgres work a plan implies.
1492
- *
1493
- * Databases before the schemas inside them, which `provisionOrder` already
1494
- * guarantees by putting parents first.
1495
- */
1496
- function postgresStatements(plan, project = "") {
1497
- const statements = [];
1498
- for (const resource of plan.resources) {
1499
- if (!resource.provisions || resource.container !== "postgres") continue;
1500
- if (resource.kind === "database") {
1501
- statements.push({
1502
- id: resource.id,
1503
- describe: `database ${resource.name}`,
1504
- exists: {
1505
- sql: "SELECT 1 FROM pg_database WHERE datname = $1",
1506
- values: [resource.name]
1507
- },
1508
- create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
1509
- });
1510
- if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project));
1511
- continue;
1512
- }
1513
- if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project));
1514
- const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
1515
- if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
1331
+ const rewrite = (value) => value.replace(/(?:localhost|127\.0\.0\.1):(\d+)/g, (match, port) => {
1332
+ return targets.get(Number(port)) ?? match;
1333
+ });
1334
+ return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
1335
+ }
1336
+ /**
1337
+ * The keys one app's environment may hold: what its own declaration provides
1338
+ * and requires, and what each construct it has an edge to provides.
1339
+ *
1340
+ * The edges, not the manifest. Every app used to be handed every key the
1341
+ * workspace resolved — a web app got the database's owner URL and the auth
1342
+ * server's signing secret, and the API got the auth server's database — which
1343
+ * is exactly what a declared edge exists to rule out. A deploy composes an
1344
+ * app's environment from its edges; so does this.
1345
+ *
1346
+ * One hop: a surface an app calls is reached over HTTP, so its URL is the
1347
+ * app's business and its database is not. A site also gets the public
1348
+ * variant of each key its bundle inlines (`NEXT_PUBLIC_API_URL`), and a surface
1349
+ * the edges of the workers whose crons and subscribers its server runs.
1350
+ *
1351
+ * `undefined` for an app no declaration describes, which gets nothing but its
1352
+ * port.
1353
+ */
1354
+ function appEnvKeys(manifest, appName, runnables = {}) {
1355
+ const entry = Object.entries(manifest).find(([id, d]) => (d.kind === "rest-api" || d.kind === "site") && appKey(id) === appName);
1356
+ if (!entry) return void 0;
1357
+ const [id, declaration] = entry;
1358
+ const keys = /* @__PURE__ */ new Set([
1359
+ ...declaration.provides ?? [],
1360
+ ...declaration.requires ?? [],
1361
+ ...declaration.kind === "rest-api" ? declaration.endpoints.flatMap((endpoint) => endpoint.requires ?? []) : []
1362
+ ]);
1363
+ const edges = [...dependenciesOf(declaration).map((edge) => edge.target), ...runnables[id] ?? []];
1364
+ if (declaration.kind === "rest-api" && declaration.endpoints.length === 0) for (const [otherId, other] of Object.entries(manifest)) {
1365
+ if (other.kind !== "worker") continue;
1366
+ edges.push(...dependenciesOf(other).map((edge) => edge.target), ...runnables[otherId] ?? []);
1367
+ keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
1368
+ keys.add("EVENT_SUBSCRIBER_CONNECTION_STRING");
1516
1369
  }
1517
- return statements;
1518
- }
1519
- /**
1520
- * A cache's table, in the schema the role that reads it resolves names in.
1521
- *
1522
- * Two things have to line up, and neither is the table's contents. The driver
1523
- * names the table unqualified and lets the connection's `search_path` place it
1524
- * — which the role carries — so creating it from the master connection, whose
1525
- * path is `public`, leaves a table the application cannot see and a
1526
- * `relation "cache" does not exist` on the first request. And the default
1527
- * privileges that make a tenant's tables reachable are granted *for the owner
1528
- * role*, so nothing covers a table the master created.
1529
- *
1530
- * Hence: qualified with the tenant's schema, handed to its owner, and granted
1531
- * explicitly — `roleStatements` grants what exists at the time it runs, and
1532
- * this table does not exist yet.
1533
- *
1534
- * A database that opted out of roles needs none of it: everything connects as
1535
- * the master, whose `search_path` finds `public`, and the table it creates
1536
- * there is the table it reads.
1537
- */
1538
- function cacheTableDdl(cache, home, plan) {
1539
- const database = rootDatabase(home, plan);
1540
- const schema = home.roles === false ? void 0 : home.schema;
1541
- const name = cache.table ?? cacheTable(cache.id);
1542
- const table = schema ? `${schema}.${name}` : name;
1543
- const created = cacheTableStatements({ table }).map((statement) => ({
1544
- id: cache.id,
1545
- describe: statement.describe,
1546
- database,
1547
- ...statement.exists ? { exists: statement.exists } : {},
1548
- create: statement.sql
1549
- }));
1550
- if (!schema) return created;
1551
- const runtime = localRole(home);
1552
- const owner = ownerRole(runtime);
1553
- const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
1554
- return [
1555
- ...created,
1556
- {
1557
- id: cache.id,
1558
- describe: `cache table ${table} is owned by ${owner}`,
1559
- database,
1560
- create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
1561
- },
1562
- {
1563
- id: cache.id,
1564
- describe: `${runtime} may read and write ${table}`,
1565
- database,
1566
- create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
1370
+ for (const id of edges) {
1371
+ const target = manifest[id];
1372
+ if (!target) continue;
1373
+ for (const key of target.provides ?? []) keys.add(key);
1374
+ if (target.kind === "queue" || target.kind === "topic") {
1375
+ keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
1376
+ keys.add("EVENT_SUBSCRIBER_CONNECTION_STRING");
1567
1377
  }
1568
- ];
1378
+ if (target.kind === "objects") {
1379
+ keys.add("AWS_ACCESS_KEY_ID");
1380
+ keys.add("AWS_SECRET_ACCESS_KEY");
1381
+ keys.add("AWS_REGION");
1382
+ }
1383
+ }
1384
+ if (declaration.kind === "site") for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
1385
+ return keys;
1386
+ }
1387
+ /** One compose service per app the workspace runs, behind the apps profile. */
1388
+ function appServices(workspace, manifest, containers, runnables = {}) {
1389
+ const env = inNetworkEnv(workspace, manifest);
1390
+ const services = {};
1391
+ for (const [name, app] of Object.entries(workspace.apps)) {
1392
+ if (app.type === "mobile" || !app.port) continue;
1393
+ const key = appKey(name);
1394
+ const health = app.type === "web" ? "/" : "/health";
1395
+ const allowed = appEnvKeys(manifest, name, runnables);
1396
+ const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
1397
+ services[key] = {
1398
+ image: `${key}:\${TAG:-latest}`,
1399
+ build: {
1400
+ context: ".",
1401
+ dockerfile: dockerfileOf(name, app.path)
1402
+ },
1403
+ profiles: [APPS_PROFILE],
1404
+ ports: [`${app.port}:${app.port}`],
1405
+ environment: {
1406
+ NODE_ENV: "production",
1407
+ PORT: String(app.port),
1408
+ ...own
1409
+ },
1410
+ ...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
1411
+ healthcheck: {
1412
+ test: [
1413
+ "CMD",
1414
+ "wget",
1415
+ "-q",
1416
+ "--spider",
1417
+ `http://localhost:${app.port}${health}`
1418
+ ],
1419
+ interval: "10s",
1420
+ timeout: "5s",
1421
+ retries: 5
1422
+ }
1423
+ };
1424
+ }
1425
+ return services;
1569
1426
  }
1427
+ //#endregion
1428
+ //#region src/reconcile/clients.ts
1570
1429
  /**
1571
- * The roles and schema for one database or tenant.
1430
+ * The real Postgres and MinIO clients the applier runs against.
1572
1431
  *
1573
- * Skipped entirely when the construct declared `roles: false` — the documented
1574
- * downgrade, where both URLs fall back to the cluster's master credential. It
1575
- * is a choice someone made rather than a default they got.
1432
+ * Kept apart from `provision.ts` so the rules there — what gets created, in what
1433
+ * order, and never dropping anything — are asserted against fakes, while the
1434
+ * drivers live here where there is nothing to assert but wiring.
1435
+ *
1436
+ * Both are constructed from the same local addresses the env writer composes,
1437
+ * so the applier cannot end up talking to a different container than the app.
1576
1438
  */
1577
- function rolesFor(resource, schema, plan, project) {
1578
- if (resource.roles === false) return [];
1579
- const runtime = localRole(resource);
1580
- const owner = ownerRole(runtime);
1581
- const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
1582
- return roleStatements({
1583
- runtime,
1584
- owner,
1585
- ...reader ? { reader } : {},
1586
- schema,
1587
- passwords: {
1588
- runtime: localRolePassword(project, plan, runtime),
1589
- owner: localRolePassword(project, plan, owner),
1590
- ...reader ? { reader: localRolePassword(project, plan, reader) } : {}
1439
+ /** The credential local containers are brought up with. */
1440
+ const LOCAL_USER$1 = "geekmidas";
1441
+ /**
1442
+ * A Postgres client that opens one connection per database it is asked about.
1443
+ *
1444
+ * `CREATE DATABASE` cannot run from a connection to the database being created,
1445
+ * and a schema must be created from inside its own database — so the database is
1446
+ * part of each query rather than fixed when the client is built.
1447
+ */
1448
+ function pgClient(port) {
1449
+ return { async query(database, sql, values) {
1450
+ const client = new Client({
1451
+ host: "localhost",
1452
+ port,
1453
+ user: LOCAL_USER$1,
1454
+ password: LOCAL_USER$1,
1455
+ database: database ?? LOCAL_USER$1
1456
+ });
1457
+ await client.connect();
1458
+ try {
1459
+ return (await client.query(sql, values)).rows;
1460
+ } finally {
1461
+ await client.end();
1591
1462
  }
1592
- }).map((statement) => ({
1593
- id: resource.id,
1594
- describe: statement.describe,
1595
- database: rootDatabase(resource, plan),
1596
- ...statement.exists ? { exists: statement.exists } : {},
1597
- create: statement.sql
1598
- }));
1463
+ } };
1599
1464
  }
1600
- /** The buckets a plan implies. */
1601
- function bucketNames(plan) {
1602
- return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
1465
+ /** A bucket client pointed at the local MinIO. */
1466
+ function bucketClient(port) {
1467
+ const s3 = new S3Client({
1468
+ region: "us-east-1",
1469
+ endpoint: `http://localhost:${port}`,
1470
+ forcePathStyle: true,
1471
+ credentials: {
1472
+ accessKeyId: LOCAL_USER$1,
1473
+ secretAccessKey: LOCAL_USER$1
1474
+ }
1475
+ });
1476
+ return {
1477
+ async exists(bucket) {
1478
+ try {
1479
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
1480
+ return true;
1481
+ } catch {
1482
+ return false;
1483
+ }
1484
+ },
1485
+ async create(bucket) {
1486
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
1487
+ },
1488
+ async policy(bucket) {
1489
+ try {
1490
+ return (await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }))).Policy;
1491
+ } catch {
1492
+ return;
1493
+ }
1494
+ },
1495
+ async setPolicy(bucket, policy) {
1496
+ await s3.send(new PutBucketPolicyCommand({
1497
+ Bucket: bucket,
1498
+ Policy: policy
1499
+ }));
1500
+ }
1501
+ };
1603
1502
  }
1503
+ //#endregion
1504
+ //#region src/reconcile/compose.ts
1604
1505
  /**
1605
- * Quote an identifier for use in DDL.
1506
+ * The compose document a plan implies.
1606
1507
  *
1607
- * Names reaching here are already canonical and stage-suffixed, so this is
1608
- * defence rather than sanitisation — but DDL cannot be parameterised, and an
1609
- * unquoted identifier is the one place a name could ever be more than a name.
1508
+ * The third declaration of the same containers disappears here: today a
1509
+ * `services: ['postgres','minio']` block is kept in step with the code by hand,
1510
+ * which is the triplication this design exists to remove, in the one place you
1511
+ * touch every day. A `database` implies Postgres; the list is derived.
1512
+ *
1513
+ * Returns an object rather than a string for the same reason `plan` is pure —
1514
+ * what should exist is asserted as data, and turning it into YAML is one line at
1515
+ * the edge.
1516
+ *
1517
+ * What stays config is unchanged: images and versions are pinned explicitly, and
1518
+ * a container no construct implies is still declared by hand. Neutral facts come
1519
+ * from the declaration, provider detail from config.
1610
1520
  */
1611
- function quoteIdentifier(name) {
1612
- return `"${name.replace(/"/g, "\"\"")}"`;
1613
- }
1521
+ /** The credential every derived container is brought up with locally. */
1522
+ const LOCAL_USER = "geekmidas";
1614
1523
  /**
1615
- * Apply the plan's Postgres statements.
1524
+ * The token the local cache accepts.
1616
1525
  *
1617
- * Each is checked before it is run, so this converges rather than erroring on a
1618
- * second pass.
1526
+ * Fixed rather than generated: it is only ever reachable on a port this project
1527
+ * published, and the URL that carries it is derived, so nothing is made safer
1528
+ * by making it unguessable and a stable one keeps `redis-cli` sessions working.
1619
1529
  */
1620
- async function applyPostgres(client, statements) {
1621
- const applied = [];
1622
- for (const statement of statements) {
1623
- if (!statement.exists) {
1624
- await client.query(statement.database, statement.create);
1625
- applied.push({
1626
- id: statement.id,
1627
- describe: statement.describe,
1628
- created: false
1629
- });
1630
- continue;
1631
- }
1632
- const rows = await client.query(statement.database, statement.exists.sql, statement.exists.values);
1633
- if (rows.length > 0) {
1634
- applied.push({
1635
- id: statement.id,
1636
- describe: statement.describe,
1637
- created: false
1638
- });
1639
- continue;
1640
- }
1641
- await client.query(statement.database, statement.create);
1642
- applied.push({
1643
- id: statement.id,
1644
- describe: statement.describe,
1645
- created: true
1646
- });
1530
+ const LOCAL_TOKEN = "geekmidas";
1531
+ /**
1532
+ * Build the compose document for a plan.
1533
+ *
1534
+ * Every container is published on its assigned port, never on the image's
1535
+ * default: the point of allocation is that two projects can run at once, and
1536
+ * one container publishing 5432 puts that back.
1537
+ */
1538
+ function composeFor(plan, options) {
1539
+ const services = {};
1540
+ const volumes = {};
1541
+ for (const container of [...plan.containers].sort()) {
1542
+ const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
1543
+ const image = declared ? postgresImage(declared) : DEFAULT_IMAGES[container];
1544
+ if (!image) throw new UnknownContainer(container);
1545
+ services[container] = define(container, image, publish(container, options.ports));
1546
+ const volume = volumeOf(container);
1547
+ if (volume) volumes[volume] = {};
1548
+ }
1549
+ return {
1550
+ name: options.project,
1551
+ services: {
1552
+ ...services,
1553
+ ...options.apps
1554
+ },
1555
+ ...Object.keys(volumes).length ? { volumes } : {}
1556
+ };
1557
+ }
1558
+ /** Render a compose document as the file it becomes. */
1559
+ function toYaml(file) {
1560
+ return `# Generated by gkm from the construct manifest — do not edit.
1561
+ # Containers are derived from what the app declares. Image pins and anything
1562
+ # no construct implies go in your own docker-compose.yml, which is merged over
1563
+ # this file and wins:
1564
+ #
1565
+ # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
1566
+ #
1567
+ # "gkm dev" and "gkm test" start the containers. The apps sit behind the
1568
+ # "apps" profile: run "gkm setup", then
1569
+ # docker compose -f docker-compose.constructs.yml --profile apps up --build
1570
+ ${stringify(file, { lineWidth: 0 })}`;
1571
+ }
1572
+ /** A container with no image, from config or from the defaults. */
1573
+ var UnknownContainer = class extends Error {
1574
+ /** The container that could not be resolved. */
1575
+ container;
1576
+ /** The containers that have a default, so the caller can see the set. */
1577
+ known = Object.keys(DEFAULT_IMAGES).sort();
1578
+ constructor(container) {
1579
+ super("No image is known for this container; pin one in config");
1580
+ this.name = "UnknownContainer";
1581
+ this.container = container;
1582
+ }
1583
+ };
1584
+ /** One container's definition, published on the port it was assigned. */
1585
+ function define(container, image, published) {
1586
+ switch (container) {
1587
+ case "postgres": return {
1588
+ image,
1589
+ restart: "unless-stopped",
1590
+ ports: published,
1591
+ environment: {
1592
+ POSTGRES_USER: LOCAL_USER,
1593
+ POSTGRES_PASSWORD: LOCAL_USER,
1594
+ POSTGRES_DB: LOCAL_USER
1595
+ },
1596
+ volumes: ["postgres-data:/var/lib/postgresql"],
1597
+ healthcheck: {
1598
+ test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
1599
+ interval: "5s",
1600
+ timeout: "3s",
1601
+ retries: 10,
1602
+ start_period: "10s"
1603
+ }
1604
+ };
1605
+ case "minio": return {
1606
+ image,
1607
+ restart: "unless-stopped",
1608
+ ports: published,
1609
+ environment: {
1610
+ MINIO_ROOT_USER: LOCAL_USER,
1611
+ MINIO_ROOT_PASSWORD: LOCAL_USER
1612
+ },
1613
+ command: "server --console-address \":9001\" /data",
1614
+ volumes: ["minio-data:/data"],
1615
+ healthcheck: {
1616
+ test: [
1617
+ "CMD",
1618
+ "mc",
1619
+ "ready",
1620
+ "local"
1621
+ ],
1622
+ interval: "10s",
1623
+ timeout: "5s",
1624
+ retries: 5,
1625
+ start_period: "10s"
1626
+ }
1627
+ };
1628
+ case "caddy": return {
1629
+ image,
1630
+ restart: "unless-stopped",
1631
+ ports: published,
1632
+ volumes: [
1633
+ "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
1634
+ "./.gkm/caddy-sites:/etc/caddy/sites:ro",
1635
+ "caddy-data:/data"
1636
+ ],
1637
+ extra_hosts: ["host.docker.internal:host-gateway"],
1638
+ healthcheck: {
1639
+ test: [
1640
+ "CMD",
1641
+ "caddy",
1642
+ "version"
1643
+ ],
1644
+ interval: "10s",
1645
+ timeout: "5s",
1646
+ retries: 5
1647
+ }
1648
+ };
1649
+ case "mailpit": return {
1650
+ image,
1651
+ restart: "unless-stopped",
1652
+ ports: published,
1653
+ environment: {
1654
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
1655
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1"
1656
+ },
1657
+ healthcheck: {
1658
+ test: [
1659
+ "CMD",
1660
+ "wget",
1661
+ "-q",
1662
+ "--spider",
1663
+ "http://localhost:8025"
1664
+ ],
1665
+ interval: "10s",
1666
+ timeout: "5s",
1667
+ retries: 5
1668
+ }
1669
+ };
1670
+ case "redis": return {
1671
+ image,
1672
+ restart: "unless-stopped",
1673
+ ports: published,
1674
+ volumes: ["redis-data:/data"],
1675
+ healthcheck: {
1676
+ test: [
1677
+ "CMD",
1678
+ "redis-cli",
1679
+ "ping"
1680
+ ],
1681
+ interval: "5s",
1682
+ timeout: "3s",
1683
+ retries: 5
1684
+ }
1685
+ };
1686
+ case "redis-http": return {
1687
+ image,
1688
+ restart: "unless-stopped",
1689
+ ports: published,
1690
+ environment: {
1691
+ SRH_MODE: "env",
1692
+ SRH_TOKEN: LOCAL_TOKEN,
1693
+ SRH_CONNECTION_STRING: "redis://redis:6379"
1694
+ },
1695
+ depends_on: ["redis"],
1696
+ healthcheck: {
1697
+ test: [
1698
+ "CMD",
1699
+ "wget",
1700
+ "-q",
1701
+ "-O",
1702
+ "/dev/null",
1703
+ "http://127.0.0.1:80/"
1704
+ ],
1705
+ interval: "5s",
1706
+ timeout: "3s",
1707
+ retries: 10
1708
+ }
1709
+ };
1710
+ case "rabbitmq": return {
1711
+ image,
1712
+ restart: "unless-stopped",
1713
+ ports: published,
1714
+ environment: {
1715
+ RABBITMQ_DEFAULT_USER: LOCAL_USER,
1716
+ RABBITMQ_DEFAULT_PASS: LOCAL_USER
1717
+ },
1718
+ volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
1719
+ healthcheck: {
1720
+ test: [
1721
+ "CMD",
1722
+ "rabbitmq-diagnostics",
1723
+ "-q",
1724
+ "ping"
1725
+ ],
1726
+ interval: "10s",
1727
+ timeout: "5s",
1728
+ retries: 5
1729
+ }
1730
+ };
1731
+ case "localstack": return {
1732
+ image,
1733
+ restart: "unless-stopped",
1734
+ ports: published,
1735
+ environment: {
1736
+ AWS_DEFAULT_REGION: "us-east-1",
1737
+ AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
1738
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER
1739
+ },
1740
+ volumes: ["localstack-data:/var/lib/localstack"],
1741
+ healthcheck: {
1742
+ test: [
1743
+ "CMD",
1744
+ "curl",
1745
+ "-f",
1746
+ "http://localhost:4566/_localstack/health"
1747
+ ],
1748
+ interval: "10s",
1749
+ timeout: "5s",
1750
+ retries: 5,
1751
+ start_period: "10s"
1752
+ }
1753
+ };
1754
+ default: throw new UnknownContainer(container);
1647
1755
  }
1648
- return applied;
1649
1756
  }
1650
1757
  /**
1651
- * What each bucket's open paths are, if anything serves it.
1758
+ * The published port mappings for a container.
1652
1759
  *
1653
- * Keyed by the *origin*, because that is where the policy lives: two servers
1654
- * over one bucket contribute to one policy, so the patterns are unioned rather
1655
- * than the last one winning.
1760
+ * Every port is looked up by its own key rather than being derived as an offset
1761
+ * from the container's first, so MinIO's console cannot land on whatever was
1762
+ * allocated to the next container.
1656
1763
  */
1657
- function bucketPolicies(plan) {
1658
- const byBucket = /* @__PURE__ */ new Map();
1659
- for (const resource of plan.resources) {
1660
- if (resource.kind !== "file-server" || !resource.open?.length) continue;
1661
- const origin = plan.resources.find((r) => r.id === resource.of);
1662
- if (!origin) continue;
1663
- const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
1664
- for (const pattern of resource.open) patterns.add(pattern);
1665
- byBucket.set(origin.name, patterns);
1666
- }
1667
- return [...byBucket].map(([bucket, open]) => ({
1668
- bucket,
1669
- open: [...open].sort()
1670
- })).sort((a, b) => a.bucket.localeCompare(b.bucket));
1764
+ function publish(container, ports) {
1765
+ return portsOf(container).map((port) => {
1766
+ const assigned = ports[port.key];
1767
+ if (assigned === void 0) throw new UnassignedPort(port.key);
1768
+ return `${assigned}:${port.inside}`;
1769
+ });
1671
1770
  }
1771
+ /** A planned port that allocation never assigned. */
1772
+ var UnassignedPort = class extends Error {
1773
+ /** The port key that went unassigned, e.g. `minio-console`. */
1774
+ key;
1775
+ constructor(key) {
1776
+ super("No port was assigned for this container port");
1777
+ this.name = "UnassignedPort";
1778
+ this.key = key;
1779
+ }
1780
+ };
1781
+ //#endregion
1782
+ //#region src/reconcile/docker.ts
1672
1783
  /**
1673
- * One bucket's open paths as an S3 bucket policy.
1784
+ * The real Docker, behind the small interface reconcile needs.
1674
1785
  *
1675
- * Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
1676
- * what "open" means everywhere: the server serves that path without a
1677
- * signature, and the bucket stays private for everything else.
1786
+ * Shells out to `docker compose` rather than talking to the daemon socket: it is
1787
+ * what a developer runs by hand when something is wrong, so the two cannot
1788
+ * disagree, and it keeps Docker out of this package's dependencies.
1678
1789
  *
1679
- * One fidelity note, stated rather than discovered: an S3 policy resource's `*`
1680
- * crosses `/`, so a single-star pattern is *wider* here than the construct's own
1681
- * runtime check, which stops at a segment boundary. The construct is the
1682
- * stricter of the two, so a key it refuses to serve unsigned is never one this
1683
- * policy was relied on to refuse — but a key fetched directly, bypassing the
1684
- * client, can be admitted by the policy where the client would have said no.
1685
- * Prefer `**` where crossing segments is what you meant.
1790
+ * Every call is scoped to the generated compose file. Nothing here can touch a
1791
+ * container this project did not generate.
1686
1792
  */
1687
- function bucketPolicy(bucket, open) {
1688
- return JSON.stringify({
1689
- Version: "2012-10-17",
1690
- Statement: [{
1691
- Sid: "GkmOpenPaths",
1692
- Effect: "Allow",
1693
- Principal: { AWS: ["*"] },
1694
- Action: ["s3:GetObject"],
1695
- Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
1696
- }]
1697
- });
1698
- }
1793
+ const run = promisify(execFile);
1699
1794
  /**
1700
- * Apply each served bucket's open-path policy, skipping those already correct.
1795
+ * The generated file, then the project's own if it has one.
1701
1796
  *
1702
- * Compared as parsed JSON rather than as text: the same policy re-serialised
1703
- * with different key order is the same policy, and rewriting it every reconcile
1704
- * would report a change on every start.
1797
+ * Merged in that order so the project's wins — an `image:` there pins a
1798
+ * container this file derived, and a service there is added beside them.
1705
1799
  */
1706
- async function applyPolicies(client, policies) {
1707
- const applied = [];
1708
- for (const { bucket, open } of policies) {
1709
- const wanted = bucketPolicy(bucket, open);
1710
- const describe = `open paths on ${bucket}`;
1711
- if (equivalent(await client.policy(bucket), wanted)) {
1712
- applied.push({
1713
- id: `${bucket}:policy`,
1714
- describe,
1715
- created: false
1716
- });
1717
- continue;
1718
- }
1719
- await client.setPolicy(bucket, wanted);
1720
- applied.push({
1721
- id: `${bucket}:policy`,
1722
- describe,
1723
- created: true
1724
- });
1725
- }
1726
- return applied;
1727
- }
1728
- /** Whether two policy documents say the same thing. */
1729
- function equivalent(current, wanted) {
1730
- if (!current) return false;
1731
- try {
1732
- return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
1733
- } catch {
1734
- return false;
1735
- }
1800
+ function composeFiles(composePath) {
1801
+ const project = join(dirname(composePath), "docker-compose.yml");
1802
+ return [
1803
+ "-f",
1804
+ composePath,
1805
+ ...existsSync(project) ? ["-f", project] : []
1806
+ ];
1736
1807
  }
1737
- /** Create every bucket the plan names, skipping those that exist. */
1738
- async function applyBuckets(client, buckets) {
1739
- const applied = [];
1740
- for (const bucket of buckets) {
1741
- if (await client.exists(bucket)) {
1742
- applied.push({
1743
- id: bucket,
1744
- describe: `bucket ${bucket}`,
1745
- created: false
1746
- });
1747
- continue;
1808
+ /** How long to wait for containers to pass their health checks. */
1809
+ const HEALTH_TIMEOUT_MS = 12e4;
1810
+ const dockerCli = {
1811
+ async publishedPort(composePath, service, inside) {
1812
+ try {
1813
+ const { stdout } = await run("docker", [
1814
+ "compose",
1815
+ ...composeFiles(composePath),
1816
+ "port",
1817
+ service,
1818
+ String(inside)
1819
+ ]);
1820
+ const port = Number(stdout.trim().split(":").pop());
1821
+ return Number.isFinite(port) && port > 0 ? port : void 0;
1822
+ } catch {
1823
+ return;
1824
+ }
1825
+ },
1826
+ async up(composePath, services) {
1827
+ await run("docker", [
1828
+ "compose",
1829
+ ...composeFiles(composePath),
1830
+ "up",
1831
+ "-d",
1832
+ "--remove-orphans",
1833
+ "--wait",
1834
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
1835
+ ...services
1836
+ ]);
1837
+ },
1838
+ async copyOut(composePath, service, from, to) {
1839
+ await mkdir(dirname(to), { recursive: true });
1840
+ await run("docker", [
1841
+ "compose",
1842
+ ...composeFiles(composePath),
1843
+ "cp",
1844
+ `${service}:${from}`,
1845
+ to
1846
+ ]);
1847
+ },
1848
+ async reload(composePath, service) {
1849
+ await run("docker", [
1850
+ "compose",
1851
+ ...composeFiles(composePath),
1852
+ "exec",
1853
+ "-T",
1854
+ service,
1855
+ "caddy",
1856
+ "reload",
1857
+ "--config",
1858
+ "/etc/caddy/Caddyfile"
1859
+ ]);
1860
+ },
1861
+ async healthy(composePath, services) {
1862
+ if (services.length === 0) return true;
1863
+ try {
1864
+ const { stdout } = await run("docker", [
1865
+ "compose",
1866
+ ...composeFiles(composePath),
1867
+ "ps",
1868
+ "--format",
1869
+ "json",
1870
+ ...services
1871
+ ]);
1872
+ const running = /* @__PURE__ */ new Set();
1873
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
1874
+ const entry = JSON.parse(line);
1875
+ if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
1876
+ }
1877
+ return services.every((service) => running.has(service));
1878
+ } catch {
1879
+ return false;
1748
1880
  }
1749
- await client.create(bucket);
1750
- applied.push({
1751
- id: bucket,
1752
- describe: `bucket ${bucket}`,
1753
- created: true
1754
- });
1755
1881
  }
1756
- return applied;
1757
- }
1758
-
1882
+ };
1759
1883
  //#endregion
1760
- //#region src/reconcile/state.ts
1761
- const STATE_DIR = ".gkm/reconcile";
1884
+ //#region src/reconcile/ports.ts
1762
1885
  /**
1763
- * The hash of everything a reconcile would act on.
1886
+ * Port allocation — why two projects can run at once.
1764
1887
  *
1765
- * Covers the compose document rather than the plan alone, because the plan is
1766
- * unchanged when only an image pin moves — and a changed image is exactly a
1767
- * container that must be recreated. `stringify` is stable here because
1768
- * `composeFor` sorts its containers, which is what makes the hash meaningful.
1888
+ * Publishing `5432:5432` means the second project on your machine cannot start.
1889
+ * Nothing solves that except giving each project its own ports, and what makes
1890
+ * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
1891
+ * never sees a host or a port, so reconcile is free to bind whatever is
1892
+ * available and compose the URL from what it bound. An app reading `DB_HOST` and
1893
+ * `DB_PORT` separately could not be moved this way.
1769
1894
  *
1770
- * Deliberately a plain function over serialisable input: the deployed target
1771
- * keys its provisioner invocation on the same idea, and the two must not be able
1772
- * to disagree about what counts as a change.
1773
- */
1774
- function planHash(plan, compose, generated = {}) {
1775
- return createHash("sha256").update(JSON.stringify({
1776
- stage: plan.stage,
1777
- plan,
1778
- compose,
1779
- generated
1780
- })).digest("hex");
1781
- }
1782
- /** Read the recorded state, or nothing if this stage has never reconciled. */
1783
- async function loadState(root, stage) {
1784
- try {
1785
- const raw = await readFile(statePath(root, stage), "utf-8");
1786
- const state = JSON.parse(raw);
1787
- return state.stage === stage ? state : void 0;
1788
- } catch {
1789
- return void 0;
1790
- }
1791
- }
1792
- /** Record what was applied, so the next run can skip it. */
1793
- async function saveState(root, state) {
1794
- const path = statePath(root, state.stage);
1795
- await mkdir(dirname(path), { recursive: true });
1796
- await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
1797
- }
1798
- function statePath(root, stage) {
1799
- return join(root, STATE_DIR, `${stage}.json`);
1800
- }
1801
-
1802
- //#endregion
1803
- //#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";
1806
- /**
1807
- * The local edge's config, beside the compose file that mounts it.
1895
+ * Assignments are **persisted, not recomputed**. Recomputing means adding a
1896
+ * construct renumbers its neighbours, and every running container, saved
1897
+ * connection string, and `psql` history goes stale at once.
1808
1898
  *
1809
- * Relative in the compose volume, so it resolves against `.gkm/` wherever the
1810
- * project lives.
1899
+ * Pure, with the liveness probe injected — a collision with another project's
1900
+ * stack is a fact about the machine, and keeping it a parameter is what lets the
1901
+ * rules be asserted without binding a socket.
1811
1902
  */
1812
- const CADDYFILE_PATH = ".gkm/Caddyfile";
1813
1903
  /**
1814
- * Where one stage's routes live, imported by the file above.
1904
+ * The window ports are drawn from.
1815
1905
  *
1816
- * Per stage because one edge serves every stage, the same way one Postgres
1817
- * holds `orders` and `orders_test`. A single file would mean `gkm test`
1818
- * deleting the routes `gkm dev` is serving.
1906
+ * Above the registered range so nothing here needs privileges, and below the
1907
+ * ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
1908
+ * the kernel might hand to an outgoing connection is one that is free when
1909
+ * checked and taken by the time it is bound.
1819
1910
  */
1820
- const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
1911
+ const WINDOW = {
1912
+ min: 2e4,
1913
+ max: 32e3
1914
+ };
1821
1915
  /**
1822
- * Where the local edge's root certificate is copied to.
1916
+ * How far apart two projects start.
1823
1917
  *
1824
- * Exported so a process can be pointed at it without installing anything:
1825
- * `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
1826
- * no sudo. A browser is the case that still wants the root in the system store,
1827
- * which is a one-time `caddy trust` rather than something a reconcile should do
1828
- * on a developer's behalf.
1918
+ * Wide enough that a project can add containers without walking into its
1919
+ * neighbour's block, narrow enough that the window holds many projects.
1829
1920
  */
1830
- const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
1831
- /** Where Caddy keeps the root of the CA it generated. */
1832
- const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
1921
+ const BLOCK = 50;
1833
1922
  /**
1834
- * Converge the local target on what the manifest declares.
1923
+ * Where a project's ports start.
1835
1924
  *
1836
- * Cheap when there is nothing to do: the hash is compared before the compose
1837
- * file is written or a container is touched, and a converged project pays one
1838
- * hash and one health check.
1839
- */
1840
- async function reconcile(options) {
1841
- const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient } = options;
1842
- const plan = planFor(manifest, stage, provisionOrder(manifest), {
1843
- events: options.events,
1844
- cache: options.cache,
1845
- extraContainers: options.extraContainers,
1846
- ...options.edge === void 0 ? {} : { edge: options.edge }
1847
- });
1848
- const composePath = join(root, COMPOSE_PATH);
1849
- const observed = await observedPorts(docker, composePath, plan.containers);
1850
- const ports = await allocate(project, portKeys(plan.containers), {
1851
- ...options.saved,
1852
- ...observed
1853
- }, probe);
1854
- const compose = composeFor(plan, {
1855
- project,
1856
- ports,
1857
- ...options.images ? { images: options.images } : {}
1858
- });
1859
- const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
1860
- const hash = planHash(plan, compose, { caddyfile });
1861
- const addresses = addressesFor(plan.containers, ports);
1862
- const env = envFor(plan, {
1863
- ports,
1864
- project,
1865
- ...options.mailFrom ? { mailFrom: options.mailFrom } : {},
1866
- ...options.addresses ? { addresses: options.addresses } : {}
1867
- });
1868
- if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
1869
- const result = {
1870
- stage,
1871
- plan,
1872
- compose,
1873
- ports,
1874
- addresses,
1875
- env,
1876
- provisioned: [],
1877
- hash,
1878
- changed: false
1879
- };
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;
1883
- await write(composePath, toYaml(compose));
1884
- if (plan.containers.includes("caddy")) {
1885
- await write(join(root, CADDYFILE_PATH), caddyfileRoot());
1886
- await write(join(root, caddySitesPath(stage)), caddyfile);
1887
- }
1888
- if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
1889
- if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
1890
- if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
1891
- const provisioned = start && provision ? await create(plan, ports, sql, buckets, project) : [];
1892
- await saveState(root, {
1893
- hash,
1894
- stage
1895
- });
1896
- return {
1897
- ...result,
1898
- provisioned,
1899
- changed: true
1900
- };
1901
- }
1902
- /**
1903
- * Create the databases, schemas, and buckets the plan names.
1925
+ * Derived from the name rather than assigned sequentially so that a fresh clone
1926
+ * of the same project on the same machine lands on the same block — the first
1927
+ * run has nothing persisted to read, and hashing is what keeps that first run
1928
+ * from colliding with whatever else is already allocated.
1904
1929
  *
1905
- * Idempotent throughout, so this runs on every non-converged reconcile rather
1906
- * than being something to remember.
1930
+ * @example startingPort('toolbox') // stable for a given name
1907
1931
  */
1908
- async function create(plan, ports, sql, buckets, project) {
1909
- const applied = [];
1910
- const postgresPort = ports[primaryPortKey("postgres")];
1911
- const statements = postgresStatements(plan, project);
1912
- if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
1913
- const minioPort = ports[primaryPortKey("minio")];
1914
- const names = bucketNames(plan);
1915
- const policies = bucketPolicies(plan);
1916
- if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
1917
- const client = buckets(minioPort);
1918
- if (names.length > 0) applied.push(...await applyBuckets(client, names));
1919
- if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
1920
- }
1921
- return applied;
1932
+ function startingPort(project) {
1933
+ const digest = createHash("sha256").update(project).digest();
1934
+ const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
1935
+ return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
1922
1936
  }
1923
1937
  /**
1924
- * The host ports of containers already running.
1938
+ * Assign a port to every container that has none, keeping the ones that do.
1925
1939
  *
1926
- * Keyed the same way allocation keys them, so the two merge without either
1927
- * knowing about the other.
1928
- */
1929
- async function observedPorts(docker, composePath, containers) {
1930
- const observed = {};
1931
- for (const container of containers) for (const port of portsOf(container)) {
1932
- const running = await docker.publishedPort(composePath, container, port.inside);
1933
- if (running !== void 0) observed[port.key] = running;
1934
- }
1935
- return observed;
1936
- }
1937
- /**
1938
- * Where each container can be reached on this machine.
1940
+ * Existing assignments are returned untouched even when the container is no
1941
+ * longer in the plan: removing a construct must not renumber what is left, and
1942
+ * keeping the entry means re-adding it later restores the same port rather than
1943
+ * inventing a new one.
1939
1944
  *
1940
- * The primary port only — a container's console is for a human to open, not
1941
- * something an app connects to.
1945
+ * @param probe - reports what is actually listening. A port assigned to this
1946
+ * project is skipped without probing, because on a converged reconcile our own
1947
+ * container is the thing listening on it.
1948
+ * @throws {NoPortAvailable} when the window is exhausted.
1942
1949
  */
1943
- function addressesFor(containers, ports) {
1944
- const addresses = {};
1945
- for (const container of containers) {
1946
- const port = ports[primaryPortKey(container)];
1947
- if (port !== void 0) addresses[container] = `localhost:${port}`;
1950
+ async function allocate(project, containers, existing = {}, probe = isPortFree) {
1951
+ const assignments = { ...existing };
1952
+ const taken = new Set(Object.values(assignments));
1953
+ for (const container of [...containers].sort()) {
1954
+ if (assignments[container] !== void 0) continue;
1955
+ const port = await nextFree(startingPort(project), taken, probe);
1956
+ assignments[container] = port;
1957
+ taken.add(port);
1948
1958
  }
1949
- return addresses;
1950
- }
1951
- /** Write a file only when its content would change, so mtimes stay meaningful. */
1952
- async function write(path, content) {
1953
- try {
1954
- if (await readFile(path, "utf-8") === content) return;
1955
- } catch {}
1956
- await mkdir(dirname(path), { recursive: true });
1957
- await writeFile(path, content);
1959
+ return assignments;
1958
1960
  }
1959
-
1960
- //#endregion
1961
- //#region src/reconcile/workspace.ts
1961
+ /** The window was exhausted. */
1962
+ var NoPortAvailable = class extends Error {
1963
+ /** Where the search started. */
1964
+ from;
1965
+ /** The window searched, so the caller can report what was full. */
1966
+ window = WINDOW;
1967
+ constructor(from) {
1968
+ super("No free port is available in the local port range");
1969
+ this.name = "NoPortAvailable";
1970
+ this.from = from;
1971
+ }
1972
+ };
1962
1973
  /**
1963
- * Every constructs glob in the workspace, resolved against its app.
1974
+ * The first port at or after `from` that is neither ours nor in use.
1964
1975
  *
1965
- * Absolute because discovery globs from a single cwd while apps live in
1966
- * different directories.
1976
+ * Wraps at the top of the window rather than stopping, so a project whose block
1977
+ * sits near the end is not the one that fails.
1967
1978
  */
1968
- function constructGlobs(workspace) {
1969
- return allConstructGlobs(workspace);
1970
- }
1971
- /** Whether any app has adopted the constructs glob. */
1972
- function usesConstructs(workspace) {
1973
- return constructGlobs(workspace).length > 0;
1979
+ async function nextFree(from, taken, probe) {
1980
+ const span = WINDOW.max - WINDOW.min;
1981
+ for (let offset = 0; offset < span; offset++) {
1982
+ const port = WINDOW.min + (from - WINDOW.min + offset) % span;
1983
+ if (taken.has(port)) continue;
1984
+ if (await probe(port)) return port;
1985
+ }
1986
+ throw new NoPortAvailable(from);
1974
1987
  }
1975
1988
  /**
1976
- * Image pins, by container. The config half of the derived/config split.
1989
+ * Whether nothing is listening on a port.
1977
1990
  *
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.
1991
+ * Binds rather than connects: a refused connection says nothing about whether
1992
+ * the port can be *published*, which is the question Docker will ask. Binds on
1993
+ * `0.0.0.0` for the same reason — a port free on loopback can still be taken on
1994
+ * the address Docker publishes to.
1995
+ *
1996
+ * Inherently racy, and that is acceptable: Docker failing to bind stays the real
1997
+ * check. This exists so the common case surfaces as a message at reconcile
1998
+ * rather than as a connection failure ten minutes later.
1982
1999
  */
1983
- function imagePins(workspace) {
1984
- return { ...workspace.services.images };
2000
+ function isPortFree(port) {
2001
+ return new Promise((resolve) => {
2002
+ const server = createServer();
2003
+ server.once("error", () => resolve(false));
2004
+ server.once("listening", () => server.close(() => resolve(true)));
2005
+ server.listen(port, "0.0.0.0");
2006
+ });
1985
2007
  }
2008
+ //#endregion
2009
+ //#region src/reconcile/provision.ts
1986
2010
  /**
1987
- * The containers a workspace's declarations imply, without reconciling.
2011
+ * Creating what the URLs name.
1988
2012
  *
1989
- * The same derivation `reconcileWorkspace` makes, available to callers that
1990
- * need to know *what would run* without starting anything or writing ports —
1991
- * `gkm setup` deciding which credentials to generate, `gkm docker` writing a
1992
- * compose file. Those used to each read a boolean per service out of config,
1993
- * which is how a container could exist because config asked for one rather than
1994
- * because something declared it.
2013
+ * Convergent and idempotent: every statement asks whether the thing exists
2014
+ * first, so reconciling repeatedly is free and a half-applied run recovers by
2015
+ * being run again. That property is what lets `gkm dev` do this on every start.
2016
+ *
2017
+ * The line the design draws is at data, not at side effects — this creates
2018
+ * databases, schemas, and buckets, and it never seeds, resets, or drops. A
2019
+ * developer who wants their data gone asks for it explicitly.
2020
+ *
2021
+ * Statement generation is separated from application for the reason the design
2022
+ * gives: the same DDL has to run in-process locally and from a provisioner in a
2023
+ * VPC, and one implementation is what stops "works locally, fails deployed".
2024
+ * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
2025
+ * growing its own.
1995
2026
  */
1996
- async function derivedContainers(workspace, stage, manifest) {
1997
- const found = manifest ?? await discover({
1998
- patterns: constructGlobs(workspace),
1999
- cwd: workspace.root
2000
- });
2001
- return planFor(found, stage, provisionOrder(found), {
2002
- events: workspace.services.events,
2003
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
2004
- }).containers;
2005
- }
2006
2027
  /**
2007
- * Reconcile a workspace for one stage.
2028
+ * The Postgres work a plan implies.
2008
2029
  *
2009
- * Ports are loaded and saved around the call rather than inside it, so the loop
2010
- * stays a pure-ish function of its inputs and the store keeps its single owner.
2030
+ * Databases before the schemas inside them, which `provisionOrder` already
2031
+ * guarantees by putting parents first.
2011
2032
  */
2012
- async function reconcileWorkspace(workspace, options) {
2013
- const manifest = options.manifest ?? await discover({
2014
- patterns: constructGlobs(workspace),
2015
- cwd: workspace.root
2016
- });
2017
- const result = await reconcile({
2018
- root: workspace.root,
2019
- project: workspace.name,
2020
- manifest,
2021
- stage: options.stage,
2022
- events: workspace.services.events,
2023
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
2024
- images: imagePins(workspace),
2025
- saved: await loadPortState(workspace.root),
2026
- addresses: surfaceAddresses(workspace, manifest),
2027
- ...options.start === void 0 ? {} : { start: options.start }
2028
- });
2029
- await savePortState(workspace.root, { ...result.ports });
2030
- return result;
2033
+ function postgresStatements(plan, project = "") {
2034
+ const statements = [];
2035
+ for (const resource of plan.resources) {
2036
+ if (!resource.provisions || resource.container !== "postgres") continue;
2037
+ if (resource.kind === "database") {
2038
+ statements.push({
2039
+ id: resource.id,
2040
+ describe: `database ${resource.name}`,
2041
+ exists: {
2042
+ sql: "SELECT 1 FROM pg_database WHERE datname = $1",
2043
+ values: [resource.name]
2044
+ },
2045
+ create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
2046
+ });
2047
+ if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project));
2048
+ continue;
2049
+ }
2050
+ if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project));
2051
+ const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
2052
+ if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
2053
+ }
2054
+ return statements;
2031
2055
  }
2032
2056
  /**
2033
- * Where each declared surface and site answers locally.
2057
+ * A cache's table, in the schema the role that reads it resolves names in.
2034
2058
  *
2035
- * The addresses come from the ports the workspace already assigns; which
2036
- * construct sits at which one comes from the manifest. That split is the point:
2037
- * this used to answer "who may call whom" by listing every app the workspace
2038
- * runs, which is a different question that happened to give the same answer in
2039
- * a single-repo workspace and no answer at all deployed. Now it answers only
2040
- * "where does this construct answer", and the graph answers the rest.
2059
+ * Two things have to line up, and neither is the table's contents. The driver
2060
+ * names the table unqualified and lets the connection's `search_path` place it
2061
+ * — which the role carries — so creating it from the master connection, whose
2062
+ * path is `public`, leaves a table the application cannot see and a
2063
+ * `relation "cache" does not exist` on the first request. And the default
2064
+ * privileges that make a tenant's tables reachable are granted *for the owner
2065
+ * role*, so nothing covers a table the master created.
2041
2066
  *
2042
- * A site is matched to its app by `path`, because that is the one thing a site
2043
- * declaration and a workspace app both name. Surfaces are matched to the
2044
- * backend app: every `rest-api` in a process answers on that process's port, so
2045
- * an app serving both its own API and an auth server publishes one address
2046
- * twice — which is exactly what it does at runtime.
2067
+ * Hence: qualified with the tenant's schema, handed to its owner, and granted
2068
+ * explicitly — `roleStatements` grants what exists at the time it runs, and
2069
+ * this table does not exist yet.
2070
+ *
2071
+ * A database that opted out of roles needs none of it: everything connects as
2072
+ * the master, whose `search_path` finds `public`, and the table it creates
2073
+ * there is the table it reads.
2047
2074
  */
2048
- function surfaceAddresses(workspace, manifest) {
2049
- const addresses = {};
2050
- for (const [id, declaration] of Object.entries(manifest)) {
2051
- if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
2052
- const host = declaration.kind === "site" ? appKey(id) : appKeyOfHost(manifest, id);
2053
- const app = host ? workspace.apps[host] : void 0;
2054
- if (app?.port) addresses[id] = localAddress(app.port);
2055
- }
2056
- return addresses;
2075
+ function cacheTableDdl(cache, home, plan) {
2076
+ const database = rootDatabase(home, plan);
2077
+ const schema = home.roles === false ? void 0 : home.schema;
2078
+ const name = cache.table ?? cacheTable(cache.id);
2079
+ const table = schema ? `${schema}.${name}` : name;
2080
+ const created = cacheTableStatements({ table }).map((statement) => ({
2081
+ id: cache.id,
2082
+ describe: statement.describe,
2083
+ database,
2084
+ ...statement.exists ? { exists: statement.exists } : {},
2085
+ create: statement.sql
2086
+ }));
2087
+ if (!schema) return created;
2088
+ const runtime = localRole(home);
2089
+ const owner = ownerRole(runtime);
2090
+ const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
2091
+ return [
2092
+ ...created,
2093
+ {
2094
+ id: cache.id,
2095
+ describe: `cache table ${table} is owned by ${owner}`,
2096
+ database,
2097
+ create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
2098
+ },
2099
+ {
2100
+ id: cache.id,
2101
+ describe: `${runtime} may read and write ${table}`,
2102
+ database,
2103
+ create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
2104
+ }
2105
+ ];
2057
2106
  }
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;
2107
+ /**
2108
+ * The roles and schema for one database or tenant.
2109
+ *
2110
+ * Skipped entirely when the construct declared `roles: false` — the documented
2111
+ * downgrade, where both URLs fall back to the cluster's master credential. It
2112
+ * is a choice someone made rather than a default they got.
2113
+ */
2114
+ function rolesFor(resource, schema, plan, project) {
2115
+ if (resource.roles === false) return [];
2116
+ const runtime = localRole(resource);
2117
+ const owner = ownerRole(runtime);
2118
+ const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
2119
+ return roleStatements({
2120
+ runtime,
2121
+ owner,
2122
+ ...reader ? { reader } : {},
2123
+ schema,
2124
+ passwords: {
2125
+ runtime: localRolePassword(project, plan, runtime),
2126
+ owner: localRolePassword(project, plan, owner),
2127
+ ...reader ? { reader: localRolePassword(project, plan, reader) } : {}
2128
+ }
2129
+ }).map((statement) => ({
2130
+ id: resource.id,
2131
+ describe: statement.describe,
2132
+ database: rootDatabase(resource, plan),
2133
+ ...statement.exists ? { exists: statement.exists } : {},
2134
+ create: statement.sql
2135
+ }));
2062
2136
  }
2063
- /** Where a local process answers, given the port the workspace gave it. */
2064
- function localAddress(port) {
2065
- return `http://localhost:${port}`;
2137
+ /** The buckets a plan implies. */
2138
+ function bucketNames(plan) {
2139
+ return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
2066
2140
  }
2067
-
2068
- //#endregion
2069
- //#region src/credentials/index.ts
2070
- const logger = console;
2071
2141
  /**
2072
- * Load environment files
2073
- * @internal Exported for testing
2142
+ * Quote an identifier for use in DDL.
2143
+ *
2144
+ * Names reaching here are already canonical and stage-suffixed, so this is
2145
+ * defence rather than sanitisation — but DDL cannot be parameterised, and an
2146
+ * unquoted identifier is the one place a name could ever be more than a name.
2074
2147
  */
2075
- function loadEnvFiles(envConfig, cwd = process.cwd()) {
2076
- const loaded = [];
2077
- const missing = [];
2078
- const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
2079
- for (const envFile of envFiles) {
2080
- const envPath = resolve(cwd, envFile);
2081
- if (existsSync(envPath)) {
2082
- config({
2083
- path: envPath,
2084
- override: true,
2085
- quiet: true
2086
- });
2087
- loaded.push(envFile);
2088
- } else if (envConfig) missing.push(envFile);
2089
- }
2090
- return {
2091
- loaded,
2092
- missing
2093
- };
2148
+ function quoteIdentifier(name) {
2149
+ return `"${name.replace(/"/g, "\"\"")}"`;
2094
2150
  }
2095
2151
  /**
2096
- * Check if a port is available
2097
- * @internal Exported for testing
2152
+ * Apply the plan's Postgres statements.
2153
+ *
2154
+ * Each is checked before it is run, so this converges rather than erroring on a
2155
+ * second pass.
2098
2156
  */
2099
- async function isPortAvailable(port) {
2100
- return new Promise((resolve$1) => {
2101
- const server = createServer();
2102
- server.once("error", (err) => {
2103
- if (err.code === "EADDRINUSE") resolve$1(false);
2104
- else resolve$1(false);
2105
- });
2106
- server.once("listening", () => {
2107
- server.close();
2108
- resolve$1(true);
2157
+ async function applyPostgres(client, statements) {
2158
+ const applied = [];
2159
+ for (const statement of statements) {
2160
+ if (!statement.exists) {
2161
+ await client.query(statement.database, statement.create);
2162
+ applied.push({
2163
+ id: statement.id,
2164
+ describe: statement.describe,
2165
+ created: false
2166
+ });
2167
+ continue;
2168
+ }
2169
+ if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
2170
+ applied.push({
2171
+ id: statement.id,
2172
+ describe: statement.describe,
2173
+ created: false
2174
+ });
2175
+ continue;
2176
+ }
2177
+ await client.query(statement.database, statement.create);
2178
+ applied.push({
2179
+ id: statement.id,
2180
+ describe: statement.describe,
2181
+ created: true
2109
2182
  });
2110
- server.listen(port);
2111
- });
2183
+ }
2184
+ return applied;
2112
2185
  }
2113
2186
  /**
2114
- * Find an available port starting from the preferred port
2115
- * @internal Exported for testing
2187
+ * What each bucket's open paths are, if anything serves it.
2188
+ *
2189
+ * Keyed by the *origin*, because that is where the policy lives: two servers
2190
+ * over one bucket contribute to one policy, so the patterns are unioned rather
2191
+ * than the last one winning.
2116
2192
  */
2117
- async function findAvailablePort(preferredPort, maxAttempts = 10) {
2118
- for (let i = 0; i < maxAttempts; i++) {
2119
- const port = preferredPort + i;
2120
- if (await isPortAvailable(port)) return port;
2121
- logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
2193
+ function bucketPolicies(plan) {
2194
+ const byBucket = /* @__PURE__ */ new Map();
2195
+ for (const resource of plan.resources) {
2196
+ if (resource.kind !== "file-server" || !resource.open?.length) continue;
2197
+ const origin = plan.resources.find((r) => r.id === resource.of);
2198
+ if (!origin) continue;
2199
+ const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
2200
+ for (const pattern of resource.open) patterns.add(pattern);
2201
+ byBucket.set(origin.name, patterns);
2122
2202
  }
2123
- throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
2203
+ return [...byBucket].map(([bucket, open]) => ({
2204
+ bucket,
2205
+ open: [...open].sort()
2206
+ })).sort((a, b) => a.bucket.localeCompare(b.bucket));
2124
2207
  }
2125
- const PORT_STATE_PATH = ".gkm/ports.json";
2126
2208
  /**
2127
- * Parse docker-compose.yml and extract all port mappings that use env var interpolation.
2128
- * Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
2129
- * Fixed port mappings like `'5050:80'` are skipped.
2130
- * @internal Exported for testing
2209
+ * One bucket's open paths as an S3 bucket policy.
2210
+ *
2211
+ * Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
2212
+ * what "open" means everywhere: the server serves that path without a
2213
+ * signature, and the bucket stays private for everything else.
2214
+ *
2215
+ * One fidelity note, stated rather than discovered: an S3 policy resource's `*`
2216
+ * crosses `/`, so a single-star pattern is *wider* here than the construct's own
2217
+ * runtime check, which stops at a segment boundary. The construct is the
2218
+ * stricter of the two, so a key it refuses to serve unsigned is never one this
2219
+ * policy was relied on to refuse — but a key fetched directly, bypassing the
2220
+ * client, can be admitted by the policy where the client would have said no.
2221
+ * Prefer `**` where crossing segments is what you meant.
2131
2222
  */
2132
- function parseComposePortMappings(composePath) {
2133
- if (!existsSync(composePath)) return [];
2134
- const content = readFileSync(composePath, "utf-8");
2135
- const compose = parse$1(content);
2136
- if (!compose?.services) return [];
2137
- const results = [];
2138
- for (const [serviceName, serviceConfig] of Object.entries(compose.services)) for (const portMapping of serviceConfig?.ports ?? []) {
2139
- const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
2140
- if (match?.[1] && match[2] && match[3]) results.push({
2141
- service: serviceName,
2142
- envVar: match[1],
2143
- defaultPort: Number(match[2]),
2144
- containerPort: Number(match[3])
2145
- });
2146
- }
2147
- return results;
2223
+ function bucketPolicy(bucket, open) {
2224
+ return JSON.stringify({
2225
+ Version: "2012-10-17",
2226
+ Statement: [{
2227
+ Sid: "GkmOpenPaths",
2228
+ Effect: "Allow",
2229
+ Principal: { AWS: ["*"] },
2230
+ Action: ["s3:GetObject"],
2231
+ Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
2232
+ }]
2233
+ });
2148
2234
  }
2149
2235
  /**
2150
- * Load saved port state from .gkm/ports.json.
2151
- * @internal Exported for testing
2236
+ * Apply each served bucket's open-path policy, skipping those already correct.
2237
+ *
2238
+ * Compared as parsed JSON rather than as text: the same policy re-serialised
2239
+ * with different key order is the same policy, and rewriting it every reconcile
2240
+ * would report a change on every start.
2152
2241
  */
2153
- async function loadPortState(workspaceRoot) {
2242
+ async function applyPolicies(client, policies) {
2243
+ const applied = [];
2244
+ for (const { bucket, open } of policies) {
2245
+ const wanted = bucketPolicy(bucket, open);
2246
+ const describe = `open paths on ${bucket}`;
2247
+ if (equivalent(await client.policy(bucket), wanted)) {
2248
+ applied.push({
2249
+ id: `${bucket}:policy`,
2250
+ describe,
2251
+ created: false
2252
+ });
2253
+ continue;
2254
+ }
2255
+ await client.setPolicy(bucket, wanted);
2256
+ applied.push({
2257
+ id: `${bucket}:policy`,
2258
+ describe,
2259
+ created: true
2260
+ });
2261
+ }
2262
+ return applied;
2263
+ }
2264
+ /** Whether two policy documents say the same thing. */
2265
+ function equivalent(current, wanted) {
2266
+ if (!current) return false;
2154
2267
  try {
2155
- const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
2156
- return JSON.parse(raw);
2268
+ return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
2157
2269
  } catch {
2158
- return {};
2270
+ return false;
2159
2271
  }
2160
2272
  }
2273
+ /** Create every bucket the plan names, skipping those that exist. */
2274
+ async function applyBuckets(client, buckets) {
2275
+ const applied = [];
2276
+ for (const bucket of buckets) {
2277
+ if (await client.exists(bucket)) {
2278
+ applied.push({
2279
+ id: bucket,
2280
+ describe: `bucket ${bucket}`,
2281
+ created: false
2282
+ });
2283
+ continue;
2284
+ }
2285
+ await client.create(bucket);
2286
+ applied.push({
2287
+ id: bucket,
2288
+ describe: `bucket ${bucket}`,
2289
+ created: true
2290
+ });
2291
+ }
2292
+ return applied;
2293
+ }
2294
+ //#endregion
2295
+ //#region src/reconcile/state.ts
2161
2296
  /**
2162
- * Save port state to .gkm/ports.json.
2163
- * @internal Exported for testing
2297
+ * What reconcile records, and the hash that lets it do nothing.
2298
+ *
2299
+ * Reconciling on every `gkm dev` is only acceptable if the converged case is
2300
+ * free, so the desired plan is hashed: matching hash plus healthy containers
2301
+ * means there is nothing to do. Without that, the automatic reconcile the design
2302
+ * asks for would tax every start.
2303
+ *
2304
+ * Almost nothing needs recording — roles are queried, migrations have their own
2305
+ * table, containers are remembered by Docker. Only ports and this hash have no
2306
+ * other owner.
2164
2307
  */
2165
- async function savePortState(workspaceRoot, ports) {
2166
- const dir = join(workspaceRoot, ".gkm");
2167
- await mkdir(dir, { recursive: true });
2168
- await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
2169
- }
2308
+ const STATE_DIR = ".gkm/reconcile";
2170
2309
  /**
2171
- * Check if a project's own Docker container is running and return its host port.
2172
- * Uses `docker compose port` scoped to the project's compose file.
2173
- * @internal Exported for testing
2310
+ * The hash of everything a reconcile would act on.
2311
+ *
2312
+ * Covers the compose document rather than the plan alone, because the plan is
2313
+ * unchanged when only an image pin moves — and a changed image is exactly a
2314
+ * container that must be recreated. `stringify` is stable here because
2315
+ * `composeFor` sorts its containers, which is what makes the hash meaningful.
2316
+ *
2317
+ * Deliberately a plain function over serialisable input: the deployed target
2318
+ * keys its provisioner invocation on the same idea, and the two must not be able
2319
+ * to disagree about what counts as a change.
2174
2320
  */
2175
- function getContainerHostPort(workspaceRoot, service, containerPort) {
2321
+ function planHash(plan, compose, generated = {}) {
2322
+ return createHash("sha256").update(JSON.stringify({
2323
+ stage: plan.stage,
2324
+ plan,
2325
+ compose,
2326
+ generated
2327
+ })).digest("hex");
2328
+ }
2329
+ /** Read the recorded state, or nothing if this stage has never reconciled. */
2330
+ async function loadState(root, stage) {
2176
2331
  try {
2177
- const result = execSync(`docker compose port ${service} ${containerPort}`, {
2178
- cwd: workspaceRoot,
2179
- stdio: "pipe"
2180
- }).toString().trim();
2181
- const match = result.match(/:(\d+)$/);
2182
- return match ? Number(match[1]) : null;
2332
+ const raw = await readFile(statePath(root, stage), "utf-8");
2333
+ const state = JSON.parse(raw);
2334
+ return state.stage === stage ? state : void 0;
2183
2335
  } catch {
2184
- return null;
2336
+ return;
2185
2337
  }
2186
2338
  }
2339
+ /** Record what was applied, so the next run can skip it. */
2340
+ async function saveState(root, state) {
2341
+ const path = statePath(root, state.stage);
2342
+ await mkdir(dirname(path), { recursive: true });
2343
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
2344
+ }
2345
+ function statePath(root, stage) {
2346
+ return join(root, STATE_DIR, `${stage}.json`);
2347
+ }
2348
+ //#endregion
2349
+ //#region src/reconcile/index.ts
2187
2350
  /**
2188
- * Resolve host ports for Docker services by parsing docker-compose.yml.
2189
- * Priority: running container → saved state → find available port.
2190
- * Persists resolved ports to .gkm/ports.json.
2191
- * @internal Exported for testing
2351
+ * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
2352
+ * all call.
2353
+ *
2354
+ * Three phases, and only this one has side effects: declaring is pure (`gkm
2355
+ * build` and the dev watcher), running reads `<NAME>_URL` and provisions
2356
+ * nothing. Reconcile computes the desired state, compares, and applies the
2357
+ * difference — which is what makes "when does it run" answerable with
2358
+ * *whenever, repeatedly*.
2359
+ *
2360
+ * It is safe to run automatically because its blast radius is entirely local:
2361
+ * this project's containers, this project's `.gkm/`. Nothing it does can reach a
2362
+ * cloud. The line is drawn at data, not at side effects — it allocates ports,
2363
+ * writes compose, starts containers, and creates roles; it never seeds, resets,
2364
+ * or drops.
2365
+ *
2366
+ * Docker is injected rather than imported so the rules above can be asserted
2367
+ * without a daemon. The default implementation is the real one.
2192
2368
  */
2193
- async function resolveServicePorts(workspaceRoot) {
2194
- const composePath = join(workspaceRoot, "docker-compose.yml");
2195
- const mappings = parseComposePortMappings(composePath);
2196
- if (mappings.length === 0) return {
2197
- dockerEnv: {},
2198
- ports: {},
2199
- mappings: []
2200
- };
2201
- const savedState = await loadPortState(workspaceRoot);
2202
- const dockerEnv = {};
2203
- const ports = {};
2204
- const assignedPorts = /* @__PURE__ */ new Set();
2205
- logger.log("\n🔌 Resolving service ports...");
2206
- for (const mapping of mappings) {
2207
- const containerPort = getContainerHostPort(workspaceRoot, mapping.service, mapping.containerPort);
2208
- if (containerPort !== null) {
2209
- ports[mapping.envVar] = containerPort;
2210
- dockerEnv[mapping.envVar] = String(containerPort);
2211
- assignedPorts.add(containerPort);
2212
- logger.log(` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`);
2213
- continue;
2214
- }
2215
- const savedPort = savedState[mapping.envVar];
2216
- if (savedPort && !assignedPorts.has(savedPort) && await isPortAvailable(savedPort)) {
2217
- ports[mapping.envVar] = savedPort;
2218
- dockerEnv[mapping.envVar] = String(savedPort);
2219
- assignedPorts.add(savedPort);
2220
- logger.log(` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`);
2221
- continue;
2222
- }
2223
- let resolvedPort = await findAvailablePort(mapping.defaultPort);
2224
- while (assignedPorts.has(resolvedPort)) resolvedPort = await findAvailablePort(resolvedPort + 1);
2225
- ports[mapping.envVar] = resolvedPort;
2226
- dockerEnv[mapping.envVar] = String(resolvedPort);
2227
- assignedPorts.add(resolvedPort);
2228
- if (resolvedPort !== mapping.defaultPort) logger.log(` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`);
2229
- else logger.log(` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`);
2230
- }
2231
- await savePortState(workspaceRoot, ports);
2232
- return {
2233
- dockerEnv,
2234
- ports,
2235
- mappings
2236
- };
2237
- }
2238
2369
  /**
2239
- * Replace a port in a URL string.
2240
- * Handles both `hostname:port` and `localhost:port` patterns.
2241
- * @internal Exported for testing
2370
+ * Where the generated compose file is written, relative to the project root.
2371
+ *
2372
+ * At the root, beside the project's own `docker-compose.yml`, because the two
2373
+ * are one stack: this file is everything the constructs imply and is never
2374
+ * edited, that one is anything they cannot express — an image pin, an extra
2375
+ * service — and is merged over it, so it wins.
2242
2376
  */
2243
- function replacePortInUrl(url, oldPort, newPort) {
2244
- if (oldPort === newPort) return url;
2245
- let result = url.replace(new RegExp(`:${oldPort}(?=[/?#]|$)`, "g"), `:${newPort}`);
2246
- result = result.replace(new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, "gi"), `%3A${newPort}`);
2247
- return result;
2248
- }
2377
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
2249
2378
  /**
2250
- * Rewrite connection URLs and port vars in secrets with resolved ports.
2251
- * Uses the parsed compose mappings to determine which default ports to replace.
2252
- * Pure transform — does not modify secrets on disk.
2253
- * @internal Exported for testing
2379
+ * The local edge's config, beside the compose file that mounts it.
2380
+ *
2381
+ * Relative in the compose volume, so it resolves against `.gkm/` wherever the
2382
+ * project lives.
2254
2383
  */
2255
- function rewriteUrlsWithPorts(secrets, resolvedPorts) {
2256
- const { ports, mappings } = resolvedPorts;
2257
- const result = { ...secrets };
2258
- const portReplacements = [];
2259
- const serviceNames = /* @__PURE__ */ new Set();
2260
- for (const mapping of mappings) {
2261
- serviceNames.add(mapping.service);
2262
- const resolved = ports[mapping.envVar];
2263
- if (resolved !== void 0) portReplacements.push({
2264
- defaultPort: mapping.defaultPort,
2265
- resolvedPort: resolved
2266
- });
2267
- }
2268
- for (const [key, value] of Object.entries(result)) {
2269
- if (!key.endsWith("_HOST")) continue;
2270
- if (serviceNames.has(value)) result[key] = "localhost";
2271
- }
2272
- for (const [key, value] of Object.entries(result)) {
2273
- if (!key.endsWith("_PORT")) continue;
2274
- for (const { defaultPort, resolvedPort } of portReplacements) if (value === String(defaultPort)) result[key] = String(resolvedPort);
2275
- }
2276
- for (const [key, value] of Object.entries(result)) {
2277
- if (!key.endsWith("_URL") && !key.endsWith("_ENDPOINT") && !key.endsWith("_CONNECTION_STRING") && key !== "DATABASE_URL") continue;
2278
- let rewritten = value;
2279
- for (const name of serviceNames) rewritten = rewritten.replace(new RegExp(`@${name}:`, "g"), "@localhost:");
2280
- for (const { defaultPort, resolvedPort } of portReplacements) rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
2281
- result[key] = rewritten;
2384
+ const CADDYFILE_PATH = ".gkm/Caddyfile";
2385
+ /**
2386
+ * Where one stage's routes live, imported by the file above.
2387
+ *
2388
+ * Per stage because one edge serves every stage, the same way one Postgres
2389
+ * holds `orders` and `orders_test`. A single file would mean `gkm test`
2390
+ * deleting the routes `gkm dev` is serving.
2391
+ */
2392
+ const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
2393
+ /**
2394
+ * Where the local edge's root certificate is copied to.
2395
+ *
2396
+ * Exported so a process can be pointed at it without installing anything:
2397
+ * `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
2398
+ * no sudo. A browser is the case that still wants the root in the system store,
2399
+ * which is a one-time `caddy trust` rather than something a reconcile should do
2400
+ * on a developer's behalf.
2401
+ */
2402
+ const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
2403
+ /** Where Caddy keeps the root of the CA it generated. */
2404
+ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
2405
+ /**
2406
+ * Converge the local target on what the manifest declares.
2407
+ *
2408
+ * Cheap when there is nothing to do: the hash is compared before the compose
2409
+ * file is written or a container is touched, and a converged project pays one
2410
+ * hash and one health check.
2411
+ */
2412
+ async function reconcile(options) {
2413
+ const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient } = options;
2414
+ const plan = planFor(manifest, stage, provisionOrder(manifest), {
2415
+ localStage: options.localStage,
2416
+ events: options.events,
2417
+ cache: options.cache,
2418
+ extraContainers: options.extraContainers,
2419
+ ...options.edge === void 0 ? {} : { edge: options.edge }
2420
+ });
2421
+ const composePath = join(root, COMPOSE_PATH);
2422
+ const observed = await observedPorts(docker, composePath, plan.containers);
2423
+ const ports = await allocate(project, portKeys(plan.containers), {
2424
+ ...options.saved,
2425
+ ...observed
2426
+ }, probe);
2427
+ const compose = composeFor(plan, {
2428
+ project,
2429
+ ports,
2430
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
2431
+ });
2432
+ const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
2433
+ const hash = planHash(plan, compose, { caddyfile });
2434
+ const addresses = addressesFor(plan.containers, ports);
2435
+ const env = envFor(plan, {
2436
+ ports,
2437
+ project,
2438
+ ...options.mailFrom ? { mailFrom: options.mailFrom } : {},
2439
+ ...options.addresses ? { addresses: options.addresses } : {}
2440
+ });
2441
+ if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
2442
+ const result = {
2443
+ stage,
2444
+ plan,
2445
+ compose,
2446
+ ports,
2447
+ addresses,
2448
+ env,
2449
+ provisioned: [],
2450
+ hash,
2451
+ changed: false
2452
+ };
2453
+ if ((await loadState(root, stage))?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers))) return result;
2454
+ await write(composePath, toYaml(compose));
2455
+ if (plan.containers.includes("caddy")) {
2456
+ await write(join(root, CADDYFILE_PATH), caddyfileRoot());
2457
+ await write(join(root, caddySitesPath(stage)), caddyfile);
2458
+ await pruneCaddySites(root, [
2459
+ stage,
2460
+ options.localStage,
2461
+ TEST_STAGE
2462
+ ]);
2282
2463
  }
2283
- return result;
2284
- }
2285
- /**
2286
- * Build the environment variables to pass to `docker compose up`.
2287
- * Merges process.env, secrets, and port mappings so that Docker Compose
2288
- * can interpolate variables like ${POSTGRES_USER} correctly.
2289
- * @internal Exported for testing
2290
- */
2291
- function buildDockerComposeEnv(secretsEnv, portEnv) {
2464
+ if (start && plan.containers.length > 0) await docker.up(composePath, plan.containers);
2465
+ if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
2466
+ if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
2467
+ const provisioned = start && provision ? await create(plan, ports, sql, buckets, project) : [];
2468
+ await saveState(root, {
2469
+ hash,
2470
+ stage
2471
+ });
2292
2472
  return {
2293
- ...process.env,
2294
- ...secretsEnv,
2295
- ...portEnv
2473
+ ...result,
2474
+ provisioned,
2475
+ changed: true
2296
2476
  };
2297
2477
  }
2298
2478
  /**
2299
- * Parse all service names from a docker-compose.yml file.
2300
- * @internal Exported for testing
2479
+ * Create the databases, schemas, and buckets the plan names.
2480
+ *
2481
+ * Idempotent throughout, so this runs on every non-converged reconcile rather
2482
+ * than being something to remember.
2301
2483
  */
2302
- function parseComposeServiceNames(composePath) {
2303
- if (!existsSync(composePath)) return [];
2304
- const content = readFileSync(composePath, "utf-8");
2305
- const compose = parse$1(content);
2306
- return Object.keys(compose?.services ?? {});
2484
+ async function create(plan, ports, sql, buckets, project) {
2485
+ const applied = [];
2486
+ const postgresPort = ports[primaryPortKey("postgres")];
2487
+ const statements = postgresStatements(plan, project);
2488
+ if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
2489
+ const minioPort = ports[primaryPortKey("minio")];
2490
+ const names = bucketNames(plan);
2491
+ const policies = bucketPolicies(plan);
2492
+ if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
2493
+ const client = buckets(minioPort);
2494
+ if (names.length > 0) applied.push(...await applyBuckets(client, names));
2495
+ if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
2496
+ }
2497
+ return applied;
2307
2498
  }
2308
2499
  /**
2309
- * Start docker-compose services for a single-app project (no workspace config).
2310
- * Starts all services defined in docker-compose.yml.
2500
+ * The host ports of containers already running.
2501
+ *
2502
+ * Keyed the same way allocation keys them, so the two merge without either
2503
+ * knowing about the other.
2311
2504
  */
2312
- async function startComposeServices(cwd, portEnv, secretsEnv) {
2313
- const composeFile = join(cwd, "docker-compose.yml");
2314
- if (!existsSync(composeFile)) return;
2315
- const servicesToStart = parseComposeServiceNames(composeFile);
2316
- if (servicesToStart.length === 0) return;
2317
- logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
2318
- try {
2319
- execSync(`docker compose up -d ${servicesToStart.join(" ")}`, {
2320
- cwd,
2321
- stdio: "inherit",
2322
- env: buildDockerComposeEnv(secretsEnv, portEnv)
2323
- });
2324
- logger.log("✅ Services started");
2325
- } catch (error) {
2326
- logger.error("❌ Failed to start services:", error.message);
2327
- throw error;
2505
+ async function observedPorts(docker, composePath, containers) {
2506
+ const observed = {};
2507
+ for (const container of containers) for (const port of portsOf(container)) {
2508
+ const running = await docker.publishedPort(composePath, container, port.inside);
2509
+ if (running !== void 0) observed[port.key] = running;
2328
2510
  }
2511
+ return observed;
2329
2512
  }
2330
2513
  /**
2331
- * Start docker-compose services for a workspace.
2332
- * Discovers all services from docker-compose.yml and starts everything
2333
- * except app services (which are managed by turbo).
2334
- * @internal Exported for testing
2514
+ * Where each container can be reached on this machine.
2515
+ *
2516
+ * The primary port only — a container's console is for a human to open, not
2517
+ * something an app connects to.
2335
2518
  */
2336
- async function startWorkspaceServices(workspace, portEnv, secretsEnv) {
2337
- const composeFile = join(workspace.root, "docker-compose.yml");
2338
- if (!existsSync(composeFile)) return;
2339
- const allServices = parseComposeServiceNames(composeFile);
2340
- const appNames = new Set(Object.keys(workspace.apps));
2341
- const servicesToStart = allServices.filter((name) => !appNames.has(name));
2342
- if (servicesToStart.length === 0) return;
2343
- logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
2344
- try {
2345
- execSync(`docker compose up -d ${servicesToStart.join(" ")}`, {
2346
- cwd: workspace.root,
2347
- stdio: "inherit",
2348
- env: buildDockerComposeEnv(secretsEnv, portEnv)
2349
- });
2350
- logger.log("✅ Services started");
2351
- } catch (error) {
2352
- logger.error("❌ Failed to start services:", error.message);
2353
- throw error;
2519
+ function addressesFor(containers, ports) {
2520
+ const addresses = {};
2521
+ for (const container of containers) {
2522
+ const port = ports[primaryPortKey(container)];
2523
+ if (port !== void 0) addresses[container] = `localhost:${port}`;
2354
2524
  }
2525
+ return addresses;
2355
2526
  }
2527
+ /** Write a file only when its content would change, so mtimes stay meaningful. */
2356
2528
  /**
2357
- * Load and flatten secrets for an app from encrypted storage.
2358
- * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
2359
- * @internal Exported for testing
2529
+ * Remove the routes of stages this project no longer reconciles.
2530
+ *
2531
+ * Only the local stage and `test` are ever written, so any other file is left
2532
+ * over from a stage that was renamed — `development` becoming `dev`. Caddy
2533
+ * imports every file, and the leftover declares the same hosts as its
2534
+ * replacement: it refuses the whole config as ambiguous, and every URL behind
2535
+ * the edge goes with it.
2360
2536
  */
2361
- async function loadSecretsForApp(secretsRoot, appName, stages = ["dev", "development"]) {
2362
- let secrets = {};
2363
- for (const stage of stages) if (secretsExist(stage, secretsRoot)) {
2364
- const stageSecrets = await readStageSecrets(stage, secretsRoot);
2365
- if (stageSecrets) {
2366
- logger.log(`🔐 Loading secrets from stage: ${stage}`);
2367
- secrets = toEmbeddableSecrets(stageSecrets);
2368
- break;
2369
- }
2370
- }
2371
- if (Object.keys(secrets).length === 0) return {};
2372
- if (!appName) return secrets;
2373
- const prefix = appName.toUpperCase();
2374
- const mapped = { ...secrets };
2375
- const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
2376
- if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
2377
- return mapped;
2537
+ async function pruneCaddySites(root, stages) {
2538
+ const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
2539
+ const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
2540
+ for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
2541
+ }
2542
+ async function write(path, content) {
2543
+ try {
2544
+ if (await readFile(path, "utf-8") === content) return;
2545
+ } catch {}
2546
+ await mkdir(dirname(path), { recursive: true });
2547
+ await writeFile(path, content);
2378
2548
  }
2549
+ //#endregion
2550
+ //#region src/reconcile/workspace.ts
2379
2551
  /**
2380
- * Walk up the directory tree to find the root containing .gkm/secrets/.
2381
- * @internal Exported for testing
2552
+ * Workspace config → reconcile.
2553
+ *
2554
+ * Nothing here is read from a `services:` block — there is none. Which
2555
+ * containers exist comes from the manifest; which backend a cache or a broker
2556
+ * resolves to comes from the deploy target; and an image pin is the project's
2557
+ * own `docker-compose.yml`, merged over the generated file.
2558
+ *
2559
+ * Shared by `gkm setup`, `gkm dev`, and `gkm test` so all three converge on the
2560
+ * same state, differing only in the stage they pass.
2382
2561
  */
2383
- function findSecretsRoot(startDir) {
2384
- let dir = startDir;
2385
- while (dir !== "/") {
2386
- if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
2387
- const parent = dirname(dir);
2388
- if (parent === dir) break;
2389
- dir = parent;
2390
- }
2391
- return startDir;
2392
- }
2562
+ var workspace_exports = /* @__PURE__ */ __exportAll({
2563
+ backendsOf: () => backendsOf,
2564
+ constructGlobs: () => constructGlobs,
2565
+ derivedContainers: () => derivedContainers,
2566
+ reconcileWorkspace: () => reconcileWorkspace,
2567
+ surfaceAddresses: () => surfaceAddresses
2568
+ });
2393
2569
  /**
2394
- * Generate the credentials injection code snippet.
2395
- * This is the common logic used by both entry wrapper and exec preload.
2396
- * @internal
2570
+ * Every constructs glob in the workspace, resolved against its app.
2571
+ *
2572
+ * Absolute because discovery globs from a single cwd while apps live in
2573
+ * different directories.
2397
2574
  */
2398
- function generateCredentialsInjection(secretsJsonPath) {
2399
- return `import { existsSync, readFileSync } from 'node:fs';
2400
-
2401
- // Inject dev secrets via globalThis and process.env
2402
- // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
2403
- // Object.assign on the Credentials export only mutates one module copy.
2404
- const secretsPath = '${secretsJsonPath}';
2405
- if (existsSync(secretsPath)) {
2406
- const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
2407
- globalThis.__gkm_credentials__ = secrets;
2408
- Object.assign(process.env, secrets);
2575
+ function constructGlobs(workspace) {
2576
+ return allConstructGlobs(workspace);
2409
2577
  }
2410
- `;
2578
+ /** The backends a workspace's target implies, as the plan takes them. */
2579
+ function backendsOf(workspace) {
2580
+ const on = providerOf(workspace);
2581
+ return {
2582
+ events: eventsBackendFor(on),
2583
+ cache: cacheBackendFor(on)
2584
+ };
2411
2585
  }
2412
2586
  /**
2413
- * Create a preload script that injects secrets into Credentials.
2414
- * Used by `gkm exec` to inject secrets before running any command.
2415
- * @internal Exported for testing
2587
+ * The containers a workspace's declarations imply, without reconciling.
2588
+ *
2589
+ * The same derivation `reconcileWorkspace` makes, available to callers that
2590
+ * need to know *what would run* without starting anything or writing ports —
2591
+ * `gkm setup` deciding which credentials to generate, `gkm docker` writing a
2592
+ * compose file. Those used to each read a boolean per service out of config,
2593
+ * which is how a container could exist because config asked for one rather than
2594
+ * because something declared it.
2416
2595
  */
2417
- async function createCredentialsPreload(preloadPath, secretsJsonPath) {
2418
- const content = `/**
2419
- * Credentials preload generated by 'gkm exec'
2420
- * This file is loaded via NODE_OPTIONS="--import <path>"
2421
- */
2422
- ${generateCredentialsInjection(secretsJsonPath)}`;
2423
- await writeFile(preloadPath, content);
2596
+ async function derivedContainers(workspace, stage, manifest) {
2597
+ const found = manifest ?? await discover({
2598
+ patterns: constructGlobs(workspace),
2599
+ cwd: workspace.root
2600
+ });
2601
+ return planFor(found, stage, provisionOrder(found), {
2602
+ localStage: workspace.stages.local,
2603
+ ...backendsOf(workspace)
2604
+ }).containers;
2424
2605
  }
2425
2606
  /**
2426
- * Create a wrapper script that injects secrets before importing the entry file.
2427
- * @internal Exported for testing
2607
+ * Reconcile a workspace for one stage.
2608
+ *
2609
+ * Ports are loaded and saved around the call rather than inside it, so the loop
2610
+ * stays a pure-ish function of its inputs and the store keeps its single owner.
2428
2611
  */
2429
- async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
2430
- const credentialsInjection = secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
2431
- ` : "";
2432
- const content = `#!/usr/bin/env node
2433
- /**
2434
- * Entry wrapper generated by 'gkm dev --entry'
2435
- */
2436
- ${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
2437
- await import('${entryPath}');
2438
- `;
2439
- await writeFile(wrapperPath, content);
2612
+ async function reconcileWorkspace(workspace, options) {
2613
+ const runnables = {};
2614
+ const discovered = await discover({
2615
+ patterns: constructGlobs(workspace),
2616
+ cwd: workspace.root,
2617
+ runnables
2618
+ });
2619
+ const manifest = options.manifest ?? discovered;
2620
+ const result = await reconcile({
2621
+ root: workspace.root,
2622
+ project: workspace.name,
2623
+ manifest,
2624
+ stage: options.stage,
2625
+ localStage: workspace.stages.local,
2626
+ ...backendsOf(workspace),
2627
+ saved: await loadPortState(workspace.root),
2628
+ addresses: surfaceAddresses(workspace, manifest),
2629
+ apps: (containers) => appServices(workspace, manifest, containers, runnables),
2630
+ ...options.start === void 0 ? {} : { start: options.start }
2631
+ });
2632
+ await savePortState(workspace.root, { ...result.ports });
2633
+ return result;
2440
2634
  }
2441
2635
  /**
2442
- * Prepare credentials for dev/exec/test modes.
2443
- * Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
2444
- * injects PORT, dependency URLs, and writes credentials JSON.
2636
+ * Where each declared surface and site answers locally.
2445
2637
  *
2446
- * @param options.resolveDockerPorts - How to resolve Docker ports:
2447
- * - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
2448
- * - `'readonly'`: check running containers and saved state only, never probe for new ports. Used by exec.
2449
- * @param options.stages - Secret stages to try, in order. Default: ['dev', 'development'].
2450
- * @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
2451
- * @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
2452
- * @internal Exported for testing
2638
+ * The addresses come from the ports the workspace already assigns; which
2639
+ * construct sits at which one comes from the manifest. That split is the point:
2640
+ * this used to answer "who may call whom" by listing every app the workspace
2641
+ * runs, which is a different question that happened to give the same answer in
2642
+ * a single-repo workspace and no answer at all deployed. Now it answers only
2643
+ * "where does this construct answer", and the graph answers the rest.
2644
+ *
2645
+ * A site is matched to its app by `path`, because that is the one thing a site
2646
+ * declaration and a workspace app both name. Surfaces are matched to the
2647
+ * backend app: every `rest-api` in a process answers on that process's port, so
2648
+ * an app serving both its own API and an auth server publishes one address
2649
+ * twice — which is exactly what it does at runtime.
2453
2650
  */
2454
- async function prepareEntryCredentials(options) {
2455
- const cwd = options.cwd ?? process.cwd();
2456
- const portMode = options.resolveDockerPorts ?? "full";
2457
- let workspaceAppPort;
2458
- let secretsRoot = cwd;
2459
- let appName;
2460
- let appInfo;
2461
- try {
2462
- appInfo = await loadWorkspaceAppInfo(cwd);
2463
- workspaceAppPort = appInfo.app.port;
2464
- secretsRoot = appInfo.workspaceRoot;
2465
- appName = appInfo.appName;
2466
- } catch {
2467
- secretsRoot = findSecretsRoot(cwd);
2468
- appName = getAppNameFromCwd(cwd) ?? void 0;
2469
- }
2470
- const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
2471
- const credentials = await loadSecretsForApp(secretsRoot, appName, options.stages);
2472
- credentials.PORT = String(resolvedPort);
2473
- const declaredKeys = [];
2474
- if (appInfo && usesConstructs(appInfo.workspace)) {
2475
- const { reconcileWorkspace: reconcileWorkspace$1 } = await import("./workspace-DK4WWJIn.mjs");
2476
- const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
2477
- stage: options.reconcileStage ?? "development",
2478
- start: options.startDocker ?? false
2479
- });
2480
- Object.assign(credentials, reconciled.env);
2481
- declaredKeys.push(...Object.keys(reconciled.env));
2482
- if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
2483
- } else {
2484
- const composePath = join(secretsRoot, "docker-compose.yml");
2485
- const mappings = parseComposePortMappings(composePath);
2486
- if (mappings.length > 0) {
2487
- let resolvedPorts;
2488
- if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
2489
- else {
2490
- const savedPorts = await loadPortState(secretsRoot);
2491
- const ports = {};
2492
- for (const mapping of mappings) {
2493
- const containerPort = getContainerHostPort(secretsRoot, mapping.service, mapping.containerPort);
2494
- if (containerPort !== null) ports[mapping.envVar] = containerPort;
2495
- else {
2496
- const saved = savedPorts[mapping.envVar];
2497
- if (saved !== void 0) ports[mapping.envVar] = saved;
2498
- }
2499
- }
2500
- resolvedPorts = {
2501
- dockerEnv: {},
2502
- ports,
2503
- mappings
2504
- };
2505
- }
2506
- if (options.startDocker) if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
2507
- else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
2508
- if (Object.keys(resolvedPorts.ports).length > 0) {
2509
- const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
2510
- Object.assign(credentials, rewritten);
2511
- logger.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
2512
- }
2513
- }
2514
- }
2515
- if (appInfo?.appName) {
2516
- const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
2517
- Object.assign(credentials, depEnv);
2518
- }
2519
- if (!credentials.EVENT_PUBLISHER_CONNECTION_STRING && !credentials.EVENT_SUBSCRIBER_CONNECTION_STRING && credentials.PGBOSS_DB_USER) {
2520
- const pgbossUrl = generatePgBossUrl({
2521
- username: credentials.PGBOSS_DB_USER,
2522
- password: credentials.PGBOSS_DB_PASSWORD ?? "",
2523
- host: credentials.POSTGRES_HOST ?? "localhost",
2524
- port: Number(credentials.POSTGRES_PORT ?? "5432"),
2525
- database: credentials.POSTGRES_DB ?? "app"
2526
- });
2527
- credentials.EVENT_PUBLISHER_CONNECTION_STRING = pgbossUrl;
2528
- credentials.EVENT_SUBSCRIBER_CONNECTION_STRING = pgbossUrl;
2651
+ function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
2652
+ const addresses = {};
2653
+ for (const [id, declaration] of Object.entries(manifest)) {
2654
+ if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
2655
+ const host = appKey(id);
2656
+ const port = workspace.apps[host]?.port;
2657
+ if (port) addresses[id] = at(host, port);
2529
2658
  }
2530
- const secretsDir = join(secretsRoot, ".gkm");
2531
- await mkdir(secretsDir, { recursive: true });
2532
- const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
2533
- const secretsJsonPath = join(secretsDir, secretsFileName);
2534
- await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
2535
- return {
2536
- credentials,
2537
- resolvedPort,
2538
- secretsJsonPath,
2539
- appName,
2540
- secretsRoot,
2541
- appInfo,
2542
- declaredKeys
2543
- };
2659
+ return addresses;
2660
+ }
2661
+ /** Where a local process answers, given the port the workspace gave it. */
2662
+ function localAddress(port) {
2663
+ return `http://localhost:${port}`;
2544
2664
  }
2545
-
2546
2665
  //#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
2666
+ export { findAvailablePort as _, workspace_exports as a, loadSecretsForApp as b, applyPostgres as c, cacheBackendFor as d, eventsBackendFor as f, createEntryWrapper as g, createCredentialsPreload as h, reconcileWorkspace as i, resourceName as l, storageBackendFor as m, constructGlobs as n, COMPOSE_PATH as o, providerOf as p, derivedContainers as r, LOCAL_CA_PATH as s, backendsOf as t, rootSite as u, isPortAvailable as v, prepareEntryCredentials as x, loadEnvFiles as y };
2667
+
2668
+ //# sourceMappingURL=workspace-C97QUCIf.mjs.map