@geekmidas/cli 10.0.0-alpha.80 → 10.0.0-alpha.82

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 (105) hide show
  1. package/dist/{EndpointGenerator-DftalLYg.mjs → EndpointGenerator-DtnIGafh.mjs} +71 -8
  2. package/dist/EndpointGenerator-DtnIGafh.mjs.map +1 -0
  3. package/dist/{EndpointGenerator-CP6sBvQV.cjs → EndpointGenerator-cKlnllIu.cjs} +88 -7
  4. package/dist/EndpointGenerator-cKlnllIu.cjs.map +1 -0
  5. package/dist/{OpenApiTsGenerator-nu1r1NgB.mjs → OpenApiTsGenerator-ByRPMbBN.mjs} +34 -7
  6. package/dist/OpenApiTsGenerator-ByRPMbBN.mjs.map +1 -0
  7. package/dist/{OpenApiTsGenerator-CKBCckKz.cjs → OpenApiTsGenerator-CdUM6Hvv.cjs} +34 -7
  8. package/dist/OpenApiTsGenerator-CdUM6Hvv.cjs.map +1 -0
  9. package/dist/{SubscriberGenerator-CajwOa5W.cjs → SubscriberGenerator-CYriX5BV.cjs} +2 -2
  10. package/dist/{SubscriberGenerator-CajwOa5W.cjs.map → SubscriberGenerator-CYriX5BV.cjs.map} +1 -1
  11. package/dist/{SubscriberGenerator-5looQ3gB.mjs → SubscriberGenerator-DQZeGkDW.mjs} +2 -2
  12. package/dist/{SubscriberGenerator-5looQ3gB.mjs.map → SubscriberGenerator-DQZeGkDW.mjs.map} +1 -1
  13. package/dist/{add-DJ0VE-y-.cjs → add-1T3y6E4M.cjs} +2 -2
  14. package/dist/{add-DJ0VE-y-.cjs.map → add-1T3y6E4M.cjs.map} +1 -1
  15. package/dist/{add-CIxT-Iq6.mjs → add-DnYHJOap.mjs} +2 -2
  16. package/dist/{add-CIxT-Iq6.mjs.map → add-DnYHJOap.mjs.map} +1 -1
  17. package/dist/{bundler-D4pl87A5.mjs → bundler-DlKdxCGh.mjs} +3 -3
  18. package/dist/{bundler-D4pl87A5.mjs.map → bundler-DlKdxCGh.mjs.map} +1 -1
  19. package/dist/{bundler-BlbSLFLn.cjs → bundler-YNvLdvlh.cjs} +3 -3
  20. package/dist/{bundler-BlbSLFLn.cjs.map → bundler-YNvLdvlh.cjs.map} +1 -1
  21. package/dist/{cli-BkNZBe_-.cjs → cli-CWee1-wU.cjs} +3 -3
  22. package/dist/{cli-BkNZBe_-.cjs.map → cli-CWee1-wU.cjs.map} +1 -1
  23. package/dist/{cli-7aLfgIGt.mjs → cli-qMszGvDu.mjs} +3 -3
  24. package/dist/{cli-7aLfgIGt.mjs.map → cli-qMszGvDu.mjs.map} +1 -1
  25. package/dist/{compose-n7K8H5j8.mjs → compose-D4sD4Lgr.mjs} +83 -351
  26. package/dist/compose-D4sD4Lgr.mjs.map +1 -0
  27. package/dist/{compose-Bn4OJONR.cjs → compose-qnDyPJNp.cjs} +101 -369
  28. package/dist/compose-qnDyPJNp.cjs.map +1 -0
  29. package/dist/deploy/public.cjs +5 -5
  30. package/dist/deploy/public.d.cts.map +1 -1
  31. package/dist/deploy/public.d.mts.map +1 -1
  32. package/dist/deploy/public.mjs +5 -5
  33. package/dist/{deploy-DK4Y6ev4.mjs → deploy-C00DAKOt.mjs} +3 -3
  34. package/dist/{deploy-DK4Y6ev4.mjs.map → deploy-C00DAKOt.mjs.map} +1 -1
  35. package/dist/{deploy-Ppnh8MHS.cjs → deploy-CXP2fXvK.cjs} +3 -3
  36. package/dist/{deploy-Ppnh8MHS.cjs.map → deploy-CXP2fXvK.cjs.map} +1 -1
  37. package/dist/{dokploy-Bjq05wkw.mjs → dokploy-B3zoCmJb.mjs} +2 -2
  38. package/dist/{dokploy-Bjq05wkw.mjs.map → dokploy-B3zoCmJb.mjs.map} +1 -1
  39. package/dist/{dokploy-CJvdoNc3.cjs → dokploy-BHcSmghb.cjs} +2 -2
  40. package/dist/{dokploy-CJvdoNc3.cjs.map → dokploy-BHcSmghb.cjs.map} +1 -1
  41. package/dist/{drivers-Ch1rJitn.mjs → drivers-B4RP2B2O.mjs} +2 -2
  42. package/dist/{drivers-Ch1rJitn.mjs.map → drivers-B4RP2B2O.mjs.map} +1 -1
  43. package/dist/{drivers-BdvtCKc7.cjs → drivers-CJx76mcb.cjs} +2 -2
  44. package/dist/{drivers-BdvtCKc7.cjs.map → drivers-CJx76mcb.cjs.map} +1 -1
  45. package/dist/{encryption-CAyR6D7B.cjs → encryption-DLin89I_.cjs} +2 -2
  46. package/dist/{encryption-CAyR6D7B.cjs.map → encryption-DLin89I_.cjs.map} +1 -1
  47. package/dist/{encryption-BrsOJFFz.mjs → encryption-DPFljXtM.mjs} +2 -2
  48. package/dist/{encryption-BrsOJFFz.mjs.map → encryption-DPFljXtM.mjs.map} +1 -1
  49. package/dist/{engine-DzBZR5D0.cjs → engine-BS3iX9Mu.cjs} +2 -2
  50. package/dist/{engine-DzBZR5D0.cjs.map → engine-BS3iX9Mu.cjs.map} +1 -1
  51. package/dist/{engine-ba9CvjTo.mjs → engine-CgzgIg-_.mjs} +2 -2
  52. package/dist/{engine-ba9CvjTo.mjs.map → engine-CgzgIg-_.mjs.map} +1 -1
  53. package/dist/{github-BLjU59Ye.cjs → github-D8KVmNRg.cjs} +2 -2
  54. package/dist/{github-BLjU59Ye.cjs.map → github-D8KVmNRg.cjs.map} +1 -1
  55. package/dist/{github-CVdbVlT5.mjs → github-DL4zwJsR.mjs} +2 -2
  56. package/dist/{github-CVdbVlT5.mjs.map → github-DL4zwJsR.mjs.map} +1 -1
  57. package/dist/index.cjs +225 -49
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.mjs +226 -50
  60. package/dist/index.mjs.map +1 -1
  61. package/dist/{migrate-iEdyEx4S.cjs → migrate-BdKhIJgT.cjs} +2 -2
  62. package/dist/{migrate-iEdyEx4S.cjs.map → migrate-BdKhIJgT.cjs.map} +1 -1
  63. package/dist/{migrate-DlBgATa-.mjs → migrate-CCejlgU0.mjs} +2 -2
  64. package/dist/{migrate-DlBgATa-.mjs.map → migrate-CCejlgU0.mjs.map} +1 -1
  65. package/dist/{migrate-pfu4aYzX.mjs → migrate-Cd3kcI7o.mjs} +2 -2
  66. package/dist/{migrate-pfu4aYzX.mjs.map → migrate-Cd3kcI7o.mjs.map} +1 -1
  67. package/dist/{migrate-DkB833Jj.cjs → migrate-DIjG8-QR.cjs} +2 -2
  68. package/dist/{migrate-DkB833Jj.cjs.map → migrate-DIjG8-QR.cjs.map} +1 -1
  69. package/dist/openapi.cjs +16 -11
  70. package/dist/openapi.cjs.map +1 -1
  71. package/dist/openapi.d.cts +28 -0
  72. package/dist/openapi.d.cts.map +1 -1
  73. package/dist/openapi.d.mts +28 -0
  74. package/dist/openapi.d.mts.map +1 -1
  75. package/dist/openapi.mjs +16 -12
  76. package/dist/openapi.mjs.map +1 -1
  77. package/dist/{ready-BB9xS4S9.cjs → ready-4kx2pAhm.cjs} +5 -5
  78. package/dist/{ready-BB9xS4S9.cjs.map → ready-4kx2pAhm.cjs.map} +1 -1
  79. package/dist/{ready-ZZoNskrr.mjs → ready-DhZbZtFw.mjs} +5 -5
  80. package/dist/{ready-ZZoNskrr.mjs.map → ready-DhZbZtFw.mjs.map} +1 -1
  81. package/dist/{resolve-4_5N67Fk.cjs → resolve-ApSUK_HB.cjs} +3 -3
  82. package/dist/{resolve-4_5N67Fk.cjs.map → resolve-ApSUK_HB.cjs.map} +1 -1
  83. package/dist/{resolve-CQPn66jr.mjs → resolve-DY9TaVeV.mjs} +3 -3
  84. package/dist/{resolve-CQPn66jr.mjs.map → resolve-DY9TaVeV.mjs.map} +1 -1
  85. package/dist/{rollback-CgYUPLbq.cjs → rollback-CdGhCk1d.cjs} +3 -3
  86. package/dist/{rollback-CgYUPLbq.cjs.map → rollback-CdGhCk1d.cjs.map} +1 -1
  87. package/dist/{rollback-C0cbdsM-.mjs → rollback-DXU6cr7q.mjs} +3 -3
  88. package/dist/{rollback-C0cbdsM-.mjs.map → rollback-DXU6cr7q.mjs.map} +1 -1
  89. package/dist/{seeds-ZljwYV_p.cjs → seeds-BLzrSwEr.cjs} +3889 -2953
  90. package/dist/seeds-BLzrSwEr.cjs.map +1 -0
  91. package/dist/{seeds-BdkFpW0P.mjs → seeds-DbyqaXW4.mjs} +2344 -1528
  92. package/dist/seeds-DbyqaXW4.mjs.map +1 -0
  93. package/dist/target/public.cjs +1 -1
  94. package/dist/target/public.mjs +1 -1
  95. package/dist/vitest.cjs +1 -1
  96. package/dist/vitest.mjs +1 -1
  97. package/package.json +15 -15
  98. package/dist/EndpointGenerator-CP6sBvQV.cjs.map +0 -1
  99. package/dist/EndpointGenerator-DftalLYg.mjs.map +0 -1
  100. package/dist/OpenApiTsGenerator-CKBCckKz.cjs.map +0 -1
  101. package/dist/OpenApiTsGenerator-nu1r1NgB.mjs.map +0 -1
  102. package/dist/compose-Bn4OJONR.cjs.map +0 -1
  103. package/dist/compose-n7K8H5j8.mjs.map +0 -1
  104. package/dist/seeds-BdkFpW0P.mjs.map +0 -1
  105. package/dist/seeds-ZljwYV_p.cjs.map +0 -1
@@ -2,14 +2,14 @@ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
2
  import { i as run$1, s as gkmHome } from "./run-BJLIerMM.mjs";
3
3
  import { d as output, r as discover, u as ConstructGenerator } from "./discover-BPelzeGW.mjs";
4
4
  import { t as appKey } from "./derive-B4vDpzgP.mjs";
5
- import { O as TEST_STAGE, R as UnknownSecretsStoreProvider, h as getPublicEnvPrefix, m as PUBLIC_ENV_PREFIXES, n as allConstructGlobs } from "./workspace-BBdqmkch.mjs";
5
+ import { F as LOGS_PORT, O as TEST_STAGE, R as UnknownSecretsStoreProvider, h as getPublicEnvPrefix, m as PUBLIC_ENV_PREFIXES, n as allConstructGlobs } from "./workspace-BBdqmkch.mjs";
6
6
  import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
7
- import { n as runtimeFor, r as generateTelemetryModule } from "./EndpointGenerator-DftalLYg.mjs";
8
- import { c as runtimeOf } from "./resolve-CQPn66jr.mjs";
9
- import { a as DEFAULT_STORAGE, i as DEFAULT_EVENTS$1, n as readFakes, o as holdsEveryCache, r as DEFAULT_CACHE, t as SubscriberGenerator } from "./SubscriberGenerator-5looQ3gB.mjs";
7
+ import { n as runtimeFor, r as generateTelemetryModule, s as telemetryArgs } from "./EndpointGenerator-DtnIGafh.mjs";
8
+ import { c as runtimeOf } from "./resolve-DY9TaVeV.mjs";
9
+ import { a as DEFAULT_STORAGE, i as DEFAULT_EVENTS$1, n as readFakes, o as holdsEveryCache, r as DEFAULT_CACHE, t as SubscriberGenerator } from "./SubscriberGenerator-DQZeGkDW.mjs";
10
10
  import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
11
11
  import { constants, existsSync, readFileSync } from "node:fs";
12
- import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
12
+ import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
13
13
  import { homedir, networkInterfaces } from "node:os";
14
14
  import { promisify } from "node:util";
15
15
  import { Cron } from "@geekmidas/constructs/crons";
@@ -18,16 +18,16 @@ import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dep
18
18
  import { createServer } from "node:net";
19
19
  import { execFile, spawn } from "node:child_process";
20
20
  import { config } from "dotenv";
21
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
21
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt } from "node:crypto";
22
+ import { stringify } from "yaml";
22
23
  import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
23
24
  import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
24
25
  import * as snsUrl from "@geekmidas/events/sns/url";
25
26
  import * as sqsUrl from "@geekmidas/events/sqs/url";
26
- import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
27
+ import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, ListBucketsCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
27
28
  import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-sdk/client-sns";
28
29
  import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
29
30
  import { Client } from "pg";
30
- import { stringify } from "yaml";
31
31
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
32
32
  //#region src/secrets/storage.ts
