@geekmidas/cli 10.0.0-alpha.11 → 10.0.0-alpha.12

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 (97) hide show
  1. package/README.md +13 -40
  2. package/dist/{bundler-CaTGtiaO.cjs → bundler-BMekgx7a.cjs} +2 -17
  3. package/dist/bundler-BMekgx7a.cjs.map +1 -0
  4. package/dist/{bundler-S3ek7ea-.mjs → bundler-Bux2iF-v.mjs} +2 -17
  5. package/dist/bundler-Bux2iF-v.mjs.map +1 -0
  6. package/dist/{config-B0L2tvvC.mjs → config-DTsVjKZD.mjs} +3 -3
  7. package/dist/{config-B0L2tvvC.mjs.map → config-DTsVjKZD.mjs.map} +1 -1
  8. package/dist/{config-HhDpJmLO.cjs → config-RYR9j6EF.cjs} +3 -3
  9. package/dist/{config-HhDpJmLO.cjs.map → config-RYR9j6EF.cjs.map} +1 -1
  10. package/dist/config.cjs +3 -3
  11. package/dist/config.d.cts +2 -2
  12. package/dist/config.d.mts +2 -2
  13. package/dist/config.mjs +3 -3
  14. package/dist/{credentials-B2Pyh3bJ.cjs → credentials-DQRvPz2S.cjs} +1165 -1077
  15. package/dist/credentials-DQRvPz2S.cjs.map +1 -0
  16. package/dist/{credentials-CZrys9C5.mjs → credentials-jMJC63qs.mjs} +1144 -1068
  17. package/dist/credentials-jMJC63qs.mjs.map +1 -0
  18. package/dist/{discover-CAlFgYec.mjs → discover-BYHQwQqu.mjs} +15 -2
  19. package/dist/discover-BYHQwQqu.mjs.map +1 -0
  20. package/dist/{discover-DTmFeloL.cjs → discover-DmW9Bscc.cjs} +20 -1
  21. package/dist/discover-DmW9Bscc.cjs.map +1 -0
  22. package/dist/{fullstack-secrets-METaRLS1.mjs → fullstack-secrets-B6KBHf-g.mjs} +2 -2
  23. package/dist/{fullstack-secrets-METaRLS1.mjs.map → fullstack-secrets-B6KBHf-g.mjs.map} +1 -1
  24. package/dist/{fullstack-secrets-D6MyHhQP.cjs → fullstack-secrets-DL8v0y4D.cjs} +1 -13
  25. package/dist/{fullstack-secrets-D6MyHhQP.cjs.map → fullstack-secrets-DL8v0y4D.cjs.map} +1 -1
  26. package/dist/{github-BrvaPkFf.cjs → github-BThvbf2W.cjs} +4 -4
  27. package/dist/{github-BrvaPkFf.cjs.map → github-BThvbf2W.cjs.map} +1 -1
  28. package/dist/{github-CrpsXSfV.mjs → github-DpMZurEK.mjs} +4 -4
  29. package/dist/{github-CrpsXSfV.mjs.map → github-DpMZurEK.mjs.map} +1 -1
  30. package/dist/{index-CR5urhWX.d.cts → index-BzJ7QJ9c.d.mts} +2 -6
  31. package/dist/index-BzJ7QJ9c.d.mts.map +1 -0
  32. package/dist/{index-Ayxg_DQY.d.mts → index-CfMjtdVC.d.cts} +2 -6
  33. package/dist/index-CfMjtdVC.d.cts.map +1 -0
  34. package/dist/index.cjs +403 -1356
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.mjs +404 -1357
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{openapi-y1d7Kalt.mjs → openapi-Bs3-5q_r.mjs} +3 -3
  39. package/dist/{openapi-y1d7Kalt.mjs.map → openapi-Bs3-5q_r.mjs.map} +1 -1
  40. package/dist/{openapi-DaKlTKxz.cjs → openapi-DWLTsYLL.cjs} +3 -3
  41. package/dist/{openapi-DaKlTKxz.cjs.map → openapi-DWLTsYLL.cjs.map} +1 -1
  42. package/dist/{openapi-react-query-DoR5CmzQ.mjs → openapi-react-query-Byltvm_7.mjs} +1 -1
  43. package/dist/{openapi-react-query-DoR5CmzQ.mjs.map → openapi-react-query-Byltvm_7.mjs.map} +1 -1
  44. package/dist/{openapi-react-query-BKH334Ug.cjs → openapi-react-query-CmJ1xDbn.cjs} +1 -1
  45. package/dist/{openapi-react-query-BKH334Ug.cjs.map → openapi-react-query-CmJ1xDbn.cjs.map} +1 -1
  46. package/dist/openapi-react-query.cjs +1 -1
  47. package/dist/openapi-react-query.mjs +1 -1
  48. package/dist/openapi.cjs +4 -4
  49. package/dist/openapi.d.cts +1 -1
  50. package/dist/openapi.d.mts +1 -1
  51. package/dist/openapi.mjs +4 -4
  52. package/dist/reconcile/public.cjs +1 -1
  53. package/dist/reconcile/public.d.cts +1 -1
  54. package/dist/reconcile/public.d.mts +1 -1
  55. package/dist/reconcile/public.mjs +1 -1
  56. package/dist/{reconcile-C13ERt1U.mjs → reconcile-BI0tWkwz.mjs} +2 -2
  57. package/dist/{reconcile-C13ERt1U.mjs.map → reconcile-BI0tWkwz.mjs.map} +1 -1
  58. package/dist/{reconcile-CYMhFSYy.cjs → reconcile-Bhw5fmKu.cjs} +2 -2
  59. package/dist/{reconcile-CYMhFSYy.cjs.map → reconcile-Bhw5fmKu.cjs.map} +1 -1
  60. package/dist/setup-BmLoNrDX.mjs +12 -0
  61. package/dist/setup-BrR1_xGy.cjs +12 -0
  62. package/dist/{setup-DOv92pIu.cjs → setup-BsNR2LBG.cjs} +14 -20
  63. package/dist/setup-BsNR2LBG.cjs.map +1 -0
  64. package/dist/{setup-DQo_h_yK.mjs → setup-D5-JMknk.mjs} +14 -20
  65. package/dist/setup-D5-JMknk.mjs.map +1 -0
  66. package/dist/{types-Y3fKQSAK.d.cts → types-DVIx8I1N.d.mts} +4 -274
  67. package/dist/types-DVIx8I1N.d.mts.map +1 -0
  68. package/dist/{types-CL7jPFM2.d.mts → types-PMP_qXRY.d.cts} +4 -274
  69. package/dist/types-PMP_qXRY.d.cts.map +1 -0
  70. package/dist/workspace/index.cjs +1 -1
  71. package/dist/workspace/index.d.cts +3 -3
  72. package/dist/workspace/index.d.mts +3 -3
  73. package/dist/workspace/index.mjs +1 -1
  74. package/dist/{workspace-DQxxs7Jk.cjs → workspace-Cji6lE8E.cjs} +4 -4
  75. package/dist/workspace-CkJAtRIX.mjs +9 -0
  76. package/dist/{workspace-DaEX7FEg.mjs → workspace-CtnZnWsa.mjs} +16 -68
  77. package/dist/workspace-CtnZnWsa.mjs.map +1 -0
  78. package/dist/{workspace-C-a3G_U0.cjs → workspace-DPV7FtAv.cjs} +16 -68
  79. package/dist/workspace-DPV7FtAv.cjs.map +1 -0
  80. package/package.json +11 -11
  81. package/dist/bundler-CaTGtiaO.cjs.map +0 -1
  82. package/dist/bundler-S3ek7ea-.mjs.map +0 -1
  83. package/dist/credentials-B2Pyh3bJ.cjs.map +0 -1
  84. package/dist/credentials-CZrys9C5.mjs.map +0 -1
  85. package/dist/discover-CAlFgYec.mjs.map +0 -1
  86. package/dist/discover-DTmFeloL.cjs.map +0 -1
  87. package/dist/index-Ayxg_DQY.d.mts.map +0 -1
  88. package/dist/index-CR5urhWX.d.cts.map +0 -1
  89. package/dist/setup-BWgMs4gg.mjs +0 -12
  90. package/dist/setup-DOv92pIu.cjs.map +0 -1
  91. package/dist/setup-DQo_h_yK.mjs.map +0 -1
  92. package/dist/setup-lCx_DcUo.cjs +0 -12
  93. package/dist/types-CL7jPFM2.d.mts.map +0 -1
  94. package/dist/types-Y3fKQSAK.d.cts.map +0 -1
  95. package/dist/workspace-C-a3G_U0.cjs.map +0 -1
  96. package/dist/workspace-DaEX7FEg.mjs.map +0 -1
  97. package/dist/workspace-DbI580qS.mjs +0 -9
@@ -1,6 +1,6 @@
1
- import { DEFAULT_CACHE, DEFAULT_EMAIL, DEFAULT_STORAGE, discover } from "./discover-CAlFgYec.mjs";
2
- import { appKey, getAppNameFromCwd, hostOf, loadWorkspaceAppInfo } from "./config-B0L2tvvC.mjs";
3
- import { allConstructGlobs, getDependencyEnvVars } from "./workspace-DaEX7FEg.mjs";
1
+ import { DEFAULT_CACHE, DEFAULT_EVENTS, DEFAULT_STORAGE, discover } from "./discover-BYHQwQqu.mjs";
2
+ import { appKey, getAppNameFromCwd, hostOf, loadWorkspaceAppInfo } from "./config-DTsVjKZD.mjs";
3
+ import { allConstructGlobs, getDependencyEnvVars } from "./workspace-CtnZnWsa.mjs";
4
4
  import { generatePgBossUrl } from "./generator-VE2JrFlY.mjs";
5
5
  import { readStageSecrets, secretsExist, toEmbeddableSecrets } from "./storage-B020InPW.mjs";
6
6
  import { existsSync, readFileSync } from "node:fs";
@@ -11,53 +11,14 @@ import { execFile, execSync } from "node:child_process";
11
11
  import { createServer } from "node:net";
12
12
  import { config } from "dotenv";
13
13
  import { parse as parse$1, stringify } from "yaml";
14
+ import { createHash } from "node:crypto";
15
+ import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
14
16
  import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
15
17
  import { Client } from "pg";
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
20
 
21
21
  //#region src/workspace/backends.ts
