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