33
33
  var storage_exports = /* @__PURE__ */ __exportAll({
@@ -499,6 +499,10 @@ function packageName$1(root) {
499
499
  return;
500
500
  }
501
501
  }
502
+ /**
503
+ * Encrypt a JSON value with a stage key: AES-256-GCM, the file format every
504
+ * stage's secrets are kept in.
505
+ */
502
506
  function encrypt(secrets, keyHex) {
503
507
  const key = Buffer.from(keyHex, "hex");
504
508
  const iv = randomBytes(IV_LENGTH);
@@ -510,6 +514,7 @@ function encrypt(secrets, keyHex) {
510
514
  iv: iv.toString("hex")
511
515
  };
512
516
  }
517
+ /** Decrypt what {@link encrypt} wrote. */
513
518
  function decrypt(data, keyHex) {
514
519
  const key = Buffer.from(keyHex, "hex");
515
520
  const combined = Buffer.from(data.encrypted, "base64");
@@ -984,269 +989,579 @@ function volumeOf(container) {
984
989
  }[container];
985
990
  }
986
991
  //#endregion
987
- //#region src/workspace/backends.ts
988
- /**
989
- * Which backend each declared kind resolves to — decided by where the project
990
- * deploys, and by nothing else.
991
- *
992
- * These used to be read out of a `services:` block, where one key answered two
993
- * questions: *does this exist* and *what backs it*. The first is the manifest's
994
- * — a declared `Cache` is why there is a cache — and the second is the deploy
995
- * target's, because the right cache for a Lambda is not the right cache for a
996
- * box already running Postgres. With both answered elsewhere there was nothing
997
- * left for the block to say, so it is gone and these read the target.
998
- *
999
- * Mail has no answer here at all: every provider speaks SMTP, so the provider
1000
- * is whichever one the stage's mail URL points at.
1001
- */
992
+ //#region src/reconcile/emulator.ts
1002
993
  /**
1003
- * Which family of defaults a workspace's deploy target belongs to.
1004
- *
1005
- * One question, asked once: **does this target run containers the project
1006
- * controls?** Dokploy does, so a bucket or a cache can live beside the app;
1007
- * AWS, Vercel and Cloudflare do not, so the default has to be something
1008
- * managed. That is the whole of the distinction, and it is why `MainProvider`
1009
- * is the right axis rather than the deploy target's own name — targets share
1010
- * answers.
994
+ * The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
995
+ * locally — the facts the compose file, the env writer and the applier must
996
+ * agree on.
1011
997
  *
1012
- * Each target declares its answer as its `runtime` — a target package in its
1013
- * `package.json`, so this never loads one. A workspace that names no target
1014
- * is on AWS: an SST project predates `deploy.default: 'sst'`.
998
+ * The emulator has one account and answers with deterministic ARNs and queue
999
+ * URLs, which is what lets a connection string be composed before the topic
1000
+ * or queue it names has been created.
1015
1001
  */
1016
- function providerOf(workspace) {
1017
- const target = workspace.deploy?.default;
1018
- return target ? runtimeOf(target, workspace) : "aws";
1019
- }
1002
+ /** The emulator's single account. */
1003
+ const EMULATOR_ACCOUNT = "000000000000";
1004
+ const EMULATOR_REGION = "us-east-1";
1020
1005
  /**
1021
- * The cache backend a target implies.
1022
- *
1023
- * The same answer locally and deployed: a local `gkm dev` for an AWS app still
1024
- * uses the Upstash protocol, because a backend that differed between the two
1025
- * would be worse than a slower one.
1006
+ * The emulator's access key id. The `LSIA` prefix is what LocalStack required
1007
+ * on issued keys, and LocalStack reads the account from the id — so it is
1008
+ * fixed, where the secret key beside it is generated per machine (see
1009
+ * `localCredentials.ts`). Floci does not care; keeping it means a project
1010
+ * that pins the old image still works.
1026
1011
  */
1027
- function cacheBackendFor(on) {
1028
- return DEFAULT_CACHE[on];
1012
+ const EMULATOR_ACCESS_KEY_ID = "LSIAQAAAAAAVNCBMPNSG";
1013
+ /** The emulator's address from the host, on the port it was published on. */
1014
+ function emulatorEndpoint(port) {
1015
+ return `http://localhost:${port}`;
1029
1016
  }
1030
- /** Where a declared bucket lives on a target. */
1031
- function storageBackendFor(on) {
1032
- return DEFAULT_STORAGE[on];
1017
+ function emulatorTopicArn(name) {
1018
+ return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
1033
1019
  }
1034
- /**
1035
- * Which broker carries declared queues and topics on a target.
1036
- *
1037
- * Only consulted when something declares one; a project with no `Topic` or
1038
- * `Queue` has no events at all, whatever the target.
1039
- */
1040
- function eventsBackendFor(on) {
1041
- return DEFAULT_EVENTS$1[on];
1020
+ function emulatorQueueUrl(name, port) {
1021
+ return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
1042
1022
  }
1043
1023
  //#endregion
1044
- //#region src/exec/imageBuild.ts
1024
+ //#region src/reconcile/compose.ts
1045
1025
  /**
1046
- * Set by a site's Dockerfile in its builder stage: this process is building
1047
- * an image.
1026
+ * The compose document a plan implies.
1048
1027
  *
1049
- * Inside an image there is no stage to load secrets for and no local
1050
- * container to resolve an address from — the public URLs the bundle inlines
1051
- * arrived as the Dockerfile's build args, already in the environment. So
1052
- * `gkm exec -- next build` there injects exactly those, and never the
1053
- * `localhost` addresses a workspace resolves on a developer's machine.
1054
- */
1055
- const IMAGE_BUILD_ENV = "GKM_IMAGE_BUILD";
1056
- /** Whether this process is building an image. */
1057
- function isImageBuild(env = process.env) {
1058
- return env[IMAGE_BUILD_ENV] === "1";
1059
- }
1060
- /**
1061
- * What `gkm exec` injects inside an image build: the public values its build
1062
- * args set — every `NEXT_PUBLIC_*`, `VITE_*` and `EXPO_PUBLIC_*` — and
1063
- * nothing read from a secrets store, a file or a container.
1064
- */
1065
- function imageBuildCredentials(env = process.env) {
1066
- const credentials = {};
1067
- for (const [key, value] of Object.entries(env)) {
1068
- if (value === void 0) continue;
1069
- if (PUBLIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) credentials[key] = value;
1070
- }
1071
- return credentials;
1072
- }
1073
- //#endregion
1074
- //#region src/docker/templates.ts
1075
- /**
1076
- * The turbo every image prunes and builds with when the build root resolves
1077
- * none of its own: a repository without turbo still needs `turbo prune` to
1078
- * cut its slice of the workspace, and an unpinned `turbo` is whatever the
1079
- * registry says on the day of the build.
1080
- */
1081
- const TURBO_VERSION = "2.5.4";
1082
- /**
1083
- * Caddy, which serves a static site's files. A 2.x tag, so a rebuild picks
1084
- * up patch releases and never a major.
1085
- */
1086
- const STATIC_SITE_IMAGE = "caddy:2.10-alpine";
1087
- const LOCKFILES = [
1088
- ["pnpm-lock.yaml", "pnpm"],
1089
- ["bun.lockb", "bun"],
1090
- ["yarn.lock", "yarn"],
1091
- ["package-lock.json", "npm"]
1092
- ];
1093
- /**
1094
- * Detect package manager from lockfiles
1095
- * Walks up the directory tree to find lockfile (for monorepos)
1096
- */
1097
- function detectPackageManager(cwd = process.cwd()) {
1098
- const lockfile = findLockfilePath(cwd);
1099
- if (!lockfile) return "pnpm";
1100
- const name = lockfile.slice(dirname(lockfile).length + 1);
1101
- return LOCKFILES.find(([file]) => file === name)?.[1] ?? "pnpm";
1102
- }
1103
- /**
1104
- * Find the lockfile path by walking up the directory tree
1105
- * Returns the full path to the lockfile, or null if not found
1028
+ * The third declaration of the same containers disappears here: today a
1029
+ * `services: ['postgres','minio']` block is kept in step with the code by hand,
1030
+ * which is the triplication this design exists to remove, in the one place you
1031
+ * touch every day. A `database` implies Postgres; the list is derived.
1032
+ *
1033
+ * Returns an object rather than a string for the same reason `plan` is pure —
1034
+ * what should exist is asserted as data, and turning it into YAML is one line at
1035
+ * the edge.
1036
+ *
1037
+ * What stays config is unchanged: images and versions are pinned explicitly, and
1038
+ * a container no construct implies is still declared by hand. Neutral facts come
1039
+ * from the declaration, provider detail from config.
1106
1040
  */
1107
- function findLockfilePath(cwd = process.cwd()) {
1108
- for (const dir of upFrom(cwd)) for (const [lockfile] of LOCKFILES) {
1109
- const lockfilePath = join(dir, lockfile);
1110
- if (existsSync(lockfilePath)) return lockfilePath;
1111
- }
1112
- return null;
1113
- }
1041
+ /** The database every connection opens first: every cluster has it. */
1042
+ const CLUSTER_DATABASE = "postgres";
1114
1043
  /**
1115
- * The directory every image is built from: the nearest at or above `cwd`
1116
- * holding a lockfile or a `pnpm-workspace.yaml` — the package manager's root,
1117
- * which is what an install resolves the workspace's dependencies against.
1044
+ * Build the compose document for a plan.
1118
1045
  *
1119
- * In a project of its own that is the gkm workspace's root. A gkm workspace
1120
- * nested inside a monorepo, whose dependencies are the monorepo's
1121
- * `workspace:*` packages, is built from the monorepo's root. `cwd` itself
1122
- * when neither is found.
1046
+ * Every container is published on its assigned port, never on the image's
1047
+ * default: the point of allocation is that two projects can run at once, and
1048
+ * one container publishing 5432 puts that back.
1123
1049
  */
1124
- function findBuildRoot(cwd = process.cwd()) {
1125
- for (const dir of upFrom(cwd)) {
1126
- if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
1127
- for (const [lockfile] of LOCKFILES) if (existsSync(join(dir, lockfile))) return dir;
1128
- }
1129
- return cwd;
1130
- }
1131
- /** `cwd` and every directory above it, the filesystem root last. */
1132
- function upFrom(cwd) {
1133
- const dirs = [];
1134
- const root = parse(cwd).root;
1135
- let dir = cwd;
1136
- while (dir !== root) {
1137
- dirs.push(dir);
1138
- dir = dirname(dir);
1050
+ function composeFor(plan, options) {
1051
+ const services = {};
1052
+ const volumes = {};
1053
+ for (const container of [...plan.containers].sort()) {
1054
+ const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
1055
+ const fake = plan.fakes?.[container];
1056
+ const image = declared ? postgresImage(declared) : fake?.image ?? DEFAULT_IMAGES[container];
1057
+ if (!image) throw new UnknownContainer(container);
1058
+ const published = publish(container, options.ports, plan.fakes);
1059
+ services[container] = fake ? {
1060
+ image,
1061
+ restart: "unless-stopped",
1062
+ ports: published
1063
+ } : define(container, image, published, options.credentials);
1064
+ const volume = volumeOf(container);
1065
+ if (volume) volumes[volume] = {};
1139
1066
  }
1140
- dirs.push(root);
1141
- return dirs;
1142
- }
1143
- /** The package manager, its version, turbo's and the shape of a build root. */
1144
- function resolveBuildTools(buildRoot) {
1145
- const pkg = readJson(join(buildRoot, "package.json"));
1146
- const packageManager = detectPackageManager(buildRoot);
1147
- const field = typeof pkg?.packageManager === "string" ? pkg.packageManager : "";
1148
- const match = /^([a-z]+)@(\d[^+\s]*)/.exec(field);
1149
- const packageManagerVersion = match && match[1] === packageManager ? match[2] : void 0;
1150
1067
  return {
1151
- packageManager,
1152
- ...packageManagerVersion ? { packageManagerVersion } : {},
1153
- turboVersion: resolveTurboVersion(buildRoot) ?? "2.5.4",
1154
- monorepo: existsSync(join(buildRoot, "pnpm-workspace.yaml")) || pkg?.workspaces !== void 0
1155
- };
1156
- }
1157
- /**
1158
- * The turbo version a build root resolves: what is installed, or what its
1159
- * lockfile pins. Undefined when it has none.
1160
- */
1161
- function resolveTurboVersion(buildRoot) {
1162
- const installed = readJson(join(buildRoot, "node_modules", "turbo", "package.json"));
1163
- if (typeof installed?.version === "string") return installed.version;
1164
- const read = (file) => {
1165
- try {
1166
- return readFileSync(join(buildRoot, file), "utf-8");
1167
- } catch {
1168
- return;
1169
- }
1068
+ name: options.project,
1069
+ services: {
1070
+ ...services,
1071
+ ...options.apps
1072
+ },
1073
+ ...Object.keys(volumes).length ? { volumes } : {}
1170
1074
  };
1171
- const pnpm = read("pnpm-lock.yaml");
1172
- const fromPnpm = pnpm && /^ {2}'?turbo@(\d[^:'(\s]*)'?:/m.exec(pnpm);
1173
- if (fromPnpm) return fromPnpm[1];
1174
- const npm = read("package-lock.json");
1175
- if (npm) {
1176
- const version = safeParse(npm)?.packages?.["node_modules/turbo"]?.version;
1177
- if (version) return version;
1178
- }
1179
- const yarn = read("yarn.lock");
1180
- const fromYarn = yarn && /^"?turbo@[^\n]*:\n\s+version:? "?(\d[^"\s]*)"?/m.exec(yarn);
1181
- if (fromYarn) return fromYarn[1];
1182
1075
  }
1183
- function readJson(path) {
1184
- try {
1185
- return safeParse(readFileSync(path, "utf-8"));
1186
- } catch {
1187
- return;
1188
- }
1076
+ /** Render a compose document as the file it becomes. */
1077
+ function toYaml(file) {
1078
+ return `# Generated by gkm from the construct manifest — do not edit.
1079
+ # Containers are derived from what the app declares. Image pins and anything
1080
+ # no construct implies go in your own docker-compose.yml, which is merged over
1081
+ # this file and wins:
1082
+ #
1083
+ # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
1084
+ #
1085
+ # "gkm dev" and "gkm test" start the containers. The apps sit behind the
1086
+ # "apps" profile: run "gkm setup", then
1087
+ # docker compose -f docker-compose.constructs.yml --profile apps up --build
1088
+ ${stringify(file, { lineWidth: 0 })}`;
1189
1089
  }
1190
- function safeParse(text) {
1191
- try {
1192
- const value = JSON.parse(text);
1193
- return value && typeof value === "object" ? value : void 0;
1194
- } catch {
1195
- return;
1090
+ /** A container with no image, from config or from the defaults. */
1091
+ var UnknownContainer = class extends Error {
1092
+ /** The container that could not be resolved. */
1093
+ container;
1094
+ /** The containers that have a default, so the caller can see the set. */
1095
+ known = Object.keys(DEFAULT_IMAGES).sort();
1096
+ constructor(container) {
1097
+ super("No image is known for this container; pin one in config");
1098
+ this.name = "UnknownContainer";
1099
+ this.container = container;
1100
+ }
1101
+ };
1102
+ /** One container's definition, published on the port it was assigned. */
1103
+ function define(container, image, published, credentials) {
1104
+ switch (container) {
1105
+ case "postgres": return {
1106
+ image,
1107
+ restart: "unless-stopped",
1108
+ ports: published,
1109
+ environment: {
1110
+ POSTGRES_USER: credentials.postgres.user,
1111
+ POSTGRES_PASSWORD: credentials.postgres.password,
1112
+ POSTGRES_DB: CLUSTER_DATABASE
1113
+ },
1114
+ volumes: ["postgres-data:/var/lib/postgresql"],
1115
+ healthcheck: {
1116
+ test: ["CMD-SHELL", `pg_isready -U ${credentials.postgres.user} -d ${CLUSTER_DATABASE}`],
1117
+ interval: "5s",
1118
+ timeout: "3s",
1119
+ retries: 10,
1120
+ start_period: "10s"
1121
+ }
1122
+ };
1123
+ case "minio": return {
1124
+ image,
1125
+ restart: "unless-stopped",
1126
+ ports: published,
1127
+ environment: {
1128
+ MINIO_ROOT_USER: credentials.minio.user,
1129
+ MINIO_ROOT_PASSWORD: credentials.minio.password
1130
+ },
1131
+ command: "server --console-address \":9001\" /data",
1132
+ volumes: ["minio-data:/data"],
1133
+ healthcheck: {
1134
+ test: [
1135
+ "CMD",
1136
+ "mc",
1137
+ "ready",
1138
+ "local"
1139
+ ],
1140
+ interval: "10s",
1141
+ timeout: "5s",
1142
+ retries: 5,
1143
+ start_period: "10s"
1144
+ }
1145
+ };
1146
+ case "caddy": return {
1147
+ image,
1148
+ restart: "unless-stopped",
1149
+ ports: published,
1150
+ volumes: [
1151
+ "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
1152
+ "./.gkm/caddy-sites:/etc/caddy/sites:ro",
1153
+ "caddy-data:/data"
1154
+ ],
1155
+ extra_hosts: ["host.docker.internal:host-gateway"],
1156
+ healthcheck: {
1157
+ test: [
1158
+ "CMD",
1159
+ "caddy",
1160
+ "version"
1161
+ ],
1162
+ interval: "10s",
1163
+ timeout: "5s",
1164
+ retries: 5
1165
+ }
1166
+ };
1167
+ case "mailpit": return {
1168
+ image,
1169
+ restart: "unless-stopped",
1170
+ ports: published,
1171
+ environment: {
1172
+ MP_SMTP_AUTH_ACCEPT_ANY: "1",
1173
+ MP_SMTP_AUTH_ALLOW_INSECURE: "1"
1174
+ },
1175
+ healthcheck: {
1176
+ test: [
1177
+ "CMD",
1178
+ "wget",
1179
+ "-q",
1180
+ "--spider",
1181
+ "http://localhost:8025"
1182
+ ],
1183
+ interval: "10s",
1184
+ timeout: "5s",
1185
+ retries: 5
1186
+ }
1187
+ };
1188
+ case "redis": return {
1189
+ image,
1190
+ restart: "unless-stopped",
1191
+ ports: published,
1192
+ command: "sh -c 'exec docker-entrypoint.sh redis-server --requirepass \"$$REDIS_PASSWORD\"'",
1193
+ environment: {
1194
+ REDIS_PASSWORD: credentials.redis.password,
1195
+ REDISCLI_AUTH: credentials.redis.password
1196
+ },
1197
+ volumes: ["redis-data:/data"],
1198
+ healthcheck: {
1199
+ test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"],
1200
+ interval: "5s",
1201
+ timeout: "3s",
1202
+ retries: 5
1203
+ }
1204
+ };
1205
+ case "redis-http": return {
1206
+ image,
1207
+ restart: "unless-stopped",
1208
+ ports: published,
1209
+ environment: {
1210
+ SRH_MODE: "env",
1211
+ SRH_TOKEN: credentials.cacheToken,
1212
+ SRH_CONNECTION_STRING: `redis://:${encodeURIComponent(credentials.redis.password)}@redis:6379`
1213
+ },
1214
+ depends_on: ["redis"],
1215
+ healthcheck: {
1216
+ test: [
1217
+ "CMD",
1218
+ "wget",
1219
+ "-q",
1220
+ "-O",
1221
+ "/dev/null",
1222
+ "http://127.0.0.1:80/"
1223
+ ],
1224
+ interval: "5s",
1225
+ timeout: "3s",
1226
+ retries: 10
1227
+ }
1228
+ };
1229
+ case "rabbitmq": return {
1230
+ image,
1231
+ restart: "unless-stopped",
1232
+ ports: published,
1233
+ environment: {
1234
+ RABBITMQ_DEFAULT_USER: credentials.rabbitmq.user,
1235
+ RABBITMQ_DEFAULT_PASS: credentials.rabbitmq.password
1236
+ },
1237
+ volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
1238
+ healthcheck: {
1239
+ test: [
1240
+ "CMD",
1241
+ "rabbitmq-diagnostics",
1242
+ "-q",
1243
+ "ping"
1244
+ ],
1245
+ interval: "10s",
1246
+ timeout: "5s",
1247
+ retries: 5
1248
+ }
1249
+ };
1250
+ case "localstack": return {
1251
+ image,
1252
+ restart: "unless-stopped",
1253
+ ports: published,
1254
+ extra_hosts: ["host.docker.internal:host-gateway"],
1255
+ environment: {
1256
+ AWS_DEFAULT_REGION: EMULATOR_REGION,
1257
+ AWS_ACCESS_KEY_ID: credentials.emulator.accessKeyId,
1258
+ AWS_SECRET_ACCESS_KEY: credentials.emulator.secretAccessKey
1259
+ },
1260
+ volumes: ["localstack-data:/var/lib/localstack"],
1261
+ healthcheck: {
1262
+ test: [
1263
+ "CMD",
1264
+ "bash",
1265
+ "-c",
1266
+ "exec 3<>/dev/tcp/localhost/4566"
1267
+ ],
1268
+ interval: "10s",
1269
+ timeout: "5s",
1270
+ retries: 5,
1271
+ start_period: "10s"
1272
+ }
1273
+ };
1274
+ default: throw new UnknownContainer(container);
1196
1275
  }
1197
1276
  }
1198
1277
  /**
1199
- * Check if turbo.json exists (walks up directory tree)
1278
+ * The published port mappings for a container.
1279
+ *
1280
+ * Every port is looked up by its own key rather than being derived as an offset
1281
+ * from the container's first, so MinIO's console cannot land on whatever was
1282
+ * allocated to the next container.
1200
1283
  */
1201
- function hasTurboConfig(cwd = process.cwd()) {
1202
- return upFrom(cwd).some((dir) => existsSync(join(dir, "turbo.json")));
1284
+ function publish(container, ports, fakes) {
1285
+ return portsOf(container, fakes).map((port) => {
1286
+ const assigned = ports[port.key];
1287
+ if (assigned === void 0) throw new UnassignedPort(port.key);
1288
+ return `${assigned}:${port.inside}`;
1289
+ });
1203
1290
  }
1291
+ /** A planned port that allocation never assigned. */
1292
+ var UnassignedPort = class extends Error {
1293
+ /** The port key that went unassigned, e.g. `minio-console`. */
1294
+ key;
1295
+ constructor(key) {
1296
+ super("No port was assigned for this container port");
1297
+ this.name = "UnassignedPort";
1298
+ this.key = key;
1299
+ }
1300
+ };
1301
+ //#endregion
1302
+ //#region src/workspace/backends.ts
1204
1303
  /**
1205
- * Package manager specific commands. `version` pins what corepack (or npm,
1206
- * for bun) installs; without it, the latest.
1304
+ * Which backend each declared kind resolves to — decided by where the project
1305
+ * deploys, and by nothing else.
1306
+ *
1307
+ * These used to be read out of a `services:` block, where one key answered two
1308
+ * questions: *does this exist* and *what backs it*. The first is the manifest's
1309
+ * — a declared `Cache` is why there is a cache — and the second is the deploy
1310
+ * target's, because the right cache for a Lambda is not the right cache for a
1311
+ * box already running Postgres. With both answered elsewhere there was nothing
1312
+ * left for the block to say, so it is gone and these read the target.
1313
+ *
1314
+ * Mail has no answer here at all: every provider speaks SMTP, so the provider
1315
+ * is whichever one the stage's mail URL points at.
1207
1316
  */
1208
- function getPmConfig(pm, version) {
1209
- return {
1210
- pnpm: {
1211
- install: `corepack enable && corepack prepare pnpm@${version ?? "latest"} --activate`,
1212
- lockfile: "pnpm-lock.yaml",
1213
- installCmd: "pnpm install",
1214
- cacheTarget: "/root/.local/share/pnpm/store",
1215
- cacheId: "pnpm",
1216
- run: "pnpm run",
1217
- dlx: "pnpm dlx"
1218
- },
1219
- npm: {
1220
- install: version ? `npm install -g npm@${version}` : "",
1221
- lockfile: "package-lock.json",
1222
- installCmd: "npm install",
1223
- cacheTarget: "/root/.npm",
1224
- cacheId: "npm",
1225
- run: "npm run",
1226
- dlx: "npx --yes"
1227
- },
1228
- yarn: {
1229
- install: `corepack enable && corepack prepare yarn@${version ?? "stable"} --activate`,
1230
- lockfile: "yarn.lock",
1231
- installCmd: "yarn install",
1232
- cacheTarget: "/root/.yarn/cache",
1233
- cacheId: "yarn",
1234
- run: "yarn run",
1235
- dlx: "yarn dlx"
1236
- },
1237
- bun: {
1238
- install: `npm install -g bun@${version ?? "latest"}`,
1239
- lockfile: "bun.lockb",
1240
- installCmd: "bun install",
1241
- cacheTarget: "/root/.bun/install/cache",
1242
- cacheId: "bun",
1243
- run: "bun run",
1244
- dlx: "bunx"
1245
- }
1246
- }[pm];
1247
- }
1248
- /** What every template's first three stages need, resolved from options. */
1249
- function layout(options) {
1317
+ /**
1318
+ * Which family of defaults a workspace's deploy target belongs to.
1319
+ *
1320
+ * One question, asked once: **does this target run containers the project
1321
+ * controls?** Dokploy does, so a bucket or a cache can live beside the app;
1322
+ * AWS, Vercel and Cloudflare do not, so the default has to be something
1323
+ * managed. That is the whole of the distinction, and it is why `MainProvider`
1324
+ * is the right axis rather than the deploy target's own name — targets share
1325
+ * answers.
1326
+ *
1327
+ * Each target declares its answer as its `runtime` — a target package in its
1328
+ * `package.json`, so this never loads one. A workspace that names no target
1329
+ * is on AWS: an SST project predates `deploy.default: 'sst'`.
1330
+ */
1331
+ function providerOf(workspace) {
1332
+ const target = workspace.deploy?.default;
1333
+ return target ? runtimeOf(target, workspace) : "aws";
1334
+ }
1335
+ /**
1336
+ * The cache backend a target implies.
1337
+ *
1338
+ * The same answer locally and deployed: a local `gkm dev` for an AWS app still
1339
+ * uses the Upstash protocol, because a backend that differed between the two
1340
+ * would be worse than a slower one.
1341
+ */
1342
+ function cacheBackendFor(on) {
1343
+ return DEFAULT_CACHE[on];
1344
+ }
1345
+ /** Where a declared bucket lives on a target. */
1346
+ function storageBackendFor(on) {
1347
+ return DEFAULT_STORAGE[on];
1348
+ }
1349
+ /**
1350
+ * Which broker carries declared queues and topics on a target.
1351
+ *
1352
+ * Only consulted when something declares one; a project with no `Topic` or
1353
+ * `Queue` has no events at all, whatever the target.
1354
+ */
1355
+ function eventsBackendFor(on) {
1356
+ return DEFAULT_EVENTS$1[on];
1357
+ }
1358
+ //#endregion
1359
+ //#region src/exec/imageBuild.ts
1360
+ /**
1361
+ * Set by a site's Dockerfile in its builder stage: this process is building
1362
+ * an image.
1363
+ *
1364
+ * Inside an image there is no stage to load secrets for and no local
1365
+ * container to resolve an address from — the public URLs the bundle inlines
1366
+ * arrived as the Dockerfile's build args, already in the environment. So
1367
+ * `gkm exec -- next build` there injects exactly those, and never the
1368
+ * `localhost` addresses a workspace resolves on a developer's machine.
1369
+ */
1370
+ const IMAGE_BUILD_ENV = "GKM_IMAGE_BUILD";
1371
+ /** Whether this process is building an image. */
1372
+ function isImageBuild(env = process.env) {
1373
+ return env[IMAGE_BUILD_ENV] === "1";
1374
+ }
1375
+ /**
1376
+ * What `gkm exec` injects inside an image build: the public values its build
1377
+ * args set — every `NEXT_PUBLIC_*`, `VITE_*` and `EXPO_PUBLIC_*` — and
1378
+ * nothing read from a secrets store, a file or a container.
1379
+ */
1380
+ function imageBuildCredentials(env = process.env) {
1381
+ const credentials = {};
1382
+ for (const [key, value] of Object.entries(env)) {
1383
+ if (value === void 0) continue;
1384
+ if (PUBLIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) credentials[key] = value;
1385
+ }
1386
+ return credentials;
1387
+ }
1388
+ //#endregion
1389
+ //#region src/docker/templates.ts
1390
+ /**
1391
+ * The turbo every image prunes and builds with when the build root resolves
1392
+ * none of its own: a repository without turbo still needs `turbo prune` to
1393
+ * cut its slice of the workspace, and an unpinned `turbo` is whatever the
1394
+ * registry says on the day of the build.
1395
+ */
1396
+ const TURBO_VERSION = "2.5.4";
1397
+ /**
1398
+ * Caddy, which serves a static site's files. A 2.x tag, so a rebuild picks
1399
+ * up patch releases and never a major.
1400
+ */
1401
+ const STATIC_SITE_IMAGE = "caddy:2.10-alpine";
1402
+ const LOCKFILES = [
1403
+ ["pnpm-lock.yaml", "pnpm"],
1404
+ ["bun.lockb", "bun"],
1405
+ ["yarn.lock", "yarn"],
1406
+ ["package-lock.json", "npm"]
1407
+ ];
1408
+ /**
1409
+ * Detect package manager from lockfiles
1410
+ * Walks up the directory tree to find lockfile (for monorepos)
1411
+ */
1412
+ function detectPackageManager(cwd = process.cwd()) {
1413
+ const lockfile = findLockfilePath(cwd);
1414
+ if (!lockfile) return "pnpm";
1415
+ const name = lockfile.slice(dirname(lockfile).length + 1);
1416
+ return LOCKFILES.find(([file]) => file === name)?.[1] ?? "pnpm";
1417
+ }
1418
+ /**
1419
+ * Find the lockfile path by walking up the directory tree
1420
+ * Returns the full path to the lockfile, or null if not found
1421
+ */
1422
+ function findLockfilePath(cwd = process.cwd()) {
1423
+ for (const dir of upFrom(cwd)) for (const [lockfile] of LOCKFILES) {
1424
+ const lockfilePath = join(dir, lockfile);
1425
+ if (existsSync(lockfilePath)) return lockfilePath;
1426
+ }
1427
+ return null;
1428
+ }
1429
+ /**
1430
+ * The directory every image is built from: the nearest at or above `cwd`
1431
+ * holding a lockfile or a `pnpm-workspace.yaml` — the package manager's root,
1432
+ * which is what an install resolves the workspace's dependencies against.
1433
+ *
1434
+ * In a project of its own that is the gkm workspace's root. A gkm workspace
1435
+ * nested inside a monorepo, whose dependencies are the monorepo's
1436
+ * `workspace:*` packages, is built from the monorepo's root. `cwd` itself
1437
+ * when neither is found.
1438
+ */
1439
+ function findBuildRoot(cwd = process.cwd()) {
1440
+ for (const dir of upFrom(cwd)) {
1441
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
1442
+ for (const [lockfile] of LOCKFILES) if (existsSync(join(dir, lockfile))) return dir;
1443
+ }
1444
+ return cwd;
1445
+ }
1446
+ /** `cwd` and every directory above it, the filesystem root last. */
1447
+ function upFrom(cwd) {
1448
+ const dirs = [];
1449
+ const root = parse(cwd).root;
1450
+ let dir = cwd;
1451
+ while (dir !== root) {
1452
+ dirs.push(dir);
1453
+ dir = dirname(dir);
1454
+ }
1455
+ dirs.push(root);
1456
+ return dirs;
1457
+ }
1458
+ /** The package manager, its version, turbo's and the shape of a build root. */
1459
+ function resolveBuildTools(buildRoot) {
1460
+ const pkg = readJson(join(buildRoot, "package.json"));
1461
+ const packageManager = detectPackageManager(buildRoot);
1462
+ const field = typeof pkg?.packageManager === "string" ? pkg.packageManager : "";
1463
+ const match = /^([a-z]+)@(\d[^+\s]*)/.exec(field);
1464
+ const packageManagerVersion = match && match[1] === packageManager ? match[2] : void 0;
1465
+ return {
1466
+ packageManager,
1467
+ ...packageManagerVersion ? { packageManagerVersion } : {},
1468
+ turboVersion: resolveTurboVersion(buildRoot) ?? "2.5.4",
1469
+ monorepo: existsSync(join(buildRoot, "pnpm-workspace.yaml")) || pkg?.workspaces !== void 0
1470
+ };
1471
+ }
1472
+ /**
1473
+ * The turbo version a build root resolves: what is installed, or what its
1474
+ * lockfile pins. Undefined when it has none.
1475
+ */
1476
+ function resolveTurboVersion(buildRoot) {
1477
+ const installed = readJson(join(buildRoot, "node_modules", "turbo", "package.json"));
1478
+ if (typeof installed?.version === "string") return installed.version;
1479
+ const read = (file) => {
1480
+ try {
1481
+ return readFileSync(join(buildRoot, file), "utf-8");
1482
+ } catch {
1483
+ return;
1484
+ }
1485
+ };
1486
+ const pnpm = read("pnpm-lock.yaml");
1487
+ const fromPnpm = pnpm && /^ {2}'?turbo@(\d[^:'(\s]*)'?:/m.exec(pnpm);
1488
+ if (fromPnpm) return fromPnpm[1];
1489
+ const npm = read("package-lock.json");
1490
+ if (npm) {
1491
+ const version = safeParse(npm)?.packages?.["node_modules/turbo"]?.version;
1492
+ if (version) return version;
1493
+ }
1494
+ const yarn = read("yarn.lock");
1495
+ const fromYarn = yarn && /^"?turbo@[^\n]*:\n\s+version:? "?(\d[^"\s]*)"?/m.exec(yarn);
1496
+ if (fromYarn) return fromYarn[1];
1497
+ }
1498
+ function readJson(path) {
1499
+ try {
1500
+ return safeParse(readFileSync(path, "utf-8"));
1501
+ } catch {
1502
+ return;
1503
+ }
1504
+ }
1505
+ function safeParse(text) {
1506
+ try {
1507
+ const value = JSON.parse(text);
1508
+ return value && typeof value === "object" ? value : void 0;
1509
+ } catch {
1510
+ return;
1511
+ }
1512
+ }
1513
+ /**
1514
+ * Check if turbo.json exists (walks up directory tree)
1515
+ */
1516
+ function hasTurboConfig(cwd = process.cwd()) {
1517
+ return upFrom(cwd).some((dir) => existsSync(join(dir, "turbo.json")));
1518
+ }
1519
+ /**
1520
+ * Package manager specific commands. `version` pins what corepack (or npm,
1521
+ * for bun) installs; without it, the latest.
1522
+ */
1523
+ function getPmConfig(pm, version) {
1524
+ return {
1525
+ pnpm: {
1526
+ install: `corepack enable && corepack prepare pnpm@${version ?? "latest"} --activate`,
1527
+ lockfile: "pnpm-lock.yaml",
1528
+ installCmd: "pnpm install",
1529
+ cacheTarget: "/root/.local/share/pnpm/store",
1530
+ cacheId: "pnpm",
1531
+ run: "pnpm run",
1532
+ dlx: "pnpm dlx"
1533
+ },
1534
+ npm: {
1535
+ install: version ? `npm install -g npm@${version}` : "",
1536
+ lockfile: "package-lock.json",
1537
+ installCmd: "npm install",
1538
+ cacheTarget: "/root/.npm",
1539
+ cacheId: "npm",
1540
+ run: "npm run",
1541
+ dlx: "npx --yes"
1542
+ },
1543
+ yarn: {
1544
+ install: `corepack enable && corepack prepare yarn@${version ?? "stable"} --activate`,
1545
+ lockfile: "yarn.lock",
1546
+ installCmd: "yarn install",
1547
+ cacheTarget: "/root/.yarn/cache",
1548
+ cacheId: "yarn",
1549
+ run: "yarn run",
1550
+ dlx: "yarn dlx"
1551
+ },
1552
+ bun: {
1553
+ install: `npm install -g bun@${version ?? "latest"}`,
1554
+ lockfile: "bun.lockb",
1555
+ installCmd: "bun install",
1556
+ cacheTarget: "/root/.bun/install/cache",
1557
+ cacheId: "bun",
1558
+ run: "bun run",
1559
+ dlx: "bunx"
1560
+ }
1561
+ }[pm];
1562
+ }
1563
+ /** What every template's first three stages need, resolved from options. */
1564
+ function layout(options) {
1250
1565
  const pm = getPmConfig(options.packageManager, options.packageManagerVersion);
1251
1566
  const turbo = `${pm.dlx} turbo@${options.turboVersion ?? "2.5.4"}`;
1252
1567
  const gkmRoot = options.gkmRoot ?? ".";
@@ -1393,7 +1708,16 @@ function siteWorkspaceSteps(options) {
1393
1708
 
1394
1709
  # The typed client of each API this site calls, generated from its endpoints
1395
1710
  # into ${gkmRoot === "." ? "" : `${gkmRoot}/`}.gkm/client/ — offline, with no secret and no container.
1396
- RUN ${clients.map(({ app, path }) => `cd /app/${path} && gkm openapi --app ${app}`).join(" && \\\n ")}` : "";
1711
+ RUN ${clients.map(({ app, path, telemetry }) => [
1712
+ "cd",
1713
+ `/app/${path}`,
1714
+ "&&",
1715
+ "gkm",
1716
+ "openapi",
1717
+ "--app",
1718
+ app,
1719
+ ...telemetryArgs(telemetry)
1720
+ ].join(" ")).join(" && \\\n ")}` : "";
1397
1721
  return `# The gkm CLI the workspace installs, as \`gkm\`
1398
1722
  RUN cd ${root} && GKM_BIN="$(node -e '${FIND_GKM_BIN}')" && \\
1399
1723
  { echo '#!/bin/sh'; echo "exec node \\"$GKM_BIN\\" \\"\\$@\\""; } > /usr/local/bin/gkm && \\
@@ -2195,42 +2519,9 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
2195
2519
  # from code; a browser needs the root trusted once.
2196
2520
  `;
2197
2521
  //#endregion
2198
- //#region src/reconcile/emulator.ts
2522
+ //#region src/workspace/rootSite.ts
2199
2523
  /**
2200
- * The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
2201
- * locally — the facts the compose file, the env writer and the applier must
2202
- * agree on.
2203
- *
2204
- * The emulator has one account and answers with deterministic ARNs and queue
2205
- * URLs, which is what lets a connection string be composed before the topic
2206
- * or queue it names has been created.
2207
- */
2208
- /** The emulator's single account. */
2209
- const EMULATOR_ACCOUNT = "000000000000";
2210
- const EMULATOR_REGION = "us-east-1";
2211
- /**
2212
- * The emulator's credential. The `LSIA` prefix is what LocalStack required on
2213
- * issued keys; floci does not care, and keeping it means a project that pins
2214
- * the old image still works.
2215
- */
2216
- const EMULATOR_CREDENTIALS = {
2217
- accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
2218
- secretAccessKey: "geekmidas"
2219
- };
2220
- /** The emulator's address from the host, on the port it was published on. */
2221
- function emulatorEndpoint(port) {
2222
- return `http://localhost:${port}`;
2223
- }
2224
- function emulatorTopicArn(name) {
2225
- return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
2226
- }
2227
- function emulatorQueueUrl(name, port) {
2228
- return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
2229
- }
2230
- //#endregion
2231
- //#region src/workspace/rootSite.ts
2232
- /**
2233
- * Which site the base domain points at — deployed and locally alike.
2524
+ * Which site the base domain points at — deployed and locally alike.
2234
2525
  *
2235
2526
  * Three rules, in order, and the last one is the point:
2236
2527
  *
@@ -2586,15 +2877,6 @@ var PgBossNeedsDatabase = class extends Error {
2586
2877
  * Locally that self-containment is what lets the port be whatever was free: the
2587
2878
  * app reads `ORDERS_URL` and never sees a host or a port.
2588
2879
  */
2589
- /**
2590
- * The credential local containers are brought up with.
2591
- *
2592
- * The design's `roles: false` case — both URLs fall back to the cluster's master
2593
- * credential. A deliberate downgrade, not a default: it holds until the role DDL
2594
- * lands in `@geekmidas/db`, at which point the runtime role's URL replaces this
2595
- * and the owner URL stops being reachable from any manifest edge.
2596
- */
2597
- const LOCAL_USER$2 = "geekmidas";
2598
2880
  /** The host containers are published on. */
2599
2881
  const LOCAL_HOST = "localhost";
2600
2882
  /** The region local buckets claim, so the client has one to sign with. */
@@ -2608,23 +2890,23 @@ const LOCAL_REGION = "us-east-1";
2608
2890
  * the same chain reads these — which is why they are injected beside the URL
2609
2891
  * rather than written into it.
2610
2892
  */
2611
- const STORAGE_CREDENTIALS = {
2612
- AWS_ACCESS_KEY_ID: LOCAL_USER$2,
2613
- AWS_SECRET_ACCESS_KEY: LOCAL_USER$2,
2614
- AWS_REGION: LOCAL_REGION
2615
- };
2616
- /** The token the local cache proxy accepts. Matches the compose definition. */
2617
- const LOCAL_TOKEN$1 = "geekmidas";
2893
+ function storageCredentials(credentials) {
2894
+ return {
2895
+ AWS_ACCESS_KEY_ID: credentials.minio.user,
2896
+ AWS_SECRET_ACCESS_KEY: credentials.minio.password,
2897
+ AWS_REGION: LOCAL_REGION
2898
+ };
2899
+ }
2618
2900
  /** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
2619
2901
  const MAILPIT_INBOX_PORT = "mailpit-web";
2620
2902
  /** The schema pg-boss keeps its tables in, inside the declared database. */
2621
2903
  const PGBOSS_SCHEMA = "pgboss";
2622
2904
  /** The exchange every local topic and queue shares on RabbitMQ. */
2623
- const RABBITMQ_EXCHANGE = "geekmidas.events";
2624
- /** The credential `options` describe, defaulting to the local one. */
2905
+ const RABBITMQ_EXCHANGE = "gkm.events";
2906
+ /** The credential `options` describe. */
2625
2907
  function credentialOf(options) {
2626
2908
  return {
2627
- master: options.master ?? LOCAL_USER$2,
2909
+ containers: options.credentials,
2628
2910
  ...options.seed ? { seed: options.seed } : {}
2629
2911
  };
2630
2912
  }
@@ -2664,7 +2946,7 @@ function envFor(plan, options) {
2664
2946
  }
2665
2947
  Object.assign(env, publicEnv(plan, env, options.addresses));
2666
2948
  Object.assign(env, brokerEnv(plan, options.ports, credential));
2667
- if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, STORAGE_CREDENTIALS);
2949
+ if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, storageCredentials(options.credentials));
2668
2950
  return env;
2669
2951
  }
2670
2952
  /**
@@ -2799,7 +3081,7 @@ function workerBroker(plan, ports, credential) {
2799
3081
  *
2800
3082
  * Every part of it comes from the resource and the port it was published on.
2801
3083
  */
2802
- function urlFor(resource, plan, ports, project, addresses = {}, credential = { master: LOCAL_USER$2 }) {
3084
+ function urlFor(resource, plan, ports, project, addresses, credential) {
2803
3085
  if (resource.kind === "secret") return localSecret(project, plan, resource);
2804
3086
  if (resource.kind === "encryption") return localKeyring(project, plan, resource);
2805
3087
  if (resource.kind === "mobile-app") return schemeBase(project, resource.scheme);
@@ -2813,7 +3095,7 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
2813
3095
  return port === void 0 ? void 0 : `http://${LOCAL_HOST}:${port}`;
2814
3096
  }
2815
3097
  if (resource.kind === "rest-api" || resource.kind === "site") {
2816
- const address = addresses[resource.id];
3098
+ const address = addresses?.[resource.id];
2817
3099
  const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
2818
3100
  return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
2819
3101
  }
@@ -2827,10 +3109,7 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
2827
3109
  const database = rootDatabase(resource, plan);
2828
3110
  if (resource.roles === false) {
2829
3111
  const schema = schemaOf(resource, plan);
2830
- const master = {
2831
- user: LOCAL_USER$2,
2832
- password: credential.master
2833
- };
3112
+ const master = credential.containers.postgres;
2834
3113
  return schema ? `${postgres(port, database, master)}?search_path=${schema}` : postgres(port, database, master);
2835
3114
  }
2836
3115
  const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
@@ -2854,8 +3133,8 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
2854
3133
  }
2855
3134
  switch (plan.cache) {
2856
3135
  case "elasticache":
2857
- case "redis": return `redis://${LOCAL_HOST}:${port}`;
2858
- default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
3136
+ case "redis": return `redis://:${encodeURIComponent(credential.containers.redis.password)}@${LOCAL_HOST}:${port}`;
3137
+ default: return `http://:${encodeURIComponent(credential.containers.cacheToken)}@${LOCAL_HOST}:${port}`;
2859
3138
  }
2860
3139
  case "queue":
2861
3140
  case "topic": return broker(plan, port, credential, resource);
@@ -2874,9 +3153,13 @@ function broker(plan, port, credential, resource) {
2874
3153
  case "pgboss": {
2875
3154
  const database = plan.resources.find((r) => r.kind === "database");
2876
3155
  if (!database) throw new PgBossNeedsDatabase([]);
2877
- return `pgboss://${LOCAL_USER$2}:${encodeURIComponent(credential.master)}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
3156
+ const { user, password } = credential.containers.postgres;
3157
+ return `pgboss://${user}:${encodeURIComponent(password)}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
3158
+ }
3159
+ case "rabbitmq": {
3160
+ const { user, password } = credential.containers.rabbitmq;
3161
+ return `rabbitmq://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
2878
3162
  }
2879
- case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
2880
3163
  case "sns": {
2881
3164
  if (!resource) throw new UnprovisionedEventsBackend("sns");
2882
3165
  const endpoint = emulatorEndpoint(port);
@@ -2890,17 +3173,15 @@ function broker(plan, port, credential, resource) {
2890
3173
  endpoint
2891
3174
  });
2892
3175
  const url = new URL(address);
2893
- url.searchParams.set("accessKeyId", EMULATOR_CREDENTIALS.accessKeyId);
2894
- url.searchParams.set("secretAccessKey", EMULATOR_CREDENTIALS.secretAccessKey);
3176
+ const { accessKeyId, secretAccessKey } = credential.containers.emulator;
3177
+ url.searchParams.set("accessKeyId", accessKeyId);
3178
+ url.searchParams.set("secretAccessKey", secretAccessKey);
2895
3179
  return url.toString();
2896
3180
  }
2897
3181
  }
2898
3182
  }
2899
- /** A local Postgres URL, master credential and all. */
2900
- function postgres(port, database, credential = {
2901
- user: LOCAL_USER$2,
2902
- password: LOCAL_USER$2
2903
- }) {
3183
+ /** A local Postgres URL, with the login it connects as. */
3184
+ function postgres(port, database, credential) {
2904
3185
  return `postgres://${credential.user}:${encodeURIComponent(credential.password)}@${LOCAL_HOST}:${port}/${database}`;
2905
3186
  }
2906
3187
  /**
@@ -3195,14 +3476,19 @@ function addEdgeKeys(manifest, edges, keys) {
3195
3476
  }
3196
3477
  }
3197
3478
  /** One compose service per app the workspace runs, behind the apps profile. */
3198
- function appServices(workspace, manifest, containers, runnables = {}, fakes = {}) {
3479
+ function appServices(workspace, manifest, containers, credentials, runnables = {}, fakes = {}) {
3199
3480
  const plan = localPlan(workspace, manifest, fakes);
3200
- const env = networkEnv(plan, {
3481
+ const derivation = {
3201
3482
  project: workspace.name,
3483
+ credentials,
3484
+ seed: credentials.seed
3485
+ };
3486
+ const env = networkEnv(plan, {
3487
+ ...derivation,
3202
3488
  addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
3203
3489
  });
3204
3490
  const browser = networkEnv(plan, {
3205
- project: workspace.name,
3491
+ ...derivation,
3206
3492
  addresses: surfaceAddresses(workspace, manifest)
3207
3493
  });
3208
3494
  const services = {};
@@ -3277,8 +3563,6 @@ function healthcheck(port, path) {
3277
3563
  * Both are constructed from the same local addresses the env writer composes,
3278
3564
  * so the applier cannot end up talking to a different container than the app.
3279
3565
  */
3280
- /** The credential local containers are brought up with. */
3281
- const LOCAL_USER$1 = "geekmidas";
3282
3566
  /**
3283
3567
  * A Postgres client that opens one connection per database it is asked about.
3284
3568
  *
@@ -3286,14 +3570,14 @@ const LOCAL_USER$1 = "geekmidas";
3286
3570
  * and a schema must be created from inside its own database — so the database is
3287
3571
  * part of each query rather than fixed when the client is built.
3288
3572
  */
3289
- function pgClient(port, password = LOCAL_USER$1) {
3573
+ function pgClient(port, login) {
3290
3574
  return { async query(database, sql, values) {
3291
3575
  const client = new Client({
3292
3576
  host: "localhost",
3293
3577
  port,
3294
- user: LOCAL_USER$1,
3295
- password,
3296
- database: database ?? LOCAL_USER$1
3578
+ user: login.user,
3579
+ password: login.password,
3580
+ database: database ?? "postgres"
3297
3581
  });
3298
3582
  await client.connect();
3299
3583
  try {
@@ -3304,10 +3588,7 @@ function pgClient(port, password = LOCAL_USER$1) {
3304
3588
  } };
3305
3589
  }
3306
3590
  /** A bucket client pointed at a MinIO on this machine. */
3307
- function bucketClient(port, credentials = {
3308
- user: LOCAL_USER$1,
3309
- password: LOCAL_USER$1
3310
- }) {
3591
+ function bucketClient(port, credentials) {
3311
3592
  const s3 = new S3Client({
3312
3593
  region: "us-east-1",
3313
3594
  endpoint: `http://localhost:${port}`,
@@ -3345,11 +3626,11 @@ function bucketClient(port, credentials = {
3345
3626
  };
3346
3627
  }
3347
3628
  /** A topic and queue client pointed at the local AWS emulator. */
3348
- function carrierClient(port) {
3629
+ function carrierClient(port, credentials) {
3349
3630
  const config = {
3350
3631
  region: EMULATOR_REGION,
3351
3632
  endpoint: emulatorEndpoint(port),
3352
- credentials: EMULATOR_CREDENTIALS
3633
+ credentials
3353
3634
  };
3354
3635
  const sns = new SNSClient(config);
3355
3636
  const sqs = new SQSClient(config);
@@ -3379,1195 +3660,1883 @@ function carrierClient(port) {
3379
3660
  };
3380
3661
  }
3381
3662
  //#endregion
3382
- //#region src/reconcile/compose.ts
3663
+ //#region src/reconcile/docker.ts
3383
3664
  /**
3384
- * The compose document a plan implies.
3385
- *
3386
- * The third declaration of the same containers disappears here: today a
3387
- * `services: ['postgres','minio']` block is kept in step with the code by hand,
3388
- * which is the triplication this design exists to remove, in the one place you
3389
- * touch every day. A `database` implies Postgres; the list is derived.
3665
+ * The real Docker, behind the small interface reconcile needs.
3390
3666
  *
3391
- * Returns an object rather than a string for the same reason `plan` is pure —
3392
- * what should exist is asserted as data, and turning it into YAML is one line at
3393
- * the edge.
3667
+ * Shells out to `docker compose` rather than talking to the daemon socket: it is
3668
+ * what a developer runs by hand when something is wrong, so the two cannot
3669
+ * disagree, and it keeps Docker out of this package's dependencies.
3394
3670
  *
3395
- * What stays config is unchanged: images and versions are pinned explicitly, and
3396
- * a container no construct implies is still declared by hand. Neutral facts come
3397
- * from the declaration, provider detail from config.
3671
+ * Every call is scoped to the generated compose file. Nothing here can touch a
3672
+ * container this project did not generate.
3398
3673
  */
3399
- /** The credential every derived container is brought up with locally. */
3400
- const LOCAL_USER = "geekmidas";
3674
+ const run = promisify(execFile);
3401
3675
  /**
3402
- * The token the local cache accepts.
3676
+ * The generated file, then the project's own if it has one.
3403
3677
  *
3404
- * Fixed rather than generated: it is only ever reachable on a port this project
3405
- * published, and the URL that carries it is derived, so nothing is made safer
3406
- * by making it unguessable and a stable one keeps `redis-cli` sessions working.
3678
+ * Merged in that order so the project's wins — an `image:` there pins a
3679
+ * container this file derived, and a service there is added beside them.
3680
+ */
3681
+ function composeFiles(composePath) {
3682
+ const project = join(dirname(composePath), "docker-compose.yml");
3683
+ return [
3684
+ "-f",
3685
+ composePath,
3686
+ ...existsSync(project) ? ["-f", project] : []
3687
+ ];
3688
+ }
3689
+ /** How long to wait for containers to pass their health checks. */
3690
+ const HEALTH_TIMEOUT_MS = 12e4;
3691
+ /** `docker compose up` exited unsuccessfully; its own output said why. */
3692
+ var ComposeUpFailed = class extends Error {
3693
+ services;
3694
+ code;
3695
+ constructor(services, code) {
3696
+ super(`docker compose could not start ${services.join(", ")} (exit ${code ?? "signal"}). Its output above names the container; \`docker compose logs <service>\` shows why it stopped.`);
3697
+ this.services = services;
3698
+ this.code = code;
3699
+ this.name = "ComposeUpFailed";
3700
+ }
3701
+ };
3702
+ /** Run compose with its output on this terminal rather than captured. */
3703
+ function shown(args, services) {
3704
+ return new Promise((resolve, reject) => {
3705
+ const child = spawn("docker", args, { stdio: [
3706
+ "ignore",
3707
+ "inherit",
3708
+ "inherit"
3709
+ ] });
3710
+ child.on("error", reject);
3711
+ child.on("close", (code) => code === 0 ? resolve() : reject(new ComposeUpFailed(services, code)));
3712
+ });
3713
+ }
3714
+ const dockerCli = {
3715
+ async publishedPort(composePath, service, inside) {
3716
+ try {
3717
+ const { stdout } = await run("docker", [
3718
+ "compose",
3719
+ ...composeFiles(composePath),
3720
+ "port",
3721
+ service,
3722
+ String(inside)
3723
+ ]);
3724
+ const port = Number(stdout.trim().split(":").pop());
3725
+ return Number.isFinite(port) && port > 0 ? port : void 0;
3726
+ } catch {
3727
+ return;
3728
+ }
3729
+ },
3730
+ async up(composePath, services, options = {}) {
3731
+ const args = [
3732
+ "compose",
3733
+ ...composeFiles(composePath),
3734
+ "up",
3735
+ "-d",
3736
+ "--remove-orphans",
3737
+ "--wait",
3738
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
3739
+ ...services
3740
+ ];
3741
+ if (options.show) return shown(args, services);
3742
+ await run("docker", args);
3743
+ },
3744
+ async copyOut(composePath, service, from, to) {
3745
+ await mkdir(dirname(to), { recursive: true });
3746
+ await run("docker", [
3747
+ "compose",
3748
+ ...composeFiles(composePath),
3749
+ "cp",
3750
+ `${service}:${from}`,
3751
+ to
3752
+ ]);
3753
+ },
3754
+ async reload(composePath, service) {
3755
+ await run("docker", [
3756
+ "compose",
3757
+ ...composeFiles(composePath),
3758
+ "exec",
3759
+ "-T",
3760
+ service,
3761
+ "caddy",
3762
+ "reload",
3763
+ "--config",
3764
+ "/etc/caddy/Caddyfile"
3765
+ ]);
3766
+ },
3767
+ async exec(composePath, service, argv) {
3768
+ try {
3769
+ const { stdout, stderr } = await run("docker", [
3770
+ "compose",
3771
+ ...composeFiles(composePath),
3772
+ "exec",
3773
+ "-T",
3774
+ service,
3775
+ ...argv
3776
+ ]);
3777
+ return {
3778
+ ok: true,
3779
+ output: `${stdout}${stderr}`
3780
+ };
3781
+ } catch (error) {
3782
+ const { stdout = "", stderr = "" } = error;
3783
+ return {
3784
+ ok: false,
3785
+ output: `${stdout}${stderr}`
3786
+ };
3787
+ }
3788
+ },
3789
+ async healthy(composePath, services) {
3790
+ if (services.length === 0) return true;
3791
+ try {
3792
+ const { stdout } = await run("docker", [
3793
+ "compose",
3794
+ ...composeFiles(composePath),
3795
+ "ps",
3796
+ "--format",
3797
+ "json",
3798
+ ...services
3799
+ ]);
3800
+ const running = /* @__PURE__ */ new Set();
3801
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
3802
+ const entry = JSON.parse(line);
3803
+ if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
3804
+ }
3805
+ return services.every((service) => running.has(service));
3806
+ } catch {
3807
+ return false;
3808
+ }
3809
+ }
3810
+ };
3811
+ //#endregion
3812
+ //#region src/reconcile/lan.ts
3813
+ /**
3814
+ * This machine's address on the local network — what a phone running the app
3815
+ * reaches it on.
3407
3816
  */
3408
- const LOCAL_TOKEN = "geekmidas";
3409
3817
  /**
3410
- * Build the compose document for a plan.
3818
+ * The first private IPv4 address of an interface that is up and not loopback.
3411
3819
  *
3412
- * Every container is published on its assigned port, never on the image's
3413
- * default: the point of allocation is that two projects can run at once, and
3414
- * one container publishing 5432 puts that back.
3820
+ * Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
3821
+ * would mean listening for a phone on the open internet, and a link-local one
3822
+ * (`169.254/16`) is what an interface has when it found no network at all.
3823
+ * Undefined when there is none — a laptop on no network still runs, its mobile
3824
+ * app reaching only a simulator.
3415
3825
  */
3416
- function composeFor(plan, options) {
3417
- const services = {};
3418
- const volumes = {};
3419
- for (const container of [...plan.containers].sort()) {
3420
- const declared = container === "postgres" ? plan.resources.find((r) => r.version)?.version : void 0;
3421
- const fake = plan.fakes?.[container];
3422
- const image = declared ? postgresImage(declared) : fake?.image ?? DEFAULT_IMAGES[container];
3423
- if (!image) throw new UnknownContainer(container);
3424
- const published = publish(container, options.ports, plan.fakes);
3425
- services[container] = fake ? {
3426
- image,
3427
- restart: "unless-stopped",
3428
- ports: published
3429
- } : define(container, image, published);
3430
- const volume = volumeOf(container);
3431
- if (volume) volumes[volume] = {};
3826
+ function lanAddress(interfaces = networkInterfaces()) {
3827
+ for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
3828
+ if (entry.family !== "IPv4" || entry.internal) continue;
3829
+ if (isPrivate(entry.address)) return entry.address;
3432
3830
  }
3433
- return {
3434
- name: options.project,
3435
- services: {
3436
- ...services,
3437
- ...options.apps
3438
- },
3439
- ...Object.keys(volumes).length ? { volumes } : {}
3440
- };
3441
3831
  }
3442
- /** Render a compose document as the file it becomes. */
3443
- function toYaml(file) {
3444
- return `# Generated by gkm from the construct manifest — do not edit.
3445
- # Containers are derived from what the app declares. Image pins and anything
3446
- # no construct implies go in your own docker-compose.yml, which is merged over
3447
- # this file and wins:
3448
- #
3449
- # docker compose -f docker-compose.constructs.yml -f docker-compose.yml …
3450
- #
3451
- # "gkm dev" and "gkm test" start the containers. The apps sit behind the
3452
- # "apps" profile: run "gkm setup", then
3453
- # docker compose -f docker-compose.constructs.yml --profile apps up --build
3454
- ${stringify(file, { lineWidth: 0 })}`;
3832
+ function isPrivate(address) {
3833
+ return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
3455
3834
  }
3456
- /** A container with no image, from config or from the defaults. */
3457
- var UnknownContainer = class extends Error {
3458
- /** The container that could not be resolved. */
3459
- container;
3460
- /** The containers that have a default, so the caller can see the set. */
3461
- known = Object.keys(DEFAULT_IMAGES).sort();
3462
- constructor(container) {
3463
- super("No image is known for this container; pin one in config");
3464
- this.name = "UnknownContainer";
3465
- this.container = container;
3835
+ //#endregion
3836
+ //#region src/reconcile/logins.ts
3837
+ /**
3838
+ * Bringing a container that already has data in line with the workspace's
3839
+ * generated logins.
3840
+ *
3841
+ * Postgres and RabbitMQ read their login from the environment once, when the
3842
+ * volume is first initialised; a volume an older gkm made was initialised
3843
+ * with the old fixed one, and a changed environment alone would lock the
3844
+ * developer out of their own data. So after the containers are up, each one
3845
+ * is asked whether the generated login opens it, and if not:
3846
+ *
3847
+ * - **Postgres**: signed in to with the old login, the generated superuser is
3848
+ * created (or given its password), and the old one stops logging in. Where
3849
+ * no login is accepted — another checkout of the same project rotated it —
3850
+ * the password is set from inside the container, which trusts its own
3851
+ * socket.
3852
+ * - **MinIO** reads its root login on every start, so recreating the
3853
+ * container rotated it already. If the old login is still the one it takes —
3854
+ * a project's own `docker-compose.yml` pins it — that is kept, with a note.
3855
+ * - **RabbitMQ**: the user is added, or given its password, with `rabbitmqctl`
3856
+ * inside the container.
3857
+ *
3858
+ * Nothing is deleted: no volume, no database, no bucket.
3859
+ */
3860
+ /**
3861
+ * The login every local container was brought up with before logins were
3862
+ * generated. Kept only to recognise — and rotate away from — a volume made
3863
+ * then.
3864
+ */
3865
+ const LEGACY_LOCAL_LOGIN = Object.freeze({
3866
+ user: "geekmidas",
3867
+ password: "geekmidas"
3868
+ });
3869
+ /** No login this workspace knows opens its Postgres. */
3870
+ var PostgresLoginRefused = class extends Error {
3871
+ port;
3872
+ user;
3873
+ constructor(port, user) {
3874
+ super(`The Postgres on port ${port} accepts neither '${user}' with this workspace's generated password nor the login older gkm versions used, and could not be reset from inside its container. If it is not this project's container, stop whatever holds the port. If it is, removing its volume starts it over with the generated login — and deletes its data: docker compose -f docker-compose.constructs.yml down -v`);
3875
+ this.port = port;
3876
+ this.user = user;
3877
+ this.name = "PostgresLoginRefused";
3466
3878
  }
3467
3879
  };
3468
- /** One container's definition, published on the port it was assigned. */
3469
- function define(container, image, published) {
3470
- switch (container) {
3471
- case "postgres": return {
3472
- image,
3473
- restart: "unless-stopped",
3474
- ports: published,
3475
- environment: {
3476
- POSTGRES_USER: LOCAL_USER,
3477
- POSTGRES_PASSWORD: LOCAL_USER,
3478
- POSTGRES_DB: LOCAL_USER
3479
- },
3480
- volumes: ["postgres-data:/var/lib/postgresql"],
3481
- healthcheck: {
3482
- test: ["CMD-SHELL", `pg_isready -U ${LOCAL_USER}`],
3483
- interval: "5s",
3484
- timeout: "3s",
3485
- retries: 10,
3486
- start_period: "10s"
3487
- }
3488
- };
3489
- case "minio": return {
3490
- image,
3491
- restart: "unless-stopped",
3492
- ports: published,
3493
- environment: {
3494
- MINIO_ROOT_USER: LOCAL_USER,
3495
- MINIO_ROOT_PASSWORD: LOCAL_USER
3496
- },
3497
- command: "server --console-address \":9001\" /data",
3498
- volumes: ["minio-data:/data"],
3499
- healthcheck: {
3500
- test: [
3501
- "CMD",
3502
- "mc",
3503
- "ready",
3504
- "local"
3505
- ],
3506
- interval: "10s",
3507
- timeout: "5s",
3508
- retries: 5,
3509
- start_period: "10s"
3510
- }
3511
- };
3512
- case "caddy": return {
3513
- image,
3514
- restart: "unless-stopped",
3515
- ports: published,
3516
- volumes: [
3517
- "./.gkm/Caddyfile:/etc/caddy/Caddyfile:ro",
3518
- "./.gkm/caddy-sites:/etc/caddy/sites:ro",
3519
- "caddy-data:/data"
3520
- ],
3521
- extra_hosts: ["host.docker.internal:host-gateway"],
3522
- healthcheck: {
3523
- test: [
3524
- "CMD",
3525
- "caddy",
3526
- "version"
3527
- ],
3528
- interval: "10s",
3529
- timeout: "5s",
3530
- retries: 5
3531
- }
3532
- };
3533
- case "mailpit": return {
3534
- image,
3535
- restart: "unless-stopped",
3536
- ports: published,
3537
- environment: {
3538
- MP_SMTP_AUTH_ACCEPT_ANY: "1",
3539
- MP_SMTP_AUTH_ALLOW_INSECURE: "1"
3540
- },
3541
- healthcheck: {
3542
- test: [
3543
- "CMD",
3544
- "wget",
3545
- "-q",
3546
- "--spider",
3547
- "http://localhost:8025"
3548
- ],
3549
- interval: "10s",
3550
- timeout: "5s",
3551
- retries: 5
3552
- }
3880
+ /** No login this workspace knows opens its MinIO. */
3881
+ var MinioLoginRefused = class extends Error {
3882
+ port;
3883
+ user;
3884
+ constructor(port, user) {
3885
+ super(`The MinIO on port ${port} accepts neither '${user}' with this workspace's generated password nor the login older gkm versions used. If your docker-compose.yml sets MINIO_ROOT_USER or MINIO_ROOT_PASSWORD, remove them so the generated login applies.`);
3886
+ this.port = port;
3887
+ this.user = user;
3888
+ this.name = "MinioLoginRefused";
3889
+ }
3890
+ };
3891
+ /** Whether `login` opens the Postgres on `port`, as a connected client. */
3892
+ async function connect$1(port, login) {
3893
+ const client = new Client({
3894
+ host: "localhost",
3895
+ port,
3896
+ user: login.user,
3897
+ password: login.password,
3898
+ database: CLUSTER_DATABASE
3899
+ });
3900
+ try {
3901
+ await client.connect();
3902
+ return client;
3903
+ } catch (error) {
3904
+ await client.end().catch(() => {});
3905
+ const code = error.code;
3906
+ if (code === "28P01" || code === "28000" || code === "3D000") return null;
3907
+ throw error;
3908
+ }
3909
+ }
3910
+ /** Quote a string literal for DDL that cannot take parameters. */
3911
+ function literal(value) {
3912
+ return `'${value.replace(/'/g, "''")}'`;
3913
+ }
3914
+ function ident(name) {
3915
+ return `"${name.replace(/"/g, "\"\"")}"`;
3916
+ }
3917
+ /** The SQL that makes `login` a superuser with its password, new or not. */
3918
+ function superuserSql(login) {
3919
+ return `DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${literal(login.user)}) THEN ALTER ROLE ${ident(login.user)} WITH SUPERUSER LOGIN PASSWORD ${literal(login.password)}; ELSE CREATE ROLE ${ident(login.user)} WITH SUPERUSER LOGIN PASSWORD ${literal(login.password)}; END IF; END \$\$`;
3920
+ }
3921
+ /**
3922
+ * Make the Postgres on `port` take `login`, rotating it from an older one
3923
+ * where that is what it takes.
3924
+ */
3925
+ async function ensurePostgresLogin(options) {
3926
+ const { port, login, legacy } = options;
3927
+ const current = await connect$1(port, login);
3928
+ if (current) {
3929
+ await current.end();
3930
+ return {
3931
+ service: "postgres",
3932
+ status: "current",
3933
+ login
3553
3934
  };
3554
- case "redis": return {
3555
- image,
3556
- restart: "unless-stopped",
3557
- ports: published,
3558
- volumes: ["redis-data:/data"],
3559
- healthcheck: {
3560
- test: [
3561
- "CMD",
3562
- "redis-cli",
3563
- "ping"
3564
- ],
3565
- interval: "5s",
3566
- timeout: "3s",
3567
- retries: 5
3568
- }
3935
+ }
3936
+ for (const old of legacy) {
3937
+ const client = await connect$1(port, old);
3938
+ if (!client) continue;
3939
+ try {
3940
+ await client.query(superuserSql(login));
3941
+ } catch {
3942
+ await client.end();
3943
+ return {
3944
+ service: "postgres",
3945
+ status: "kept",
3946
+ login: old,
3947
+ notice: `⚠️ Postgres still signs in as '${old.user}' with the old fixed password: it could not be rotated. To start over with a generated one, remove its volume (this deletes its data): docker compose -f docker-compose.constructs.yml down -v`
3948
+ };
3949
+ }
3950
+ await client.end();
3951
+ await retire(port, login, old);
3952
+ return {
3953
+ service: "postgres",
3954
+ status: "rotated",
3955
+ login,
3956
+ notice: `🔑 Postgres moved from the old fixed login to a generated one ('${login.user}'); its data is unchanged. See it with: gkm dev:credentials`
3569
3957
  };
3570
- case "redis-http": return {
3571
- image,
3572
- restart: "unless-stopped",
3573
- ports: published,
3574
- environment: {
3575
- SRH_MODE: "env",
3576
- SRH_TOKEN: LOCAL_TOKEN,
3577
- SRH_CONNECTION_STRING: "redis://redis:6379"
3578
- },
3579
- depends_on: ["redis"],
3580
- healthcheck: {
3581
- test: [
3582
- "CMD",
3583
- "wget",
3584
- "-q",
3585
- "-O",
3586
- "/dev/null",
3587
- "http://127.0.0.1:80/"
3588
- ],
3589
- interval: "5s",
3590
- timeout: "3s",
3591
- retries: 10
3592
- }
3958
+ }
3959
+ if (options.exec) for (const user of [login.user, ...legacy.map((l) => l.user)]) {
3960
+ if (!(await options.exec("postgres", [
3961
+ "psql",
3962
+ "-v",
3963
+ "ON_ERROR_STOP=1",
3964
+ "-U",
3965
+ user,
3966
+ "-d",
3967
+ "postgres",
3968
+ "-c",
3969
+ superuserSql(login)
3970
+ ])).ok) continue;
3971
+ const client = await connect$1(port, login);
3972
+ if (!client) continue;
3973
+ await client.end();
3974
+ for (const old of legacy) await retire(port, login, old);
3975
+ return {
3976
+ service: "postgres",
3977
+ status: "rotated",
3978
+ login,
3979
+ notice: `🔑 Postgres's login was reset to this workspace's generated one ('${login.user}'); its data is unchanged.`
3593
3980
  };
3594
- case "rabbitmq": return {
3595
- image,
3596
- restart: "unless-stopped",
3597
- ports: published,
3598
- environment: {
3599
- RABBITMQ_DEFAULT_USER: LOCAL_USER,
3600
- RABBITMQ_DEFAULT_PASS: LOCAL_USER
3601
- },
3602
- volumes: ["rabbitmq-data:/var/lib/rabbitmq"],
3603
- healthcheck: {
3604
- test: [
3605
- "CMD",
3606
- "rabbitmq-diagnostics",
3607
- "-q",
3608
- "ping"
3609
- ],
3610
- interval: "10s",
3611
- timeout: "5s",
3612
- retries: 5
3613
- }
3981
+ }
3982
+ throw new PostgresLoginRefused(port, login.user);
3983
+ }
3984
+ /** Stop an older login from opening the cluster, now another does. */
3985
+ async function retire(port, login, old) {
3986
+ if (old.user === login.user) return;
3987
+ const client = await connect$1(port, login);
3988
+ if (!client) return;
3989
+ try {
3990
+ if ((await client.query("SELECT 1 FROM pg_roles WHERE rolname = $1", [old.user])).rowCount) await client.query(`ALTER ROLE ${ident(old.user)} WITH NOLOGIN PASSWORD NULL`);
3991
+ } finally {
3992
+ await client.end();
3993
+ }
3994
+ }
3995
+ /** Whether `login` signs in to the MinIO on `port`. */
3996
+ async function minioAccepts(port, login) {
3997
+ const s3 = new S3Client({
3998
+ region: "us-east-1",
3999
+ endpoint: `http://localhost:${port}`,
4000
+ forcePathStyle: true,
4001
+ credentials: {
4002
+ accessKeyId: login.user,
4003
+ secretAccessKey: login.password
4004
+ },
4005
+ maxAttempts: 1
4006
+ });
4007
+ try {
4008
+ await s3.send(new ListBucketsCommand({}));
4009
+ return true;
4010
+ } catch (error) {
4011
+ const name = error.name ?? "";
4012
+ if (name === "InvalidAccessKeyId" || name === "SignatureDoesNotMatch" || name === "AccessDenied") return false;
4013
+ throw error;
4014
+ } finally {
4015
+ s3.destroy();
4016
+ }
4017
+ }
4018
+ /**
4019
+ * Check the MinIO on `port` takes `login`. It reads its root login on every
4020
+ * start, so a recreated container already does — unless something pins an
4021
+ * older one, which is then kept.
4022
+ */
4023
+ async function ensureMinioLogin(options) {
4024
+ const { port, login, legacy } = options;
4025
+ if (await minioAccepts(port, login)) return {
4026
+ service: "minio",
4027
+ status: "current",
4028
+ login
4029
+ };
4030
+ for (const old of legacy) if (await minioAccepts(port, old)) return {
4031
+ service: "minio",
4032
+ status: "kept",
4033
+ login: old,
4034
+ notice: `⚠️ MinIO still signs in as '${old.user}' with the old fixed password: something — usually your docker-compose.yml's MINIO_ROOT_USER/MINIO_ROOT_PASSWORD — pins it. Remove that to use the generated login.`
4035
+ };
4036
+ throw new MinioLoginRefused(port, login.user);
4037
+ }
4038
+ /**
4039
+ * Make the RabbitMQ in the project's container take `login`, with
4040
+ * `rabbitmqctl` inside it — which needs no password.
4041
+ */
4042
+ async function ensureRabbitmqLogin(options) {
4043
+ const { login, legacy, exec } = options;
4044
+ const ctl = (...args) => exec("rabbitmq", ["rabbitmqctl", ...args]);
4045
+ if ((await ctl("authenticate_user", login.user, login.password)).ok) return {
4046
+ service: "rabbitmq",
4047
+ status: "current",
4048
+ login
4049
+ };
4050
+ if (!(await ctl("add_user", login.user, login.password)).ok) await ctl("change_password", login.user, login.password);
4051
+ await ctl("set_user_tags", login.user, "administrator");
4052
+ await ctl("set_permissions", "-p", "/", login.user, ".*", ".*", ".*");
4053
+ for (const old of legacy) if (old.user !== login.user) await ctl("delete_user", old.user);
4054
+ return {
4055
+ service: "rabbitmq",
4056
+ status: "rotated",
4057
+ login,
4058
+ notice: `🔑 RabbitMQ moved from the old fixed login to a generated one ('${login.user}'); its queues are unchanged.`
4059
+ };
4060
+ }
4061
+ /**
4062
+ * Check the login of each running container that keeps one, and the
4063
+ * credentials to use from here: the generated ones, or an older login a
4064
+ * service could not be moved off.
4065
+ */
4066
+ async function ensureLogins(options) {
4067
+ const { containers, ports, exec } = options;
4068
+ let credentials = options.credentials;
4069
+ const outcomes = [];
4070
+ const legacy = [LEGACY_LOCAL_LOGIN];
4071
+ if (containers.includes("postgres") && ports.postgres !== void 0) {
4072
+ const outcome = await ensurePostgresLogin({
4073
+ port: ports.postgres,
4074
+ login: credentials.postgres,
4075
+ legacy,
4076
+ ...exec ? { exec } : {}
4077
+ });
4078
+ outcomes.push(outcome);
4079
+ credentials = {
4080
+ ...credentials,
4081
+ postgres: outcome.login
3614
4082
  };
3615
- case "localstack": return {
3616
- image,
3617
- restart: "unless-stopped",
3618
- ports: published,
3619
- extra_hosts: ["host.docker.internal:host-gateway"],
3620
- environment: {
3621
- AWS_DEFAULT_REGION: EMULATOR_REGION,
3622
- AWS_ACCESS_KEY_ID: EMULATOR_CREDENTIALS.accessKeyId,
3623
- AWS_SECRET_ACCESS_KEY: EMULATOR_CREDENTIALS.secretAccessKey
3624
- },
3625
- volumes: ["localstack-data:/var/lib/localstack"],
3626
- healthcheck: {
3627
- test: [
3628
- "CMD",
3629
- "bash",
3630
- "-c",
3631
- "exec 3<>/dev/tcp/localhost/4566"
3632
- ],
3633
- interval: "10s",
3634
- timeout: "5s",
3635
- retries: 5,
3636
- start_period: "10s"
3637
- }
4083
+ }
4084
+ if (containers.includes("minio") && ports.minio !== void 0) {
4085
+ const outcome = await ensureMinioLogin({
4086
+ port: ports.minio,
4087
+ login: credentials.minio,
4088
+ legacy
4089
+ });
4090
+ outcomes.push(outcome);
4091
+ credentials = {
4092
+ ...credentials,
4093
+ minio: outcome.login
3638
4094
  };
3639
- default: throw new UnknownContainer(container);
3640
4095
  }
4096
+ if (containers.includes("rabbitmq") && exec) outcomes.push(await ensureRabbitmqLogin({
4097
+ login: credentials.rabbitmq,
4098
+ legacy,
4099
+ exec
4100
+ }));
4101
+ return {
4102
+ credentials,
4103
+ outcomes
4104
+ };
4105
+ }
4106
+ //#endregion
4107
+ //#region src/reconcile/ports.ts
4108
+ /**
4109
+ * Port allocation — why two projects can run at once.
4110
+ *
4111
+ * Publishing `5432:5432` means the second project on your machine cannot start.
4112
+ * Nothing solves that except giving each project its own ports, and what makes
4113
+ * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
4114
+ * never sees a host or a port, so reconcile is free to bind whatever is
4115
+ * available and compose the URL from what it bound. An app reading `DB_HOST` and
4116
+ * `DB_PORT` separately could not be moved this way.
4117
+ *
4118
+ * Assignments are **persisted, not recomputed**. Recomputing means adding a
4119
+ * construct renumbers its neighbours, and every running container, saved
4120
+ * connection string, and `psql` history goes stale at once.
4121
+ *
4122
+ * Pure, with the liveness probe injected — a collision with another project's
4123
+ * stack is a fact about the machine, and keeping it a parameter is what lets the
4124
+ * rules be asserted without binding a socket.
4125
+ */
4126
+ /**
4127
+ * The window ports are drawn from.
4128
+ *
4129
+ * Above the registered range so nothing here needs privileges, and below the
4130
+ * ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
4131
+ * the kernel might hand to an outgoing connection is one that is free when
4132
+ * checked and taken by the time it is bound.
4133
+ */
4134
+ const WINDOW = {
4135
+ min: 2e4,
4136
+ max: 32e3
4137
+ };
4138
+ /**
4139
+ * How far apart two projects start.
4140
+ *
4141
+ * Wide enough that a project can add containers without walking into its
4142
+ * neighbour's block, narrow enough that the window holds many projects.
4143
+ */
4144
+ const BLOCK = 50;
4145
+ /**
4146
+ * Where a project's ports start.
4147
+ *
4148
+ * Derived from the name rather than assigned sequentially so that a fresh clone
4149
+ * of the same project on the same machine lands on the same block — the first
4150
+ * run has nothing persisted to read, and hashing is what keeps that first run
4151
+ * from colliding with whatever else is already allocated.
4152
+ *
4153
+ * @example startingPort('toolbox') // stable for a given name
4154
+ */
4155
+ function startingPort(project) {
4156
+ const digest = createHash("sha256").update(project).digest();
4157
+ const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
4158
+ return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
4159
+ }
4160
+ /**
4161
+ * Assign a port to every container that has none, keeping the ones that do.
4162
+ *
4163
+ * Existing assignments are returned untouched even when the container is no
4164
+ * longer in the plan: removing a construct must not renumber what is left, and
4165
+ * keeping the entry means re-adding it later restores the same port rather than
4166
+ * inventing a new one.
4167
+ *
4168
+ * @param probe - reports what is actually listening. A port assigned to this
4169
+ * project is skipped without probing, because on a converged reconcile our own
4170
+ * container is the thing listening on it.
4171
+ * @throws {NoPortAvailable} when the window is exhausted.
4172
+ */
4173
+ async function allocate(project, containers, existing = {}, probe = isPortFree) {
4174
+ const assignments = { ...existing };
4175
+ const taken = new Set(Object.values(assignments));
4176
+ for (const container of [...containers].sort()) {
4177
+ if (assignments[container] !== void 0) continue;
4178
+ const port = await nextFree(startingPort(project), taken, probe);
4179
+ assignments[container] = port;
4180
+ taken.add(port);
4181
+ }
4182
+ return assignments;
4183
+ }
4184
+ /**
4185
+ * The ports to keep: what running containers are observed on, then what was
4186
+ * saved — minus any saved entry whose port something else already holds.
4187
+ *
4188
+ * Spreading one over the other let an observed port override its own key and
4189
+ * leave a *different* key still saved on the same number: two services on one
4190
+ * port, and whichever answered first won. Saved keys for containers the plan no
4191
+ * longer has — a backend switched and back — were the usual source. Observed
4192
+ * wins because it is what is actually bound; a dropped entry is simply
4193
+ * allocated again.
4194
+ */
4195
+ function keptPorts(saved = {}, observed = {}) {
4196
+ const kept = { ...observed };
4197
+ const taken = new Set(Object.values(observed));
4198
+ for (const [key, port] of Object.entries(saved).sort(([a], [b]) => a < b ? -1 : 1)) {
4199
+ if (key in kept || taken.has(port)) continue;
4200
+ kept[key] = port;
4201
+ taken.add(port);
4202
+ }
4203
+ return kept;
4204
+ }
4205
+ /** The window was exhausted. */
4206
+ var NoPortAvailable = class extends Error {
4207
+ /** Where the search started. */
4208
+ from;
4209
+ /** The window searched, so the caller can report what was full. */
4210
+ window = WINDOW;
4211
+ constructor(from) {
4212
+ super("No free port is available in the local port range");
4213
+ this.name = "NoPortAvailable";
4214
+ this.from = from;
4215
+ }
4216
+ };
4217
+ /**
4218
+ * The first port at or after `from` that is neither ours nor in use.
4219
+ *
4220
+ * Wraps at the top of the window rather than stopping, so a project whose block
4221
+ * sits near the end is not the one that fails.
4222
+ */
4223
+ async function nextFree(from, taken, probe) {
4224
+ const span = WINDOW.max - WINDOW.min;
4225
+ for (let offset = 0; offset < span; offset++) {
4226
+ const port = WINDOW.min + (from - WINDOW.min + offset) % span;
4227
+ if (taken.has(port)) continue;
4228
+ if (await probe(port)) return port;
4229
+ }
4230
+ throw new NoPortAvailable(from);
4231
+ }
4232
+ /**
4233
+ * Whether nothing is listening on a port.
4234
+ *
4235
+ * Binds rather than connects: a refused connection says nothing about whether
4236
+ * the port can be *published*, which is the question Docker will ask. Binds on
4237
+ * `0.0.0.0` for the same reason — a port free on loopback can still be taken on
4238
+ * the address Docker publishes to.
4239
+ *
4240
+ * Inherently racy, and that is acceptable: Docker failing to bind stays the real
4241
+ * check. This exists so the common case surfaces as a message at reconcile
4242
+ * rather than as a connection failure ten minutes later.
4243
+ */
4244
+ function isPortFree(port) {
4245
+ return new Promise((resolve) => {
4246
+ const server = createServer();
4247
+ server.once("error", () => resolve(false));
4248
+ server.once("listening", () => server.close(() => resolve(true)));
4249
+ server.listen(port, "0.0.0.0");
4250
+ });
4251
+ }
4252
+ //#endregion
4253
+ //#region src/reconcile/provision.ts
4254
+ /**
4255
+ * Creating what the URLs name.
4256
+ *
4257
+ * Convergent and idempotent: every statement asks whether the thing exists
4258
+ * first, so reconciling repeatedly is free and a half-applied run recovers by
4259
+ * being run again. That property is what lets `gkm dev` do this on every start.
4260
+ *
4261
+ * The line the design draws is at data, not at side effects — this creates
4262
+ * databases, schemas, and buckets, and it never seeds, resets, or drops. A
4263
+ * developer who wants their data gone asks for it explicitly.
4264
+ *
4265
+ * Statement generation is separated from application for the reason the design
4266
+ * gives: the same DDL has to run in-process locally and from a provisioner in a
4267
+ * VPC, and one implementation is what stops "works locally, fails deployed".
4268
+ * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
4269
+ * growing its own.
4270
+ */
4271
+ /**
4272
+ * The Postgres work a plan implies.
4273
+ *
4274
+ * Databases before the schemas inside them, which `provisionOrder` already
4275
+ * guarantees by putting parents first.
4276
+ */
4277
+ function postgresStatements(plan, project = "", seed) {
4278
+ const statements = [];
4279
+ for (const resource of plan.resources) {
4280
+ if (!resource.provisions || resource.container !== "postgres") continue;
4281
+ if (resource.kind === "database") {
4282
+ statements.push({
4283
+ id: resource.id,
4284
+ describe: `database ${resource.name}`,
4285
+ exists: {
4286
+ sql: "SELECT 1 FROM pg_database WHERE datname = $1",
4287
+ values: [resource.name]
4288
+ },
4289
+ create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
4290
+ });
4291
+ if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
4292
+ continue;
4293
+ }
4294
+ if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
4295
+ const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
4296
+ if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
4297
+ }
4298
+ return statements;
4299
+ }
4300
+ /**
4301
+ * A cache's table, in the schema the role that reads it resolves names in.
4302
+ *
4303
+ * Two things have to line up, and neither is the table's contents. The driver
4304
+ * names the table unqualified and lets the connection's `search_path` place it
4305
+ * — which the role carries — so creating it from the master connection, whose
4306
+ * path is `public`, leaves a table the application cannot see and a
4307
+ * `relation "cache" does not exist` on the first request. And the default
4308
+ * privileges that make a tenant's tables reachable are granted *for the owner
4309
+ * role*, so nothing covers a table the master created.
4310
+ *
4311
+ * Hence: qualified with the tenant's schema, handed to its owner, and granted
4312
+ * explicitly — `roleStatements` grants what exists at the time it runs, and
4313
+ * this table does not exist yet.
4314
+ *
4315
+ * A database that opted out of roles needs none of it: everything connects as
4316
+ * the master, whose `search_path` finds `public`, and the table it creates
4317
+ * there is the table it reads.
4318
+ */
4319
+ function cacheTableDdl(cache, home, plan) {
4320
+ const database = rootDatabase(home, plan);
4321
+ const schema = home.roles === false ? void 0 : home.schema;
4322
+ const name = cache.table ?? cacheTable(cache.id);
4323
+ const table = schema ? `${schema}.${name}` : name;
4324
+ const created = cacheTableStatements({ table }).map((statement) => ({
4325
+ id: cache.id,
4326
+ describe: statement.describe,
4327
+ database,
4328
+ ...statement.exists ? { exists: statement.exists } : {},
4329
+ create: statement.sql
4330
+ }));
4331
+ if (!schema) return created;
4332
+ const runtime = localRole(home);
4333
+ const owner = ownerRole(runtime);
4334
+ const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
4335
+ return [
4336
+ ...created,
4337
+ {
4338
+ id: cache.id,
4339
+ describe: `cache table ${table} is owned by ${owner}`,
4340
+ database,
4341
+ create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
4342
+ },
4343
+ {
4344
+ id: cache.id,
4345
+ describe: `${runtime} may read and write ${table}`,
4346
+ database,
4347
+ create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
4348
+ }
4349
+ ];
4350
+ }
4351
+ /**
4352
+ * The roles and schema for one database or tenant.
4353
+ *
4354
+ * Skipped entirely when the construct declared `roles: false` — the documented
4355
+ * downgrade, where both URLs fall back to the cluster's master credential. It
4356
+ * is a choice someone made rather than a default they got.
4357
+ */
4358
+ function rolesFor(resource, schema, plan, project, seed) {
4359
+ if (resource.roles === false) return [];
4360
+ const runtime = localRole(resource);
4361
+ const owner = ownerRole(runtime);
4362
+ const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
4363
+ return roleStatements({
4364
+ runtime,
4365
+ owner,
4366
+ ...reader ? { reader } : {},
4367
+ schema,
4368
+ ...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
4369
+ passwords: {
4370
+ runtime: localRolePassword(project, plan, runtime, seed),
4371
+ owner: localRolePassword(project, plan, owner, seed),
4372
+ ...reader ? { reader: localRolePassword(project, plan, reader, seed) } : {}
4373
+ }
4374
+ }).map((statement) => ({
4375
+ id: resource.id,
4376
+ describe: statement.describe,
4377
+ database: rootDatabase(resource, plan),
4378
+ ...statement.exists ? { exists: statement.exists } : {},
4379
+ create: statement.sql
4380
+ }));
4381
+ }
4382
+ /** The buckets a plan implies. */
4383
+ function bucketNames(plan) {
4384
+ return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
4385
+ }
4386
+ /**
4387
+ * Quote an identifier for use in DDL.
4388
+ *
4389
+ * Names reaching here are already canonical and stage-suffixed, so this is
4390
+ * defence rather than sanitisation — but DDL cannot be parameterised, and an
4391
+ * unquoted identifier is the one place a name could ever be more than a name.
4392
+ */
4393
+ function quoteIdentifier(name) {
4394
+ return `"${name.replace(/"/g, "\"\"")}"`;
4395
+ }
4396
+ /**
4397
+ * Apply the plan's Postgres statements.
4398
+ *
4399
+ * Each is checked before it is run, so this converges rather than erroring on a
4400
+ * second pass.
4401
+ */
4402
+ async function applyPostgres(client, statements) {
4403
+ const applied = [];
4404
+ for (const statement of statements) {
4405
+ if (!statement.exists) {
4406
+ await client.query(statement.database, statement.create);
4407
+ applied.push({
4408
+ id: statement.id,
4409
+ describe: statement.describe,
4410
+ created: false
4411
+ });
4412
+ continue;
4413
+ }
4414
+ if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
4415
+ applied.push({
4416
+ id: statement.id,
4417
+ describe: statement.describe,
4418
+ created: false
4419
+ });
4420
+ continue;
4421
+ }
4422
+ await client.query(statement.database, statement.create);
4423
+ applied.push({
4424
+ id: statement.id,
4425
+ describe: statement.describe,
4426
+ created: true
4427
+ });
4428
+ }
4429
+ return applied;
3641
4430
  }
3642
4431
  /**
3643
- * The published port mappings for a container.
4432
+ * What each bucket's open paths are, if anything serves it.
3644
4433
  *
3645
- * Every port is looked up by its own key rather than being derived as an offset
3646
- * from the container's first, so MinIO's console cannot land on whatever was
3647
- * allocated to the next container.
4434
+ * Keyed by the *origin*, because that is where the policy lives: two servers
4435
+ * over one bucket contribute to one policy, so the patterns are unioned rather
4436
+ * than the last one winning.
3648
4437
  */
3649
- function publish(container, ports, fakes) {
3650
- return portsOf(container, fakes).map((port) => {
3651
- const assigned = ports[port.key];
3652
- if (assigned === void 0) throw new UnassignedPort(port.key);
3653
- return `${assigned}:${port.inside}`;
3654
- });
3655
- }
3656
- /** A planned port that allocation never assigned. */
3657
- var UnassignedPort = class extends Error {
3658
- /** The port key that went unassigned, e.g. `minio-console`. */
3659
- key;
3660
- constructor(key) {
3661
- super("No port was assigned for this container port");
3662
- this.name = "UnassignedPort";
3663
- this.key = key;
4438
+ function bucketPolicies(plan) {
4439
+ const byBucket = /* @__PURE__ */ new Map();
4440
+ for (const resource of plan.resources) {
4441
+ if (resource.kind !== "file-server" || !resource.open?.length) continue;
4442
+ const origin = plan.resources.find((r) => r.id === resource.of);
4443
+ if (!origin) continue;
4444
+ const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
4445
+ for (const pattern of resource.open) patterns.add(pattern);
4446
+ byBucket.set(origin.name, patterns);
3664
4447
  }
3665
- };
3666
- //#endregion
3667
- //#region src/reconcile/docker.ts
4448
+ return [...byBucket].map(([bucket, open]) => ({
4449
+ bucket,
4450
+ open: [...open].sort()
4451
+ })).sort((a, b) => a.bucket.localeCompare(b.bucket));
4452
+ }
3668
4453
  /**
3669
- * The real Docker, behind the small interface reconcile needs.
4454
+ * One bucket's open paths as an S3 bucket policy.
3670
4455
  *
3671
- * Shells out to `docker compose` rather than talking to the daemon socket: it is
3672
- * what a developer runs by hand when something is wrong, so the two cannot
3673
- * disagree, and it keeps Docker out of this package's dependencies.
4456
+ * Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
4457
+ * what "open" means everywhere: the server serves that path without a
4458
+ * signature, and the bucket stays private for everything else.
3674
4459
  *
3675
- * Every call is scoped to the generated compose file. Nothing here can touch a
3676
- * container this project did not generate.
4460
+ * One fidelity note, stated rather than discovered: an S3 policy resource's `*`
4461
+ * crosses `/`, so a single-star pattern is *wider* here than the construct's own
4462
+ * runtime check, which stops at a segment boundary. The construct is the
4463
+ * stricter of the two, so a key it refuses to serve unsigned is never one this
4464
+ * policy was relied on to refuse — but a key fetched directly, bypassing the
4465
+ * client, can be admitted by the policy where the client would have said no.
4466
+ * Prefer `**` where crossing segments is what you meant.
3677
4467
  */
3678
- const run = promisify(execFile);
4468
+ function bucketPolicy(bucket, open) {
4469
+ return JSON.stringify({
4470
+ Version: "2012-10-17",
4471
+ Statement: [{
4472
+ Sid: "GkmOpenPaths",
4473
+ Effect: "Allow",
4474
+ Principal: { AWS: ["*"] },
4475
+ Action: ["s3:GetObject"],
4476
+ Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
4477
+ }]
4478
+ });
4479
+ }
3679
4480
  /**
3680
- * The generated file, then the project's own if it has one.
4481
+ * The topics and queues to create on the AWS emulator.
3681
4482
  *
3682
- * Merged in that order so the project's wins — an `image:` there pins a
3683
- * container this file derived, and a service there is added beside them.
4483
+ * Only on the `sns` backend: pg-boss makes its own tables on first connect and
4484
+ * a RabbitMQ exchange is declared by the client, but an SNS topic and an SQS
4485
+ * queue must exist before anything can publish to them.
3684
4486
  */
3685
- function composeFiles(composePath) {
3686
- const project = join(dirname(composePath), "docker-compose.yml");
3687
- return [
3688
- "-f",
3689
- composePath,
3690
- ...existsSync(project) ? ["-f", project] : []
3691
- ];
4487
+ function carrierNames(plan) {
4488
+ if (plan.events !== "sns") return {
4489
+ topics: [],
4490
+ queues: []
4491
+ };
4492
+ const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
4493
+ return {
4494
+ topics: named("topic"),
4495
+ queues: named("queue")
4496
+ };
3692
4497
  }
3693
- /** How long to wait for containers to pass their health checks. */
3694
- const HEALTH_TIMEOUT_MS = 12e4;
3695
- /** `docker compose up` exited unsuccessfully; its own output said why. */
3696
- var ComposeUpFailed = class extends Error {
3697
- services;
3698
- code;
3699
- constructor(services, code) {
3700
- super(`docker compose could not start ${services.join(", ")} (exit ${code ?? "signal"}). Its output above names the container; \`docker compose logs <service>\` shows why it stopped.`);
3701
- this.services = services;
3702
- this.code = code;
3703
- this.name = "ComposeUpFailed";
4498
+ /** Create each topic and queue the plan names, skipping those that exist. */
4499
+ async function applyCarriers(client, carriers) {
4500
+ const applied = [];
4501
+ for (const name of carriers.topics) {
4502
+ const created = !await client.topicExists(name);
4503
+ if (created) await client.createTopic(name);
4504
+ applied.push({
4505
+ id: name,
4506
+ describe: `topic ${name}`,
4507
+ created
4508
+ });
3704
4509
  }
3705
- };
3706
- /** Run compose with its output on this terminal rather than captured. */
3707
- function shown(args, services) {
3708
- return new Promise((resolve, reject) => {
3709
- const child = spawn("docker", args, { stdio: [
3710
- "ignore",
3711
- "inherit",
3712
- "inherit"
3713
- ] });
3714
- child.on("error", reject);
3715
- child.on("close", (code) => code === 0 ? resolve() : reject(new ComposeUpFailed(services, code)));
3716
- });
4510
+ for (const name of carriers.queues) {
4511
+ const created = !await client.queueExists(name);
4512
+ if (created) await client.createQueue(name);
4513
+ applied.push({
4514
+ id: name,
4515
+ describe: `queue ${name}`,
4516
+ created
4517
+ });
4518
+ }
4519
+ return applied;
3717
4520
  }
3718
- const dockerCli = {
3719
- async publishedPort(composePath, service, inside) {
3720
- try {
3721
- const { stdout } = await run("docker", [
3722
- "compose",
3723
- ...composeFiles(composePath),
3724
- "port",
3725
- service,
3726
- String(inside)
3727
- ]);
3728
- const port = Number(stdout.trim().split(":").pop());
3729
- return Number.isFinite(port) && port > 0 ? port : void 0;
3730
- } catch {
3731
- return;
3732
- }
3733
- },
3734
- async up(composePath, services, options = {}) {
3735
- const args = [
3736
- "compose",
3737
- ...composeFiles(composePath),
3738
- "up",
3739
- "-d",
3740
- "--remove-orphans",
3741
- "--wait",
3742
- `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
3743
- ...services
3744
- ];
3745
- if (options.show) return shown(args, services);
3746
- await run("docker", args);
3747
- },
3748
- async copyOut(composePath, service, from, to) {
3749
- await mkdir(dirname(to), { recursive: true });
3750
- await run("docker", [
3751
- "compose",
3752
- ...composeFiles(composePath),
3753
- "cp",
3754
- `${service}:${from}`,
3755
- to
3756
- ]);
3757
- },
3758
- async reload(composePath, service) {
3759
- await run("docker", [
3760
- "compose",
3761
- ...composeFiles(composePath),
3762
- "exec",
3763
- "-T",
3764
- service,
3765
- "caddy",
3766
- "reload",
3767
- "--config",
3768
- "/etc/caddy/Caddyfile"
3769
- ]);
3770
- },
3771
- async healthy(composePath, services) {
3772
- if (services.length === 0) return true;
3773
- try {
3774
- const { stdout } = await run("docker", [
3775
- "compose",
3776
- ...composeFiles(composePath),
3777
- "ps",
3778
- "--format",
3779
- "json",
3780
- ...services
3781
- ]);
3782
- const running = /* @__PURE__ */ new Set();
3783
- for (const line of stdout.trim().split("\n").filter(Boolean)) {
3784
- const entry = JSON.parse(line);
3785
- if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
3786
- }
3787
- return services.every((service) => running.has(service));
3788
- } catch {
3789
- return false;
4521
+ /** The Postgres databases the plan creates — each is a `CREATE DATABASE`. */
4522
+ function postgresDatabaseNames(plan) {
4523
+ return plan.resources.filter((resource) => resource.kind === "database" && resource.provisions && resource.container === "postgres").map((resource) => resource.name);
4524
+ }
4525
+ /**
4526
+ * Whether every one of these databases exists. A database dropped behind the
4527
+ * recorded state's back — by another checkout's test teardown, against the
4528
+ * same container — makes the state a lie, and only asking Postgres finds out.
4529
+ * Unreachable counts as missing: applying again is what finds out why.
4530
+ */
4531
+ async function databasesExist(client, names) {
4532
+ if (names.length === 0) return true;
4533
+ try {
4534
+ const rows = await client.query(void 0, "SELECT datname FROM pg_catalog.pg_database WHERE datname = ANY($1)", [names]);
4535
+ return new Set(rows.map((row) => row.datname)).size === names.length;
4536
+ } catch {
4537
+ return false;
4538
+ }
4539
+ }
4540
+ /**
4541
+ * Apply each served bucket's open-path policy, skipping those already correct.
4542
+ *
4543
+ * Compared as parsed JSON rather than as text: the same policy re-serialised
4544
+ * with different key order is the same policy, and rewriting it every reconcile
4545
+ * would report a change on every start.
4546
+ */
4547
+ async function applyPolicies(client, policies) {
4548
+ const applied = [];
4549
+ for (const { bucket, open } of policies) {
4550
+ const wanted = bucketPolicy(bucket, open);
4551
+ const describe = `open paths on ${bucket}`;
4552
+ if (equivalent(await client.policy(bucket), wanted)) {
4553
+ applied.push({
4554
+ id: `${bucket}:policy`,
4555
+ describe,
4556
+ created: false
4557
+ });
4558
+ continue;
3790
4559
  }
4560
+ await client.setPolicy(bucket, wanted);
4561
+ applied.push({
4562
+ id: `${bucket}:policy`,
4563
+ describe,
4564
+ created: true
4565
+ });
3791
4566
  }
3792
- };
3793
- //#endregion
3794
- //#region src/reconcile/lan.ts
3795
- /**
3796
- * This machine's address on the local network — what a phone running the app
3797
- * reaches it on.
3798
- */
3799
- /**
3800
- * The first private IPv4 address of an interface that is up and not loopback.
3801
- *
3802
- * Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
3803
- * would mean listening for a phone on the open internet, and a link-local one
3804
- * (`169.254/16`) is what an interface has when it found no network at all.
3805
- * Undefined when there is none — a laptop on no network still runs, its mobile
3806
- * app reaching only a simulator.
3807
- */
3808
- function lanAddress(interfaces = networkInterfaces()) {
3809
- for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
3810
- if (entry.family !== "IPv4" || entry.internal) continue;
3811
- if (isPrivate(entry.address)) return entry.address;
4567
+ return applied;
4568
+ }
4569
+ /** Whether two policy documents say the same thing. */
4570
+ function equivalent(current, wanted) {
4571
+ if (!current) return false;
4572
+ try {
4573
+ return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
4574
+ } catch {
4575
+ return false;
3812
4576
  }
3813
4577
  }
3814
- function isPrivate(address) {
3815
- return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
4578
+ /** Create every bucket the plan names, skipping those that exist. */
4579
+ async function applyBuckets(client, buckets) {
4580
+ const applied = [];
4581
+ for (const bucket of buckets) {
4582
+ if (await client.exists(bucket)) {
4583
+ applied.push({
4584
+ id: bucket,
4585
+ describe: `bucket ${bucket}`,
4586
+ created: false
4587
+ });
4588
+ continue;
4589
+ }
4590
+ await client.create(bucket);
4591
+ applied.push({
4592
+ id: bucket,
4593
+ describe: `bucket ${bucket}`,
4594
+ created: true
4595
+ });
4596
+ }
4597
+ return applied;
3816
4598
  }
3817
4599
  //#endregion
3818
- //#region src/reconcile/ports.ts
3819
- /**
3820
- * Port allocation — why two projects can run at once.
3821
- *
3822
- * Publishing `5432:5432` means the second project on your machine cannot start.
3823
- * Nothing solves that except giving each project its own ports, and what makes
3824
- * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
3825
- * never sees a host or a port, so reconcile is free to bind whatever is
3826
- * available and compose the URL from what it bound. An app reading `DB_HOST` and
3827
- * `DB_PORT` separately could not be moved this way.
3828
- *
3829
- * Assignments are **persisted, not recomputed**. Recomputing means adding a
3830
- * construct renumbers its neighbours, and every running container, saved
3831
- * connection string, and `psql` history goes stale at once.
3832
- *
3833
- * Pure, with the liveness probe injected — a collision with another project's
3834
- * stack is a fact about the machine, and keeping it a parameter is what lets the
3835
- * rules be asserted without binding a socket.
3836
- */
4600
+ //#region src/reconcile/state.ts
3837
4601
  /**
3838
- * The window ports are drawn from.
4602
+ * What reconcile records, and the hash that lets it do nothing.
3839
4603
  *
3840
- * Above the registered range so nothing here needs privileges, and below the
3841
- * ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
3842
- * the kernel might hand to an outgoing connection is one that is free when
3843
- * checked and taken by the time it is bound.
3844
- */
3845
- const WINDOW = {
3846
- min: 2e4,
3847
- max: 32e3
3848
- };
3849
- /**
3850
- * How far apart two projects start.
4604
+ * Reconciling on every `gkm dev` is only acceptable if the converged case is
4605
+ * free, so the desired plan is hashed: matching hash plus healthy containers
4606
+ * means there is nothing to do. Without that, the automatic reconcile the design
4607
+ * asks for would tax every start.
3851
4608
  *
3852
- * Wide enough that a project can add containers without walking into its
3853
- * neighbour's block, narrow enough that the window holds many projects.
4609
+ * Almost nothing needs recording — roles are queried, migrations have their own
4610
+ * table, containers are remembered by Docker. Only ports and this hash have no
4611
+ * other owner.
3854
4612
  */
3855
- const BLOCK = 50;
4613
+ const STATE_DIR = ".gkm/reconcile";
3856
4614
  /**
3857
- * Where a project's ports start.
4615
+ * The hash of everything a reconcile would act on.
3858
4616
  *
3859
- * Derived from the name rather than assigned sequentially so that a fresh clone
3860
- * of the same project on the same machine lands on the same block — the first
3861
- * run has nothing persisted to read, and hashing is what keeps that first run
3862
- * from colliding with whatever else is already allocated.
4617
+ * Covers the compose document rather than the plan alone, because the plan is
4618
+ * unchanged when only an image pin moves — and a changed image is exactly a
4619
+ * container that must be recreated. `stringify` is stable here because
4620
+ * `composeFor` sorts its containers, which is what makes the hash meaningful.
3863
4621
  *
3864
- * @example startingPort('toolbox') // stable for a given name
4622
+ * Deliberately a plain function over serialisable input: the deployed target
4623
+ * keys its provisioner invocation on the same idea, and the two must not be able
4624
+ * to disagree about what counts as a change.
3865
4625
  */
3866
- function startingPort(project) {
3867
- const digest = createHash("sha256").update(project).digest();
3868
- const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
3869
- return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
4626
+ function planHash(plan, compose, generated = {}) {
4627
+ return createHash("sha256").update(JSON.stringify({
4628
+ stage: plan.stage,
4629
+ plan,
4630
+ compose,
4631
+ generated
4632
+ })).digest("hex");
3870
4633
  }
3871
- /**
3872
- * Assign a port to every container that has none, keeping the ones that do.
3873
- *
3874
- * Existing assignments are returned untouched even when the container is no
3875
- * longer in the plan: removing a construct must not renumber what is left, and
3876
- * keeping the entry means re-adding it later restores the same port rather than
3877
- * inventing a new one.
3878
- *
3879
- * @param probe - reports what is actually listening. A port assigned to this
3880
- * project is skipped without probing, because on a converged reconcile our own
3881
- * container is the thing listening on it.
3882
- * @throws {NoPortAvailable} when the window is exhausted.
3883
- */
3884
- async function allocate(project, containers, existing = {}, probe = isPortFree) {
3885
- const assignments = { ...existing };
3886
- const taken = new Set(Object.values(assignments));
3887
- for (const container of [...containers].sort()) {
3888
- if (assignments[container] !== void 0) continue;
3889
- const port = await nextFree(startingPort(project), taken, probe);
3890
- assignments[container] = port;
3891
- taken.add(port);
4634
+ /** Read the recorded state, or nothing if this stage has never reconciled. */
4635
+ async function loadState(root, stage) {
4636
+ try {
4637
+ const raw = await readFile(statePath(root, stage), "utf-8");
4638
+ const state = JSON.parse(raw);
4639
+ return state.stage === stage ? state : void 0;
4640
+ } catch {
4641
+ return;
3892
4642
  }
3893
- return assignments;
3894
4643
  }
3895
- /**
3896
- * The ports to keep: what running containers are observed on, then what was
3897
- * saved — minus any saved entry whose port something else already holds.
3898
- *
3899
- * Spreading one over the other let an observed port override its own key and
3900
- * leave a *different* key still saved on the same number: two services on one
3901
- * port, and whichever answered first won. Saved keys for containers the plan no
3902
- * longer has — a backend switched and back — were the usual source. Observed
3903
- * wins because it is what is actually bound; a dropped entry is simply
3904
- * allocated again.
3905
- */
3906
- function keptPorts(saved = {}, observed = {}) {
3907
- const kept = { ...observed };
3908
- const taken = new Set(Object.values(observed));
3909
- for (const [key, port] of Object.entries(saved).sort(([a], [b]) => a < b ? -1 : 1)) {
3910
- if (key in kept || taken.has(port)) continue;
3911
- kept[key] = port;
3912
- taken.add(port);
3913
- }
3914
- return kept;
4644
+ /** Record what was applied, so the next run can skip it. */
4645
+ async function saveState(root, state) {
4646
+ const path = statePath(root, state.stage);
4647
+ await mkdir(dirname(path), { recursive: true });
4648
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
3915
4649
  }
3916
- /** The window was exhausted. */
3917
- var NoPortAvailable = class extends Error {
3918
- /** Where the search started. */
3919
- from;
3920
- /** The window searched, so the caller can report what was full. */
3921
- window = WINDOW;
3922
- constructor(from) {
3923
- super("No free port is available in the local port range");
3924
- this.name = "NoPortAvailable";
3925
- this.from = from;
3926
- }
3927
- };
3928
4650
  /**
3929
- * The first port at or after `from` that is neither ours nor in use.
3930
- *
3931
- * Wraps at the top of the window rather than stopping, so a project whose block
3932
- * sits near the end is not the one that fails.
4651
+ * Forget what was applied, so the next reconcile of this stage converges from
4652
+ * nothing — creating again whatever was dropped underneath the record.
3933
4653
  */
3934
- async function nextFree(from, taken, probe) {
3935
- const span = WINDOW.max - WINDOW.min;
3936
- for (let offset = 0; offset < span; offset++) {
3937
- const port = WINDOW.min + (from - WINDOW.min + offset) % span;
3938
- if (taken.has(port)) continue;
3939
- if (await probe(port)) return port;
3940
- }
3941
- throw new NoPortAvailable(from);
4654
+ async function forgetState(root, stage) {
4655
+ await rm(statePath(root, stage), { force: true });
3942
4656
  }
3943
- /**
3944
- * Whether nothing is listening on a port.
3945
- *
3946
- * Binds rather than connects: a refused connection says nothing about whether
3947
- * the port can be *published*, which is the question Docker will ask. Binds on
3948
- * `0.0.0.0` for the same reason — a port free on loopback can still be taken on
3949
- * the address Docker publishes to.
3950
- *
3951
- * Inherently racy, and that is acceptable: Docker failing to bind stays the real
3952
- * check. This exists so the common case surfaces as a message at reconcile
3953
- * rather than as a connection failure ten minutes later.
3954
- */
3955
- function isPortFree(port) {
3956
- return new Promise((resolve) => {
3957
- const server = createServer();
3958
- server.once("error", () => resolve(false));
3959
- server.once("listening", () => server.close(() => resolve(true)));
3960
- server.listen(port, "0.0.0.0");
3961
- });
4657
+ function statePath(root, stage) {
4658
+ return join(root, STATE_DIR, `${stage}.json`);
3962
4659
  }
3963
4660
  //#endregion
3964
- //#region src/reconcile/provision.ts
4661
+ //#region src/reconcile/index.ts
3965
4662
  /**
3966
- * Creating what the URLs name.
3967
- *
3968
- * Convergent and idempotent: every statement asks whether the thing exists
3969
- * first, so reconciling repeatedly is free and a half-applied run recovers by
3970
- * being run again. That property is what lets `gkm dev` do this on every start.
4663
+ * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
4664
+ * all call.
3971
4665
  *
3972
- * The line the design draws is at data, not at side effects — this creates
3973
- * databases, schemas, and buckets, and it never seeds, resets, or drops. A
3974
- * developer who wants their data gone asks for it explicitly.
4666
+ * Three phases, and only this one has side effects: declaring is pure (`gkm
4667
+ * build` and the dev watcher), running reads `<NAME>_URL` and provisions
4668
+ * nothing. Reconcile computes the desired state, compares, and applies the
4669
+ * difference — which is what makes "when does it run" answerable with
4670
+ * *whenever, repeatedly*.
3975
4671
  *
3976
- * Statement generation is separated from application for the reason the design
3977
- * gives: the same DDL has to run in-process locally and from a provisioner in a
3978
- * VPC, and one implementation is what stops "works locally, fails deployed".
3979
- * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
3980
- * growing its own.
4672
+ * It is safe to run automatically because its blast radius is entirely local:
4673
+ * this project's containers, this project's `.gkm/`. Nothing it does can reach a
4674
+ * cloud. The line is drawn at data, not at side effects — it allocates ports,
4675
+ * writes compose, starts containers, and creates roles; it never seeds, resets,
4676
+ * or drops.
4677
+ *
4678
+ * Docker is injected rather than imported so the rules above can be asserted
4679
+ * without a daemon. The default implementation is the real one.
3981
4680
  */
3982
4681
  /**
3983
- * The Postgres work a plan implies.
4682
+ * Where the generated compose file is written, relative to the project root.
3984
4683
  *
3985
- * Databases before the schemas inside them, which `provisionOrder` already
3986
- * guarantees by putting parents first.
4684
+ * At the root, beside the project's own `docker-compose.yml`, because the two
4685
+ * are one stack: this file is everything the constructs imply and is never
4686
+ * edited, that one is anything they cannot express — an image pin, an extra
4687
+ * service — and is merged over it, so it wins.
3987
4688
  */
3988
- function postgresStatements(plan, project = "", seed) {
3989
- const statements = [];
3990
- for (const resource of plan.resources) {
3991
- if (!resource.provisions || resource.container !== "postgres") continue;
3992
- if (resource.kind === "database") {
3993
- statements.push({
3994
- id: resource.id,
3995
- describe: `database ${resource.name}`,
3996
- exists: {
3997
- sql: "SELECT 1 FROM pg_database WHERE datname = $1",
3998
- values: [resource.name]
3999
- },
4000
- create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
4001
- });
4002
- if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
4003
- continue;
4004
- }
4005
- if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
4006
- const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
4007
- if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
4008
- }
4009
- return statements;
4010
- }
4689
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
4011
4690
  /**
4012
- * A cache's table, in the schema the role that reads it resolves names in.
4013
- *
4014
- * Two things have to line up, and neither is the table's contents. The driver
4015
- * names the table unqualified and lets the connection's `search_path` place it
4016
- * — which the role carries — so creating it from the master connection, whose
4017
- * path is `public`, leaves a table the application cannot see and a
4018
- * `relation "cache" does not exist` on the first request. And the default
4019
- * privileges that make a tenant's tables reachable are granted *for the owner
4020
- * role*, so nothing covers a table the master created.
4691
+ * The local edge's config, beside the compose file that mounts it.
4021
4692
  *
4022
- * Hence: qualified with the tenant's schema, handed to its owner, and granted
4023
- * explicitly — `roleStatements` grants what exists at the time it runs, and
4024
- * this table does not exist yet.
4693
+ * Relative in the compose volume, so it resolves against `.gkm/` wherever the
4694
+ * project lives.
4695
+ */
4696
+ const CADDYFILE_PATH = ".gkm/Caddyfile";
4697
+ /**
4698
+ * Where one stage's routes live, imported by the file above.
4025
4699
  *
4026
- * A database that opted out of roles needs none of it: everything connects as
4027
- * the master, whose `search_path` finds `public`, and the table it creates
4028
- * there is the table it reads.
4700
+ * Per stage because one edge serves every stage, the same way one Postgres
4701
+ * holds `orders` and `orders_test`. A single file would mean `gkm test`
4702
+ * deleting the routes `gkm dev` is serving.
4029
4703
  */
4030
- function cacheTableDdl(cache, home, plan) {
4031
- const database = rootDatabase(home, plan);
4032
- const schema = home.roles === false ? void 0 : home.schema;
4033
- const name = cache.table ?? cacheTable(cache.id);
4034
- const table = schema ? `${schema}.${name}` : name;
4035
- const created = cacheTableStatements({ table }).map((statement) => ({
4036
- id: cache.id,
4037
- describe: statement.describe,
4038
- database,
4039
- ...statement.exists ? { exists: statement.exists } : {},
4040
- create: statement.sql
4041
- }));
4042
- if (!schema) return created;
4043
- const runtime = localRole(home);
4044
- const owner = ownerRole(runtime);
4045
- const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
4046
- return [
4047
- ...created,
4048
- {
4049
- id: cache.id,
4050
- describe: `cache table ${table} is owned by ${owner}`,
4051
- database,
4052
- create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
4053
- },
4054
- {
4055
- id: cache.id,
4056
- describe: `${runtime} may read and write ${table}`,
4057
- database,
4058
- create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
4059
- }
4060
- ];
4061
- }
4704
+ const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
4062
4705
  /**
4063
- * The roles and schema for one database or tenant.
4706
+ * Where the local edge's root certificate is copied to.
4064
4707
  *
4065
- * Skipped entirely when the construct declared `roles: false` — the documented
4066
- * downgrade, where both URLs fall back to the cluster's master credential. It
4067
- * is a choice someone made rather than a default they got.
4708
+ * Exported so a process can be pointed at it without installing anything:
4709
+ * `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
4710
+ * no sudo. A browser is the case that still wants the root in the system store,
4711
+ * which is a one-time `caddy trust` rather than something a reconcile should do
4712
+ * on a developer's behalf.
4068
4713
  */
4069
- function rolesFor(resource, schema, plan, project, seed) {
4070
- if (resource.roles === false) return [];
4071
- const runtime = localRole(resource);
4072
- const owner = ownerRole(runtime);
4073
- const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
4074
- return roleStatements({
4075
- runtime,
4076
- owner,
4077
- ...reader ? { reader } : {},
4078
- schema,
4079
- ...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
4080
- passwords: {
4081
- runtime: localRolePassword(project, plan, runtime, seed),
4082
- owner: localRolePassword(project, plan, owner, seed),
4083
- ...reader ? { reader: localRolePassword(project, plan, reader, seed) } : {}
4084
- }
4085
- }).map((statement) => ({
4086
- id: resource.id,
4087
- describe: statement.describe,
4088
- database: rootDatabase(resource, plan),
4089
- ...statement.exists ? { exists: statement.exists } : {},
4090
- create: statement.sql
4091
- }));
4092
- }
4093
- /** The buckets a plan implies. */
4094
- function bucketNames(plan) {
4095
- return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
4096
- }
4714
+ const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
4715
+ /** Where Caddy keeps the root of the CA it generated. */
4716
+ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
4097
4717
  /**
4098
- * Quote an identifier for use in DDL.
4718
+ * Converge the local target on what the manifest declares.
4099
4719
  *
4100
- * Names reaching here are already canonical and stage-suffixed, so this is
4101
- * defence rather than sanitisation — but DDL cannot be parameterised, and an
4102
- * unquoted identifier is the one place a name could ever be more than a name.
4720
+ * Cheap when there is nothing to do: the hash is compared before the compose
4721
+ * file is written or a container is touched, and a converged project pays one
4722
+ * hash and one health check.
4103
4723
  */
4104
- function quoteIdentifier(name) {
4105
- return `"${name.replace(/"/g, "\"\"")}"`;
4724
+ async function reconcile(options) {
4725
+ const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient, carriers = carrierClient, logins = ensureLogins } = options;
4726
+ const { seed } = options.credentials;
4727
+ const plan = planFor(manifest, stage, provisionOrder(manifest), {
4728
+ localStage: options.localStage,
4729
+ events: options.events,
4730
+ cache: options.cache,
4731
+ extraContainers: options.extraContainers,
4732
+ ...options.fakes ? { fakes: options.fakes } : {},
4733
+ ...options.edge === void 0 ? {} : { edge: options.edge }
4734
+ });
4735
+ const composePath = join(root, COMPOSE_PATH);
4736
+ const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
4737
+ const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
4738
+ const compose = composeFor(plan, {
4739
+ project,
4740
+ ports,
4741
+ credentials: options.credentials,
4742
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
4743
+ });
4744
+ const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
4745
+ const hash = planHash(plan, compose, {
4746
+ caddyfile,
4747
+ postgres: postgresStatements(plan, project, seed).map((statement) => statement.create).join(";\n")
4748
+ });
4749
+ const services = serviceAddresses(plan.containers, ports, plan.fakes);
4750
+ const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
4751
+ const envWith = (credentials) => {
4752
+ const env = envFor(plan, {
4753
+ ports,
4754
+ project,
4755
+ credentials,
4756
+ seed,
4757
+ ...options.mailFrom ? { mailFrom: options.mailFrom } : {},
4758
+ ...options.addresses ? { addresses: options.addresses } : {},
4759
+ ...lan ? { lanAddress: lan } : {},
4760
+ ...options.metroPorts ? { metroPorts: options.metroPorts } : {}
4761
+ });
4762
+ if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
4763
+ return env;
4764
+ };
4765
+ const result = {
4766
+ stage,
4767
+ plan,
4768
+ compose,
4769
+ ports,
4770
+ services,
4771
+ env: envWith(options.credentials),
4772
+ credentials: options.credentials,
4773
+ logins: [],
4774
+ provisioned: [],
4775
+ fakes: options.fakes ?? {},
4776
+ hash,
4777
+ changed: false
4778
+ };
4779
+ const recorded = await loadState(root, stage);
4780
+ const postgresPort = ports[primaryPortKey("postgres")];
4781
+ if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort, options.credentials.postgres), postgresDatabaseNames(plan)))) return result;
4782
+ await write(composePath, toYaml(compose), 384);
4783
+ if (plan.containers.includes("caddy")) {
4784
+ await write(join(root, CADDYFILE_PATH), caddyfileRoot());
4785
+ await write(join(root, caddySitesPath(stage)), caddyfile);
4786
+ await pruneCaddySites(root, [
4787
+ stage,
4788
+ options.localStage,
4789
+ TEST_STAGE
4790
+ ]);
4791
+ }
4792
+ if (start && plan.containers.length > 0) {
4793
+ options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
4794
+ await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
4795
+ }
4796
+ if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
4797
+ if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
4798
+ const checked = start && plan.containers.length > 0 ? await logins({
4799
+ credentials: options.credentials,
4800
+ containers: plan.containers,
4801
+ ports: {
4802
+ ...postgresPort !== void 0 ? { postgres: postgresPort } : {},
4803
+ ...ports[primaryPortKey("minio")] !== void 0 ? { minio: ports[primaryPortKey("minio")] } : {}
4804
+ },
4805
+ ...docker.exec ? { exec: (service, argv) => docker.exec(composePath, service, argv) } : {}
4806
+ }) : {
4807
+ credentials: options.credentials,
4808
+ outcomes: []
4809
+ };
4810
+ const credentials = checked.credentials;
4811
+ if (start && provision) options.progress?.("🗄️ Creating what the constructs declare — databases, roles, buckets, topics");
4812
+ const provisioned = start && provision ? await create(plan, ports, credentials, sql, buckets, carriers, project) : [];
4813
+ await saveState(root, {
4814
+ hash,
4815
+ stage
4816
+ });
4817
+ return {
4818
+ ...result,
4819
+ ...credentials === options.credentials ? {} : { env: envWith(credentials) },
4820
+ credentials,
4821
+ logins: checked.outcomes,
4822
+ provisioned,
4823
+ changed: true
4824
+ };
4106
4825
  }
4107
4826
  /**
4108
- * Apply the plan's Postgres statements.
4827
+ * Create the databases, schemas, buckets, topics and queues the plan names.
4109
4828
  *
4110
- * Each is checked before it is run, so this converges rather than erroring on a
4111
- * second pass.
4829
+ * Idempotent throughout, so this runs on every non-converged reconcile rather
4830
+ * than being something to remember.
4112
4831
  */
4113
- async function applyPostgres(client, statements) {
4832
+ async function create(plan, ports, credentials, sql, buckets, emulatorCarriers, project) {
4114
4833
  const applied = [];
4115
- for (const statement of statements) {
4116
- if (!statement.exists) {
4117
- await client.query(statement.database, statement.create);
4118
- applied.push({
4119
- id: statement.id,
4120
- describe: statement.describe,
4121
- created: false
4122
- });
4123
- continue;
4124
- }
4125
- if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
4126
- applied.push({
4127
- id: statement.id,
4128
- describe: statement.describe,
4129
- created: false
4130
- });
4131
- continue;
4132
- }
4133
- await client.query(statement.database, statement.create);
4134
- applied.push({
4135
- id: statement.id,
4136
- describe: statement.describe,
4137
- created: true
4138
- });
4834
+ const postgresPort = ports[primaryPortKey("postgres")];
4835
+ const statements = postgresStatements(plan, project, credentials.seed);
4836
+ if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort, credentials.postgres), statements));
4837
+ const minioPort = ports[primaryPortKey("minio")];
4838
+ const names = bucketNames(plan);
4839
+ const policies = bucketPolicies(plan);
4840
+ if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
4841
+ const client = buckets(minioPort, credentials.minio);
4842
+ if (names.length > 0) applied.push(...await applyBuckets(client, names));
4843
+ if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
4139
4844
  }
4845
+ const emulatorPort = ports[primaryPortKey("localstack")];
4846
+ const carriers = carrierNames(plan);
4847
+ if (emulatorPort !== void 0 && carriers.topics.length + carriers.queues.length > 0) applied.push(...await applyCarriers(emulatorCarriers(emulatorPort, credentials.emulator), carriers));
4140
4848
  return applied;
4141
4849
  }
4142
4850
  /**
4143
- * What each bucket's open paths are, if anything serves it.
4851
+ * The host ports of containers already running.
4144
4852
  *
4145
- * Keyed by the *origin*, because that is where the policy lives: two servers
4146
- * over one bucket contribute to one policy, so the patterns are unioned rather
4147
- * than the last one winning.
4853
+ * Keyed the same way allocation keys them, so the two merge without either
4854
+ * knowing about the other.
4148
4855
  */
4149
- function bucketPolicies(plan) {
4150
- const byBucket = /* @__PURE__ */ new Map();
4151
- for (const resource of plan.resources) {
4152
- if (resource.kind !== "file-server" || !resource.open?.length) continue;
4153
- const origin = plan.resources.find((r) => r.id === resource.of);
4154
- if (!origin) continue;
4155
- const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
4156
- for (const pattern of resource.open) patterns.add(pattern);
4157
- byBucket.set(origin.name, patterns);
4856
+ async function observedPorts(docker, composePath, containers, fakes) {
4857
+ const observed = {};
4858
+ for (const container of containers) for (const port of portsOf(container, fakes)) {
4859
+ const running = await docker.publishedPort(composePath, container, port.inside);
4860
+ if (running !== void 0) observed[port.key] = running;
4158
4861
  }
4159
- return [...byBucket].map(([bucket, open]) => ({
4160
- bucket,
4161
- open: [...open].sort()
4162
- })).sort((a, b) => a.bucket.localeCompare(b.bucket));
4862
+ return observed;
4163
4863
  }
4864
+ /** Write a file only when its content would change, so mtimes stay meaningful. */
4164
4865
  /**
4165
- * One bucket's open paths as an S3 bucket policy.
4166
- *
4167
- * Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
4168
- * what "open" means everywhere: the server serves that path without a
4169
- * signature, and the bucket stays private for everything else.
4866
+ * Remove the routes of stages this project no longer reconciles.
4170
4867
  *
4171
- * One fidelity note, stated rather than discovered: an S3 policy resource's `*`
4172
- * crosses `/`, so a single-star pattern is *wider* here than the construct's own
4173
- * runtime check, which stops at a segment boundary. The construct is the
4174
- * stricter of the two, so a key it refuses to serve unsigned is never one this
4175
- * policy was relied on to refuse — but a key fetched directly, bypassing the
4176
- * client, can be admitted by the policy where the client would have said no.
4177
- * Prefer `**` where crossing segments is what you meant.
4868
+ * Only the local stage and `test` are ever written, so any other file is left
4869
+ * over from a stage that was renamed — `development` becoming `dev`. Caddy
4870
+ * imports every file, and the leftover declares the same hosts as its
4871
+ * replacement: it refuses the whole config as ambiguous, and every URL behind
4872
+ * the edge goes with it.
4178
4873
  */
4179
- function bucketPolicy(bucket, open) {
4180
- return JSON.stringify({
4181
- Version: "2012-10-17",
4182
- Statement: [{
4183
- Sid: "GkmOpenPaths",
4184
- Effect: "Allow",
4185
- Principal: { AWS: ["*"] },
4186
- Action: ["s3:GetObject"],
4187
- Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
4188
- }]
4189
- });
4874
+ async function pruneCaddySites(root, stages) {
4875
+ const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
4876
+ const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
4877
+ for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
4878
+ }
4879
+ async function write(path, content, mode) {
4880
+ try {
4881
+ if (await readFile(path, "utf-8") === content) {
4882
+ if (mode !== void 0) await chmod(path, mode);
4883
+ return;
4884
+ }
4885
+ } catch {}
4886
+ await mkdir(dirname(path), { recursive: true });
4887
+ await writeFile(path, content, mode === void 0 ? {} : { mode });
4888
+ if (mode !== void 0) await chmod(path, mode);
4190
4889
  }
4890
+ //#endregion
4891
+ //#region src/deploy/otel.ts
4191
4892
  /**
4192
- * The topics and queues to create on the AWS emulator.
4893
+ * The OpenTelemetry variables a stage's secrets pass to every backend.
4193
4894
  *
4194
- * Only on the `sns` backend: pg-boss makes its own tables on first connect and
4195
- * a RabbitMQ exchange is declared by the client, but an SNS topic and an SQS
4196
- * queue must exist before anything can publish to them.
4895
+ * A production server starts OpenTelemetry when `OTEL_EXPORTER_OTLP_ENDPOINT`
4896
+ * is set (`generators/telemetry.ts`), and the SDK reads the rest of its
4897
+ * configuration from the standard `OTEL_*` variables. No construct declares
4898
+ * them — they say where the telemetry goes, not what the app is — so a target
4899
+ * that writes each backend only the keys it reads would never hand them over.
4900
+ * Every target that runs backends asks this module instead.
4901
+ *
4902
+ * Which keys pass is a pattern over the `OTEL_` prefix rather than the prefix
4903
+ * alone or a list:
4904
+ *
4905
+ * - The bare prefix would forward anything a stage happened to name `OTEL_*`
4906
+ * — `OTEL_LOG_LEVEL=debug`, `OTEL_SDK_DISABLED` — to every backend, without
4907
+ * anyone choosing to.
4908
+ * - A list would have to spell out the exporter's five settings once for all
4909
+ * signals and once per signal — twenty keys — and would miss the next one.
4910
+ *
4911
+ * The pattern names the families the server uses: the OTLP exporter's
4912
+ * endpoint, headers, protocol, timeout and compression, for every signal or
4913
+ * for one (`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`); sampling; and the resource's
4914
+ * identity.
4915
+ *
4916
+ * Sites get none of it: a site's environment is inlined into a bundle that
4917
+ * every browser downloads, and an exporter's headers are a credential.
4197
4918
  */
4198
- function carrierNames(plan) {
4199
- if (plan.events !== "sns") return {
4200
- topics: [],
4201
- queues: []
4202
- };
4203
- const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
4204
- return {
4205
- topics: named("topic"),
4206
- queues: named("queue")
4207
- };
4208
- }
4209
- /** Create each topic and queue the plan names, skipping those that exist. */
4210
- async function applyCarriers(client, carriers) {
4211
- const applied = [];
4212
- for (const name of carriers.topics) {
4213
- const created = !await client.topicExists(name);
4214
- if (created) await client.createTopic(name);
4215
- applied.push({
4216
- id: name,
4217
- describe: `topic ${name}`,
4218
- created
4219
- });
4220
- }
4221
- for (const name of carriers.queues) {
4222
- const created = !await client.queueExists(name);
4223
- if (created) await client.createQueue(name);
4224
- applied.push({
4225
- id: name,
4226
- describe: `queue ${name}`,
4227
- created
4228
- });
4229
- }
4230
- return applied;
4919
+ /** The `OTEL_*` variables a backend is handed from the stage's secrets. */
4920
+ const OTEL_PASSTHROUGH = /^OTEL_(?:EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION)|TRACES_SAMPLER(?:_ARG)?|RESOURCE_ATTRIBUTES|SERVICE_NAME)$/;
4921
+ /** Whether a stage secret is one of the passed-through `OTEL_*` variables. */
4922
+ function isOtelVariable(key) {
4923
+ return OTEL_PASSTHROUGH.test(key);
4231
4924
  }
4232
- /** The Postgres databases the plan creates — each is a `CREATE DATABASE`. */
4233
- function postgresDatabaseNames(plan) {
4234
- return plan.resources.filter((resource) => resource.kind === "database" && resource.provisions && resource.container === "postgres").map((resource) => resource.name);
4925
+ /** Whether a variable says where an OTLP exporter sends, or how it signs. */
4926
+ function isOtlpDestination(key) {
4927
+ return /^OTEL_EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS)$/.test(key);
4235
4928
  }
4236
4929
  /**
4237
- * Whether every one of these databases exists. A database dropped behind the
4238
- * recorded state's back — by another checkout's test teardown, against the
4239
- * same container — makes the state a lie, and only asking Postgres finds out.
4240
- * Unreachable counts as missing: applying again is what finds out why.
4930
+ * The `OTEL_*` variables one backend runs with: every one the stage's secrets
4931
+ * hold, and `OTEL_SERVICE_NAME` — the app's name — where the stage set
4932
+ * telemetry up without naming the service. Empty when the stage set none.
4241
4933
  */
4242
- async function databasesExist(client, names) {
4243
- if (names.length === 0) return true;
4244
- try {
4245
- const rows = await client.query(void 0, "SELECT datname FROM pg_catalog.pg_database WHERE datname = ANY($1)", [names]);
4246
- return new Set(rows.map((row) => row.datname)).size === names.length;
4247
- } catch {
4248
- return false;
4249
- }
4934
+ function otelEnv(custom, app) {
4935
+ const env = {};
4936
+ for (const [key, value] of Object.entries(custom)) if (isOtelVariable(key) && value !== "") env[key] = value;
4937
+ if (Object.keys(env).length > 0 && !env.OTEL_SERVICE_NAME) env.OTEL_SERVICE_NAME = app;
4938
+ return env;
4250
4939
  }
4940
+ //#endregion
4941
+ //#region src/target/dokploy/domain.ts
4942
+ /** A stage was deployed that `deploy.domains` gives no domain. */
4943
+ var NoDomainForStage = class extends Error {
4944
+ stage;
4945
+ constructor(stage) {
4946
+ super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
4947
+ this.stage = stage;
4948
+ this.name = "NoDomainForStage";
4949
+ }
4950
+ };
4251
4951
  /**
4252
- * Apply each served bucket's open-path policy, skipping those already correct.
4952
+ * The hostname an app answers on for a stage.
4253
4953
  *
4254
- * Compared as parsed JSON rather than as text: the same policy re-serialised
4255
- * with different key order is the same policy, and rewriting it every reconcile
4256
- * would report a change on every start.
4954
+ * 1. An explicit `app.domain` — a string, or one per stage — wins.
4955
+ * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
4956
+ * answers on the domain itself, and every other app on
4957
+ * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
4958
+ *
4959
+ * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
4257
4960
  */
4258
- async function applyPolicies(client, policies) {
4259
- const applied = [];
4260
- for (const { bucket, open } of policies) {
4261
- const wanted = bucketPolicy(bucket, open);
4262
- const describe = `open paths on ${bucket}`;
4263
- if (equivalent(await client.policy(bucket), wanted)) {
4264
- applied.push({
4265
- id: `${bucket}:policy`,
4266
- describe,
4267
- created: false
4268
- });
4269
- continue;
4270
- }
4271
- await client.setPolicy(bucket, wanted);
4272
- applied.push({
4273
- id: `${bucket}:policy`,
4274
- describe,
4275
- created: true
4276
- });
4961
+ function resolveHost(appName, app, stage, domains, isMainFrontend) {
4962
+ if (app.domain) {
4963
+ if (typeof app.domain === "string") return app.domain;
4964
+ if (app.domain[stage]) return app.domain[stage];
4277
4965
  }
4278
- return applied;
4966
+ const baseDomain = domains?.[stage];
4967
+ if (!baseDomain) throw new NoDomainForStage(stage);
4968
+ if (isMainFrontend) return baseDomain;
4969
+ return `${app.subdomain ?? appName}.${baseDomain}`;
4279
4970
  }
4280
- /** Whether two policy documents say the same thing. */
4281
- function equivalent(current, wanted) {
4282
- if (!current) return false;
4283
- try {
4284
- return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
4285
- } catch {
4286
- return false;
4287
- }
4971
+ /**
4972
+ * Whether this app is the site the base domain points at — see `rootSite`,
4973
+ * which the local edge asks too.
4974
+ *
4975
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
4976
+ */
4977
+ function isMainFrontendApp(appName, app, allApps) {
4978
+ if (app.type !== "web") return false;
4979
+ return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
4980
+ name,
4981
+ root: a.root
4982
+ }))) === appName;
4288
4983
  }
4289
- /** Create every bucket the plan names, skipping those that exist. */
4290
- async function applyBuckets(client, buckets) {
4291
- const applied = [];
4292
- for (const bucket of buckets) {
4293
- if (await client.exists(bucket)) {
4294
- applied.push({
4295
- id: bucket,
4296
- describe: `bucket ${bucket}`,
4297
- created: false
4298
- });
4299
- continue;
4300
- }
4301
- await client.create(bucket);
4302
- applied.push({
4303
- id: bucket,
4304
- describe: `bucket ${bucket}`,
4305
- created: true
4306
- });
4307
- }
4308
- return applied;
4984
+ /**
4985
+ * Get public URL arg names from app dependencies.
4986
+ *
4987
+ * @param app - The web/mobile app configuration
4988
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
4989
+ * without a public prefix (e.g. Remix).
4990
+ */
4991
+ function getPublicUrlArgNames(app) {
4992
+ const prefix = getPublicEnvPrefix(app.framework);
4993
+ if (!prefix) return [];
4994
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
4309
4995
  }
4310
4996
  //#endregion
4311
- //#region src/reconcile/state.ts
4997
+ //#region src/compose/logs.ts
4312
4998
  /**
4313
- * What reconcile records, and the hash that lets it do nothing.
4999
+ * The stack's log UI: OpenObserve, run beside the apps when
5000
+ * `deploy.compose.logs` asks for it, and every backend's OpenTelemetry
5001
+ * pointed at it.
4314
5002
  *
4315
- * Reconciling on every `gkm dev` is only acceptable if the converged case is
4316
- * free, so the desired plan is hashed: matching hash plus healthy containers
4317
- * means there is nothing to do. Without that, the automatic reconcile the design
4318
- * asks for would tax every start.
5003
+ * The production server already exports its traces and pino logs over
5004
+ * OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. OpenObserve takes OTLP
5005
+ * at `/api/<org>/v1/{logs,traces}` with HTTP basic auth, so wiring an app is
5006
+ * two variables: the endpoint, `http://openobserve:5080/api/default`, and
5007
+ * the header that signs in as the root user.
4319
5008
  *
4320
- * Almost nothing needs recording — roles are queried, migrations have their own
4321
- * table, containers are remembered by Docker. Only ports and this hash have no
4322
- * other owner.
5009
+ * It is reached, by default, through an SSH tunnel: published on 127.0.0.1
5010
+ * only, because a port Docker publishes on every interface is opened in the
5011
+ * firewall by Docker itself, past ufw's rules. Served publicly, it is a host
5012
+ * on the stack's Caddy that refuses every address not allowed.
5013
+ *
5014
+ * Pure: the stack decides it, and the phases print and check it.
4323
5015
  */
4324
- const STATE_DIR = ".gkm/reconcile";
4325
5016
  /**
4326
- * The hash of everything a reconcile would act on.
4327
- *
4328
- * Covers the compose document rather than the plan alone, because the plan is
4329
- * unchanged when only an image pin moves — and a changed image is exactly a
4330
- * container that must be recreated. `stringify` is stable here because
4331
- * `composeFor` sorts its containers, which is what makes the hash meaningful.
4332
- *
4333
- * Deliberately a plain function over serialisable input: the deployed target
4334
- * keys its provisioner invocation on the same idea, and the two must not be able
4335
- * to disagree about what counts as a change.
5017
+ * The image, pinned. A log store's on-disk format is the thing that must not
5018
+ * move under a running stack, so a new version is a change to this line.
4336
5019
  */
4337
- function planHash(plan, compose, generated = {}) {
4338
- return createHash("sha256").update(JSON.stringify({
4339
- stage: plan.stage,
4340
- plan,
4341
- compose,
4342
- generated
4343
- })).digest("hex");
4344
- }
4345
- /** Read the recorded state, or nothing if this stage has never reconciled. */
4346
- async function loadState(root, stage) {
4347
- try {
4348
- const raw = await readFile(statePath(root, stage), "utf-8");
4349
- const state = JSON.parse(raw);
4350
- return state.stage === stage ? state : void 0;
4351
- } catch {
4352
- return;
5020
+ const OPENOBSERVE_IMAGE = "public.ecr.aws/zinclabs/openobserve:v1.0.4";
5021
+ /** Its compose service name — the host the apps send to. */
5022
+ const LOGS_SERVICE = "openobserve";
5023
+ /** The organisation every app's telemetry lands in. */
5024
+ const LOGS_ORG = "default";
5025
+ /** The stage secrets that sign in as OpenObserve's root user. */
5026
+ const LOGS_EMAIL_KEY = "ZO_ROOT_USER_EMAIL";
5027
+ const LOGS_PASSWORD_KEY = "ZO_ROOT_USER_PASSWORD";
5028
+ /**
5029
+ * The local stage's root user. Its password is generated per machine with
5030
+ * the other local logins — see `reconcile/localCredentials.ts`.
5031
+ */
5032
+ const LOCAL_LOGS_EMAIL = "admin@gkm.localhost";
5033
+ /** The stage sends its telemetry somewhere already, and logs are on too. */
5034
+ var LogsEndpointConflict = class extends Error {
5035
+ stage;
5036
+ keys;
5037
+ constructor(stage, keys) {
5038
+ super(`The stage '${stage}' sets ${keys.join(", ")} in its secrets, and deploy.compose.logs also runs OpenObserve for it — each backend can send its telemetry to one place. Either keep your own OTLP backend: remove logs from deploy.compose in gkm.config.ts; or use the bundled OpenObserve: remove ${keys.join(", ")} from the stage's secrets, and every backend is pointed at it.`);
5039
+ this.stage = stage;
5040
+ this.keys = keys;
5041
+ this.name = "LogsEndpointConflict";
5042
+ }
5043
+ };
5044
+ /** A root password OpenObserve would refuse to start with. */
5045
+ var LogsPasswordWeak = class extends Error {
5046
+ stage;
5047
+ constructor(stage) {
5048
+ super(`The stage '${stage}' sets ${LOGS_PASSWORD_KEY}, and OpenObserve refuses it: a root password must be 8 to 128 characters with a lowercase letter, an uppercase letter, a digit and a symbol. Set another with gkm secrets:set ${LOGS_PASSWORD_KEY} '…' --stage ${stage}, or unset it to have one generated.`);
5049
+ this.stage = stage;
5050
+ this.name = "LogsPasswordWeak";
5051
+ }
5052
+ };
5053
+ /** A deployed stage with logs on and no root password generated yet. */
5054
+ var LogsPasswordMissing = class extends Error {
5055
+ stage;
5056
+ constructor(stage) {
5057
+ super(`The stage '${stage}' runs OpenObserve and has no ${LOGS_PASSWORD_KEY} in its secrets. Run gkm compose --stage ${stage} without --dry-run once to generate it, or set one: gkm secrets:set ${LOGS_PASSWORD_KEY} '…' --stage ${stage}`);
5058
+ this.stage = stage;
5059
+ this.name = "LogsPasswordMissing";
4353
5060
  }
5061
+ };
5062
+ /** Whether OpenObserve accepts `password` for its root user. */
5063
+ function isStrongLogsPassword(password) {
5064
+ return password.length >= 8 && password.length <= 128 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password) && /[^A-Za-z0-9]/.test(password);
4354
5065
  }
4355
- /** Record what was applied, so the next run can skip it. */
4356
- async function saveState(root, state) {
4357
- const path = statePath(root, state.stage);
4358
- await mkdir(dirname(path), { recursive: true });
4359
- await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
5066
+ /**
5067
+ * A root password: 192 random bits, with a fixed tail so every class
5068
+ * OpenObserve requires is present whatever the random part drew.
5069
+ */
5070
+ function generateLogsPassword() {
5071
+ return `${randomBytes(24).toString("base64url")}-Zo1`;
4360
5072
  }
4361
5073
  /**
4362
- * Forget what was applied, so the next reconcile of this stage converges from
4363
- * nothing — creating again whatever was dropped underneath the record.
5074
+ * The stage's secrets with a generated root password, where it has none.
5075
+ * Pure, like `withGeneratedSecrets`: the caller keeps the result once the
5076
+ * run goes ahead.
4364
5077
  */
4365
- async function forgetState(root, stage) {
4366
- await rm(statePath(root, stage), { force: true });
5078
+ function withLogsPassword(secrets) {
5079
+ if (secrets.custom?.["ZO_ROOT_USER_PASSWORD"]) return {
5080
+ secrets,
5081
+ generated: []
5082
+ };
5083
+ return {
5084
+ secrets: {
5085
+ ...secrets,
5086
+ custom: {
5087
+ ...secrets.custom,
5088
+ [LOGS_PASSWORD_KEY]: generateLogsPassword()
5089
+ },
5090
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5091
+ },
5092
+ generated: [LOGS_PASSWORD_KEY]
5093
+ };
4367
5094
  }
4368
- function statePath(root, stage) {
4369
- return join(root, STATE_DIR, `${stage}.json`);
5095
+ /**
5096
+ * `OTEL_EXPORTER_OTLP_HEADERS` signing in as `email`.
5097
+ *
5098
+ * The variable's values are percent-decoded by the SDK (they follow the W3C
5099
+ * baggage format), so the space after `Basic` is written `%20`. Base64's own
5100
+ * `+`, `/` and `=` pass through decoding unchanged.
5101
+ */
5102
+ function otlpHeaders(email, password) {
5103
+ return `Authorization=Basic%20${Buffer.from(`${email}:${password}`).toString("base64")}`;
5104
+ }
5105
+ /** The stack's OpenObserve for one stage. */
5106
+ function stackLogs(options) {
5107
+ const { config, stage, local, custom } = options;
5108
+ const conflicting = Object.keys(custom).filter(isOtlpDestination).sort();
5109
+ if (conflicting.length > 0) throw new LogsEndpointConflict(stage, conflicting);
5110
+ if (!local && !options.domain) throw new NoDomainForStage(stage);
5111
+ const email = custom["ZO_ROOT_USER_EMAIL"] ?? (local ? options.localLogin?.email ?? "admin@gkm.localhost" : `admin@${options.domain}`);
5112
+ const set = custom[LOGS_PASSWORD_KEY];
5113
+ const password = set ?? (local ? options.localLogin?.password : void 0);
5114
+ if (!password) throw new LogsPasswordMissing(stage);
5115
+ if (!isStrongLogsPassword(password)) throw new LogsPasswordWeak(stage);
5116
+ const host = config.public ? local ? `logs.${options.project}.localhost` : `logs.${options.domain}` : void 0;
5117
+ const url = host ? `https://${host}${local && options.https !== 443 ? `:${options.https}` : ""}` : `http://localhost:${config.port}`;
5118
+ return {
5119
+ ...config,
5120
+ email,
5121
+ password,
5122
+ passwordFromSecrets: Boolean(set),
5123
+ url,
5124
+ ...host ? { host } : {},
5125
+ env: {
5126
+ [LOGS_EMAIL_KEY]: email,
5127
+ [LOGS_PASSWORD_KEY]: password,
5128
+ ZO_DATA_DIR: "/data",
5129
+ ZO_COMPACT_DATA_RETENTION_DAYS: String(config.retentionDays),
5130
+ ZO_TELEMETRY: "false"
5131
+ },
5132
+ appEnv: {
5133
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://${LOGS_SERVICE}:${LOGS_PORT}/api/${LOGS_ORG}`,
5134
+ OTEL_EXPORTER_OTLP_HEADERS: otlpHeaders(email, password)
5135
+ }
5136
+ };
5137
+ }
5138
+ /**
5139
+ * OpenObserve's service. The image has no shell, `wget` or `curl`, so its
5140
+ * health check is its own binary asking the running server for its status —
5141
+ * which exits non-zero when nothing answers on its port.
5142
+ */
5143
+ function logsService(logs) {
5144
+ return {
5145
+ image: OPENOBSERVE_IMAGE,
5146
+ restart: "unless-stopped",
5147
+ env_file: [{
5148
+ path: `./${LOGS_SERVICE}.env`,
5149
+ format: "raw"
5150
+ }],
5151
+ volumes: ["openobserve-data:/data"],
5152
+ ...logs.public ? {} : { ports: [`127.0.0.1:${logs.port}:${LOGS_PORT}`] },
5153
+ healthcheck: {
5154
+ test: [
5155
+ "CMD",
5156
+ "/openobserve",
5157
+ "node",
5158
+ "status"
5159
+ ],
5160
+ interval: "10s",
5161
+ timeout: "5s",
5162
+ retries: 12,
5163
+ start_period: "20s"
5164
+ }
5165
+ };
5166
+ }
5167
+ /** What `gkm compose` prints once the stack is up: how to open the logs. */
5168
+ function logsAccess(logs, ctx) {
5169
+ const login = `login: ${logs.email}, password: ${ctx.local && !logs.passwordFromSecrets ? logs.password : `gkm secrets:show --stage ${ctx.stage} --reveal → ${LOGS_PASSWORD_KEY}`}`;
5170
+ if (logs.public) return [`📜 Logs (OpenObserve): ${logs.url} (${login})`, ` answered only for ${logs.public.allow.join(", ")}; every other address gets 403`];
5171
+ return [
5172
+ `📜 Logs (OpenObserve) on 127.0.0.1:${logs.port} — from your computer:`,
5173
+ ...ctx.local ? [` on this machine, open http://localhost:${logs.port} directly; from another:`] : [],
5174
+ ` ssh -N -L ${logs.port}:localhost:${logs.port} ${ctx.user}@${ctx.hostname} (user and host are guesses: this machine's)`,
5175
+ ` then open http://localhost:${logs.port} (${login})`,
5176
+ `⚠️ Docker-published ports bypass ufw, so OpenObserve is bound to 127.0.0.1 only — reach it through the tunnel, not by opening the port.`
5177
+ ];
4370
5178
  }
4371
5179
  //#endregion
4372
- //#region src/reconcile/index.ts
5180
+ //#region src/compose/redis.ts
5181
+ /**
5182
+ * The stack's Redis: what every declared cache lives in when a workspace runs
5183
+ * as a `gkm compose` stack, on the local stage and on a deployed one alike.
5184
+ *
5185
+ * A cache is the one store whose contents can always be rebuilt, so unlike
5186
+ * Mailpit and MinIO it is a production service, not a dev stand-in: it runs
5187
+ * for every stage, on the compose network only, with nothing published. It
5188
+ * is bounded (`maxmemory`, evicting the least recently used key), persisted
5189
+ * (an append-only file on a named volume, kept by `--down`) and password
5190
+ * protected — the stage's own password, generated once like its seed.
5191
+ *
5192
+ * A stage that sets a cache's URL in its secrets — a managed Redis — keeps
5193
+ * it, and a stack whose every cache is set that way runs no Redis at all.
5194
+ *
5195
+ * Pure: the stack decides it, and the phases keep the password.
5196
+ */
5197
+ /**
5198
+ * Where a stack's caches live: its own Redis, whatever the deploy target's
5199
+ * default is. A stack is one machine running containers, and a Redis beside
5200
+ * the apps is cheaper for a cache than a table in the database they share.
5201
+ */
5202
+ const STACK_CACHE = "redis";
5203
+ /** Its compose service name — the host every cache URL points at. */
5204
+ const REDIS_SERVICE = "redis";
5205
+ /** The port it answers on, inside the network. */
5206
+ const REDIS_PORT = 6379;
5207
+ /** Reconcile's pin, so `gkm dev` and the stack run one Redis. */
5208
+ const REDIS_IMAGE = DEFAULT_IMAGES.redis;
5209
+ /**
5210
+ * How much it may hold before it evicts. A cache that grows until the box
5211
+ * runs out of memory takes the apps with it; 256 MB is a lot of sessions and
5212
+ * little of a small server. A project's `docker-compose.<stage>.yml` can
5213
+ * replace the command to change it.
5214
+ */
5215
+ const REDIS_MAXMEMORY = "256mb";
5216
+ /** The stage secret holding its password. */
5217
+ const REDIS_PASSWORD_KEY = "REDIS_PASSWORD";
5218
+ /** Redis's own count of logical databases, before `--databases` raises it. */
5219
+ const REDIS_DATABASES = 16;
5220
+ /** A deployed stage that runs the stack's Redis and has no password yet. */
5221
+ var RedisPasswordMissing = class extends Error {
5222
+ stage;
5223
+ constructor(stage) {
5224
+ super(`The stage '${stage}' runs the stack's Redis and has no ${REDIS_PASSWORD_KEY} in its secrets. Run gkm compose --stage ${stage} without --dry-run once to generate it, or set one: gkm secrets:set ${REDIS_PASSWORD_KEY} '…' --stage ${stage}`);
5225
+ this.stage = stage;
5226
+ this.name = "RedisPasswordMissing";
5227
+ }
5228
+ };
4373
5229
  /**
4374
- * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
4375
- * all call.
4376
- *
4377
- * Three phases, and only this one has side effects: declaring is pure (`gkm
4378
- * build` and the dev watcher), running reads `<NAME>_URL` and provisions
4379
- * nothing. Reconcile computes the desired state, compares, and applies the
4380
- * difference — which is what makes "when does it run" answerable with
4381
- * *whenever, repeatedly*.
4382
- *
4383
- * It is safe to run automatically because its blast radius is entirely local:
4384
- * this project's containers, this project's `.gkm/`. Nothing it does can reach a
4385
- * cloud. The line is drawn at data, not at side effects — it allocates ports,
4386
- * writes compose, starts containers, and creates roles; it never seeds, resets,
4387
- * or drops.
4388
- *
4389
- * Docker is injected rather than imported so the rules above can be asserted
4390
- * without a daemon. The default implementation is the real one.
5230
+ * Whether a stage's stack runs Redis: a cache is declared and the stage's
5231
+ * secrets do not set the URL of every one.
4391
5232
  */
5233
+ function runsRedis(manifest, custom) {
5234
+ return Object.values(manifest).some((declaration) => declaration?.kind === "cache" && (declaration.provides ?? []).some((key) => custom[key] === void 0));
5235
+ }
5236
+ /** A password: 256 random bits, URL-safe, so it sits in a URL unescaped. */
5237
+ function generateRedisPassword() {
5238
+ return randomBytes(32).toString("base64url");
5239
+ }
4392
5240
  /**
4393
- * Where the generated compose file is written, relative to the project root.
4394
- *
4395
- * At the root, beside the project's own `docker-compose.yml`, because the two
4396
- * are one stack: this file is everything the constructs imply and is never
4397
- * edited, that one is anything they cannot express — an image pin, an extra
4398
- * service — and is merged over it, so it wins.
5241
+ * The stage's secrets with a generated Redis password, where it has none.
5242
+ * Pure, like `withGeneratedSecrets`: the caller keeps the result once the run
5243
+ * goes ahead.
4399
5244
  */
4400
- const COMPOSE_PATH = "docker-compose.constructs.yml";
5245
+ function withRedisPassword(secrets) {
5246
+ if (secrets.custom?.["REDIS_PASSWORD"]) return {
5247
+ secrets,
5248
+ generated: []
5249
+ };
5250
+ return {
5251
+ secrets: {
5252
+ ...secrets,
5253
+ custom: {
5254
+ ...secrets.custom,
5255
+ [REDIS_PASSWORD_KEY]: generateRedisPassword()
5256
+ },
5257
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5258
+ },
5259
+ generated: [REDIS_PASSWORD_KEY]
5260
+ };
5261
+ }
4401
5262
  /**
4402
- * The local edge's config, beside the compose file that mounts it.
5263
+ * The stack's Redis for one stage, or nothing where no cache needs it.
4403
5264
  *
4404
- * Relative in the compose volume, so it resolves against `.gkm/` wherever the
4405
- * project lives.
5265
+ * Each cache it holds gets a logical database of its own, by the order of
5266
+ * their ids, so two caches never read each other's keys.
4406
5267
  */
4407
- const CADDYFILE_PATH = ".gkm/Caddyfile";
5268
+ function stackRedis(options) {
5269
+ const { plan, stage, local, custom } = options;
5270
+ const caches = plan.resources.filter((r) => r.kind === "cache" && custom[r.envKey] === void 0).sort((a, b) => a.id.localeCompare(b.id));
5271
+ if (caches.length === 0) return void 0;
5272
+ const set = custom[REDIS_PASSWORD_KEY];
5273
+ const password = set ?? (local ? options.localPassword : void 0);
5274
+ if (!password) throw new RedisPasswordMissing(stage);
5275
+ const auth = encodeURIComponent(password);
5276
+ return {
5277
+ password,
5278
+ passwordFromSecrets: Boolean(set),
5279
+ urls: Object.fromEntries(caches.map((cache, index) => [cache.envKey, `redis://:${auth}@${REDIS_SERVICE}:${REDIS_PORT}/${index}`])),
5280
+ env: {
5281
+ [REDIS_PASSWORD_KEY]: password,
5282
+ REDISCLI_AUTH: password
5283
+ },
5284
+ databases: Math.max(REDIS_DATABASES, caches.length)
5285
+ };
5286
+ }
4408
5287
  /**
4409
- * Where one stage's routes live, imported by the file above.
5288
+ * Redis's service: bounded, persisted and password protected, on the
5289
+ * network alone.
4410
5290
  *
4411
- * Per stage because one edge serves every stage, the same way one Postgres
4412
- * holds `orders` and `orders_test`. A single file would mean `gkm test`
4413
- * deleting the routes `gkm dev` is serving.
5291
+ * The command runs the image's own entrypoint with `redis-server` first, so
5292
+ * it still drops to the `redis` user and owns `/data`; the password is read
5293
+ * from the env file by the shell (`$$` is compose's escape for `$`), so
5294
+ * neither the compose file nor the container's command holds it.
4414
5295
  */
4415
- const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
5296
+ function redisService(redis) {
5297
+ const flags = [
5298
+ `--requirepass "$$${REDIS_PASSWORD_KEY}"`,
5299
+ `--maxmemory ${REDIS_MAXMEMORY}`,
5300
+ "--maxmemory-policy allkeys-lru",
5301
+ "--appendonly yes",
5302
+ ...redis.databases > REDIS_DATABASES ? [`--databases ${redis.databases}`] : []
5303
+ ];
5304
+ return {
5305
+ image: REDIS_IMAGE,
5306
+ restart: "unless-stopped",
5307
+ command: [
5308
+ "sh",
5309
+ "-c",
5310
+ `exec docker-entrypoint.sh redis-server ${flags.join(" ")}`
5311
+ ],
5312
+ env_file: [{
5313
+ path: `./${REDIS_SERVICE}.env`,
5314
+ format: "raw"
5315
+ }],
5316
+ volumes: ["redis-data:/data"],
5317
+ healthcheck: {
5318
+ test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"],
5319
+ interval: "5s",
5320
+ timeout: "3s",
5321
+ retries: 10
5322
+ }
5323
+ };
5324
+ }
5325
+ //#endregion
5326
+ //#region src/deploy/generated.ts
4416
5327
  /**
4417
- * Where the local edge's root certificate is copied to.
5328
+ * The values a deployed stage generates once and keeps: what nothing can
5329
+ * provision and nobody else issues.
4418
5330
  *
4419
- * Exported so a process can be pointed at it without installing anything:
4420
- * `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
4421
- * no sudo. A browser is the case that still wants the root in the system store,
4422
- * which is a one-time `caddy trust` rather than something a reconcile should do
4423
- * on a developer's behalf.
5331
+ * - **A seed**, which salts every password a deploy derives — a database's
5332
+ * master and roles, a bucket's root user. Derived from repo facts alone
5333
+ * (project, stage, construct id), those passwords were computable by anyone
5334
+ * who could read the repo.
5335
+ * - **Each `secret` construct's value** — an auth server's signing secret —
5336
+ * random, rather than derived.
5337
+ * - **Each `encryption` construct's keyring** — one random key and an index
5338
+ * key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
5339
+ *
5340
+ * Both are generated on the first deploy that needs them and written to the
5341
+ * stage's store, so every later deploy reads the same values: sessions survive
5342
+ * a redeploy, and a role's password does not move. A value set by hand with
5343
+ * `gkm secrets:set` is kept, never replaced.
4424
5344
  */
4425
- const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
4426
- /** Where Caddy keeps the root of the CA it generated. */
4427
- const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
4428
5345
  /**
4429
- * Converge the local target on what the manifest declares.
4430
- *
4431
- * Cheap when there is nothing to do: the hash is compared before the compose
4432
- * file is written or a container is touched, and a converged project pays one
4433
- * hash and one health check.
5346
+ * The stage's secrets with its seed and every declared `secret`'s value
5347
+ * present, generating what is missing. Pure: the caller writes the result
5348
+ * back to the stage's store when `generated` is not empty.
4434
5349
  */
4435
- async function reconcile(options) {
4436
- const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient, carriers = carrierClient } = options;
4437
- const plan = planFor(manifest, stage, provisionOrder(manifest), {
4438
- localStage: options.localStage,
4439
- events: options.events,
4440
- cache: options.cache,
4441
- extraContainers: options.extraContainers,
4442
- ...options.fakes ? { fakes: options.fakes } : {},
4443
- ...options.edge === void 0 ? {} : { edge: options.edge }
4444
- });
4445
- const composePath = join(root, COMPOSE_PATH);
4446
- const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
4447
- const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
4448
- const compose = composeFor(plan, {
4449
- project,
4450
- ports,
4451
- ...options.apps ? { apps: options.apps(plan.containers) } : {}
4452
- });
4453
- const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
4454
- const hash = planHash(plan, compose, {
4455
- caddyfile,
4456
- postgres: postgresStatements(plan, project).map((statement) => statement.create).join(";\n")
4457
- });
4458
- const services = serviceAddresses(plan.containers, ports, plan.fakes);
4459
- const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
4460
- const env = envFor(plan, {
4461
- ports,
4462
- project,
4463
- ...options.mailFrom ? { mailFrom: options.mailFrom } : {},
4464
- ...options.addresses ? { addresses: options.addresses } : {},
4465
- ...lan ? { lanAddress: lan } : {},
4466
- ...options.metroPorts ? { metroPorts: options.metroPorts } : {}
4467
- });
4468
- if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
4469
- const result = {
4470
- stage,
4471
- plan,
4472
- compose,
4473
- ports,
4474
- services,
4475
- env,
4476
- provisioned: [],
4477
- fakes: options.fakes ?? {},
4478
- hash,
4479
- changed: false
4480
- };
4481
- const recorded = await loadState(root, stage);
4482
- const postgresPort = ports[primaryPortKey("postgres")];
4483
- if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort), postgresDatabaseNames(plan)))) return result;
4484
- await write(composePath, toYaml(compose));
4485
- if (plan.containers.includes("caddy")) {
4486
- await write(join(root, CADDYFILE_PATH), caddyfileRoot());
4487
- await write(join(root, caddySitesPath(stage)), caddyfile);
4488
- await pruneCaddySites(root, [
4489
- stage,
4490
- options.localStage,
4491
- TEST_STAGE
4492
- ]);
4493
- }
4494
- if (start && plan.containers.length > 0) {
4495
- options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
4496
- await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
4497
- }
4498
- if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
4499
- if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
4500
- if (start && provision) options.progress?.("🗄️ Creating what the constructs declare — databases, roles, buckets, topics");
4501
- const provisioned = start && provision ? await create(plan, ports, sql, buckets, carriers, project) : [];
4502
- await saveState(root, {
4503
- hash,
4504
- stage
4505
- });
5350
+ function withGeneratedSecrets(secrets, manifest) {
5351
+ const generated = [];
5352
+ const custom = { ...secrets.custom };
5353
+ for (const declaration of Object.values(manifest)) {
5354
+ if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
5355
+ const key = declaration.provides?.[0];
5356
+ if (!key || custom[key]) continue;
5357
+ custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
5358
+ generated.push(key);
5359
+ }
5360
+ const seed = secrets.seed ?? generateSeed();
5361
+ if (!secrets.seed) generated.push("seed");
4506
5362
  return {
4507
- ...result,
4508
- provisioned,
4509
- changed: true
5363
+ secrets: generated.length ? {
5364
+ ...secrets,
5365
+ seed,
5366
+ custom,
5367
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5368
+ } : secrets,
5369
+ generated
4510
5370
  };
4511
5371
  }
5372
+ /** A stage's seed: 256 random bits, URL-safe. */
5373
+ function generateSeed() {
5374
+ return random();
5375
+ }
5376
+ /** 256 bits, URL-safe. */
5377
+ function random() {
5378
+ return randomBytes(32).toString("base64url");
5379
+ }
5380
+ /** MinIO's root user, the same for every workspace: it is not a secret. */
5381
+ const LOCAL_MINIO_USER = "minio";
5382
+ /** RabbitMQ's administrator, the same way. */
5383
+ const LOCAL_RABBITMQ_USER = "rabbitmq";
4512
5384
  /**
4513
- * Create the databases, schemas, buckets, topics and queues the plan names.
4514
- *
4515
- * Idempotent throughout, so this runs on every non-converged reconcile rather
4516
- * than being something to remember.
5385
+ * The Postgres superuser for a workspace: its name as an identifier, with
5386
+ * `_admin` — never the bare name, which a database construct of the same
5387
+ * name would claim as its runtime role.
4517
5388
  */
4518
- async function create(plan, ports, sql, buckets, emulatorCarriers, project) {
4519
- const applied = [];
4520
- const postgresPort = ports[primaryPortKey("postgres")];
4521
- const statements = postgresStatements(plan, project);
4522
- if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort), statements));
4523
- const minioPort = ports[primaryPortKey("minio")];
4524
- const names = bucketNames(plan);
4525
- const policies = bucketPolicies(plan);
4526
- if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
4527
- const client = buckets(minioPort);
4528
- if (names.length > 0) applied.push(...await applyBuckets(client, names));
4529
- if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
4530
- }
4531
- const emulatorPort = ports[primaryPortKey("localstack")];
4532
- const carriers = carrierNames(plan);
4533
- if (emulatorPort !== void 0 && carriers.topics.length + carriers.queues.length > 0) applied.push(...await applyCarriers(emulatorCarriers(emulatorPort), carriers));
4534
- return applied;
5389
+ function postgresSuperuser(project) {
5390
+ return `${project.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/^(\d)/, "_$1") || "gkm"}_admin`;
5391
+ }
5392
+ /** 256 random bits, URL-safe. */
5393
+ function token() {
5394
+ return randomBytes(32).toString("base64url");
5395
+ }
5396
+ /** `length` characters of letters and digits, which every client takes as is. */
5397
+ function alphanumeric(length) {
5398
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
5399
+ let out = "";
5400
+ for (let i = 0; i < length; i++) out += alphabet[randomInt(62)];
5401
+ return out;
5402
+ }
5403
+ /** A fresh set of local logins for a workspace. */
5404
+ function generateLocalCredentials(project) {
5405
+ return {
5406
+ seed: generateSeed(),
5407
+ postgres: {
5408
+ user: postgresSuperuser(project),
5409
+ password: token()
5410
+ },
5411
+ minio: {
5412
+ user: LOCAL_MINIO_USER,
5413
+ password: alphanumeric(32)
5414
+ },
5415
+ rabbitmq: {
5416
+ user: LOCAL_RABBITMQ_USER,
5417
+ password: token()
5418
+ },
5419
+ redis: { password: generateRedisPassword() },
5420
+ cacheToken: token(),
5421
+ emulator: {
5422
+ accessKeyId: EMULATOR_ACCESS_KEY_ID,
5423
+ secretAccessKey: alphanumeric(40)
5424
+ },
5425
+ logs: {
5426
+ email: LOCAL_LOGS_EMAIL,
5427
+ password: generateLogsPassword()
5428
+ }
5429
+ };
4535
5430
  }
4536
5431
  /**
4537
- * The host ports of containers already running.
4538
- *
4539
- * Keyed the same way allocation keys them, so the two merge without either
4540
- * knowing about the other.
5432
+ * The stored logins with anything missing generated — a file an older gkm
5433
+ * wrote before a service existed. Pure: the caller writes the result back
5434
+ * when `generated` is not empty.
4541
5435
  */
4542
- async function observedPorts(docker, composePath, containers, fakes) {
4543
- const observed = {};
4544
- for (const container of containers) for (const port of portsOf(container, fakes)) {
4545
- const running = await docker.publishedPort(composePath, container, port.inside);
4546
- if (running !== void 0) observed[port.key] = running;
5436
+ function withLocalCredentials(stored, project) {
5437
+ const fresh = generateLocalCredentials(project);
5438
+ const generated = Object.keys(fresh).filter((key) => stored?.[key] === void 0);
5439
+ return {
5440
+ credentials: {
5441
+ ...fresh,
5442
+ ...stored
5443
+ },
5444
+ generated
5445
+ };
5446
+ }
5447
+ /** Where a workspace's local logins are kept on this machine. */
5448
+ function localCredentialsPath(project) {
5449
+ const keys = getKeystoreDir(project);
5450
+ const name = keys.split(/[\\/]/).at(-1);
5451
+ const namespace = keys.split(/[\\/]/).at(-2);
5452
+ return join(project.home ?? gkmHome(), "local", namespace, `${name}.json`);
5453
+ }
5454
+ /** The local stage's logins file is there, and its key is not. */
5455
+ var LocalCredentialsKeyMissing = class extends Error {
5456
+ path;
5457
+ stage;
5458
+ constructor(path, stage) {
5459
+ super(`The local logins at ${path} are encrypted with the '${stage}' stage's key, and this machine has none. Remove the file to generate new ones — the containers' volumes are rotated to them on the next gkm dev.`);
5460
+ this.path = path;
5461
+ this.stage = stage;
5462
+ this.name = "LocalCredentialsKeyMissing";
4547
5463
  }
4548
- return observed;
5464
+ };
5465
+ /** Another gkm held the logins file's lock for longer than any write takes. */
5466
+ var LocalCredentialsLocked = class extends Error {
5467
+ lock;
5468
+ constructor(lock) {
5469
+ super(`Another gkm has been writing the local logins for over ${LOCK_TIMEOUT_MS / 1e3}s. If none is running, remove ${lock} and run the command again.`);
5470
+ this.lock = lock;
5471
+ this.name = "LocalCredentialsLocked";
5472
+ }
5473
+ };
5474
+ /** Read the stored logins, or `null` where none were generated yet. */
5475
+ async function readLocalCredentials(workspace, home) {
5476
+ const project = keystoreProject(workspace, home);
5477
+ const path = localCredentialsPath(project);
5478
+ if (!existsSync(path)) return null;
5479
+ const stage = workspace.stages.local;
5480
+ const key = await readKey(stage, project);
5481
+ if (!key) throw new LocalCredentialsKeyMissing(path, stage);
5482
+ return decrypt(JSON.parse(await readFile(path, "utf-8")), key);
5483
+ }
5484
+ /** Write the logins, encrypted with the local stage's key, owner-only. */
5485
+ async function saveLocalCredentials(workspace, credentials, home) {
5486
+ const project = keystoreProject(workspace, home);
5487
+ const path = localCredentialsPath(project);
5488
+ const key = await getOrCreateKey(workspace.stages.local, project);
5489
+ await mkdir(dirname(path), {
5490
+ recursive: true,
5491
+ mode: 448
5492
+ });
5493
+ await writeFile(path, JSON.stringify(encrypt(credentials, key), null, 2), { mode: 384 });
4549
5494
  }
4550
- /** Write a file only when its content would change, so mtimes stay meaningful. */
4551
5495
  /**
4552
- * Remove the routes of stages this project no longer reconciles.
5496
+ * The workspace's local logins: read back, or generated and kept the first
5497
+ * time anything needs them.
4553
5498
  *
4554
- * Only the local stage and `test` are ever written, so any other file is left
4555
- * over from a stage that was renamed — `development` becoming `dev`. Caddy
4556
- * imports every file, and the leftover declares the same hosts as its
4557
- * replacement: it refuses the whole config as ambiguous, and every URL behind
4558
- * the edge goes with it.
5499
+ * Under a lock, because a workspace's `gkm dev` starts one per app and the
5500
+ * first run of each would otherwise generate — and keep — its own.
4559
5501
  */
4560
- async function pruneCaddySites(root, stages) {
4561
- const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
4562
- const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
4563
- for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
5502
+ async function loadLocalCredentials(workspace, home) {
5503
+ return withLock(`${localCredentialsPath(keystoreProject(workspace, home))}.lock`, async () => {
5504
+ const result = withLocalCredentials(await readLocalCredentials(workspace, home), workspace.name);
5505
+ if (result.generated.length > 0) await saveLocalCredentials(workspace, result.credentials, home);
5506
+ return result;
5507
+ });
4564
5508
  }
4565
- async function write(path, content) {
5509
+ /** How long a lock may be held before it is someone else's problem. */
5510
+ const LOCK_TIMEOUT_MS = 15e3;
5511
+ async function withLock(lock, body) {
5512
+ await mkdir(dirname(lock), {
5513
+ recursive: true,
5514
+ mode: 448
5515
+ });
5516
+ const started = Date.now();
5517
+ for (;;) try {
5518
+ await mkdir(lock);
5519
+ break;
5520
+ } catch (error) {
5521
+ if (error.code !== "EEXIST") throw error;
5522
+ if (await stat(lock).then((s) => Date.now() - s.mtimeMs).catch(() => 0) > LOCK_TIMEOUT_MS) {
5523
+ await rm(lock, {
5524
+ recursive: true,
5525
+ force: true
5526
+ });
5527
+ continue;
5528
+ }
5529
+ if (Date.now() - started > LOCK_TIMEOUT_MS) throw new LocalCredentialsLocked(lock);
5530
+ await new Promise((resolve) => setTimeout(resolve, 50));
5531
+ }
4566
5532
  try {
4567
- if (await readFile(path, "utf-8") === content) return;
4568
- } catch {}
4569
- await mkdir(dirname(path), { recursive: true });
4570
- await writeFile(path, content);
5533
+ return await body();
5534
+ } finally {
5535
+ await rm(lock, {
5536
+ recursive: true,
5537
+ force: true
5538
+ });
5539
+ }
4571
5540
  }
4572
5541
  //#endregion
4573
5542
  //#region src/reconcile/workspace.ts
@@ -4657,7 +5626,9 @@ async function reconcileWorkspace(workspace, options) {
4657
5626
  });
4658
5627
  const manifest = options.manifest ?? discovered;
4659
5628
  const fakes = fakesApply(options.stage, options.fake) ? await readFakes(workspace.root, manifest) : {};
5629
+ const { credentials } = await loadLocalCredentials(workspace);
4660
5630
  const result = await reconcile({
5631
+ credentials,
4661
5632
  root: workspace.root,
4662
5633
  project: workspace.name,
4663
5634
  manifest,
@@ -4668,11 +5639,13 @@ async function reconcileWorkspace(workspace, options) {
4668
5639
  addresses: surfaceAddresses(workspace, manifest),
4669
5640
  metroPorts: metroPorts(workspace, manifest),
4670
5641
  fakes,
4671
- apps: (containers) => appServices(workspace, manifest, containers, runnables, fakes),
5642
+ apps: (containers) => appServices(workspace, manifest, containers, credentials, runnables, fakes),
4672
5643
  ...options.start === void 0 ? {} : { start: options.start },
4673
5644
  ...options.progress ? { progress: options.progress } : {}
4674
5645
  });
4675
5646
  await savePortState(workspace.root, { ...result.ports });
5647
+ if (result.credentials !== credentials) await saveLocalCredentials(workspace, result.credentials);
5648
+ for (const outcome of result.logins) if (outcome.notice) console.log(outcome.notice);
4676
5649
  return {
4677
5650
  ...result,
4678
5651
  manifest
@@ -5771,112 +6744,6 @@ function reportDevServices(ctx, uses) {
5771
6744
  }
5772
6745
  }
5773
6746
  //#endregion
5774
- //#region src/deploy/otel.ts
5775
- /**
5776
- * The OpenTelemetry variables a stage's secrets pass to every backend.
5777
- *
5778
- * A production server starts OpenTelemetry when `OTEL_EXPORTER_OTLP_ENDPOINT`
5779
- * is set (`generators/telemetry.ts`), and the SDK reads the rest of its
5780
- * configuration from the standard `OTEL_*` variables. No construct declares
5781
- * them — they say where the telemetry goes, not what the app is — so a target
5782
- * that writes each backend only the keys it reads would never hand them over.
5783
- * Every target that runs backends asks this module instead.
5784
- *
5785
- * Which keys pass is a pattern over the `OTEL_` prefix rather than the prefix
5786
- * alone or a list:
5787
- *
5788
- * - The bare prefix would forward anything a stage happened to name `OTEL_*`
5789
- * — `OTEL_LOG_LEVEL=debug`, `OTEL_SDK_DISABLED` — to every backend, without
5790
- * anyone choosing to.
5791
- * - A list would have to spell out the exporter's five settings once for all
5792
- * signals and once per signal — twenty keys — and would miss the next one.
5793
- *
5794
- * The pattern names the families the server uses: the OTLP exporter's
5795
- * endpoint, headers, protocol, timeout and compression, for every signal or
5796
- * for one (`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`); sampling; and the resource's
5797
- * identity.
5798
- *
5799
- * Sites get none of it: a site's environment is inlined into a bundle that
5800
- * every browser downloads, and an exporter's headers are a credential.
5801
- */
5802
- /** The `OTEL_*` variables a backend is handed from the stage's secrets. */
5803
- const OTEL_PASSTHROUGH = /^OTEL_(?:EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION)|TRACES_SAMPLER(?:_ARG)?|RESOURCE_ATTRIBUTES|SERVICE_NAME)$/;
5804
- /** Whether a stage secret is one of the passed-through `OTEL_*` variables. */
5805
- function isOtelVariable(key) {
5806
- return OTEL_PASSTHROUGH.test(key);
5807
- }
5808
- /** Whether a variable says where an OTLP exporter sends, or how it signs. */
5809
- function isOtlpDestination(key) {
5810
- return /^OTEL_EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS)$/.test(key);
5811
- }
5812
- /**
5813
- * The `OTEL_*` variables one backend runs with: every one the stage's secrets
5814
- * hold, and `OTEL_SERVICE_NAME` — the app's name — where the stage set
5815
- * telemetry up without naming the service. Empty when the stage set none.
5816
- */
5817
- function otelEnv(custom, app) {
5818
- const env = {};
5819
- for (const [key, value] of Object.entries(custom)) if (isOtelVariable(key) && value !== "") env[key] = value;
5820
- if (Object.keys(env).length > 0 && !env.OTEL_SERVICE_NAME) env.OTEL_SERVICE_NAME = app;
5821
- return env;
5822
- }
5823
- //#endregion
5824
- //#region src/target/dokploy/domain.ts
5825
- /** A stage was deployed that `deploy.domains` gives no domain. */
5826
- var NoDomainForStage = class extends Error {
5827
- stage;
5828
- constructor(stage) {
5829
- super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
5830
- this.stage = stage;
5831
- this.name = "NoDomainForStage";
5832
- }
5833
- };
5834
- /**
5835
- * The hostname an app answers on for a stage.
5836
- *
5837
- * 1. An explicit `app.domain` — a string, or one per stage — wins.
5838
- * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
5839
- * answers on the domain itself, and every other app on
5840
- * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
5841
- *
5842
- * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
5843
- */
5844
- function resolveHost(appName, app, stage, domains, isMainFrontend) {
5845
- if (app.domain) {
5846
- if (typeof app.domain === "string") return app.domain;
5847
- if (app.domain[stage]) return app.domain[stage];
5848
- }
5849
- const baseDomain = domains?.[stage];
5850
- if (!baseDomain) throw new NoDomainForStage(stage);
5851
- if (isMainFrontend) return baseDomain;
5852
- return `${app.subdomain ?? appName}.${baseDomain}`;
5853
- }
5854
- /**
5855
- * Whether this app is the site the base domain points at — see `rootSite`,
5856
- * which the local edge asks too.
5857
- *
5858
- * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
5859
- */
5860
- function isMainFrontendApp(appName, app, allApps) {
5861
- if (app.type !== "web") return false;
5862
- return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
5863
- name,
5864
- root: a.root
5865
- }))) === appName;
5866
- }
5867
- /**
5868
- * Get public URL arg names from app dependencies.
5869
- *
5870
- * @param app - The web/mobile app configuration
5871
- * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
5872
- * without a public prefix (e.g. Remix).
5873
- */
5874
- function getPublicUrlArgNames(app) {
5875
- const prefix = getPublicEnvPrefix(app.framework);
5876
- if (!prefix) return [];
5877
- return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
5878
- }
5879
- //#endregion
5880
6747
  //#region src/docker/imageRef.ts
5881
6748
  /**
5882
6749
  * An image reference, checked before it reaches `docker`.
@@ -6186,57 +7053,6 @@ function printRunInstructions(workspace) {
6186
7053
  logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
6187
7054
  }
6188
7055
  //#endregion
6189
- //#region src/deploy/generated.ts
6190
- /**
6191
- * The values a deployed stage generates once and keeps: what nothing can
6192
- * provision and nobody else issues.
6193
- *
6194
- * - **A seed**, which salts every password a deploy derives — a database's
6195
- * master and roles, a bucket's root user. Derived from repo facts alone
6196
- * (project, stage, construct id), those passwords were computable by anyone
6197
- * who could read the repo.
6198
- * - **Each `secret` construct's value** — an auth server's signing secret —
6199
- * random, rather than derived.
6200
- * - **Each `encryption` construct's keyring** — one random key and an index
6201
- * key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
6202
- *
6203
- * Both are generated on the first deploy that needs them and written to the
6204
- * stage's store, so every later deploy reads the same values: sessions survive
6205
- * a redeploy, and a role's password does not move. A value set by hand with
6206
- * `gkm secrets:set` is kept, never replaced.
6207
- */
6208
- /**
6209
- * The stage's secrets with its seed and every declared `secret`'s value
6210
- * present, generating what is missing. Pure: the caller writes the result
6211
- * back to the stage's store when `generated` is not empty.
6212
- */
6213
- function withGeneratedSecrets(secrets, manifest) {
6214
- const generated = [];
6215
- const custom = { ...secrets.custom };
6216
- for (const declaration of Object.values(manifest)) {
6217
- if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
6218
- const key = declaration.provides?.[0];
6219
- if (!key || custom[key]) continue;
6220
- custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
6221
- generated.push(key);
6222
- }
6223
- const seed = secrets.seed ?? random();
6224
- if (!secrets.seed) generated.push("seed");
6225
- return {
6226
- secrets: generated.length ? {
6227
- ...secrets,
6228
- seed,
6229
- custom,
6230
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6231
- } : secrets,
6232
- generated
6233
- };
6234
- }
6235
- /** 256 bits, URL-safe. */
6236
- function random() {
6237
- return randomBytes(32).toString("base64url");
6238
- }
6239
- //#endregion
6240
7056
  //#region src/deploy/journal.ts
6241
7057
  var DeployJournal = class DeployJournal {
6242
7058
  store;
@@ -6635,6 +7451,6 @@ function reportPlannedSeeds(planned, log) {
6635
7451
  for (const { folder, seeds } of planned) log(`🌱 ${folder}: would run ${seeds.length} (${seeds.join(", ")})`);
6636
7452
  }
6637
7453
  //#endregion
6638
- export { constructGlobs as $, getKeyPath as $t, isOtlpDestination as A, imageBuildCredentials as At, manifestServiceDeclarations as B, primaryPortKey as Bt, ensureDockerignore as C, hostFor as Ct, NoDomainForStage as D, WORKER_PORT as Dt, validateImageRef as E, TURBO_VERSION as Et, STORAGE_KEY_PAIR as F, storageBackendFor as Ft, workerUnits as G, loadEnvFiles as Gt, reportDevServices as H, createEntryWrapper as Ht, UnknownDevService as I, DEFAULT_IMAGES as It, workerEntryDir as J, isRemoteStore as Jt, WorkerGenerator as K, loadSecretsForApp as Kt, assertExternalServices as L, describeServices as Lt, DEV_SERVICES as M, cacheBackendFor as Mt, DevServicesNeedServerTarget as N, eventsBackendFor as Nt, isMainFrontendApp as O, detectPackageManager as Ot, ExternalServicesNotConfigured as P, providerOf as Pt, backendsOf as Q, FileSecretsStore as Qt, devServicesUsed as R, fakeKey as Rt, dockerCommand as S, resourceName as St, workerDockerfileOf as T, imageLayout as Tt, requiredStageKeys as U, findAvailablePort as Ut, parseDevServices as V, createCredentialsPreload as Vt, suppliedOnly as W, isPortAvailable as Wt, CronGenerator as X, storeFromConfig as Xt, QueueGenerator as Y, secretsStoreFor as Yt, FAKE_ENV as Z, store_exports as Zt, DeployJournal as _, networkEnv as _t, createEmptyState as a, imageName as an, applyBuckets as at, DockerPushFailed as b, localRolePassword as bt, isDnsVerified as c, markerOf as cn, bucketPolicies as ct, setApplicationId as d, initStageSecrets as dn, quoteIdentifier as dt, keystoreProject as en, derivedContainers as et, setBackupState as f, maskPassword as fn, composeFor as ft, setPostgresBackupId as g, dockerfileOf as gt, setGeneratedSecret as h, withCustomSecret as hn, appEnvKeys as ht, assertNoStaleSecrets as i, deployIdentity as in, forgetState as it, otelEnv as j, isImageBuild as jt, resolveHost as k, findBuildRoot as kt, recordRelease as l, ownershipMarker as ln, postgresDatabaseNames as lt, setDnsVerification as m, toEmbeddableSecrets as mn, pgClient as mt, reportDatabaseRuns as n, readKey as nn, workspace_exports as nt, getBackupState as o, imageRef as on, applyPolicies as ot, setDnsRecord as p, storage_exports as pn, bucketClient as pt, workerBundleName as q, prepareEntryCredentials as qt, reportPlannedSeeds as r, applicationName as rn, LOCAL_CA_PATH as rt, getGeneratedSecret as s, isOwnedBy as sn, applyPostgres as st, DeploySeedsFailed as t, projectKey as tn, reconcileWorkspace as tt, recordRollback as u, projectName as un, postgresStatements as ut, withGeneratedSecrets as v, workerEnvKeys as vt, workerDockerfile as w, composeBuildPaths as wt, appDockerfile as x, planFor as xt, DockerBuildFailed as y, envFor as yt, externalServices as z, portKeys as zt };
7454
+ export { postgresSuperuser as $, EMULATOR_ACCESS_KEY_ID as $t, UnknownDevService as A, projectName as An, appEnvKeys as At, WorkerGenerator as B, imageLayout as Bt, workerDockerfile as C, applicationName as Cn, postgresDatabaseNames as Ct, DevServicesNeedServerTarget as D, isOwnedBy as Dn, ensurePostgresLogin as Dt, DEV_SERVICES as E, imageRef as En, LEGACY_LOCAL_LOGIN as Et, parseDevServices as F, withCustomSecret as Fn, localRolePassword as Ft, FAKE_ENV as G, imageBuildCredentials as Gt, workerEntryDir as H, WORKER_PORT as Ht, reportDevServices as I, planFor as It, derivedContainers as J, eventsBackendFor as Jt, backendsOf as K, isImageBuild as Kt, requiredStageKeys as L, resourceName as Lt, devServicesUsed as M, maskPassword as Mn, networkEnv as Mt, externalServices as N, storage_exports as Nn, workerEnvKeys as Nt, ExternalServicesNotConfigured as O, markerOf as On, bucketClient as Ot, manifestServiceDeclarations as P, toEmbeddableSecrets as Pn, envFor as Pt, loadLocalCredentials as Q, composeFor as Qt, suppliedOnly as R, hostFor as Rt, ensureDockerignore as S, readKey as Sn, bucketPolicies as St, validateImageRef as T, imageName as Tn, quoteIdentifier as Tt, QueueGenerator as U, detectPackageManager as Ut, workerBundleName as V, TURBO_VERSION as Vt, CronGenerator as W, findBuildRoot as Wt, workspace_exports as X, storageBackendFor as Xt, reconcileWorkspace as Y, providerOf as Yt, LOCAL_RABBITMQ_USER as Z, CLUSTER_DATABASE as Zt, DeployJournal as _, store_exports as _n, LOCAL_CA_PATH as _t, createEmptyState as a, primaryPortKey as an, runsRedis as at, appDockerfile as b, keystoreProject as bn, applyPolicies as bt, isDnsVerified as c, findAvailablePort as cn, LOGS_SERVICE as ct, setApplicationId as d, loadPortState as dn, stackLogs as dt, DEFAULT_IMAGES as en, readLocalCredentials as et, setBackupState as f, loadSecretsForApp as fn, withLogsPassword as ft, setPostgresBackupId as g, storeFromConfig as gn, otelEnv as gt, setGeneratedSecret as h, secretsStoreFor as hn, resolveHost as ht, assertNoStaleSecrets as i, portsOf as in, redisService as it, assertExternalServices as j, initStageSecrets as jn, dockerfileOf as jt, STORAGE_KEY_PAIR as k, ownershipMarker as kn, pgClient as kt, recordRelease as l, isPortAvailable as ln, logsAccess as lt, setDnsVerification as m, isRemoteStore as mn, isMainFrontendApp as mt, reportDatabaseRuns as n, fakeKey as nn, REDIS_SERVICE as nt, getBackupState as o, createCredentialsPreload as on, stackRedis as ot, setDnsRecord as p, prepareEntryCredentials as pn, NoDomainForStage as pt, constructGlobs as q, cacheBackendFor as qt, reportPlannedSeeds as r, portKeys as rn, STACK_CACHE as rt, getGeneratedSecret as s, createEntryWrapper as sn, withRedisPassword as st, DeploySeedsFailed as t, describeServices as tn, withGeneratedSecrets as tt, recordRollback as u, loadEnvFiles as un, logsService as ut, DockerBuildFailed as v, FileSecretsStore as vn, forgetState as vt, workerDockerfileOf as w, deployIdentity as wn, postgresStatements as wt, dockerCommand as x, projectKey as xn, applyPostgres as xt, DockerPushFailed as y, getKeyPath as yn, applyBuckets as yt, workerUnits as z, composeBuildPaths as zt };
6639
7455
 
6640
- //# sourceMappingURL=seeds-BdkFpW0P.mjs.map
7456
+ //# sourceMappingURL=seeds-DbyqaXW4.mjs.map