22
- const CACHE_BACKENDS = [
23
- "upstash",
24
- "elasticache",
25
- "db"
26
- ];
27
- const EMAIL_BACKENDS = [
28
- "resend",
29
- "ses",
30
- "smtp"
31
- ];
32
- const STORAGE_BACKENDS = [
33
- "minio",
34
- "s3",
35
- "r2"
36
- ];
37
- /**
38
- * The cache backend a workspace selected, or the one its target implies.
39
- *
40
- * Defaulted here rather than by every caller, so "what happens when nobody
41
- * said" is answered once and the answer is greppable — and now answered by
42
- * *where it deploys*, because there is no single right answer for both a
43
- * Lambda and a box running its own Postgres.
44
- *
45
- * `on` is the deploy target, not where this code happens to be running. A
46
- * local `gkm dev` for an AWS app still defaults to Upstash, because a backend
47
- * that differed between local and deployed would be worse than a slower one —
48
- * which is the property the type's own documentation asks for.
49
- */
50
- function cacheBackendOf(value, on = "aws") {
51
- return isBackend(value, CACHE_BACKENDS) ? value : DEFAULT_CACHE[on];
52
- }
53
- /** The email backend a workspace selected, defaulting to SES on every target. */
54
- function emailBackendOf(value) {
55
- return isBackend(value, EMAIL_BACKENDS) ? value : DEFAULT_EMAIL;
56
- }
57
- /** The storage backend a workspace selected, or the one its target implies. */
58
- function storageBackendOf(value, on = "aws") {
59
- return isBackend(value, STORAGE_BACKENDS) ? value : DEFAULT_STORAGE[on];
60
- }
61
22
  /**
62
23
  * Which family of defaults a workspace's deploy target belongs to.
63
24
  *
@@ -76,15 +37,163 @@ function providerOf(workspace) {
76
37
  return target === "dokploy" || target === "server" ? "server" : "aws";
77
38
  }
78
39
  /**
79
- * Whether a string names a backend, as opposed to being one of the other things
80
- * a `services:` value can be.
40
+ * The cache backend a target implies.
41
+ *
42
+ * The same answer locally and deployed: a local `gkm dev` for an AWS app still
43
+ * uses the Upstash protocol, because a backend that differed between the two
44
+ * would be worse than a slower one.
45
+ */
46
+ function cacheBackendFor(on) {
47
+ return DEFAULT_CACHE[on];
48
+ }
49
+ /** Where a declared bucket lives on a target. */
50
+ function storageBackendFor(on) {
51
+ return DEFAULT_STORAGE[on];
52
+ }
53
+ /**
54
+ * Which broker carries declared queues and topics on a target.
55
+ *
56
+ * Only consulted when something declares one; a project with no `Topic` or
57
+ * `Queue` has no events at all, whatever the target.
58
+ */
59
+ function eventsBackendFor(on) {
60
+ return DEFAULT_EVENTS[on];
61
+ }
62
+
63
+ //#endregion
64
+ //#region src/reconcile/containers.ts
65
+ /**
66
+ * What gkm knows about each container it can derive.
67
+ *
68
+ * Everything provider-specific about the local target lives here: the image, the
69
+ * ports the image listens on, and whether its data is worth keeping. The plan
70
+ * says *which* containers exist and never says any of this, which is the split
71
+ * the design asks for — neutral facts from the declaration, provider detail
72
+ * from here and from config.
73
+ *
74
+ * Shared by allocation and by the compose writer so the two cannot disagree
75
+ * about how many ports a container needs.
76
+ */
77
+ /**
78
+ * The Postgres image for a major version.
79
+ *
80
+ * One place that knows the tag shape, so a declared version reaches the
81
+ * container without anyone restating `-alpine` beside it.
82
+ */
83
+ function postgresImage(version) {
84
+ return `postgres:${version}-alpine`;
85
+ }
86
+ /**
87
+ * The default image for each container.
88
+ *
89
+ * Pinned to a major rather than `latest` where the image publishes one, so a
90
+ * rebuild does not silently move a developer's Postgres a version. Overridable
91
+ * per project: a project needing `postgis/postgis` says so in config.
92
+ */
93
+ const DEFAULT_IMAGES = {
94
+ postgres: postgresImage(DEFAULT_POSTGRES_VERSION),
95
+ minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
96
+ mailpit: "axllent/mailpit:latest",
97
+ redis: "redis:8-alpine",
98
+ "redis-http": "hiett/serverless-redis-http:latest",
99
+ rabbitmq: "rabbitmq:4-management-alpine",
100
+ localstack: "floci/floci:latest",
101
+ caddy: "caddy:2-alpine"
102
+ };
103
+ /**
104
+ * The ports each container listens on.
105
+ *
106
+ * The first is always the one an app connects to; the rest are consoles and
107
+ * management UIs, published because a bucket or an inbox you can look at is most
108
+ * of why these are real containers rather than stubs.
109
+ */
110
+ const PORTS = {
111
+ postgres: [{
112
+ key: "postgres",
113
+ inside: 5432,
114
+ label: "postgres"
115
+ }],
116
+ minio: [{
117
+ key: "minio",
118
+ inside: 9e3,
119
+ label: "minio api"
120
+ }, {
121
+ key: "minio-console",
122
+ inside: 9001,
123
+ label: "minio console"
124
+ }],
125
+ mailpit: [{
126
+ key: "mailpit",
127
+ inside: 1025,
128
+ label: "smtp"
129
+ }, {
130
+ key: "mailpit-web",
131
+ inside: 8025,
132
+ label: "mailpit inbox"
133
+ }],
134
+ redis: [{
135
+ key: "redis",
136
+ inside: 6379,
137
+ label: "redis"
138
+ }],
139
+ "redis-http": [{
140
+ key: "redis-http",
141
+ inside: 80,
142
+ label: "cache"
143
+ }],
144
+ rabbitmq: [{
145
+ key: "rabbitmq",
146
+ inside: 5672,
147
+ label: "amqp"
148
+ }, {
149
+ key: "rabbitmq-management",
150
+ inside: 15672,
151
+ label: "rabbitmq console"
152
+ }],
153
+ localstack: [{
154
+ key: "localstack",
155
+ inside: 4566,
156
+ label: "localstack"
157
+ }],
158
+ caddy: [{
159
+ key: "caddy",
160
+ inside: 443,
161
+ label: "https edge"
162
+ }]
163
+ };
164
+ /** The ports a container needs published. Empty for one gkm does not know. */
165
+ function portsOf(container) {
166
+ return PORTS[container] ?? [];
167
+ }
168
+ /** The key a container's primary port is allocated under. */
169
+ function primaryPortKey(container) {
170
+ return portsOf(container)[0]?.key ?? container;
171
+ }
172
+ /**
173
+ * Every port key a set of containers needs, in a stable order.
174
+ *
175
+ * What allocation is handed: it assigns ports to names and knows nothing about
176
+ * what listens on them.
177
+ */
178
+ function portKeys(containers) {
179
+ return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
180
+ }
181
+ /**
182
+ * The named volume a container's data lives in.
81
183
  *
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.
184
+ * Mailpit deliberately has none: local mail is disposable, and an inbox that
185
+ * survives days of dev is noise rather than state.
85
186
  */
86
- function isBackend(value, backends) {
87
- return typeof value === "string" && backends.includes(value);
187
+ function volumeOf(container) {
188
+ const volumes = {
189
+ postgres: "postgres-data",
190
+ minio: "minio-data",
191
+ redis: "redis-data",
192
+ rabbitmq: "rabbitmq-data",
193
+ localstack: "localstack-data",
194
+ caddy: "caddy-data"
195
+ };
196
+ return volumes[container];
88
197
  }
89
198
 
90
199
  //#endregion
@@ -248,1132 +357,1097 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
248
357
  `;
249
358
 
250
359
  //#endregion
251
- //#region src/reconcile/clients.ts
252
- /** The credential local containers are brought up with. */
253
- const LOCAL_USER$2 = "geekmidas";
360
+ //#region src/reconcile/plan.ts
254
361
  /**
255
- * A Postgres client that opens one connection per database it is asked about.
362
+ * Which container serves a kind.
256
363
  *
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.
364
+ * Derived kinds map to the same container as their parent — a schema tenant
365
+ * lives in its parent's Postgres — so the set is deduplicated rather than
366
+ * counted.
260
367
  */
261
- function pgClient(port) {
262
- return { async query(database, sql, values) {
263
- const client = new Client({
264
- host: "localhost",
265
- port,
266
- user: LOCAL_USER$2,
267
- password: LOCAL_USER$2,
268
- database: database ?? LOCAL_USER$2
269
- });
270
- await client.connect();
271
- try {
272
- const result = await client.query(sql, values);
273
- return result.rows;
274
- } finally {
275
- await client.end();
276
- }
277
- } };
278
- }
279
- /** A bucket client pointed at the local MinIO. */
280
- function bucketClient(port) {
281
- const s3 = new S3Client({
282
- region: "us-east-1",
283
- endpoint: `http://localhost:${port}`,
284
- forcePathStyle: true,
285
- credentials: {
286
- accessKeyId: LOCAL_USER$2,
287
- secretAccessKey: LOCAL_USER$2
288
- }
289
- });
290
- return {
291
- async exists(bucket) {
292
- try {
293
- await s3.send(new HeadBucketCommand({ Bucket: bucket }));
294
- return true;
295
- } catch {
296
- return false;
297
- }
298
- },
299
- async create(bucket) {
300
- await s3.send(new CreateBucketCommand({ Bucket: bucket }));
301
- },
302
- async policy(bucket) {
303
- try {
304
- const result = await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }));
305
- return result.Policy;
306
- } catch {
307
- return void 0;
308
- }
309
- },
310
- async setPolicy(bucket, policy) {
311
- await s3.send(new PutBucketPolicyCommand({
312
- Bucket: bucket,
313
- Policy: policy
314
- }));
315
- }
316
- };
317
- }
318
-
319
- //#endregion
320
- //#region src/reconcile/containers.ts
368
+ const CONTAINERS = {
369
+ database: "postgres",
370
+ "database-reader": "postgres",
371
+ "database-schema": "postgres",
372
+ objects: "minio",
373
+ "file-server": "caddy",
374
+ email: "mailpit"
375
+ };
321
376
  /**
322
- * What gkm knows about each container it can derive.
377
+ * The container a cache backend needs locally.
323
378
  *
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.
379
+ * Each one matches the protocol its deployed form speaks, which is the property
380
+ * worth having: a cache that behaves differently in the two places is worse than
381
+ * a slower one.
329
382
  *
330
- * Shared by allocation and by the compose writer so the two cannot disagree
331
- * about how many ports a container needs.
383
+ * `db` is deliberately absent, the same way `pgboss` is absent from
384
+ * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
385
+ * declares, so mapping it here would start a container for a project that needs
386
+ * none.
332
387
  */
388
+ const CACHE_CONTAINERS = {
389
+ upstash: "redis-http",
390
+ elasticache: "redis"
391
+ };
333
392
  /**
334
- * The Postgres image for a major version.
393
+ * Kinds that resolve a value without anything running.
335
394
  *
336
- * One place that knows the tag shape, so a declared version reaches the
337
- * container without anyone restating `-alpine` beside it.
395
+ * A secret is the case: it has no address, so there is nothing to start and
396
+ * nothing to connect to — but it still appears in the plan, because the target
397
+ * still has to resolve it.
338
398
  */
339
- function postgresImage(version) {
340
- return `postgres:${version}-alpine`;
341
- }
342
- /**
343
- * The default image for each container.
344
- *
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.
399
+ const CONTAINERLESS = {
400
+ secret: true,
401
+ credential: true,
402
+ "rest-api": true,
403
+ site: true
404
+ };
405
+ /**
406
+ * Containers that cannot run alone.
407
+ *
408
+ * The one case is the cache: `serverless-redis-http` is a proxy and needs the
409
+ * Redis it proxies. Kept here rather than in the compose definition so the plan
410
+ * -- which is what decides what must be running -- still names everything.
348
411
  */
349
- const DEFAULT_IMAGES = {
350
- postgres: postgresImage(DEFAULT_POSTGRES_VERSION),
351
- minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
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"
412
+ const REQUIRES = { "redis-http": ["redis"] };
413
+ /**
414
+ * The role each kind's one key plays, where it is not a URL.
415
+ *
416
+ * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
417
+ * construct declares, this is what the local target resolves, and both derive
418
+ * the key from the same `provideKey` so they cannot disagree.
419
+ */
420
+ const ROLES = {
421
+ queue: "publisherConnectionString",
422
+ topic: "publisherConnectionString",
423
+ credential: "credential"
424
+ };
425
+ /** The kinds whose container is the events backend's rather than their own. */
426
+ const EVENT_KINDS = {
427
+ queue: true,
428
+ topic: true
358
429
  };
359
430
  /**
360
- * The ports each container listens on.
431
+ * Which kinds provision something *inside* their container.
361
432
  *
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.
433
+ * A database creates a database, a bucket creates a bucket; mail creates
434
+ * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
435
+ * to name. Both still appear in the plan, because both still resolve a URL; the
436
+ * flag is the difference between "must be running" and "must be created".
365
437
  */
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
- }]
438
+ const PROVISIONS = {
439
+ database: true,
440
+ "database-reader": true,
441
+ "database-schema": true,
442
+ objects: true,
443
+ cache: true,
444
+ "file-server": true
419
445
  };
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
446
  /**
429
- * Every port key a set of containers needs, in a stable order.
447
+ * The container each events backend needs, where it needs one of its own.
430
448
  *
431
- * What allocation is handed: it assigns ports to names and knows nothing about
432
- * what listens on them.
449
+ * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
450
+ * schema tenant in a database the manifest already declares, so mapping it here
451
+ * would start a Postgres for a project that declared no database at all.
452
+ *
453
+ * This one comes from config rather than from a declaration because there are
454
+ * no `topic` or `queue` kinds yet. When they land the mapping moves to
455
+ * {@link CONTAINERS} keyed by kind, and this constant goes with them.
433
456
  */
434
- function portKeys(containers) {
435
- return [...containers].sort().flatMap((container) => portsOf(container).map((port) => port.key));
436
- }
457
+ const EVENT_CONTAINERS = {
458
+ sns: "localstack",
459
+ rabbitmq: "rabbitmq"
460
+ };
461
+ /** The backend a project gets when it declares queues or topics and says nothing. */
462
+ const DEFAULT_EVENTS$1 = "pgboss";
437
463
  /**
438
- * The named volume a container's data lives in.
464
+ * How a resource of each kind separates its stage suffix.
439
465
  *
440
- * Mailpit deliberately has none: local mail is disposable, and an inbox that
441
- * survives days of dev is noise rather than state.
466
+ * Postgres identifiers are snake_case and a hyphen would need quoting
467
+ * everywhere; buckets and queues are kebab and disallow underscores in some
468
+ * providers. The suffix is the same word either way.
442
469
  */
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];
470
+ const SEPARATORS = {
471
+ database: "_",
472
+ "database-reader": "_",
473
+ "database-schema": "_",
474
+ objects: "-"
475
+ };
476
+ /**
477
+ * The stage-scoped name for a resource.
478
+ *
479
+ * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
480
+ * developer's psql history, saved connections, and muscle memory survive a
481
+ * change of stage name. Every other stage is suffixed.
482
+ */
483
+ function resourceName(id, kind, stage, localStage) {
484
+ const base = id.toLowerCase();
485
+ if (stage === localStage) return base;
486
+ return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
453
487
  }
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
488
  /**
460
- * The token the local cache accepts.
489
+ * The container a kind needs, or `undefined` if it needs none.
461
490
  *
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.
491
+ * Queues and topics have no container of their own — they live in whichever
492
+ * broker the project selected, which is why the backend is an argument here
493
+ * rather than a second pass that adds containers after the fact.
465
494
  */
466
- const LOCAL_TOKEN$1 = "geekmidas";
495
+ function containerFor(kind, events = DEFAULT_EVENTS$1, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
496
+ if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
497
+ if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
498
+ if (kind === "cache") {
499
+ if (derived) return CONTAINERS.database;
500
+ return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
501
+ }
502
+ return CONTAINERS[kind];
503
+ }
467
504
  /**
468
- * Build the compose document for a plan.
505
+ * Build the plan for a manifest and stage.
469
506
  *
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.
507
+ * `order` is the caller's provisioning order — parents before children — which
508
+ * `provisionOrder` produces from the manifest. Passing it in keeps this
509
+ * function free of the traversal and makes the ordering assertable on its own.
473
510
  */
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] = {};
511
+ function planFor(manifest, stage, order, options = {}) {
512
+ const containers = /* @__PURE__ */ new Set();
513
+ const resources = [];
514
+ const events = options.events ?? DEFAULT_EVENTS$1;
515
+ const cache = options.cache ?? DEFAULT_CACHE.aws;
516
+ for (const id of order) {
517
+ const declaration = manifest[id];
518
+ if (!declaration) continue;
519
+ const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
520
+ if (!container && !CONTAINERLESS[declaration.kind]) continue;
521
+ if (container) {
522
+ containers.add(container);
523
+ for (const required of REQUIRES[container] ?? []) containers.add(required);
524
+ }
525
+ resources.push({
526
+ id,
527
+ kind: declaration.kind,
528
+ ...container ? { container } : {},
529
+ name: resourceName(id, declaration.kind, stage, options.localStage),
530
+ envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
531
+ provisions: PROVISIONS[declaration.kind] === true,
532
+ ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
533
+ ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
534
+ ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
535
+ ..."of" in declaration ? { of: declaration.of } : {},
536
+ ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
537
+ ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
538
+ ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
539
+ ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
540
+ });
541
+ }
542
+ if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
543
+ for (const extra of options.extraContainers ?? []) containers.add(extra);
544
+ 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));
545
+ if (cache === "db") {
546
+ const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
547
+ if (placeless.length > 0) {
548
+ const databases = resources.filter((r) => r.kind === "database");
549
+ const [only] = databases;
550
+ if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
551
+ if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
552
+ for (const resource of placeless) resource.of = only.id;
553
+ }
484
554
  }
485
555
  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 })}`;
556
+ stage,
557
+ events,
558
+ cache,
559
+ containers: [...containers],
560
+ resources
561
+ };
500
562
  }
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;
563
+ /**
564
+ * Queues or topics were declared with pg-boss and no database to hold them.
565
+ *
566
+ * Not a reconcile failure but a manifest one: the fix is a line of application
567
+ * code either way — declare a database, or select a backend that brings its own
568
+ * broker.
569
+ */
570
+ var CacheNeedsDatabase = class extends Error {
571
+ constructor(ids) {
572
+ 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(", ")}.`);
573
+ this.ids = ids;
574
+ this.name = "CacheNeedsDatabase";
511
575
  }
512
576
  };
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
577
  /**
687
- * The published port mappings for a container.
578
+ * A `'db'` cache — a server target's default — in an app that declares more
579
+ * than one database.
688
580
  *
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.
581
+ * The target says *a* database and the graph offers several, so there is no
582
+ * answer to give — and the wrong kind of failure would be to pick one, since a
583
+ * cache silently landing in the wrong database is a bug that surfaces as
584
+ * missing rows much later. The fix is a stronger statement in application code:
585
+ * `orders.cache('Sessions')` names the database, which is a fact about the
586
+ * application rather than a deployment choice.
692
587
  */
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;
588
+ var CacheIsAmbiguous = class extends Error {
589
+ constructor(ids, databases) {
590
+ 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(", ")}.`);
591
+ this.ids = ids;
592
+ this.databases = databases;
593
+ this.name = "CacheIsAmbiguous";
708
594
  }
709
595
  };
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
- }
596
+ var PgBossNeedsDatabase = class extends Error {
597
+ constructor(ids) {
598
+ 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(", ")}.`);
599
+ this.ids = ids;
600
+ this.name = "PgBossNeedsDatabase";
793
601
  }
794
602
  };
795
603
 
796
604
  //#endregion
797
- //#region src/reconcile/plan.ts
798
- /**
799
- * Which container serves a kind.
800
- *
801
- * Derived kinds map to the same container as their parent — a schema tenant
802
- * lives in its parent's Postgres — so the set is deduplicated rather than
803
- * counted.
804
- */
805
- const CONTAINERS = {
806
- database: "postgres",
807
- "database-reader": "postgres",
808
- "database-schema": "postgres",
809
- objects: "minio",
810
- "file-server": "caddy",
811
- email: "mailpit"
812
- };
605
+ //#region src/reconcile/env.ts
813
606
  /**
814
- * The container a cache backend needs locally.
815
- *
816
- * Each one matches the protocol its deployed form speaks, which is the property
817
- * worth having: a cache that behaves differently in the two places is worse than
818
- * a slower one.
607
+ * The credential local containers are brought up with.
819
608
  *
820
- * `db` is deliberately absent, the same way `pgboss` is absent from
821
- * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
822
- * declares, so mapping it here would start a container for a project that needs
823
- * none.
609
+ * The design's `roles: false` case — both URLs fall back to the cluster's master
610
+ * credential. A deliberate downgrade, not a default: it holds until the role DDL
611
+ * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
612
+ * and the owner URL stops being reachable from any manifest edge.
824
613
  */
825
- const CACHE_CONTAINERS = {
826
- upstash: "redis-http",
827
- elasticache: "redis"
828
- };
614
+ const LOCAL_USER$2 = "geekmidas";
615
+ /** The host containers are published on. */
616
+ const LOCAL_HOST = "localhost";
617
+ /** The region local buckets claim, so the client has one to sign with. */
618
+ const LOCAL_REGION = "us-east-1";
829
619
  /**
830
- * Kinds that resolve a value without anything running.
620
+ * The credentials the S3 client resolves for MinIO.
831
621
  *
832
- * A secret is the case: it has no address, so there is nothing to start and
833
- * nothing to connect to — but it still appears in the plan, because the target
834
- * still has to resolve it.
622
+ * An `s3://` URL deliberately carries none: deployed, the SDK reads them from
623
+ * the execution role, so a URL that embedded a key would be one more thing to
624
+ * rotate and one more thing to leak into a log. Locally there is no role, and
625
+ * the same chain reads these — which is why they are injected beside the URL
626
+ * rather than written into it.
835
627
  */
836
- const CONTAINERLESS = {
837
- secret: true,
838
- credential: true,
839
- "rest-api": true,
840
- site: true
628
+ const STORAGE_CREDENTIALS = {
629
+ AWS_ACCESS_KEY_ID: LOCAL_USER$2,
630
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER$2,
631
+ AWS_REGION: LOCAL_REGION
841
632
  };
633
+ /** The token the local cache proxy accepts. Matches the compose definition. */
634
+ const LOCAL_TOKEN$1 = "geekmidas";
635
+ /** The schema pg-boss keeps its tables in, inside the declared database. */
636
+ const PGBOSS_SCHEMA = "pgboss";
637
+ /** The exchange every local topic and queue shares on RabbitMQ. */
638
+ const RABBITMQ_EXCHANGE = "geekmidas.events";
842
639
  /**
843
- * Containers that cannot run alone.
844
- *
845
- * The one case is the cache: `serverless-redis-http` is a proxy and needs the
846
- * Redis it proxies. Kept here rather than in the compose definition so the plan
847
- * -- which is what decides what must be running -- still names everything.
848
- */
849
- const REQUIRES = { "redis-http": ["redis"] };
850
- /**
851
- * The role each kind's one key plays, where it is not a URL.
640
+ * Every env key a reconciled plan resolves, and its value.
852
641
  *
853
- * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
854
- * construct declares, this is what the local target resolves, and both derive
855
- * the key from the same `provideKey` so they cannot disagree.
642
+ * Keys come from the declaration rather than being re-derived, so what the build
643
+ * publishes and what the app reads cannot drift.
856
644
  */
857
- const ROLES = {
858
- queue: "publisherConnectionString",
859
- topic: "publisherConnectionString",
860
- credential: "credential"
861
- };
862
- /** The kinds whose container is the events backend's rather than their own. */
863
- const EVENT_KINDS = {
864
- queue: true,
865
- topic: true
866
- };
645
+ function envFor(plan, options) {
646
+ const env = {};
647
+ const resolved = {};
648
+ for (const resource of plan.resources) {
649
+ const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
650
+ if (url) resolved[resource.id] = url;
651
+ }
652
+ for (const resource of plan.resources) {
653
+ const url = resolved[resource.id];
654
+ if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
655
+ if (url) env[resource.envKey] = url;
656
+ if (isDatabase(resource.kind) && resource.roles !== false) {
657
+ const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
658
+ if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
659
+ }
660
+ if (resource.kind === "email" && url) env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
661
+ }
662
+ Object.assign(env, publicEnv(plan, env));
663
+ Object.assign(env, brokerEnv(plan, options.ports));
664
+ if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
665
+ return env;
666
+ }
867
667
  /**
868
- * Which kinds provision something *inside* their container.
668
+ * The values a site's bundler inlines, under the names it inlines them by.
869
669
  *
870
- * A database creates a database, a bucket creates a bucket; mail creates
871
- * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
872
- * to name. Both still appear in the plan, because both still resolve a URL; the
873
- * flag is the difference between "must be running" and "must be created".
670
+ * A rename and nothing more. `API_URL` was resolved once, by the same code that
671
+ * resolved it for the server; a site reads the same value under `VITE_API_URL`
672
+ * because that prefix is how its bundler is told to ship it. Nothing is derived
673
+ * twice, so a site and its API cannot come to disagree about where the API is.
674
+ *
675
+ * A source that resolved to nothing is skipped rather than written empty: an
676
+ * inlined empty string is a frontend that builds and then fails at runtime
677
+ * against `http:///`, where a missing variable fails at build with the name of
678
+ * the thing that is missing.
874
679
  */
875
- const PROVISIONS = {
876
- database: true,
877
- "database-reader": true,
878
- "database-schema": true,
879
- objects: true,
880
- cache: true,
881
- "file-server": true
882
- };
680
+ function publicEnv(plan, resolved) {
681
+ const env = {};
682
+ for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
683
+ const value = resolved[source];
684
+ if (value) env[key] = value;
685
+ }
686
+ return env;
687
+ }
883
688
  /**
884
- * The container each events backend needs, where it needs one of its own.
689
+ * What a surface publishes beyond its own address: who may call it, and where
690
+ * a cookie set by it is readable.
885
691
  *
886
- * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
887
- * schema tenant in a database the manifest already declares, so mapping it here
888
- * would start a Postgres for a project that declared no database at all.
692
+ * Both are read off the same list — the constructs that declared an edge to
693
+ * this surface — which is why neither appears in application code. A surface
694
+ * that listed its own callers would be edited every time something new called
695
+ * it, and the thing being edited is already recorded in the graph.
889
696
  *
890
- * This one comes from config rather than from a declaration because there are
891
- * no `topic` or `queue` kinds yet. When they land the mapping moves to
892
- * {@link CONTAINERS} keyed by kind, and this constant goes with them.
697
+ * An empty origin list is written, and a surface with no address writes nothing
698
+ * at all. The two cases look alike and are not: "nothing depends on this yet" is
699
+ * a real state a target should publish, while "this surface has no address here"
700
+ * means it is not running in this stage, and keys belong with the address they
701
+ * describe.
893
702
  */
894
- const EVENT_CONTAINERS = {
895
- sns: "localstack",
896
- rabbitmq: "rabbitmq"
897
- };
898
- /** The backend a project gets when it declares queues or topics and says nothing. */
899
- const DEFAULT_EVENTS = "pgboss";
703
+ function surfaceEnv(resource, url, resolved = {}) {
704
+ if (!url) return {};
705
+ const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
706
+ const domain = cookieDomain([url, ...origins]);
707
+ return {
708
+ [provideKey(resource.id, "trustedOrigins")]: origins.join(","),
709
+ ...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {}
710
+ };
711
+ }
712
+ /** An address reduced to the origin a browser compares against. */
713
+ function originOf(address) {
714
+ try {
715
+ return new URL(address).origin;
716
+ } catch {
717
+ return void 0;
718
+ }
719
+ }
900
720
  /**
901
- * How a resource of each kind separates its stage suffix.
721
+ * The runtime role of whatever a derived node hangs off.
902
722
  *
903
- * Postgres identifiers are snake_case and a hyphen would need quoting
904
- * everywhere; buckets and queues are kebab and disallow underscores in some
905
- * providers. The suffix is the same word either way.
723
+ * A reader's parent may itself be a schema tenant, so this follows `of` by one
724
+ * hop rather than assuming the cluster — the reader of a tenant reads that
725
+ * tenant's schema, not the database's.
906
726
  */
907
- const SEPARATORS = {
908
- database: "_",
909
- "database-reader": "_",
910
- "database-schema": "_",
911
- objects: "-"
912
- };
727
+ function parentRole(resource, plan) {
728
+ const parent = plan.resources.find((r) => r.id === resource.of);
729
+ return parent ? localRole(parent) : resource.name;
730
+ }
731
+ /** Whether a kind connects to Postgres and therefore has an owner role. */
732
+ function isDatabase(kind) {
733
+ return kind === "database" || kind === "database-schema";
734
+ }
913
735
  /**
914
- * The stage-scoped name for a resource.
736
+ * The URL a migrator connects with — the owner role, which can create, alter
737
+ * and drop.
915
738
  *
916
- * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
917
- * developer's psql history, saved connections, and muscle memory survive a
918
- * change of stage name. Every other stage is suffixed.
739
+ * Never in `provides`, which is the whole point. A handler that could reach this
740
+ * could `DROP TABLE`, and the security property the role split exists for is
741
+ * that it cannot: the value is injected by `gkm exec` for the migrate step and
742
+ * by nothing else.
919
743
  */
920
- function resourceName(id, kind, stage, localStage) {
921
- const base = id.toLowerCase();
922
- if (stage === localStage) return base;
923
- return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
744
+ function ownerUrl(resource, plan, ports, project) {
745
+ if (!resource.container) return void 0;
746
+ const port = ports[primaryPortKey(resource.container)];
747
+ if (port === void 0) return void 0;
748
+ const owner = ownerRole(localRole(resource));
749
+ return postgres(port, rootDatabase(resource, plan), {
750
+ user: owner,
751
+ password: localRolePassword(project, plan, owner)
752
+ });
924
753
  }
925
754
  /**
926
- * The container a kind needs, or `undefined` if it needs none.
755
+ * The shared broker keys, when anything is published or consumed.
927
756
  *
928
- * Queues and topics have no container of their own — they live in whichever
929
- * broker the project selected, which is why the backend is an argument here
930
- * rather than a second pass that adds containers after the fact.
757
+ * A queue's own key is the producer's. These two are the *connection* itself,
758
+ * which locally is one broker for every queue and topic in the project: the
759
+ * generated pollers open a single connection and subscribe each worker by name
760
+ * on it. Deployed there is no such thing — a Lambda is handed its own event
761
+ * source — so this pair exists for the local target and says so.
931
762
  */
932
- function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
933
- if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
934
- if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
935
- if (kind === "cache") {
936
- if (derived) return CONTAINERS.database;
937
- return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
938
- }
939
- return CONTAINERS[kind];
763
+ function brokerEnv(plan, ports) {
764
+ const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
765
+ if (!carrier) return {};
766
+ const publisher = urlFor(carrier, plan, ports, "");
767
+ if (!publisher) return {};
768
+ return {
769
+ EVENT_PUBLISHER_CONNECTION_STRING: publisher,
770
+ EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
771
+ };
940
772
  }
941
773
  /**
942
- * Build the plan for a manifest and stage.
774
+ * The URL for one planned resource.
943
775
  *
944
- * `order` is the caller's provisioning order — parents before children — which
945
- * `provisionOrder` produces from the manifest. Passing it in keeps this
946
- * function free of the traversal and makes the ordering assertable on its own.
776
+ * Every part of it comes from the resource and the port it was published on.
947
777
  */
948
- function planFor(manifest, stage, order, options = {}) {
949
- const containers = /* @__PURE__ */ new Set();
950
- const resources = [];
951
- const events = options.events ?? DEFAULT_EVENTS;
952
- const cache = options.cache ?? DEFAULT_CACHE.aws;
953
- for (const id of order) {
954
- const declaration = manifest[id];
955
- if (!declaration) continue;
956
- const container = containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
957
- if (!container && !CONTAINERLESS[declaration.kind]) continue;
958
- if (container) {
959
- containers.add(container);
960
- for (const required of REQUIRES[container] ?? []) containers.add(required);
778
+ function urlFor(resource, plan, ports, project, addresses = {}) {
779
+ if (resource.kind === "secret") return localSecret(project, plan, resource);
780
+ if (resource.kind === "credential") return void 0;
781
+ if (resource.kind === "rest-api" || resource.kind === "site") {
782
+ const address = addresses[resource.id];
783
+ const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
784
+ return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
785
+ }
786
+ if (!resource.container) return void 0;
787
+ const port = ports[primaryPortKey(resource.container)];
788
+ if (port === void 0) return void 0;
789
+ switch (resource.kind) {
790
+ case "database":
791
+ case "database-schema":
792
+ case "database-reader": {
793
+ const database = rootDatabase(resource, plan);
794
+ if (resource.roles === false) {
795
+ const schema = schemaOf(resource, plan);
796
+ return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
797
+ }
798
+ const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
799
+ return postgres(port, database, {
800
+ user: role,
801
+ password: localRolePassword(project, plan, role)
802
+ });
961
803
  }
962
- resources.push({
963
- id,
964
- kind: declaration.kind,
965
- ...container ? { container } : {},
966
- name: resourceName(id, declaration.kind, stage, options.localStage),
967
- envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
968
- provisions: PROVISIONS[declaration.kind] === true,
969
- ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
970
- ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
971
- ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
972
- ..."of" in declaration ? { of: declaration.of } : {},
973
- ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
974
- ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
975
- ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
976
- ..."table" in declaration && declaration.table ? { table: declaration.table } : {}
977
- });
804
+ case "email": return `smtp://${LOCAL_HOST}:${port}`;
805
+ case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
806
+ case "file-server": {
807
+ const origin = plan.resources.find((r) => r.id === resource.of);
808
+ if (!origin) return void 0;
809
+ return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
810
+ }
811
+ case "cache":
812
+ if (resource.of) {
813
+ const parent = plan.resources.find((r) => r.id === resource.of);
814
+ const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
815
+ return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
816
+ }
817
+ switch (plan.cache) {
818
+ case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
819
+ default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
820
+ }
821
+ case "queue":
822
+ case "topic": return broker(plan, port);
823
+ default: return void 0;
978
824
  }
979
- if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
980
- for (const extra of options.extraContainers ?? []) containers.add(extra);
981
- 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));
982
- if (cache === "db") {
983
- const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
984
- if (placeless.length > 0) {
985
- const databases = resources.filter((r) => r.kind === "database");
986
- const [only] = databases;
987
- if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
988
- if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
989
- for (const resource of placeless) resource.of = only.id;
825
+ }
826
+ /**
827
+ * The producer's connection string, composed from the backend the plan chose.
828
+ *
829
+ * The protocol is what picks the transport, so a producer never branches: the
830
+ * same `.publish()` reaches pg-boss here and SQS deployed because the string it
831
+ * was handed said so.
832
+ */
833
+ function broker(plan, port) {
834
+ switch (plan.events) {
835
+ case "pgboss": {
836
+ const database = plan.resources.find((r) => r.kind === "database");
837
+ if (!database) throw new PgBossNeedsDatabase([]);
838
+ return `pgboss://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
990
839
  }
840
+ case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
841
+ case "sns": throw new UnprovisionedEventsBackend("sns");
991
842
  }
992
- return {
993
- stage,
994
- events,
995
- cache,
996
- containers: [...containers],
997
- resources
998
- };
843
+ }
844
+ /** A local Postgres URL, master credential and all. */
845
+ function postgres(port, database, credential = {
846
+ user: LOCAL_USER$2,
847
+ password: LOCAL_USER$2
848
+ }) {
849
+ return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
850
+ }
851
+ /**
852
+ * The role a handler connects as, for one database or tenant.
853
+ *
854
+ * The construct's id, lowercased and stage-scoped exactly as its database or
855
+ * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
856
+ * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
857
+ * in Postgres, which is why the suffix is not optional here the way it is for a
858
+ * schema.
859
+ */
860
+ function localRole(resource) {
861
+ return resource.name;
999
862
  }
1000
863
  /**
1001
- * Queues or topics were declared with pg-boss and no database to hold them.
864
+ * The password for a derived local role.
1002
865
  *
1003
- * Not a reconcile failure but a manifest one: the fix is a line of application
1004
- * code either way — declare a database, or select a backend that brings its own
1005
- * broker.
866
+ * Derived rather than random, for the same reason the local secret is: a
867
+ * password that changed on every `gkm dev` would lock a developer out of the
868
+ * data they had a moment ago. Seeded by the project so two checkouts do not
869
+ * share one, and by the stage so `test` and `development` do not.
870
+ *
871
+ * It is a *local* credential by construction — the cluster is on loopback and
872
+ * the seed is a checkout path — and no deployed target uses this function.
1006
873
  */
1007
- var CacheNeedsDatabase = class extends Error {
1008
- constructor(ids) {
1009
- 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(", ")}.`);
1010
- this.ids = ids;
1011
- this.name = "CacheNeedsDatabase";
1012
- }
1013
- };
874
+ function localRolePassword(project, plan, role) {
875
+ return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
876
+ }
1014
877
  /**
1015
- * `services.cache: 'db'` in an app that declares more than one database.
878
+ * A cache's address: its database's URL, carrying the table it reads.
1016
879
  *
1017
- * The config says *a* database and the graph offers several, so there is no
1018
- * answer to give — and the wrong kind of failure would be to pick one, since a
1019
- * cache silently landing in the wrong database is a bug that surfaces as
1020
- * missing rows much later. The fix is a stronger statement in application code:
1021
- * `orders.cache('Sessions')` names the database, which is a fact about the
1022
- * application rather than a deployment choice.
880
+ * Appended rather than kept beside the URL as a second key, for the reason the
881
+ * design gives everywhere else — an address and what it opens are one fact, and
882
+ * two keys are one more thing to keep in step.
1023
883
  */
1024
- var CacheIsAmbiguous = class extends Error {
1025
- constructor(ids, databases) {
1026
- 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(", ")}.`);
1027
- this.ids = ids;
1028
- this.databases = databases;
1029
- this.name = "CacheIsAmbiguous";
1030
- }
1031
- };
1032
- var PgBossNeedsDatabase = class extends Error {
1033
- constructor(ids) {
1034
- 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(", ")}.`);
1035
- this.ids = ids;
1036
- this.name = "PgBossNeedsDatabase";
1037
- }
1038
- };
1039
-
1040
- //#endregion
1041
- //#region src/reconcile/env.ts
884
+ function withTable(url, table) {
885
+ const parsed = new URL(url);
886
+ parsed.searchParams.set("table", table);
887
+ return parsed.toString();
888
+ }
1042
889
  /**
1043
- * The credential local containers are brought up with.
890
+ * The physical database a derived resource lives in.
1044
891
  *
1045
- * The design's `roles: false` case — both URLs fall back to the cluster's master
1046
- * credential. A deliberate downgrade, not a default: it holds until the role DDL
1047
- * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
1048
- * and the owner URL stops being reachable from any manifest edge.
892
+ * Walks `of` to the top rather than reading the immediate parent, because a
893
+ * reader's parent may itself be a schema tenant — two hops from the database
894
+ * that actually exists.
1049
895
  */
1050
- const LOCAL_USER = "geekmidas";
1051
- /** The host containers are published on. */
1052
- const LOCAL_HOST = "localhost";
1053
- /** The region local buckets claim, so the client has one to sign with. */
1054
- const LOCAL_REGION = "us-east-1";
896
+ function rootDatabase(resource, plan) {
897
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
898
+ let current = resource;
899
+ while (current.of) {
900
+ const parent = byId.get(current.of);
901
+ if (!parent) break;
902
+ current = parent;
903
+ }
904
+ return current.name;
905
+ }
906
+ /** The schema a derived resource puts on its search path, if it has one. */
907
+ function schemaOf(resource, plan) {
908
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
909
+ let current = resource;
910
+ while (current) {
911
+ if (current.kind === "database-schema") return current.schema;
912
+ current = current.of ? byId.get(current.of) : void 0;
913
+ }
914
+ return void 0;
915
+ }
1055
916
  /**
1056
- * The credentials the S3 client resolves for MinIO.
917
+ * A backend whose local addresses cannot be derived yet.
1057
918
  *
1058
- * An `s3://` URL deliberately carries none: deployed, the SDK reads them from
1059
- * the execution role, so a URL that embedded a key would be one more thing to
1060
- * rotate and one more thing to leak into a log. Locally there is no role, and
1061
- * the same chain reads these — which is why they are injected beside the URL
1062
- * rather than written into it.
919
+ * SNS and SQS are addressed by ARN, which means the topic and queue have to be
920
+ * created in LocalStack before a URL can name them. Until that provisioning
921
+ * lands, saying so is better than handing out a string that fails on first use.
1063
922
  */
1064
- const STORAGE_CREDENTIALS = {
1065
- AWS_ACCESS_KEY_ID: LOCAL_USER,
1066
- AWS_SECRET_ACCESS_KEY: LOCAL_USER,
1067
- AWS_REGION: LOCAL_REGION
923
+ var UnprovisionedEventsBackend = class extends Error {
924
+ constructor(backend) {
925
+ 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.`);
926
+ this.backend = backend;
927
+ this.name = "UnprovisionedEventsBackend";
928
+ }
1068
929
  };
1069
- /** The token the local cache proxy accepts. Matches the compose definition. */
1070
- const LOCAL_TOKEN = "geekmidas";
1071
- /** The schema pg-boss keeps its tables in, inside the declared database. */
1072
- const PGBOSS_SCHEMA = "pgboss";
1073
- /** The exchange every local topic and queue shares on RabbitMQ. */
1074
- const RABBITMQ_EXCHANGE = "geekmidas.events";
1075
930
  /**
1076
- * Every env key a reconciled plan resolves, and its value.
931
+ * The value a secret resolves to locally.
1077
932
  *
1078
- * Keys come from the declaration rather than being re-derived, so what the build
1079
- * publishes and what the app reads cannot drift.
933
+ * A hash of what identifies it, so it is stable, distinct per project, stage
934
+ * and construct, and never written to disk as a literal. Deployed this is the
935
+ * one kind whose value does *not* come from here — a secret manager generates
936
+ * it once and the target reads it back.
1080
937
  */
1081
- function envFor(plan, options) {
1082
- const env = {};
1083
- const resolved = {};
1084
- for (const resource of plan.resources) {
1085
- const url = urlFor(resource, plan, options.ports, options.project ?? "", options.addresses);
1086
- if (url) resolved[resource.id] = url;
1087
- }
1088
- for (const resource of plan.resources) {
1089
- const url = resolved[resource.id];
1090
- if (resource.kind === "rest-api") Object.assign(env, surfaceEnv(resource, url, resolved));
1091
- if (url) env[resource.envKey] = url;
1092
- if (isDatabase(resource.kind) && resource.roles !== false) {
1093
- const owner = ownerUrl(resource, plan, options.ports, options.project ?? "");
1094
- if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
1095
- }
1096
- if (resource.kind === "email" && url) env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
1097
- }
1098
- Object.assign(env, publicEnv(plan, env));
1099
- Object.assign(env, brokerEnv(plan, options.ports));
1100
- if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
1101
- return env;
938
+ function localSecret(project, plan, resource) {
939
+ return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
1102
940
  }
941
+
942
+ //#endregion
943
+ //#region src/reconcile/apps.ts
944
+ /** The profile app services carry, so `gkm dev` starts only the containers. */
945
+ const APPS_PROFILE = "apps";
1103
946
  /**
1104
- * The values a site's bundler inlines, under the names it inlines them by.
947
+ * Where an app's image is built from.
1105
948
  *
1106
- * A rename and nothing more. `API_URL` was resolved once, by the same code that
1107
- * resolved it for the server; a site reads the same value under `VITE_API_URL`
1108
- * because that prefix is how its bundler is told to ship it. Nothing is derived
1109
- * twice, so a site and its API cannot come to disagree about where the API is.
949
+ * An app at the project root is a single-app project, whose `gkm docker`
950
+ * writes `.gkm/docker/Dockerfile`; a workspace writes one per app.
951
+ */
952
+ function dockerfileOf(appName, path) {
953
+ return path === "." ? ".gkm/docker/Dockerfile" : `.gkm/docker/Dockerfile.${appName}`;
954
+ }
955
+ /**
956
+ * Every construct key, addressed across the compose network.
1110
957
  *
1111
- * A source that resolved to nothing is skipped rather than written empty: an
1112
- * inlined empty string is a frontend that builds and then fails at runtime
1113
- * against `http:///`, where a missing variable fails at build with the name of
1114
- * the thing that is missing.
958
+ * `envFor` is asked with a placeholder port for every port key, so each
959
+ * `localhost:<placeholder>` in its answer names exactly one container port —
960
+ * two containers listening on the same port inside cannot be confused — and is
961
+ * rewritten to `<container>:<inside>`.
1115
962
  */
1116
- function publicEnv(plan, resolved) {
1117
- const env = {};
1118
- for (const resource of plan.resources) for (const [key, source] of Object.entries(resource.publicEnv ?? {})) {
1119
- const value = resolved[source];
1120
- if (value) env[key] = value;
963
+ function inNetworkEnv(workspace, manifest) {
964
+ const plan = planFor(manifest, workspace.stages.local, provisionOrder(manifest), {
965
+ localStage: workspace.stages.local,
966
+ ...backendsOf(workspace)
967
+ });
968
+ const placeholders = {};
969
+ const targets = /* @__PURE__ */ new Map();
970
+ let next = 49152;
971
+ for (const container of [...plan.containers].sort()) for (const port of portsOf(container)) {
972
+ placeholders[port.key] = next;
973
+ targets.set(next, `${container}:${port.inside}`);
974
+ next += 1;
1121
975
  }
1122
- return env;
976
+ for (const key of portKeys(plan.containers)) placeholders[key] ??= next++;
977
+ const env = envFor(plan, {
978
+ ports: placeholders,
979
+ project: workspace.name,
980
+ addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
981
+ });
982
+ const rewrite = (value) => value.replace(/(?:localhost|127\.0\.0\.1):(\d+)/g, (match, port) => {
983
+ return targets.get(Number(port)) ?? match;
984
+ });
985
+ return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
986
+ }
987
+ /** One compose service per app the workspace runs, behind the apps profile. */
988
+ function appServices(workspace, manifest, containers) {
989
+ const env = inNetworkEnv(workspace, manifest);
990
+ const services = {};
991
+ for (const [name, app] of Object.entries(workspace.apps)) {
992
+ if (app.type === "mobile" || !app.port) continue;
993
+ const key = appKey(name);
994
+ const health = app.type === "web" ? "/" : "/health";
995
+ services[key] = {
996
+ image: `${key}:\${TAG:-latest}`,
997
+ build: {
998
+ context: ".",
999
+ dockerfile: dockerfileOf(name, app.path)
1000
+ },
1001
+ profiles: [APPS_PROFILE],
1002
+ ports: [`${app.port}:${app.port}`],
1003
+ environment: {
1004
+ NODE_ENV: "production",
1005
+ PORT: String(app.port),
1006
+ ...env
1007
+ },
1008
+ ...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
1009
+ healthcheck: {
1010
+ test: [
1011
+ "CMD",
1012
+ "wget",
1013
+ "-q",
1014
+ "--spider",
1015
+ `http://localhost:${app.port}${health}`
1016
+ ],
1017
+ interval: "10s",
1018
+ timeout: "5s",
1019
+ retries: 5
1020
+ }
1021
+ };
1022
+ }
1023
+ return services;
1123
1024
  }
1025
+
1026
+ //#endregion
1027
+ //#region src/reconcile/clients.ts
1028
+ /** The credential local containers are brought up with. */
1029
+ const LOCAL_USER$1 = "geekmidas";
1124
1030
  /**
1125
- * What a surface publishes beyond its own address: who may call it, and where
1126
- * a cookie set by it is readable.
1127
- *
1128
- * Both are read off the same list — the constructs that declared an edge to
1129
- * this surface — which is why neither appears in application code. A surface
1130
- * that listed its own callers would be edited every time something new called
1131
- * it, and the thing being edited is already recorded in the graph.
1031
+ * A Postgres client that opens one connection per database it is asked about.
1132
1032
  *
1133
- * An empty origin list is written, and a surface with no address writes nothing
1134
- * at all. The two cases look alike and are not: "nothing depends on this yet" is
1135
- * a real state a target should publish, while "this surface has no address here"
1136
- * means it is not running in this stage, and keys belong with the address they
1137
- * describe.
1033
+ * `CREATE DATABASE` cannot run from a connection to the database being created,
1034
+ * and a schema must be created from inside its own database — so the database is
1035
+ * part of each query rather than fixed when the client is built.
1138
1036
  */
1139
- function surfaceEnv(resource, url, resolved = {}) {
1140
- if (!url) return {};
1141
- const origins = [...new Set((resource.callers ?? []).map((caller) => resolved[caller]).filter((address) => Boolean(address)).map(originOf).filter((origin) => Boolean(origin)))].sort();
1142
- const domain = cookieDomain([url, ...origins]);
1037
+ function pgClient(port) {
1038
+ return { async query(database, sql, values) {
1039
+ const client = new Client({
1040
+ host: "localhost",
1041
+ port,
1042
+ user: LOCAL_USER$1,
1043
+ password: LOCAL_USER$1,
1044
+ database: database ?? LOCAL_USER$1
1045
+ });
1046
+ await client.connect();
1047
+ try {
1048
+ const result = await client.query(sql, values);
1049
+ return result.rows;
1050
+ } finally {
1051
+ await client.end();
1052
+ }
1053
+ } };
1054
+ }
1055
+ /** A bucket client pointed at the local MinIO. */
1056
+ function bucketClient(port) {
1057
+ const s3 = new S3Client({
1058
+ region: "us-east-1",
1059
+ endpoint: `http://localhost:${port}`,
1060
+ forcePathStyle: true,
1061
+ credentials: {
1062
+ accessKeyId: LOCAL_USER$1,
1063
+ secretAccessKey: LOCAL_USER$1
1064
+ }
1065
+ });
1143
1066
  return {
1144
- [provideKey(resource.id, "trustedOrigins")]: origins.join(","),
1145
- ...domain ? { [provideKey(resource.id, "cookieDomain")]: domain } : {}
1067
+ async exists(bucket) {
1068
+ try {
1069
+ await s3.send(new HeadBucketCommand({ Bucket: bucket }));
1070
+ return true;
1071
+ } catch {
1072
+ return false;
1073
+ }
1074
+ },
1075
+ async create(bucket) {
1076
+ await s3.send(new CreateBucketCommand({ Bucket: bucket }));
1077
+ },
1078
+ async policy(bucket) {
1079
+ try {
1080
+ const result = await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }));
1081
+ return result.Policy;
1082
+ } catch {
1083
+ return void 0;
1084
+ }
1085
+ },
1086
+ async setPolicy(bucket, policy) {
1087
+ await s3.send(new PutBucketPolicyCommand({
1088
+ Bucket: bucket,
1089
+ Policy: policy
1090
+ }));
1091
+ }
1146
1092
  };
1147
1093
  }
1148
- /** An address reduced to the origin a browser compares against. */
1149
- function originOf(address) {
1150
- try {
1151
- return new URL(address).origin;
1152
- } catch {
1153
- return void 0;
1154
- }
1155
- }
1156
- /**
1157
- * The runtime role of whatever a derived node hangs off.
1158
- *
1159
- * A reader's parent may itself be a schema tenant, so this follows `of` by one
1160
- * hop rather than assuming the cluster — the reader of a tenant reads that
1161
- * tenant's schema, not the database's.
1162
- */
1163
- function parentRole(resource, plan) {
1164
- const parent = plan.resources.find((r) => r.id === resource.of);
1165
- return parent ? localRole(parent) : resource.name;
1166
- }
1167
- /** Whether a kind connects to Postgres and therefore has an owner role. */
1168
- function isDatabase(kind) {
1169
- return kind === "database" || kind === "database-schema";
1170
- }
1094
+
1095
+ //#endregion
1096
+ //#region src/reconcile/compose.ts
1097
+ /** The credential every derived container is brought up with locally. */
1098
+ const LOCAL_USER = "geekmidas";
1171
1099
  /**
1172
- * The URL a migrator connects with — the owner role, which can create, alter
1173
- * and drop.
1100
+ * The token the local cache accepts.
1174
1101
  *
1175
- * Never in `provides`, which is the whole point. A handler that could reach this
1176
- * could `DROP TABLE`, and the security property the role split exists for is
1177
- * that it cannot: the value is injected by `gkm exec` for the migrate step and
1178
- * by nothing else.
1102
+ * Fixed rather than generated: it is only ever reachable on a port this project
1103
+ * published, and the URL that carries it is derived, so nothing is made safer
1104
+ * by making it unguessable and a stable one keeps `redis-cli` sessions working.
1179
1105
  */
1180
- function ownerUrl(resource, plan, ports, project) {
1181
- if (!resource.container) return void 0;
1182
- const port = ports[primaryPortKey(resource.container)];
1183
- if (port === void 0) return void 0;
1184
- const owner = ownerRole(localRole(resource));
1185
- return postgres(port, rootDatabase(resource, plan), {
1186
- user: owner,
1187
- password: localRolePassword(project, plan, owner)
1188
- });
1189
- }
1106
+ const LOCAL_TOKEN = "geekmidas";
1190
1107
  /**
1191
- * The shared broker keys, when anything is published or consumed.
1108
+ * Build the compose document for a plan.
1192
1109
  *
1193
- * A queue's own key is the producer's. These two are the *connection* itself,
1194
- * which locally is one broker for every queue and topic in the project: the
1195
- * generated pollers open a single connection and subscribe each worker by name
1196
- * on it. Deployed there is no such thing — a Lambda is handed its own event
1197
- * source — so this pair exists for the local target and says so.
1110
+ * Every container is published on its assigned port, never on the image's
1111
+ * default: the point of allocation is that two projects can run at once, and
1112
+ * one container publishing 5432 puts that back.
1198
1113
  */
1199
- function brokerEnv(plan, ports) {
1200
- const carrier = plan.resources.find((r) => r.kind === "queue" || r.kind === "topic");
1201
- if (!carrier) return {};
1202
- const publisher = urlFor(carrier, plan, ports, "");
1203
- if (!publisher) return {};
1114
+ function composeFor(plan, options) {
1115
+ const services = {};
1116
+ const volumes = {};
1117
+ for (const container of [...plan.containers].sort()) {
1118
+ const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
1119
+ const image = declared ? postgresImage(declared) : DEFAULT_IMAGES[container];
1120
+ if (!image) throw new UnknownContainer(container);
1121
+ services[container] = define(container, image, publish(container, options.ports));
1122
+ const volume = volumeOf(container);
1123
+ if (volume) volumes[volume] = {};
1124
+ }
1204
1125
  return {
1205
- EVENT_PUBLISHER_CONNECTION_STRING: publisher,
1206
- EVENT_SUBSCRIBER_CONNECTION_STRING: plan.events === "sns" ? publisher.replace(/^sns:/, "sqs:") : publisher
1126
+ name: options.project,
1127
+ services: {
1128
+ ...services,
1129
+ ...options.apps
1130
+ },
1131
+ ...Object.keys(volumes).length ? { volumes } : {}
1207
1132
  };
1208
1133
  }
1209
- /**
1210
- * The URL for one planned resource.
1211
- *
1212
- * Every part of it comes from the resource and the port it was published on.
1213
- */
1214
- function urlFor(resource, plan, ports, project, addresses = {}) {
1215
- if (resource.kind === "secret") return localSecret(project, plan, resource);
1216
- if (resource.kind === "credential") return void 0;
1217
- if (resource.kind === "rest-api" || resource.kind === "site") {
1218
- const address = addresses[resource.id];
1219
- const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
1220
- return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
1134
+ /** Render a compose document as the file it becomes. */
1135
+ function toYaml(file) {
1136
+ return `# Generated by gkm from the construct manifest — do not edit.
1137
+ # Containers are derived from what the app declares. Image pins and anything
1138
+ # no construct implies go in your own docker-compose.yml, which is merged over
1139
+ # this file and wins:
1140
+ #
1141
+ # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
1142
+ #
1143
+ # "gkm dev" and "gkm test" start the containers. The apps sit behind the
1144
+ # "apps" profile: run "gkm setup", then
1145
+ # docker compose -f docker-compose.constructs.yml --profile apps up --build
1146
+ ${stringify(file, { lineWidth: 0 })}`;
1147
+ }
1148
+ /** A container with no image, from config or from the defaults. */
1149
+ var UnknownContainer = class extends Error {
1150
+ /** The container that could not be resolved. */
1151
+ container;
1152
+ /** The containers that have a default, so the caller can see the set. */
1153
+ known = Object.keys(DEFAULT_IMAGES).sort();
1154
+ constructor(container) {
1155
+ super("No image is known for this container; pin one in config");
1156
+ this.name = "UnknownContainer";
1157
+ this.container = container;
1221
1158
  }
1222
- if (!resource.container) return void 0;
1223
- const port = ports[primaryPortKey(resource.container)];
1224
- if (port === void 0) return void 0;
1225
- switch (resource.kind) {
1226
- case "database":
1227
- case "database-schema":
1228
- case "database-reader": {
1229
- const database = rootDatabase(resource, plan);
1230
- if (resource.roles === false) {
1231
- const schema = schemaOf(resource, plan);
1232
- return schema ? `${postgres(port, database)}?search_path=${schema}` : postgres(port, database);
1159
+ };
1160
+ /** One container's definition, published on the port it was assigned. */
1161
+ function define(container, image, published) {
1162
+ switch (container) {
1163
+ case "postgres": return {
1164
+ image,
1165
+ restart: "unless-stopped",
1166
+ ports: published,
1167
+ environment: {
1168
+ POSTGRES_USER: LOCAL_USER,
1169
+ POSTGRES_PASSWORD: LOCAL_USER,
1170
+ POSTGRES_DB: LOCAL_USER
1171
+ },
1172
+ volumes: ["postgres-data:/var/lib/postgresql"],
1173
+ healthcheck: {
1174
+ test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
1175
+ interval: "5s",
1176
+ timeout: "3s",
1177
+ retries: 10,
1178
+ start_period: "10s"
1233
1179
  }
1234
- const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
1235
- return postgres(port, database, {
1236
- user: role,
1237
- password: localRolePassword(project, plan, role)
1238
- });
1239
- }
1240
- case "email": return `smtp://${LOCAL_HOST}:${port}`;
1241
- case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
1242
- case "file-server": {
1243
- const origin = plan.resources.find((r) => r.id === resource.of);
1244
- if (!origin) return void 0;
1245
- return plan.containers.includes("caddy") ? `https://${hostFor(resource, project)}:${port}` : `http://${LOCAL_HOST}:${port}/${origin.name}`;
1246
- }
1247
- case "cache":
1248
- if (resource.of) {
1249
- const parent = plan.resources.find((r) => r.id === resource.of);
1250
- const url = parent ? urlFor(parent, plan, ports, project, addresses) : void 0;
1251
- return url ? withTable(url, resource.table ?? cacheTable(resource.id)) : void 0;
1180
+ };
1181
+ case "minio": return {
1182
+ image,
1183
+ restart: "unless-stopped",
1184
+ ports: published,
1185
+ environment: {
1186
+ MINIO_ROOT_USER: LOCAL_USER,
1187
+ MINIO_ROOT_PASSWORD: LOCAL_USER
1188
+ },
1189
+ command: "server --console-address \":9001\" /data",
1190
+ volumes: ["minio-data:/data"],
1191
+ healthcheck: {
1192
+ test: [
1193
+ "CMD",
1194
+ "mc",
1195
+ "ready",
1196
+ "local"
1197
+ ],
1198
+ interval: "10s",
1199
+ timeout: "5s",
1200
+ retries: 5,
1201
+ start_period: "10s"
1202
+ }
1203
+ };
1204
+ case "caddy": return {
1205
+ image,
1206
+ restart: "unless-stopped",
1207
+ ports: published,
1208
+ volumes: [
1209
+ "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
1210
+ "./.gkm/caddy-sites:/etc/caddy/sites:ro",
1211
+ "caddy-data:/data"
1212
+ ],
1213
+ extra_hosts: ["host.docker.internal:host-gateway"],
1214
+ healthcheck: {
1215
+ test: [
1216
+ "CMD",
1217
+ "caddy",
1218
+ "version"
1219
+ ],
1220
+ interval: "10s",
1221
+ timeout: "5s",
1222
+ retries: 5
1223
+ }
1224
+ };
1225
+ case "mailpit": return {
1226
+ image,
1227
+ restart: "unless-stopped",
1228
+ ports: published,
1229
+ environment: {
1230
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
1231
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1"
1232
+ },
1233
+ healthcheck: {
1234
+ test: [
1235
+ "CMD",
1236
+ "wget",
1237
+ "-q",
1238
+ "--spider",
1239
+ "http://localhost:8025"
1240
+ ],
1241
+ interval: "10s",
1242
+ timeout: "5s",
1243
+ retries: 5
1244
+ }
1245
+ };
1246
+ case "redis": return {
1247
+ image,
1248
+ restart: "unless-stopped",
1249
+ ports: published,
1250
+ volumes: ["redis-data:/data"],
1251
+ healthcheck: {
1252
+ test: [
1253
+ "CMD",
1254
+ "redis-cli",
1255
+ "ping"
1256
+ ],
1257
+ interval: "5s",
1258
+ timeout: "3s",
1259
+ retries: 5
1260
+ }
1261
+ };
1262
+ case "redis-http": return {
1263
+ image,
1264
+ restart: "unless-stopped",
1265
+ ports: published,
1266
+ environment: {
1267
+ SRH_MODE: "env",
1268
+ SRH_TOKEN: LOCAL_TOKEN,
1269
+ SRH_CONNECTION_STRING: "redis://redis:6379"
1270
+ },
1271
+ depends_on: ["redis"],
1272
+ healthcheck: {
1273
+ test: [
1274
+ "CMD",
1275
+ "wget",
1276
+ "-q",
1277
+ "-O",
1278
+ "/dev/null",
1279
+ "http://127.0.0.1:80/"
1280
+ ],
1281
+ interval: "5s",
1282
+ timeout: "3s",
1283
+ retries: 10
1252
1284
  }
1253
- switch (plan.cache) {
1254
- case "elasticache": return `redis://${LOCAL_HOST}:${port}`;
1255
- default: return `http://:${LOCAL_TOKEN}@${LOCAL_HOST}:${port}`;
1285
+ };
1286
+ case "rabbitmq": return {
1287
+ image,
1288
+ restart: "unless-stopped",
1289
+ ports: published,
1290
+ environment: {
1291
+ RABBITMQ_DEFAULT_USER: LOCAL_USER,
1292
+ RABBITMQ_DEFAULT_PASS: LOCAL_USER
1293
+ },
1294
+ volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
1295
+ healthcheck: {
1296
+ test: [
1297
+ "CMD",
1298
+ "rabbitmq-diagnostics",
1299
+ "-q",
1300
+ "ping"
1301
+ ],
1302
+ interval: "10s",
1303
+ timeout: "5s",
1304
+ retries: 5
1256
1305
  }
1257
- case "queue":
1258
- case "topic": return broker(plan, port);
1259
- default: return void 0;
1260
- }
1261
- }
1262
- /**
1263
- * The producer's connection string, composed from the backend the plan chose.
1264
- *
1265
- * The protocol is what picks the transport, so a producer never branches: the
1266
- * same `.publish()` reaches pg-boss here and SQS deployed because the string it
1267
- * was handed said so.
1268
- */
1269
- function broker(plan, port) {
1270
- switch (plan.events) {
1271
- case "pgboss": {
1272
- const database = plan.resources.find((r) => r.kind === "database");
1273
- if (!database) throw new PgBossNeedsDatabase([]);
1274
- return `pgboss://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
1275
- }
1276
- case "rabbitmq": return `rabbitmq://${LOCAL_USER}:${LOCAL_USER}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
1277
- case "sns": throw new UnprovisionedEventsBackend("sns");
1306
+ };
1307
+ case "localstack": return {
1308
+ image,
1309
+ restart: "unless-stopped",
1310
+ ports: published,
1311
+ environment: {
1312
+ AWS_DEFAULT_REGION: "us-east-1",
1313
+ AWS_ACCESS_KEY_ID: "LSIAQAAAAAAVNCBMPNSG",
1314
+ AWS_SECRET_ACCESS_KEY: LOCAL_USER
1315
+ },
1316
+ volumes: ["localstack-data:/var/lib/localstack"],
1317
+ healthcheck: {
1318
+ test: [
1319
+ "CMD",
1320
+ "curl",
1321
+ "-f",
1322
+ "http://localhost:4566/_localstack/health"
1323
+ ],
1324
+ interval: "10s",
1325
+ timeout: "5s",
1326
+ retries: 5,
1327
+ start_period: "10s"
1328
+ }
1329
+ };
1330
+ default: throw new UnknownContainer(container);
1278
1331
  }
1279
1332
  }
1280
- /** A local Postgres URL, master credential and all. */
1281
- function postgres(port, database, credential = {
1282
- user: LOCAL_USER,
1283
- password: LOCAL_USER
1284
- }) {
1285
- return `postgres://${credential.user}:${credential.password}@${LOCAL_HOST}:${port}/${database}`;
1286
- }
1287
- /**
1288
- * The role a handler connects as, for one database or tenant.
1289
- *
1290
- * The construct's id, lowercased and stage-scoped exactly as its database or
1291
- * schema is — so `Orders` in the `test` stage connects as `orders_test`, and two
1292
- * stages sharing one cluster cannot share a credential. Roles are cluster-scoped
1293
- * in Postgres, which is why the suffix is not optional here the way it is for a
1294
- * schema.
1295
- */
1296
- function localRole(resource) {
1297
- return resource.name;
1298
- }
1299
- /**
1300
- * The password for a derived local role.
1301
- *
1302
- * Derived rather than random, for the same reason the local secret is: a
1303
- * password that changed on every `gkm dev` would lock a developer out of the
1304
- * data they had a moment ago. Seeded by the project so two checkouts do not
1305
- * share one, and by the stage so `test` and `development` do not.
1306
- *
1307
- * It is a *local* credential by construction — the cluster is on loopback and
1308
- * the seed is a checkout path — and no deployed target uses this function.
1309
- */
1310
- function localRolePassword(project, plan, role) {
1311
- return createHash("sha256").update(`${project}:${plan.stage}:role:${role}`).digest("base64url").slice(0, 32);
1312
- }
1313
- /**
1314
- * A cache's address: its database's URL, carrying the table it reads.
1315
- *
1316
- * Appended rather than kept beside the URL as a second key, for the reason the
1317
- * design gives everywhere else — an address and what it opens are one fact, and
1318
- * two keys are one more thing to keep in step.
1319
- */
1320
- function withTable(url, table) {
1321
- const parsed = new URL(url);
1322
- parsed.searchParams.set("table", table);
1323
- return parsed.toString();
1324
- }
1325
1333
  /**
1326
- * The physical database a derived resource lives in.
1334
+ * The published port mappings for a container.
1327
1335
  *
1328
- * Walks `of` to the top rather than reading the immediate parent, because a
1329
- * reader's parent may itself be a schema tenant — two hops from the database
1330
- * that actually exists.
1336
+ * Every port is looked up by its own key rather than being derived as an offset
1337
+ * from the container's first, so MinIO's console cannot land on whatever was
1338
+ * allocated to the next container.
1331
1339
  */
1332
- function rootDatabase(resource, plan) {
1333
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1334
- let current = resource;
1335
- while (current.of) {
1336
- const parent = byId.get(current.of);
1337
- if (!parent) break;
1338
- current = parent;
1339
- }
1340
- return current.name;
1341
- }
1342
- /** The schema a derived resource puts on its search path, if it has one. */
1343
- function schemaOf(resource, plan) {
1344
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1345
- let current = resource;
1346
- while (current) {
1347
- if (current.kind === "database-schema") return current.schema;
1348
- current = current.of ? byId.get(current.of) : void 0;
1349
- }
1350
- return void 0;
1340
+ function publish(container, ports) {
1341
+ return portsOf(container).map((port) => {
1342
+ const assigned = ports[port.key];
1343
+ if (assigned === void 0) throw new UnassignedPort(port.key);
1344
+ return `${assigned}:${port.inside}`;
1345
+ });
1351
1346
  }
1352
- /**
1353
- * A backend whose local addresses cannot be derived yet.
1354
- *
1355
- * SNS and SQS are addressed by ARN, which means the topic and queue have to be
1356
- * created in LocalStack before a URL can name them. Until that provisioning
1357
- * lands, saying so is better than handing out a string that fails on first use.
1358
- */
1359
- var UnprovisionedEventsBackend = class extends Error {
1360
- constructor(backend) {
1361
- 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.`);
1362
- this.backend = backend;
1363
- this.name = "UnprovisionedEventsBackend";
1347
+ /** A planned port that allocation never assigned. */
1348
+ var UnassignedPort = class extends Error {
1349
+ /** The port key that went unassigned, e.g. `minio-console`. */
1350
+ key;
1351
+ constructor(key) {
1352
+ super("No port was assigned for this container port");
1353
+ this.name = "UnassignedPort";
1354
+ this.key = key;
1364
1355
  }
1365
1356
  };
1357
+
1358
+ //#endregion
1359
+ //#region src/reconcile/docker.ts
1360
+ const run = promisify(execFile);
1366
1361
  /**
1367
- * The value a secret resolves to locally.
1362
+ * The generated file, then the project's own if it has one.
1368
1363
  *
1369
- * A hash of what identifies it, so it is stable, distinct per project, stage
1370
- * and construct, and never written to disk as a literal. Deployed this is the
1371
- * one kind whose value does *not* come from here — a secret manager generates
1372
- * it once and the target reads it back.
1364
+ * Merged in that order so the project's wins — an `image:` there pins a
1365
+ * container this file derived, and a service there is added beside them.
1373
1366
  */
1374
- function localSecret(project, plan, resource) {
1375
- return createHash("sha256").update(`${project}:${plan.stage}:${resource.id}`).digest("base64url").slice(0, 43);
1367
+ function composeFiles(composePath) {
1368
+ const project = join(dirname(composePath), "docker-compose.yml");
1369
+ return [
1370
+ "-f",
1371
+ composePath,
1372
+ ...existsSync(project) ? ["-f", project] : []
1373
+ ];
1376
1374
  }
1375
+ /** How long to wait for containers to pass their health checks. */
1376
+ const HEALTH_TIMEOUT_MS = 12e4;
1377
+ const dockerCli = {
1378
+ async publishedPort(composePath, service, inside) {
1379
+ try {
1380
+ const { stdout } = await run("docker", [
1381
+ "compose",
1382
+ ...composeFiles(composePath),
1383
+ "port",
1384
+ service,
1385
+ String(inside)
1386
+ ]);
1387
+ const port = Number(stdout.trim().split(":").pop());
1388
+ return Number.isFinite(port) && port > 0 ? port : void 0;
1389
+ } catch {
1390
+ return void 0;
1391
+ }
1392
+ },
1393
+ async up(composePath, services) {
1394
+ await run("docker", [
1395
+ "compose",
1396
+ ...composeFiles(composePath),
1397
+ "up",
1398
+ "-d",
1399
+ "--remove-orphans",
1400
+ "--wait",
1401
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
1402
+ ...services
1403
+ ]);
1404
+ },
1405
+ async copyOut(composePath, service, from, to) {
1406
+ await mkdir(dirname(to), { recursive: true });
1407
+ await run("docker", [
1408
+ "compose",
1409
+ ...composeFiles(composePath),
1410
+ "cp",
1411
+ `${service}:${from}`,
1412
+ to
1413
+ ]);
1414
+ },
1415
+ async reload(composePath, service) {
1416
+ await run("docker", [
1417
+ "compose",
1418
+ ...composeFiles(composePath),
1419
+ "exec",
1420
+ "-T",
1421
+ service,
1422
+ "caddy",
1423
+ "reload",
1424
+ "--config",
1425
+ "/etc/caddy/Caddyfile"
1426
+ ]);
1427
+ },
1428
+ async healthy(composePath, services) {
1429
+ if (services.length === 0) return true;
1430
+ try {
1431
+ const { stdout } = await run("docker", [
1432
+ "compose",
1433
+ ...composeFiles(composePath),
1434
+ "ps",
1435
+ "--format",
1436
+ "json",
1437
+ ...services
1438
+ ]);
1439
+ const running = /* @__PURE__ */ new Set();
1440
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
1441
+ const entry = JSON.parse(line);
1442
+ const ok = entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy");
1443
+ if (ok && entry.Service) running.add(entry.Service);
1444
+ }
1445
+ return services.every((service) => running.has(service));
1446
+ } catch {
1447
+ return false;
1448
+ }
1449
+ }
1450
+ };
1377
1451
 
1378
1452
  //#endregion
1379
1453
  //#region src/reconcile/ports.ts
@@ -1799,8 +1873,15 @@ function statePath(root, stage) {
1799
1873
 
1800
1874
  //#endregion
1801
1875
  //#region src/reconcile/index.ts
1802
- /** Where the generated compose file is written, relative to the project root. */
1803
- const COMPOSE_PATH = ".gkm/docker-compose.yml";
1876
+ /**
1877
+ * Where the generated compose file is written, relative to the project root.
1878
+ *
1879
+ * At the root, beside the project's own `docker-compose.yml`, because the two
1880
+ * are one stack: this file is everything the constructs imply and is never
1881
+ * edited, that one is anything they cannot express — an image pin, an extra
1882
+ * service — and is merged over it, so it wins.
1883
+ */
1884
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
1804
1885
  /**
1805
1886
  * The local edge's config, beside the compose file that mounts it.
1806
1887
  *
@@ -1853,7 +1934,7 @@ async function reconcile(options) {
1853
1934
  const compose = composeFor(plan, {
1854
1935
  project,
1855
1936
  ports,
1856
- ...options.images ? { images: options.images } : {}
1937
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
1857
1938
  });
1858
1939
  const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
1859
1940
  const hash = planHash(plan, compose, { caddyfile });
@@ -1877,7 +1958,7 @@ async function reconcile(options) {
1877
1958
  changed: false
1878
1959
  };
1879
1960
  const recorded = await loadState(root, stage);
1880
- const converged = recorded?.hash === hash && (!start || await docker.healthy(composePath, plan.containers));
1961
+ const converged = recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers));
1881
1962
  if (converged) return result;
1882
1963
  await write(composePath, toYaml(compose));
1883
1964
  if (plan.containers.includes("caddy")) {
@@ -1971,16 +2052,13 @@ function constructGlobs(workspace) {
1971
2052
  function usesConstructs(workspace) {
1972
2053
  return constructGlobs(workspace).length > 0;
1973
2054
  }
1974
- /**
1975
- * Image pins, by container. The config half of the derived/config split.
1976
- *
1977
- * Read from `services.images` rather than from the backend keys beside it.
1978
- * They used to share one key, which is how `cache: true` came to mean "start a
1979
- * Redis" — the last way a container could exist because config asked for one
1980
- * instead of because something declared it.
1981
- */
1982
- function imagePins(workspace) {
1983
- return { ...workspace.services.images };
2055
+ /** The backends a workspace's target implies, as the plan takes them. */
2056
+ function backendsOf(workspace) {
2057
+ const on = providerOf(workspace);
2058
+ return {
2059
+ events: eventsBackendFor(on),
2060
+ cache: cacheBackendFor(on)
2061
+ };
1984
2062
  }
1985
2063
  /**
1986
2064
  * The containers a workspace's declarations imply, without reconciling.
@@ -1999,8 +2077,7 @@ async function derivedContainers(workspace, stage, manifest) {
1999
2077
  });
2000
2078
  return planFor(found, stage, provisionOrder(found), {
2001
2079
  localStage: workspace.stages.local,
2002
- events: workspace.services.events,
2003
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace))
2080
+ ...backendsOf(workspace)
2004
2081
  }).containers;
2005
2082
  }
2006
2083
  /**
@@ -2020,11 +2097,10 @@ async function reconcileWorkspace(workspace, options) {
2020
2097
  manifest,
2021
2098
  stage: options.stage,
2022
2099
  localStage: workspace.stages.local,
2023
- events: workspace.services.events,
2024
- cache: cacheBackendOf(workspace.services.cache, providerOf(workspace)),
2025
- images: imagePins(workspace),
2100
+ ...backendsOf(workspace),
2026
2101
  saved: await loadPortState(workspace.root),
2027
2102
  addresses: surfaceAddresses(workspace, manifest),
2103
+ apps: (containers) => appServices(workspace, manifest, containers),
2028
2104
  ...options.start === void 0 ? {} : { start: options.start }
2029
2105
  });
2030
2106
  await savePortState(workspace.root, { ...result.ports });
@@ -2046,13 +2122,13 @@ async function reconcileWorkspace(workspace, options) {
2046
2122
  * an app serving both its own API and an auth server publishes one address
2047
2123
  * twice — which is exactly what it does at runtime.
2048
2124
  */
2049
- function surfaceAddresses(workspace, manifest) {
2125
+ function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
2050
2126
  const addresses = {};
2051
2127
  for (const [id, declaration] of Object.entries(manifest)) {
2052
2128
  if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
2053
2129
  const host = declaration.kind === "site" ? appKey(id) : appKeyOfHost(manifest, id);
2054
2130
  const app = host ? workspace.apps[host] : void 0;
2055
- if (app?.port) addresses[id] = localAddress(app.port);
2131
+ if (host && app?.port) addresses[id] = at(host, app.port);
2056
2132
  }
2057
2133
  return addresses;
2058
2134
  }
@@ -2473,7 +2549,7 @@ async function prepareEntryCredentials(options) {
2473
2549
  credentials.PORT = String(resolvedPort);
2474
2550
  const declaredKeys = [];
2475
2551
  if (appInfo && usesConstructs(appInfo.workspace)) {
2476
- const { reconcileWorkspace: reconcileWorkspace$1 } = await import("./workspace-DbI580qS.mjs");
2552
+ const { reconcileWorkspace: reconcileWorkspace$1 } = await import("./workspace-CkJAtRIX.mjs");
2477
2553
  const reconciled = await reconcileWorkspace$1(appInfo.workspace, {
2478
2554
  stage: options.reconcileStage ?? appInfo.workspace.stages.local,
2479
2555
  start: options.startDocker ?? false
@@ -2545,5 +2621,5 @@ async function prepareEntryCredentials(options) {
2545
2621
  }
2546
2622
 
2547
2623
  //#endregion
2548
- 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 };
2549
- //# sourceMappingURL=credentials-CZrys9C5.mjs.map
2624
+ export { COMPOSE_PATH, LOCAL_CA_PATH, applyPostgres, backendsOf, cacheBackendFor, constructGlobs, createCredentialsPreload, createEntryWrapper, derivedContainers, eventsBackendFor, findAvailablePort, isPortAvailable, loadEnvFiles, loadSecretsForApp, prepareEntryCredentials, providerOf, reconcileWorkspace, resolveServicePorts, resourceName, rewriteUrlsWithPorts, startWorkspaceServices, storageBackendFor, surfaceAddresses, usesConstructs };
2625
+ //# sourceMappingURL=credentials-jMJC63qs.mjs.map