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

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 (69) hide show
  1. package/dist/{add-CIxT-Iq6.mjs → add-Bn1bqt0c.mjs} +2 -2
  2. package/dist/{add-CIxT-Iq6.mjs.map → add-Bn1bqt0c.mjs.map} +1 -1
  3. package/dist/{add-DJ0VE-y-.cjs → add-DF9IHQov.cjs} +2 -2
  4. package/dist/{add-DJ0VE-y-.cjs.map → add-DF9IHQov.cjs.map} +1 -1
  5. package/dist/{bundler-D4pl87A5.mjs → bundler-Ba_LoFXI.mjs} +3 -3
  6. package/dist/{bundler-D4pl87A5.mjs.map → bundler-Ba_LoFXI.mjs.map} +1 -1
  7. package/dist/{bundler-BlbSLFLn.cjs → bundler-DeIVbloM.cjs} +3 -3
  8. package/dist/{bundler-BlbSLFLn.cjs.map → bundler-DeIVbloM.cjs.map} +1 -1
  9. package/dist/{cli-BkNZBe_-.cjs → cli-BB1KOkfo.cjs} +3 -3
  10. package/dist/{cli-BkNZBe_-.cjs.map → cli-BB1KOkfo.cjs.map} +1 -1
  11. package/dist/{cli-7aLfgIGt.mjs → cli-CznHL-4-.mjs} +3 -3
  12. package/dist/{cli-7aLfgIGt.mjs.map → cli-CznHL-4-.mjs.map} +1 -1
  13. package/dist/{compose-Bn4OJONR.cjs → compose-76ho0pUg.cjs} +99 -367
  14. package/dist/compose-76ho0pUg.cjs.map +1 -0
  15. package/dist/{compose-n7K8H5j8.mjs → compose-BxIsChn5.mjs} +81 -349
  16. package/dist/compose-BxIsChn5.mjs.map +1 -0
  17. package/dist/deploy/public.cjs +5 -5
  18. package/dist/deploy/public.mjs +5 -5
  19. package/dist/{deploy-DK4Y6ev4.mjs → deploy-BRUx9hiA.mjs} +3 -3
  20. package/dist/{deploy-DK4Y6ev4.mjs.map → deploy-BRUx9hiA.mjs.map} +1 -1
  21. package/dist/{deploy-Ppnh8MHS.cjs → deploy-DuHRWNeJ.cjs} +3 -3
  22. package/dist/{deploy-Ppnh8MHS.cjs.map → deploy-DuHRWNeJ.cjs.map} +1 -1
  23. package/dist/{dokploy-Bjq05wkw.mjs → dokploy-BTO37Co8.mjs} +2 -2
  24. package/dist/{dokploy-Bjq05wkw.mjs.map → dokploy-BTO37Co8.mjs.map} +1 -1
  25. package/dist/{dokploy-CJvdoNc3.cjs → dokploy-Q4iYDmls.cjs} +2 -2
  26. package/dist/{dokploy-CJvdoNc3.cjs.map → dokploy-Q4iYDmls.cjs.map} +1 -1
  27. package/dist/{encryption-CAyR6D7B.cjs → encryption-9YA5ThNl.cjs} +2 -2
  28. package/dist/{encryption-CAyR6D7B.cjs.map → encryption-9YA5ThNl.cjs.map} +1 -1
  29. package/dist/{encryption-BrsOJFFz.mjs → encryption-BESGvjHD.mjs} +2 -2
  30. package/dist/{encryption-BrsOJFFz.mjs.map → encryption-BESGvjHD.mjs.map} +1 -1
  31. package/dist/{engine-ba9CvjTo.mjs → engine-CHij99aX.mjs} +2 -2
  32. package/dist/{engine-ba9CvjTo.mjs.map → engine-CHij99aX.mjs.map} +1 -1
  33. package/dist/{engine-DzBZR5D0.cjs → engine-U0xzB5i4.cjs} +2 -2
  34. package/dist/{engine-DzBZR5D0.cjs.map → engine-U0xzB5i4.cjs.map} +1 -1
  35. package/dist/{github-CVdbVlT5.mjs → github-2OSRBf9G.mjs} +2 -2
  36. package/dist/{github-CVdbVlT5.mjs.map → github-2OSRBf9G.mjs.map} +1 -1
  37. package/dist/{github-BLjU59Ye.cjs → github-BM-Y9M2G.cjs} +2 -2
  38. package/dist/{github-BLjU59Ye.cjs.map → github-BM-Y9M2G.cjs.map} +1 -1
  39. package/dist/index.cjs +215 -43
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.mjs +216 -44
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/{migrate-pfu4aYzX.mjs → migrate-3oLefufw.mjs} +2 -2
  44. package/dist/{migrate-pfu4aYzX.mjs.map → migrate-3oLefufw.mjs.map} +1 -1
  45. package/dist/{migrate-DkB833Jj.cjs → migrate-BlYiMA23.cjs} +2 -2
  46. package/dist/{migrate-DkB833Jj.cjs.map → migrate-BlYiMA23.cjs.map} +1 -1
  47. package/dist/{migrate-iEdyEx4S.cjs → migrate-C440QQvZ.cjs} +2 -2
  48. package/dist/{migrate-iEdyEx4S.cjs.map → migrate-C440QQvZ.cjs.map} +1 -1
  49. package/dist/{migrate-DlBgATa-.mjs → migrate-KwZsJFce.mjs} +2 -2
  50. package/dist/{migrate-DlBgATa-.mjs.map → migrate-KwZsJFce.mjs.map} +1 -1
  51. package/dist/{resolve-CQPn66jr.mjs → resolve-BPIDcuQ3.mjs} +3 -3
  52. package/dist/{resolve-CQPn66jr.mjs.map → resolve-BPIDcuQ3.mjs.map} +1 -1
  53. package/dist/{resolve-4_5N67Fk.cjs → resolve-CBrJTyDO.cjs} +3 -3
  54. package/dist/{resolve-4_5N67Fk.cjs.map → resolve-CBrJTyDO.cjs.map} +1 -1
  55. package/dist/{rollback-C0cbdsM-.mjs → rollback-B7uQKHEk.mjs} +3 -3
  56. package/dist/{rollback-C0cbdsM-.mjs.map → rollback-B7uQKHEk.mjs.map} +1 -1
  57. package/dist/{rollback-CgYUPLbq.cjs → rollback-P09wRaW4.cjs} +3 -3
  58. package/dist/{rollback-CgYUPLbq.cjs.map → rollback-P09wRaW4.cjs.map} +1 -1
  59. package/dist/{seeds-BdkFpW0P.mjs → seeds-B4PiO_48.mjs} +2332 -1525
  60. package/dist/seeds-B4PiO_48.mjs.map +1 -0
  61. package/dist/{seeds-ZljwYV_p.cjs → seeds-BjHiy9tW.cjs} +3877 -2950
  62. package/dist/seeds-BjHiy9tW.cjs.map +1 -0
  63. package/dist/target/public.cjs +1 -1
  64. package/dist/target/public.mjs +1 -1
  65. package/package.json +15 -15
  66. package/dist/compose-Bn4OJONR.cjs.map +0 -1
  67. package/dist/compose-n7K8H5j8.mjs.map +0 -1
  68. package/dist/seeds-BdkFpW0P.mjs.map +0 -1
  69. 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
7
  import { n as runtimeFor, r as generateTelemetryModule } from "./EndpointGenerator-DftalLYg.mjs";
8
- import { c as runtimeOf } from "./resolve-CQPn66jr.mjs";
8
+ import { c as runtimeOf } from "./resolve-BPIDcuQ3.mjs";
9
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";
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 ?? ".";
@@ -2195,42 +2510,9 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
2195
2510
  # from code; a browser needs the root trusted once.
2196
2511
  `;
2197
2512
  //#endregion
2198
- //#region src/reconcile/emulator.ts
2513
+ //#region src/workspace/rootSite.ts
2199
2514
  /**
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.
2515
+ * Which site the base domain points at — deployed and locally alike.
2234
2516
  *
2235
2517
  * Three rules, in order, and the last one is the point:
2236
2518
  *
@@ -2586,15 +2868,6 @@ var PgBossNeedsDatabase = class extends Error {
2586
2868
  * Locally that self-containment is what lets the port be whatever was free: the
2587
2869
  * app reads `ORDERS_URL` and never sees a host or a port.
2588
2870
  */
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
2871
  /** The host containers are published on. */
2599
2872
  const LOCAL_HOST = "localhost";
2600
2873
  /** The region local buckets claim, so the client has one to sign with. */
@@ -2608,23 +2881,23 @@ const LOCAL_REGION = "us-east-1";
2608
2881
  * the same chain reads these — which is why they are injected beside the URL
2609
2882
  * rather than written into it.
2610
2883
  */
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";
2884
+ function storageCredentials(credentials) {
2885
+ return {
2886
+ AWS_ACCESS_KEY_ID: credentials.minio.user,
2887
+ AWS_SECRET_ACCESS_KEY: credentials.minio.password,
2888
+ AWS_REGION: LOCAL_REGION
2889
+ };
2890
+ }
2618
2891
  /** The port Mailpit's inbox — its web UI and HTTP API — is published under. */
2619
2892
  const MAILPIT_INBOX_PORT = "mailpit-web";
2620
2893
  /** The schema pg-boss keeps its tables in, inside the declared database. */
2621
2894
  const PGBOSS_SCHEMA = "pgboss";
2622
2895
  /** 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. */
2896
+ const RABBITMQ_EXCHANGE = "gkm.events";
2897
+ /** The credential `options` describe. */
2625
2898
  function credentialOf(options) {
2626
2899
  return {
2627
- master: options.master ?? LOCAL_USER$2,
2900
+ containers: options.credentials,
2628
2901
  ...options.seed ? { seed: options.seed } : {}
2629
2902
  };
2630
2903
  }
@@ -2664,7 +2937,7 @@ function envFor(plan, options) {
2664
2937
  }
2665
2938
  Object.assign(env, publicEnv(plan, env, options.addresses));
2666
2939
  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);
2940
+ if (plan.resources.some((r) => r.kind === "objects" && env[r.envKey])) Object.assign(env, storageCredentials(options.credentials));
2668
2941
  return env;
2669
2942
  }
2670
2943
  /**
@@ -2799,7 +3072,7 @@ function workerBroker(plan, ports, credential) {
2799
3072
  *
2800
3073
  * Every part of it comes from the resource and the port it was published on.
2801
3074
  */
2802
- function urlFor(resource, plan, ports, project, addresses = {}, credential = { master: LOCAL_USER$2 }) {
3075
+ function urlFor(resource, plan, ports, project, addresses, credential) {
2803
3076
  if (resource.kind === "secret") return localSecret(project, plan, resource);
2804
3077
  if (resource.kind === "encryption") return localKeyring(project, plan, resource);
2805
3078
  if (resource.kind === "mobile-app") return schemeBase(project, resource.scheme);
@@ -2813,7 +3086,7 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
2813
3086
  return port === void 0 ? void 0 : `http://${LOCAL_HOST}:${port}`;
2814
3087
  }
2815
3088
  if (resource.kind === "rest-api" || resource.kind === "site") {
2816
- const address = addresses[resource.id];
3089
+ const address = addresses?.[resource.id];
2817
3090
  const edge = plan.containers.includes("caddy") ? ports[primaryPortKey("caddy")] : void 0;
2818
3091
  return address && edge !== void 0 ? `https://${hostFor(resource, project)}:${edge}` : address;
2819
3092
  }
@@ -2827,10 +3100,7 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
2827
3100
  const database = rootDatabase(resource, plan);
2828
3101
  if (resource.roles === false) {
2829
3102
  const schema = schemaOf(resource, plan);
2830
- const master = {
2831
- user: LOCAL_USER$2,
2832
- password: credential.master
2833
- };
3103
+ const master = credential.containers.postgres;
2834
3104
  return schema ? `${postgres(port, database, master)}?search_path=${schema}` : postgres(port, database, master);
2835
3105
  }
2836
3106
  const role = resource.kind === "database-reader" ? readerRole(parentRole(resource, plan)) : localRole(resource);
@@ -2854,8 +3124,8 @@ function urlFor(resource, plan, ports, project, addresses = {}, credential = { m
2854
3124
  }
2855
3125
  switch (plan.cache) {
2856
3126
  case "elasticache":
2857
- case "redis": return `redis://${LOCAL_HOST}:${port}`;
2858
- default: return `http://:${LOCAL_TOKEN$1}@${LOCAL_HOST}:${port}`;
3127
+ case "redis": return `redis://:${encodeURIComponent(credential.containers.redis.password)}@${LOCAL_HOST}:${port}`;
3128
+ default: return `http://:${encodeURIComponent(credential.containers.cacheToken)}@${LOCAL_HOST}:${port}`;
2859
3129
  }
2860
3130
  case "queue":
2861
3131
  case "topic": return broker(plan, port, credential, resource);
@@ -2874,9 +3144,13 @@ function broker(plan, port, credential, resource) {
2874
3144
  case "pgboss": {
2875
3145
  const database = plan.resources.find((r) => r.kind === "database");
2876
3146
  if (!database) throw new PgBossNeedsDatabase([]);
2877
- return `pgboss://${LOCAL_USER$2}:${encodeURIComponent(credential.master)}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
3147
+ const { user, password } = credential.containers.postgres;
3148
+ return `pgboss://${user}:${encodeURIComponent(password)}@${LOCAL_HOST}:${port}/${database.name}?schema=${PGBOSS_SCHEMA}`;
3149
+ }
3150
+ case "rabbitmq": {
3151
+ const { user, password } = credential.containers.rabbitmq;
3152
+ return `rabbitmq://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
2878
3153
  }
2879
- case "rabbitmq": return `rabbitmq://${LOCAL_USER$2}:${LOCAL_USER$2}@${LOCAL_HOST}:${port}?exchange=${RABBITMQ_EXCHANGE}`;
2880
3154
  case "sns": {
2881
3155
  if (!resource) throw new UnprovisionedEventsBackend("sns");
2882
3156
  const endpoint = emulatorEndpoint(port);
@@ -2890,17 +3164,15 @@ function broker(plan, port, credential, resource) {
2890
3164
  endpoint
2891
3165
  });
2892
3166
  const url = new URL(address);
2893
- url.searchParams.set("accessKeyId", EMULATOR_CREDENTIALS.accessKeyId);
2894
- url.searchParams.set("secretAccessKey", EMULATOR_CREDENTIALS.secretAccessKey);
3167
+ const { accessKeyId, secretAccessKey } = credential.containers.emulator;
3168
+ url.searchParams.set("accessKeyId", accessKeyId);
3169
+ url.searchParams.set("secretAccessKey", secretAccessKey);
2895
3170
  return url.toString();
2896
3171
  }
2897
3172
  }
2898
3173
  }
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
- }) {
3174
+ /** A local Postgres URL, with the login it connects as. */
3175
+ function postgres(port, database, credential) {
2904
3176
  return `postgres://${credential.user}:${encodeURIComponent(credential.password)}@${LOCAL_HOST}:${port}/${database}`;
2905
3177
  }
2906
3178
  /**
@@ -3195,14 +3467,19 @@ function addEdgeKeys(manifest, edges, keys) {
3195
3467
  }
3196
3468
  }
3197
3469
  /** One compose service per app the workspace runs, behind the apps profile. */
3198
- function appServices(workspace, manifest, containers, runnables = {}, fakes = {}) {
3470
+ function appServices(workspace, manifest, containers, credentials, runnables = {}, fakes = {}) {
3199
3471
  const plan = localPlan(workspace, manifest, fakes);
3200
- const env = networkEnv(plan, {
3472
+ const derivation = {
3201
3473
  project: workspace.name,
3474
+ credentials,
3475
+ seed: credentials.seed
3476
+ };
3477
+ const env = networkEnv(plan, {
3478
+ ...derivation,
3202
3479
  addresses: surfaceAddresses(workspace, manifest, (app, port) => `http://${app}:${port}`)
3203
3480
  });
3204
3481
  const browser = networkEnv(plan, {
3205
- project: workspace.name,
3482
+ ...derivation,
3206
3483
  addresses: surfaceAddresses(workspace, manifest)
3207
3484
  });
3208
3485
  const services = {};
@@ -3277,8 +3554,6 @@ function healthcheck(port, path) {
3277
3554
  * Both are constructed from the same local addresses the env writer composes,
3278
3555
  * so the applier cannot end up talking to a different container than the app.
3279
3556
  */
3280
- /** The credential local containers are brought up with. */
3281
- const LOCAL_USER$1 = "geekmidas";
3282
3557
  /**
3283
3558
  * A Postgres client that opens one connection per database it is asked about.
3284
3559
  *
@@ -3286,14 +3561,14 @@ const LOCAL_USER$1 = "geekmidas";
3286
3561
  * and a schema must be created from inside its own database — so the database is
3287
3562
  * part of each query rather than fixed when the client is built.
3288
3563
  */
3289
- function pgClient(port, password = LOCAL_USER$1) {
3564
+ function pgClient(port, login) {
3290
3565
  return { async query(database, sql, values) {
3291
3566
  const client = new Client({
3292
3567
  host: "localhost",
3293
3568
  port,
3294
- user: LOCAL_USER$1,
3295
- password,
3296
- database: database ?? LOCAL_USER$1
3569
+ user: login.user,
3570
+ password: login.password,
3571
+ database: database ?? "postgres"
3297
3572
  });
3298
3573
  await client.connect();
3299
3574
  try {
@@ -3304,10 +3579,7 @@ function pgClient(port, password = LOCAL_USER$1) {
3304
3579
  } };
3305
3580
  }
3306
3581
  /** 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
- }) {
3582
+ function bucketClient(port, credentials) {
3311
3583
  const s3 = new S3Client({
3312
3584
  region: "us-east-1",
3313
3585
  endpoint: `http://localhost:${port}`,
@@ -3345,11 +3617,11 @@ function bucketClient(port, credentials = {
3345
3617
  };
3346
3618
  }
3347
3619
  /** A topic and queue client pointed at the local AWS emulator. */
3348
- function carrierClient(port) {
3620
+ function carrierClient(port, credentials) {
3349
3621
  const config = {
3350
3622
  region: EMULATOR_REGION,
3351
3623
  endpoint: emulatorEndpoint(port),
3352
- credentials: EMULATOR_CREDENTIALS
3624
+ credentials
3353
3625
  };
3354
3626
  const sns = new SNSClient(config);
3355
3627
  const sqs = new SQSClient(config);
@@ -3379,1195 +3651,1883 @@ function carrierClient(port) {
3379
3651
  };
3380
3652
  }
3381
3653
  //#endregion
3382
- //#region src/reconcile/compose.ts
3654
+ //#region src/reconcile/docker.ts
3383
3655
  /**
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.
3656
+ * The real Docker, behind the small interface reconcile needs.
3390
3657
  *
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.
3658
+ * Shells out to `docker compose` rather than talking to the daemon socket: it is
3659
+ * what a developer runs by hand when something is wrong, so the two cannot
3660
+ * disagree, and it keeps Docker out of this package's dependencies.
3394
3661
  *
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.
3662
+ * Every call is scoped to the generated compose file. Nothing here can touch a
3663
+ * container this project did not generate.
3398
3664
  */
3399
- /** The credential every derived container is brought up with locally. */
3400
- const LOCAL_USER = "geekmidas";
3665
+ const run = promisify(execFile);
3401
3666
  /**
3402
- * The token the local cache accepts.
3667
+ * The generated file, then the project's own if it has one.
3403
3668
  *
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.
3669
+ * Merged in that order so the project's wins — an `image:` there pins a
3670
+ * container this file derived, and a service there is added beside them.
3671
+ */
3672
+ function composeFiles(composePath) {
3673
+ const project = join(dirname(composePath), "docker-compose.yml");
3674
+ return [
3675
+ "-f",
3676
+ composePath,
3677
+ ...existsSync(project) ? ["-f", project] : []
3678
+ ];
3679
+ }
3680
+ /** How long to wait for containers to pass their health checks. */
3681
+ const HEALTH_TIMEOUT_MS = 12e4;
3682
+ /** `docker compose up` exited unsuccessfully; its own output said why. */
3683
+ var ComposeUpFailed = class extends Error {
3684
+ services;
3685
+ code;
3686
+ constructor(services, code) {
3687
+ 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.`);
3688
+ this.services = services;
3689
+ this.code = code;
3690
+ this.name = "ComposeUpFailed";
3691
+ }
3692
+ };
3693
+ /** Run compose with its output on this terminal rather than captured. */
3694
+ function shown(args, services) {
3695
+ return new Promise((resolve, reject) => {
3696
+ const child = spawn("docker", args, { stdio: [
3697
+ "ignore",
3698
+ "inherit",
3699
+ "inherit"
3700
+ ] });
3701
+ child.on("error", reject);
3702
+ child.on("close", (code) => code === 0 ? resolve() : reject(new ComposeUpFailed(services, code)));
3703
+ });
3704
+ }
3705
+ const dockerCli = {
3706
+ async publishedPort(composePath, service, inside) {
3707
+ try {
3708
+ const { stdout } = await run("docker", [
3709
+ "compose",
3710
+ ...composeFiles(composePath),
3711
+ "port",
3712
+ service,
3713
+ String(inside)
3714
+ ]);
3715
+ const port = Number(stdout.trim().split(":").pop());
3716
+ return Number.isFinite(port) && port > 0 ? port : void 0;
3717
+ } catch {
3718
+ return;
3719
+ }
3720
+ },
3721
+ async up(composePath, services, options = {}) {
3722
+ const args = [
3723
+ "compose",
3724
+ ...composeFiles(composePath),
3725
+ "up",
3726
+ "-d",
3727
+ "--remove-orphans",
3728
+ "--wait",
3729
+ `--wait-timeout=${Math.floor(HEALTH_TIMEOUT_MS / 1e3)}`,
3730
+ ...services
3731
+ ];
3732
+ if (options.show) return shown(args, services);
3733
+ await run("docker", args);
3734
+ },
3735
+ async copyOut(composePath, service, from, to) {
3736
+ await mkdir(dirname(to), { recursive: true });
3737
+ await run("docker", [
3738
+ "compose",
3739
+ ...composeFiles(composePath),
3740
+ "cp",
3741
+ `${service}:${from}`,
3742
+ to
3743
+ ]);
3744
+ },
3745
+ async reload(composePath, service) {
3746
+ await run("docker", [
3747
+ "compose",
3748
+ ...composeFiles(composePath),
3749
+ "exec",
3750
+ "-T",
3751
+ service,
3752
+ "caddy",
3753
+ "reload",
3754
+ "--config",
3755
+ "/etc/caddy/Caddyfile"
3756
+ ]);
3757
+ },
3758
+ async exec(composePath, service, argv) {
3759
+ try {
3760
+ const { stdout, stderr } = await run("docker", [
3761
+ "compose",
3762
+ ...composeFiles(composePath),
3763
+ "exec",
3764
+ "-T",
3765
+ service,
3766
+ ...argv
3767
+ ]);
3768
+ return {
3769
+ ok: true,
3770
+ output: `${stdout}${stderr}`
3771
+ };
3772
+ } catch (error) {
3773
+ const { stdout = "", stderr = "" } = error;
3774
+ return {
3775
+ ok: false,
3776
+ output: `${stdout}${stderr}`
3777
+ };
3778
+ }
3779
+ },
3780
+ async healthy(composePath, services) {
3781
+ if (services.length === 0) return true;
3782
+ try {
3783
+ const { stdout } = await run("docker", [
3784
+ "compose",
3785
+ ...composeFiles(composePath),
3786
+ "ps",
3787
+ "--format",
3788
+ "json",
3789
+ ...services
3790
+ ]);
3791
+ const running = /* @__PURE__ */ new Set();
3792
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
3793
+ const entry = JSON.parse(line);
3794
+ if (entry.State === "running" && (entry.Health === void 0 || entry.Health === "" || entry.Health === "healthy") && entry.Service) running.add(entry.Service);
3795
+ }
3796
+ return services.every((service) => running.has(service));
3797
+ } catch {
3798
+ return false;
3799
+ }
3800
+ }
3801
+ };
3802
+ //#endregion
3803
+ //#region src/reconcile/lan.ts
3804
+ /**
3805
+ * This machine's address on the local network — what a phone running the app
3806
+ * reaches it on.
3407
3807
  */
3408
- const LOCAL_TOKEN = "geekmidas";
3409
3808
  /**
3410
- * Build the compose document for a plan.
3809
+ * The first private IPv4 address of an interface that is up and not loopback.
3411
3810
  *
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.
3811
+ * Private ranges only (`10/8`, `172.16/12`, `192.168/16`): a public address
3812
+ * would mean listening for a phone on the open internet, and a link-local one
3813
+ * (`169.254/16`) is what an interface has when it found no network at all.
3814
+ * Undefined when there is none — a laptop on no network still runs, its mobile
3815
+ * app reaching only a simulator.
3415
3816
  */
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] = {};
3817
+ function lanAddress(interfaces = networkInterfaces()) {
3818
+ for (const entries of Object.values(interfaces)) for (const entry of entries ?? []) {
3819
+ if (entry.family !== "IPv4" || entry.internal) continue;
3820
+ if (isPrivate(entry.address)) return entry.address;
3432
3821
  }
3433
- return {
3434
- name: options.project,
3435
- services: {
3436
- ...services,
3437
- ...options.apps
3438
- },
3439
- ...Object.keys(volumes).length ? { volumes } : {}
3440
- };
3441
3822
  }
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 })}`;
3823
+ function isPrivate(address) {
3824
+ return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
3455
3825
  }
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;
3826
+ //#endregion
3827
+ //#region src/reconcile/logins.ts
3828
+ /**
3829
+ * Bringing a container that already has data in line with the workspace's
3830
+ * generated logins.
3831
+ *
3832
+ * Postgres and RabbitMQ read their login from the environment once, when the
3833
+ * volume is first initialised; a volume an older gkm made was initialised
3834
+ * with the old fixed one, and a changed environment alone would lock the
3835
+ * developer out of their own data. So after the containers are up, each one
3836
+ * is asked whether the generated login opens it, and if not:
3837
+ *
3838
+ * - **Postgres**: signed in to with the old login, the generated superuser is
3839
+ * created (or given its password), and the old one stops logging in. Where
3840
+ * no login is accepted — another checkout of the same project rotated it —
3841
+ * the password is set from inside the container, which trusts its own
3842
+ * socket.
3843
+ * - **MinIO** reads its root login on every start, so recreating the
3844
+ * container rotated it already. If the old login is still the one it takes —
3845
+ * a project's own `docker-compose.yml` pins it — that is kept, with a note.
3846
+ * - **RabbitMQ**: the user is added, or given its password, with `rabbitmqctl`
3847
+ * inside the container.
3848
+ *
3849
+ * Nothing is deleted: no volume, no database, no bucket.
3850
+ */
3851
+ /**
3852
+ * The login every local container was brought up with before logins were
3853
+ * generated. Kept only to recognise — and rotate away from — a volume made
3854
+ * then.
3855
+ */
3856
+ const LEGACY_LOCAL_LOGIN = Object.freeze({
3857
+ user: "geekmidas",
3858
+ password: "geekmidas"
3859
+ });
3860
+ /** No login this workspace knows opens its Postgres. */
3861
+ var PostgresLoginRefused = class extends Error {
3862
+ port;
3863
+ user;
3864
+ constructor(port, user) {
3865
+ 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`);
3866
+ this.port = port;
3867
+ this.user = user;
3868
+ this.name = "PostgresLoginRefused";
3466
3869
  }
3467
3870
  };
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
- }
3871
+ /** No login this workspace knows opens its MinIO. */
3872
+ var MinioLoginRefused = class extends Error {
3873
+ port;
3874
+ user;
3875
+ constructor(port, user) {
3876
+ 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.`);
3877
+ this.port = port;
3878
+ this.user = user;
3879
+ this.name = "MinioLoginRefused";
3880
+ }
3881
+ };
3882
+ /** Whether `login` opens the Postgres on `port`, as a connected client. */
3883
+ async function connect$1(port, login) {
3884
+ const client = new Client({
3885
+ host: "localhost",
3886
+ port,
3887
+ user: login.user,
3888
+ password: login.password,
3889
+ database: CLUSTER_DATABASE
3890
+ });
3891
+ try {
3892
+ await client.connect();
3893
+ return client;
3894
+ } catch (error) {
3895
+ await client.end().catch(() => {});
3896
+ const code = error.code;
3897
+ if (code === "28P01" || code === "28000" || code === "3D000") return null;
3898
+ throw error;
3899
+ }
3900
+ }
3901
+ /** Quote a string literal for DDL that cannot take parameters. */
3902
+ function literal(value) {
3903
+ return `'${value.replace(/'/g, "''")}'`;
3904
+ }
3905
+ function ident(name) {
3906
+ return `"${name.replace(/"/g, "\"\"")}"`;
3907
+ }
3908
+ /** The SQL that makes `login` a superuser with its password, new or not. */
3909
+ function superuserSql(login) {
3910
+ 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 \$\$`;
3911
+ }
3912
+ /**
3913
+ * Make the Postgres on `port` take `login`, rotating it from an older one
3914
+ * where that is what it takes.
3915
+ */
3916
+ async function ensurePostgresLogin(options) {
3917
+ const { port, login, legacy } = options;
3918
+ const current = await connect$1(port, login);
3919
+ if (current) {
3920
+ await current.end();
3921
+ return {
3922
+ service: "postgres",
3923
+ status: "current",
3924
+ login
3553
3925
  };
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
- }
3926
+ }
3927
+ for (const old of legacy) {
3928
+ const client = await connect$1(port, old);
3929
+ if (!client) continue;
3930
+ try {
3931
+ await client.query(superuserSql(login));
3932
+ } catch {
3933
+ await client.end();
3934
+ return {
3935
+ service: "postgres",
3936
+ status: "kept",
3937
+ login: old,
3938
+ 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`
3939
+ };
3940
+ }
3941
+ await client.end();
3942
+ await retire(port, login, old);
3943
+ return {
3944
+ service: "postgres",
3945
+ status: "rotated",
3946
+ login,
3947
+ 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
3948
  };
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
- }
3949
+ }
3950
+ if (options.exec) for (const user of [login.user, ...legacy.map((l) => l.user)]) {
3951
+ if (!(await options.exec("postgres", [
3952
+ "psql",
3953
+ "-v",
3954
+ "ON_ERROR_STOP=1",
3955
+ "-U",
3956
+ user,
3957
+ "-d",
3958
+ "postgres",
3959
+ "-c",
3960
+ superuserSql(login)
3961
+ ])).ok) continue;
3962
+ const client = await connect$1(port, login);
3963
+ if (!client) continue;
3964
+ await client.end();
3965
+ for (const old of legacy) await retire(port, login, old);
3966
+ return {
3967
+ service: "postgres",
3968
+ status: "rotated",
3969
+ login,
3970
+ notice: `🔑 Postgres's login was reset to this workspace's generated one ('${login.user}'); its data is unchanged.`
3593
3971
  };
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
- }
3972
+ }
3973
+ throw new PostgresLoginRefused(port, login.user);
3974
+ }
3975
+ /** Stop an older login from opening the cluster, now another does. */
3976
+ async function retire(port, login, old) {
3977
+ if (old.user === login.user) return;
3978
+ const client = await connect$1(port, login);
3979
+ if (!client) return;
3980
+ try {
3981
+ 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`);
3982
+ } finally {
3983
+ await client.end();
3984
+ }
3985
+ }
3986
+ /** Whether `login` signs in to the MinIO on `port`. */
3987
+ async function minioAccepts(port, login) {
3988
+ const s3 = new S3Client({
3989
+ region: "us-east-1",
3990
+ endpoint: `http://localhost:${port}`,
3991
+ forcePathStyle: true,
3992
+ credentials: {
3993
+ accessKeyId: login.user,
3994
+ secretAccessKey: login.password
3995
+ },
3996
+ maxAttempts: 1
3997
+ });
3998
+ try {
3999
+ await s3.send(new ListBucketsCommand({}));
4000
+ return true;
4001
+ } catch (error) {
4002
+ const name = error.name ?? "";
4003
+ if (name === "InvalidAccessKeyId" || name === "SignatureDoesNotMatch" || name === "AccessDenied") return false;
4004
+ throw error;
4005
+ } finally {
4006
+ s3.destroy();
4007
+ }
4008
+ }
4009
+ /**
4010
+ * Check the MinIO on `port` takes `login`. It reads its root login on every
4011
+ * start, so a recreated container already does — unless something pins an
4012
+ * older one, which is then kept.
4013
+ */
4014
+ async function ensureMinioLogin(options) {
4015
+ const { port, login, legacy } = options;
4016
+ if (await minioAccepts(port, login)) return {
4017
+ service: "minio",
4018
+ status: "current",
4019
+ login
4020
+ };
4021
+ for (const old of legacy) if (await minioAccepts(port, old)) return {
4022
+ service: "minio",
4023
+ status: "kept",
4024
+ login: old,
4025
+ 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.`
4026
+ };
4027
+ throw new MinioLoginRefused(port, login.user);
4028
+ }
4029
+ /**
4030
+ * Make the RabbitMQ in the project's container take `login`, with
4031
+ * `rabbitmqctl` inside it — which needs no password.
4032
+ */
4033
+ async function ensureRabbitmqLogin(options) {
4034
+ const { login, legacy, exec } = options;
4035
+ const ctl = (...args) => exec("rabbitmq", ["rabbitmqctl", ...args]);
4036
+ if ((await ctl("authenticate_user", login.user, login.password)).ok) return {
4037
+ service: "rabbitmq",
4038
+ status: "current",
4039
+ login
4040
+ };
4041
+ if (!(await ctl("add_user", login.user, login.password)).ok) await ctl("change_password", login.user, login.password);
4042
+ await ctl("set_user_tags", login.user, "administrator");
4043
+ await ctl("set_permissions", "-p", "/", login.user, ".*", ".*", ".*");
4044
+ for (const old of legacy) if (old.user !== login.user) await ctl("delete_user", old.user);
4045
+ return {
4046
+ service: "rabbitmq",
4047
+ status: "rotated",
4048
+ login,
4049
+ notice: `🔑 RabbitMQ moved from the old fixed login to a generated one ('${login.user}'); its queues are unchanged.`
4050
+ };
4051
+ }
4052
+ /**
4053
+ * Check the login of each running container that keeps one, and the
4054
+ * credentials to use from here: the generated ones, or an older login a
4055
+ * service could not be moved off.
4056
+ */
4057
+ async function ensureLogins(options) {
4058
+ const { containers, ports, exec } = options;
4059
+ let credentials = options.credentials;
4060
+ const outcomes = [];
4061
+ const legacy = [LEGACY_LOCAL_LOGIN];
4062
+ if (containers.includes("postgres") && ports.postgres !== void 0) {
4063
+ const outcome = await ensurePostgresLogin({
4064
+ port: ports.postgres,
4065
+ login: credentials.postgres,
4066
+ legacy,
4067
+ ...exec ? { exec } : {}
4068
+ });
4069
+ outcomes.push(outcome);
4070
+ credentials = {
4071
+ ...credentials,
4072
+ postgres: outcome.login
3614
4073
  };
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
- }
4074
+ }
4075
+ if (containers.includes("minio") && ports.minio !== void 0) {
4076
+ const outcome = await ensureMinioLogin({
4077
+ port: ports.minio,
4078
+ login: credentials.minio,
4079
+ legacy
4080
+ });
4081
+ outcomes.push(outcome);
4082
+ credentials = {
4083
+ ...credentials,
4084
+ minio: outcome.login
3638
4085
  };
3639
- default: throw new UnknownContainer(container);
3640
4086
  }
4087
+ if (containers.includes("rabbitmq") && exec) outcomes.push(await ensureRabbitmqLogin({
4088
+ login: credentials.rabbitmq,
4089
+ legacy,
4090
+ exec
4091
+ }));
4092
+ return {
4093
+ credentials,
4094
+ outcomes
4095
+ };
4096
+ }
4097
+ //#endregion
4098
+ //#region src/reconcile/ports.ts
4099
+ /**
4100
+ * Port allocation — why two projects can run at once.
4101
+ *
4102
+ * Publishing `5432:5432` means the second project on your machine cannot start.
4103
+ * Nothing solves that except giving each project its own ports, and what makes
4104
+ * it *possible* is the single-`<NAME>_URL` rule: an app reads `ORDERS_URL` and
4105
+ * never sees a host or a port, so reconcile is free to bind whatever is
4106
+ * available and compose the URL from what it bound. An app reading `DB_HOST` and
4107
+ * `DB_PORT` separately could not be moved this way.
4108
+ *
4109
+ * Assignments are **persisted, not recomputed**. Recomputing means adding a
4110
+ * construct renumbers its neighbours, and every running container, saved
4111
+ * connection string, and `psql` history goes stale at once.
4112
+ *
4113
+ * Pure, with the liveness probe injected — a collision with another project's
4114
+ * stack is a fact about the machine, and keeping it a parameter is what lets the
4115
+ * rules be asserted without binding a socket.
4116
+ */
4117
+ /**
4118
+ * The window ports are drawn from.
4119
+ *
4120
+ * Above the registered range so nothing here needs privileges, and below the
4121
+ * ephemeral range both Linux (32768+) and macOS (49152+) allocate from — a port
4122
+ * the kernel might hand to an outgoing connection is one that is free when
4123
+ * checked and taken by the time it is bound.
4124
+ */
4125
+ const WINDOW = {
4126
+ min: 2e4,
4127
+ max: 32e3
4128
+ };
4129
+ /**
4130
+ * How far apart two projects start.
4131
+ *
4132
+ * Wide enough that a project can add containers without walking into its
4133
+ * neighbour's block, narrow enough that the window holds many projects.
4134
+ */
4135
+ const BLOCK = 50;
4136
+ /**
4137
+ * Where a project's ports start.
4138
+ *
4139
+ * Derived from the name rather than assigned sequentially so that a fresh clone
4140
+ * of the same project on the same machine lands on the same block — the first
4141
+ * run has nothing persisted to read, and hashing is what keeps that first run
4142
+ * from colliding with whatever else is already allocated.
4143
+ *
4144
+ * @example startingPort('toolbox') // stable for a given name
4145
+ */
4146
+ function startingPort(project) {
4147
+ const digest = createHash("sha256").update(project).digest();
4148
+ const blocks = Math.floor((WINDOW.max - WINDOW.min) / BLOCK);
4149
+ return WINDOW.min + digest.readUInt32BE(0) % blocks * BLOCK;
4150
+ }
4151
+ /**
4152
+ * Assign a port to every container that has none, keeping the ones that do.
4153
+ *
4154
+ * Existing assignments are returned untouched even when the container is no
4155
+ * longer in the plan: removing a construct must not renumber what is left, and
4156
+ * keeping the entry means re-adding it later restores the same port rather than
4157
+ * inventing a new one.
4158
+ *
4159
+ * @param probe - reports what is actually listening. A port assigned to this
4160
+ * project is skipped without probing, because on a converged reconcile our own
4161
+ * container is the thing listening on it.
4162
+ * @throws {NoPortAvailable} when the window is exhausted.
4163
+ */
4164
+ async function allocate(project, containers, existing = {}, probe = isPortFree) {
4165
+ const assignments = { ...existing };
4166
+ const taken = new Set(Object.values(assignments));
4167
+ for (const container of [...containers].sort()) {
4168
+ if (assignments[container] !== void 0) continue;
4169
+ const port = await nextFree(startingPort(project), taken, probe);
4170
+ assignments[container] = port;
4171
+ taken.add(port);
4172
+ }
4173
+ return assignments;
4174
+ }
4175
+ /**
4176
+ * The ports to keep: what running containers are observed on, then what was
4177
+ * saved — minus any saved entry whose port something else already holds.
4178
+ *
4179
+ * Spreading one over the other let an observed port override its own key and
4180
+ * leave a *different* key still saved on the same number: two services on one
4181
+ * port, and whichever answered first won. Saved keys for containers the plan no
4182
+ * longer has — a backend switched and back — were the usual source. Observed
4183
+ * wins because it is what is actually bound; a dropped entry is simply
4184
+ * allocated again.
4185
+ */
4186
+ function keptPorts(saved = {}, observed = {}) {
4187
+ const kept = { ...observed };
4188
+ const taken = new Set(Object.values(observed));
4189
+ for (const [key, port] of Object.entries(saved).sort(([a], [b]) => a < b ? -1 : 1)) {
4190
+ if (key in kept || taken.has(port)) continue;
4191
+ kept[key] = port;
4192
+ taken.add(port);
4193
+ }
4194
+ return kept;
4195
+ }
4196
+ /** The window was exhausted. */
4197
+ var NoPortAvailable = class extends Error {
4198
+ /** Where the search started. */
4199
+ from;
4200
+ /** The window searched, so the caller can report what was full. */
4201
+ window = WINDOW;
4202
+ constructor(from) {
4203
+ super("No free port is available in the local port range");
4204
+ this.name = "NoPortAvailable";
4205
+ this.from = from;
4206
+ }
4207
+ };
4208
+ /**
4209
+ * The first port at or after `from` that is neither ours nor in use.
4210
+ *
4211
+ * Wraps at the top of the window rather than stopping, so a project whose block
4212
+ * sits near the end is not the one that fails.
4213
+ */
4214
+ async function nextFree(from, taken, probe) {
4215
+ const span = WINDOW.max - WINDOW.min;
4216
+ for (let offset = 0; offset < span; offset++) {
4217
+ const port = WINDOW.min + (from - WINDOW.min + offset) % span;
4218
+ if (taken.has(port)) continue;
4219
+ if (await probe(port)) return port;
4220
+ }
4221
+ throw new NoPortAvailable(from);
4222
+ }
4223
+ /**
4224
+ * Whether nothing is listening on a port.
4225
+ *
4226
+ * Binds rather than connects: a refused connection says nothing about whether
4227
+ * the port can be *published*, which is the question Docker will ask. Binds on
4228
+ * `0.0.0.0` for the same reason — a port free on loopback can still be taken on
4229
+ * the address Docker publishes to.
4230
+ *
4231
+ * Inherently racy, and that is acceptable: Docker failing to bind stays the real
4232
+ * check. This exists so the common case surfaces as a message at reconcile
4233
+ * rather than as a connection failure ten minutes later.
4234
+ */
4235
+ function isPortFree(port) {
4236
+ return new Promise((resolve) => {
4237
+ const server = createServer();
4238
+ server.once("error", () => resolve(false));
4239
+ server.once("listening", () => server.close(() => resolve(true)));
4240
+ server.listen(port, "0.0.0.0");
4241
+ });
4242
+ }
4243
+ //#endregion
4244
+ //#region src/reconcile/provision.ts
4245
+ /**
4246
+ * Creating what the URLs name.
4247
+ *
4248
+ * Convergent and idempotent: every statement asks whether the thing exists
4249
+ * first, so reconciling repeatedly is free and a half-applied run recovers by
4250
+ * being run again. That property is what lets `gkm dev` do this on every start.
4251
+ *
4252
+ * The line the design draws is at data, not at side effects — this creates
4253
+ * databases, schemas, and buckets, and it never seeds, resets, or drops. A
4254
+ * developer who wants their data gone asks for it explicitly.
4255
+ *
4256
+ * Statement generation is separated from application for the reason the design
4257
+ * gives: the same DDL has to run in-process locally and from a provisioner in a
4258
+ * VPC, and one implementation is what stops "works locally, fails deployed".
4259
+ * When the role DDL lands in `@geekmidas/db` this delegates to it rather than
4260
+ * growing its own.
4261
+ */
4262
+ /**
4263
+ * The Postgres work a plan implies.
4264
+ *
4265
+ * Databases before the schemas inside them, which `provisionOrder` already
4266
+ * guarantees by putting parents first.
4267
+ */
4268
+ function postgresStatements(plan, project = "", seed) {
4269
+ const statements = [];
4270
+ for (const resource of plan.resources) {
4271
+ if (!resource.provisions || resource.container !== "postgres") continue;
4272
+ if (resource.kind === "database") {
4273
+ statements.push({
4274
+ id: resource.id,
4275
+ describe: `database ${resource.name}`,
4276
+ exists: {
4277
+ sql: "SELECT 1 FROM pg_database WHERE datname = $1",
4278
+ values: [resource.name]
4279
+ },
4280
+ create: `CREATE DATABASE ${quoteIdentifier(resource.name)}`
4281
+ });
4282
+ if (resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
4283
+ continue;
4284
+ }
4285
+ if (resource.kind === "database-schema" && resource.schema) statements.push(...rolesFor(resource, resource.schema, plan, project, seed));
4286
+ const cacheHome = resource.kind === "cache" && resource.of ? plan.resources.find((r) => r.id === resource.of) : void 0;
4287
+ if (cacheHome) statements.push(...cacheTableDdl(resource, cacheHome, plan));
4288
+ }
4289
+ return statements;
4290
+ }
4291
+ /**
4292
+ * A cache's table, in the schema the role that reads it resolves names in.
4293
+ *
4294
+ * Two things have to line up, and neither is the table's contents. The driver
4295
+ * names the table unqualified and lets the connection's `search_path` place it
4296
+ * — which the role carries — so creating it from the master connection, whose
4297
+ * path is `public`, leaves a table the application cannot see and a
4298
+ * `relation "cache" does not exist` on the first request. And the default
4299
+ * privileges that make a tenant's tables reachable are granted *for the owner
4300
+ * role*, so nothing covers a table the master created.
4301
+ *
4302
+ * Hence: qualified with the tenant's schema, handed to its owner, and granted
4303
+ * explicitly — `roleStatements` grants what exists at the time it runs, and
4304
+ * this table does not exist yet.
4305
+ *
4306
+ * A database that opted out of roles needs none of it: everything connects as
4307
+ * the master, whose `search_path` finds `public`, and the table it creates
4308
+ * there is the table it reads.
4309
+ */
4310
+ function cacheTableDdl(cache, home, plan) {
4311
+ const database = rootDatabase(home, plan);
4312
+ const schema = home.roles === false ? void 0 : home.schema;
4313
+ const name = cache.table ?? cacheTable(cache.id);
4314
+ const table = schema ? `${schema}.${name}` : name;
4315
+ const created = cacheTableStatements({ table }).map((statement) => ({
4316
+ id: cache.id,
4317
+ describe: statement.describe,
4318
+ database,
4319
+ ...statement.exists ? { exists: statement.exists } : {},
4320
+ create: statement.sql
4321
+ }));
4322
+ if (!schema) return created;
4323
+ const runtime = localRole(home);
4324
+ const owner = ownerRole(runtime);
4325
+ const qualified = table.split(".").map((part) => quoteIdentifier(part)).join(".");
4326
+ return [
4327
+ ...created,
4328
+ {
4329
+ id: cache.id,
4330
+ describe: `cache table ${table} is owned by ${owner}`,
4331
+ database,
4332
+ create: `ALTER TABLE ${qualified} OWNER TO ${quoteIdentifier(owner)}`
4333
+ },
4334
+ {
4335
+ id: cache.id,
4336
+ describe: `${runtime} may read and write ${table}`,
4337
+ database,
4338
+ create: `GRANT SELECT, INSERT, UPDATE, DELETE ON ${qualified} TO ${quoteIdentifier(runtime)}`
4339
+ }
4340
+ ];
4341
+ }
4342
+ /**
4343
+ * The roles and schema for one database or tenant.
4344
+ *
4345
+ * Skipped entirely when the construct declared `roles: false` — the documented
4346
+ * downgrade, where both URLs fall back to the cluster's master credential. It
4347
+ * is a choice someone made rather than a default they got.
4348
+ */
4349
+ function rolesFor(resource, schema, plan, project, seed) {
4350
+ if (resource.roles === false) return [];
4351
+ const runtime = localRole(resource);
4352
+ const owner = ownerRole(runtime);
4353
+ const reader = plan.resources.some((r) => r.kind === "database-reader" && r.of === resource.id) ? readerRole(runtime) : void 0;
4354
+ return roleStatements({
4355
+ runtime,
4356
+ owner,
4357
+ ...reader ? { reader } : {},
4358
+ schema,
4359
+ ...resource.kind === "database" ? { database: rootDatabase(resource, plan) } : {},
4360
+ passwords: {
4361
+ runtime: localRolePassword(project, plan, runtime, seed),
4362
+ owner: localRolePassword(project, plan, owner, seed),
4363
+ ...reader ? { reader: localRolePassword(project, plan, reader, seed) } : {}
4364
+ }
4365
+ }).map((statement) => ({
4366
+ id: resource.id,
4367
+ describe: statement.describe,
4368
+ database: rootDatabase(resource, plan),
4369
+ ...statement.exists ? { exists: statement.exists } : {},
4370
+ create: statement.sql
4371
+ }));
4372
+ }
4373
+ /** The buckets a plan implies. */
4374
+ function bucketNames(plan) {
4375
+ return plan.resources.filter((r) => r.provisions && r.kind === "objects").map((r) => r.name);
4376
+ }
4377
+ /**
4378
+ * Quote an identifier for use in DDL.
4379
+ *
4380
+ * Names reaching here are already canonical and stage-suffixed, so this is
4381
+ * defence rather than sanitisation — but DDL cannot be parameterised, and an
4382
+ * unquoted identifier is the one place a name could ever be more than a name.
4383
+ */
4384
+ function quoteIdentifier(name) {
4385
+ return `"${name.replace(/"/g, "\"\"")}"`;
4386
+ }
4387
+ /**
4388
+ * Apply the plan's Postgres statements.
4389
+ *
4390
+ * Each is checked before it is run, so this converges rather than erroring on a
4391
+ * second pass.
4392
+ */
4393
+ async function applyPostgres(client, statements) {
4394
+ const applied = [];
4395
+ for (const statement of statements) {
4396
+ if (!statement.exists) {
4397
+ await client.query(statement.database, statement.create);
4398
+ applied.push({
4399
+ id: statement.id,
4400
+ describe: statement.describe,
4401
+ created: false
4402
+ });
4403
+ continue;
4404
+ }
4405
+ if ((await client.query(statement.database, statement.exists.sql, statement.exists.values)).length > 0) {
4406
+ applied.push({
4407
+ id: statement.id,
4408
+ describe: statement.describe,
4409
+ created: false
4410
+ });
4411
+ continue;
4412
+ }
4413
+ await client.query(statement.database, statement.create);
4414
+ applied.push({
4415
+ id: statement.id,
4416
+ describe: statement.describe,
4417
+ created: true
4418
+ });
4419
+ }
4420
+ return applied;
3641
4421
  }
3642
4422
  /**
3643
- * The published port mappings for a container.
4423
+ * What each bucket's open paths are, if anything serves it.
3644
4424
  *
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.
4425
+ * Keyed by the *origin*, because that is where the policy lives: two servers
4426
+ * over one bucket contribute to one policy, so the patterns are unioned rather
4427
+ * than the last one winning.
3648
4428
  */
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;
4429
+ function bucketPolicies(plan) {
4430
+ const byBucket = /* @__PURE__ */ new Map();
4431
+ for (const resource of plan.resources) {
4432
+ if (resource.kind !== "file-server" || !resource.open?.length) continue;
4433
+ const origin = plan.resources.find((r) => r.id === resource.of);
4434
+ if (!origin) continue;
4435
+ const patterns = byBucket.get(origin.name) ?? /* @__PURE__ */ new Set();
4436
+ for (const pattern of resource.open) patterns.add(pattern);
4437
+ byBucket.set(origin.name, patterns);
3664
4438
  }
3665
- };
3666
- //#endregion
3667
- //#region src/reconcile/docker.ts
4439
+ return [...byBucket].map(([bucket, open]) => ({
4440
+ bucket,
4441
+ open: [...open].sort()
4442
+ })).sort((a, b) => a.bucket.localeCompare(b.bucket));
4443
+ }
3668
4444
  /**
3669
- * The real Docker, behind the small interface reconcile needs.
4445
+ * One bucket's open paths as an S3 bucket policy.
3670
4446
  *
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.
4447
+ * Anonymous `s3:GetObject` on the named prefixes and nothing else — which is
4448
+ * what "open" means everywhere: the server serves that path without a
4449
+ * signature, and the bucket stays private for everything else.
3674
4450
  *
3675
- * Every call is scoped to the generated compose file. Nothing here can touch a
3676
- * container this project did not generate.
4451
+ * One fidelity note, stated rather than discovered: an S3 policy resource's `*`
4452
+ * crosses `/`, so a single-star pattern is *wider* here than the construct's own
4453
+ * runtime check, which stops at a segment boundary. The construct is the
4454
+ * stricter of the two, so a key it refuses to serve unsigned is never one this
4455
+ * policy was relied on to refuse — but a key fetched directly, bypassing the
4456
+ * client, can be admitted by the policy where the client would have said no.
4457
+ * Prefer `**` where crossing segments is what you meant.
3677
4458
  */
3678
- const run = promisify(execFile);
4459
+ function bucketPolicy(bucket, open) {
4460
+ return JSON.stringify({
4461
+ Version: "2012-10-17",
4462
+ Statement: [{
4463
+ Sid: "GkmOpenPaths",
4464
+ Effect: "Allow",
4465
+ Principal: { AWS: ["*"] },
4466
+ Action: ["s3:GetObject"],
4467
+ Resource: open.map((pattern) => `arn:aws:s3:::${bucket}/${pattern.replace(/\*\*/g, "*")}`)
4468
+ }]
4469
+ });
4470
+ }
3679
4471
  /**
3680
- * The generated file, then the project's own if it has one.
4472
+ * The topics and queues to create on the AWS emulator.
3681
4473
  *
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.
4474
+ * Only on the `sns` backend: pg-boss makes its own tables on first connect and
4475
+ * a RabbitMQ exchange is declared by the client, but an SNS topic and an SQS
4476
+ * queue must exist before anything can publish to them.
3684
4477
  */
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
- ];
4478
+ function carrierNames(plan) {
4479
+ if (plan.events !== "sns") return {
4480
+ topics: [],
4481
+ queues: []
4482
+ };
4483
+ const named = (kind) => plan.resources.filter((r) => r.kind === kind).map((r) => r.name);
4484
+ return {
4485
+ topics: named("topic"),
4486
+ queues: named("queue")
4487
+ };
3692
4488
  }
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";
4489
+ /** Create each topic and queue the plan names, skipping those that exist. */
4490
+ async function applyCarriers(client, carriers) {
4491
+ const applied = [];
4492
+ for (const name of carriers.topics) {
4493
+ const created = !await client.topicExists(name);
4494
+ if (created) await client.createTopic(name);
4495
+ applied.push({
4496
+ id: name,
4497
+ describe: `topic ${name}`,
4498
+ created
4499
+ });
3704
4500
  }
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
- });
4501
+ for (const name of carriers.queues) {
4502
+ const created = !await client.queueExists(name);
4503
+ if (created) await client.createQueue(name);
4504
+ applied.push({
4505
+ id: name,
4506
+ describe: `queue ${name}`,
4507
+ created
4508
+ });
4509
+ }
4510
+ return applied;
3717
4511
  }
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;
4512
+ /** The Postgres databases the plan creates — each is a `CREATE DATABASE`. */
4513
+ function postgresDatabaseNames(plan) {
4514
+ return plan.resources.filter((resource) => resource.kind === "database" && resource.provisions && resource.container === "postgres").map((resource) => resource.name);
4515
+ }
4516
+ /**
4517
+ * Whether every one of these databases exists. A database dropped behind the
4518
+ * recorded state's back — by another checkout's test teardown, against the
4519
+ * same container — makes the state a lie, and only asking Postgres finds out.
4520
+ * Unreachable counts as missing: applying again is what finds out why.
4521
+ */
4522
+ async function databasesExist(client, names) {
4523
+ if (names.length === 0) return true;
4524
+ try {
4525
+ const rows = await client.query(void 0, "SELECT datname FROM pg_catalog.pg_database WHERE datname = ANY($1)", [names]);
4526
+ return new Set(rows.map((row) => row.datname)).size === names.length;
4527
+ } catch {
4528
+ return false;
4529
+ }
4530
+ }
4531
+ /**
4532
+ * Apply each served bucket's open-path policy, skipping those already correct.
4533
+ *
4534
+ * Compared as parsed JSON rather than as text: the same policy re-serialised
4535
+ * with different key order is the same policy, and rewriting it every reconcile
4536
+ * would report a change on every start.
4537
+ */
4538
+ async function applyPolicies(client, policies) {
4539
+ const applied = [];
4540
+ for (const { bucket, open } of policies) {
4541
+ const wanted = bucketPolicy(bucket, open);
4542
+ const describe = `open paths on ${bucket}`;
4543
+ if (equivalent(await client.policy(bucket), wanted)) {
4544
+ applied.push({
4545
+ id: `${bucket}:policy`,
4546
+ describe,
4547
+ created: false
4548
+ });
4549
+ continue;
3790
4550
  }
4551
+ await client.setPolicy(bucket, wanted);
4552
+ applied.push({
4553
+ id: `${bucket}:policy`,
4554
+ describe,
4555
+ created: true
4556
+ });
3791
4557
  }
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;
4558
+ return applied;
4559
+ }
4560
+ /** Whether two policy documents say the same thing. */
4561
+ function equivalent(current, wanted) {
4562
+ if (!current) return false;
4563
+ try {
4564
+ return JSON.stringify(JSON.parse(current)) === JSON.stringify(JSON.parse(wanted));
4565
+ } catch {
4566
+ return false;
3812
4567
  }
3813
4568
  }
3814
- function isPrivate(address) {
3815
- return /^10\./.test(address) || /^192\.168\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address);
4569
+ /** Create every bucket the plan names, skipping those that exist. */
4570
+ async function applyBuckets(client, buckets) {
4571
+ const applied = [];
4572
+ for (const bucket of buckets) {
4573
+ if (await client.exists(bucket)) {
4574
+ applied.push({
4575
+ id: bucket,
4576
+ describe: `bucket ${bucket}`,
4577
+ created: false
4578
+ });
4579
+ continue;
4580
+ }
4581
+ await client.create(bucket);
4582
+ applied.push({
4583
+ id: bucket,
4584
+ describe: `bucket ${bucket}`,
4585
+ created: true
4586
+ });
4587
+ }
4588
+ return applied;
3816
4589
  }
3817
4590
  //#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
- */
4591
+ //#region src/reconcile/state.ts
3837
4592
  /**
3838
- * The window ports are drawn from.
4593
+ * What reconcile records, and the hash that lets it do nothing.
3839
4594
  *
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.
4595
+ * Reconciling on every `gkm dev` is only acceptable if the converged case is
4596
+ * free, so the desired plan is hashed: matching hash plus healthy containers
4597
+ * means there is nothing to do. Without that, the automatic reconcile the design
4598
+ * asks for would tax every start.
3851
4599
  *
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.
4600
+ * Almost nothing needs recording — roles are queried, migrations have their own
4601
+ * table, containers are remembered by Docker. Only ports and this hash have no
4602
+ * other owner.
3854
4603
  */
3855
- const BLOCK = 50;
4604
+ const STATE_DIR = ".gkm/reconcile";
3856
4605
  /**
3857
- * Where a project's ports start.
4606
+ * The hash of everything a reconcile would act on.
3858
4607
  *
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.
4608
+ * Covers the compose document rather than the plan alone, because the plan is
4609
+ * unchanged when only an image pin moves — and a changed image is exactly a
4610
+ * container that must be recreated. `stringify` is stable here because
4611
+ * `composeFor` sorts its containers, which is what makes the hash meaningful.
3863
4612
  *
3864
- * @example startingPort('toolbox') // stable for a given name
4613
+ * Deliberately a plain function over serialisable input: the deployed target
4614
+ * keys its provisioner invocation on the same idea, and the two must not be able
4615
+ * to disagree about what counts as a change.
3865
4616
  */
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;
4617
+ function planHash(plan, compose, generated = {}) {
4618
+ return createHash("sha256").update(JSON.stringify({
4619
+ stage: plan.stage,
4620
+ plan,
4621
+ compose,
4622
+ generated
4623
+ })).digest("hex");
3870
4624
  }
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);
4625
+ /** Read the recorded state, or nothing if this stage has never reconciled. */
4626
+ async function loadState(root, stage) {
4627
+ try {
4628
+ const raw = await readFile(statePath(root, stage), "utf-8");
4629
+ const state = JSON.parse(raw);
4630
+ return state.stage === stage ? state : void 0;
4631
+ } catch {
4632
+ return;
3892
4633
  }
3893
- return assignments;
3894
4634
  }
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;
4635
+ /** Record what was applied, so the next run can skip it. */
4636
+ async function saveState(root, state) {
4637
+ const path = statePath(root, state.stage);
4638
+ await mkdir(dirname(path), { recursive: true });
4639
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
3915
4640
  }
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
4641
  /**
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.
4642
+ * Forget what was applied, so the next reconcile of this stage converges from
4643
+ * nothing — creating again whatever was dropped underneath the record.
3933
4644
  */
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);
4645
+ async function forgetState(root, stage) {
4646
+ await rm(statePath(root, stage), { force: true });
3942
4647
  }
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
- });
4648
+ function statePath(root, stage) {
4649
+ return join(root, STATE_DIR, `${stage}.json`);
3962
4650
  }
3963
4651
  //#endregion
3964
- //#region src/reconcile/provision.ts
4652
+ //#region src/reconcile/index.ts
3965
4653
  /**
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.
4654
+ * Reconcile — the one convergent function `gkm dev`, `gkm test`, and `gkm setup`
4655
+ * all call.
3971
4656
  *
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.
4657
+ * Three phases, and only this one has side effects: declaring is pure (`gkm
4658
+ * build` and the dev watcher), running reads `<NAME>_URL` and provisions
4659
+ * nothing. Reconcile computes the desired state, compares, and applies the
4660
+ * difference — which is what makes "when does it run" answerable with
4661
+ * *whenever, repeatedly*.
3975
4662
  *
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.
4663
+ * It is safe to run automatically because its blast radius is entirely local:
4664
+ * this project's containers, this project's `.gkm/`. Nothing it does can reach a
4665
+ * cloud. The line is drawn at data, not at side effects — it allocates ports,
4666
+ * writes compose, starts containers, and creates roles; it never seeds, resets,
4667
+ * or drops.
4668
+ *
4669
+ * Docker is injected rather than imported so the rules above can be asserted
4670
+ * without a daemon. The default implementation is the real one.
3981
4671
  */
3982
4672
  /**
3983
- * The Postgres work a plan implies.
4673
+ * Where the generated compose file is written, relative to the project root.
3984
4674
  *
3985
- * Databases before the schemas inside them, which `provisionOrder` already
3986
- * guarantees by putting parents first.
4675
+ * At the root, beside the project's own `docker-compose.yml`, because the two
4676
+ * are one stack: this file is everything the constructs imply and is never
4677
+ * edited, that one is anything they cannot express — an image pin, an extra
4678
+ * service — and is merged over it, so it wins.
3987
4679
  */
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
- }
4680
+ const COMPOSE_PATH = "docker-compose.constructs.yml";
4011
4681
  /**
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.
4682
+ * The local edge's config, beside the compose file that mounts it.
4021
4683
  *
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.
4684
+ * Relative in the compose volume, so it resolves against `.gkm/` wherever the
4685
+ * project lives.
4686
+ */
4687
+ const CADDYFILE_PATH = ".gkm/Caddyfile";
4688
+ /**
4689
+ * Where one stage's routes live, imported by the file above.
4025
4690
  *
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.
4691
+ * Per stage because one edge serves every stage, the same way one Postgres
4692
+ * holds `orders` and `orders_test`. A single file would mean `gkm test`
4693
+ * deleting the routes `gkm dev` is serving.
4029
4694
  */
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
- }
4695
+ const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
4062
4696
  /**
4063
- * The roles and schema for one database or tenant.
4697
+ * Where the local edge's root certificate is copied to.
4064
4698
  *
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.
4699
+ * Exported so a process can be pointed at it without installing anything:
4700
+ * `NODE_EXTRA_CA_CERTS` is the whole of the trust story for Node, and it needs
4701
+ * no sudo. A browser is the case that still wants the root in the system store,
4702
+ * which is a one-time `caddy trust` rather than something a reconcile should do
4703
+ * on a developer's behalf.
4068
4704
  */
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
- }
4705
+ const LOCAL_CA_PATH = ".gkm/caddy-root.crt";
4706
+ /** Where Caddy keeps the root of the CA it generated. */
4707
+ const CADDY_ROOT_IN_CONTAINER = "/data/caddy/pki/authorities/local/root.crt";
4097
4708
  /**
4098
- * Quote an identifier for use in DDL.
4709
+ * Converge the local target on what the manifest declares.
4099
4710
  *
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.
4711
+ * Cheap when there is nothing to do: the hash is compared before the compose
4712
+ * file is written or a container is touched, and a converged project pays one
4713
+ * hash and one health check.
4103
4714
  */
4104
- function quoteIdentifier(name) {
4105
- return `"${name.replace(/"/g, "\"\"")}"`;
4715
+ async function reconcile(options) {
4716
+ const { root, project, manifest, stage, start = true, provision = true, docker = dockerCli, probe = isPortFree, sql = pgClient, buckets = bucketClient, carriers = carrierClient, logins = ensureLogins } = options;
4717
+ const { seed } = options.credentials;
4718
+ const plan = planFor(manifest, stage, provisionOrder(manifest), {
4719
+ localStage: options.localStage,
4720
+ events: options.events,
4721
+ cache: options.cache,
4722
+ extraContainers: options.extraContainers,
4723
+ ...options.fakes ? { fakes: options.fakes } : {},
4724
+ ...options.edge === void 0 ? {} : { edge: options.edge }
4725
+ });
4726
+ const composePath = join(root, COMPOSE_PATH);
4727
+ const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
4728
+ const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
4729
+ const compose = composeFor(plan, {
4730
+ project,
4731
+ ports,
4732
+ credentials: options.credentials,
4733
+ ...options.apps ? { apps: options.apps(plan.containers) } : {}
4734
+ });
4735
+ const caddyfile = toCaddyfile(sitesFor(plan, project, options.addresses));
4736
+ const hash = planHash(plan, compose, {
4737
+ caddyfile,
4738
+ postgres: postgresStatements(plan, project, seed).map((statement) => statement.create).join(";\n")
4739
+ });
4740
+ const services = serviceAddresses(plan.containers, ports, plan.fakes);
4741
+ const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
4742
+ const envWith = (credentials) => {
4743
+ const env = envFor(plan, {
4744
+ ports,
4745
+ project,
4746
+ credentials,
4747
+ seed,
4748
+ ...options.mailFrom ? { mailFrom: options.mailFrom } : {},
4749
+ ...options.addresses ? { addresses: options.addresses } : {},
4750
+ ...lan ? { lanAddress: lan } : {},
4751
+ ...options.metroPorts ? { metroPorts: options.metroPorts } : {}
4752
+ });
4753
+ if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
4754
+ return env;
4755
+ };
4756
+ const result = {
4757
+ stage,
4758
+ plan,
4759
+ compose,
4760
+ ports,
4761
+ services,
4762
+ env: envWith(options.credentials),
4763
+ credentials: options.credentials,
4764
+ logins: [],
4765
+ provisioned: [],
4766
+ fakes: options.fakes ?? {},
4767
+ hash,
4768
+ changed: false
4769
+ };
4770
+ const recorded = await loadState(root, stage);
4771
+ const postgresPort = ports[primaryPortKey("postgres")];
4772
+ 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;
4773
+ await write(composePath, toYaml(compose), 384);
4774
+ if (plan.containers.includes("caddy")) {
4775
+ await write(join(root, CADDYFILE_PATH), caddyfileRoot());
4776
+ await write(join(root, caddySitesPath(stage)), caddyfile);
4777
+ await pruneCaddySites(root, [
4778
+ stage,
4779
+ options.localStage,
4780
+ TEST_STAGE
4781
+ ]);
4782
+ }
4783
+ if (start && plan.containers.length > 0) {
4784
+ options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
4785
+ await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
4786
+ }
4787
+ if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
4788
+ if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
4789
+ const checked = start && plan.containers.length > 0 ? await logins({
4790
+ credentials: options.credentials,
4791
+ containers: plan.containers,
4792
+ ports: {
4793
+ ...postgresPort !== void 0 ? { postgres: postgresPort } : {},
4794
+ ...ports[primaryPortKey("minio")] !== void 0 ? { minio: ports[primaryPortKey("minio")] } : {}
4795
+ },
4796
+ ...docker.exec ? { exec: (service, argv) => docker.exec(composePath, service, argv) } : {}
4797
+ }) : {
4798
+ credentials: options.credentials,
4799
+ outcomes: []
4800
+ };
4801
+ const credentials = checked.credentials;
4802
+ if (start && provision) options.progress?.("🗄️ Creating what the constructs declare — databases, roles, buckets, topics");
4803
+ const provisioned = start && provision ? await create(plan, ports, credentials, sql, buckets, carriers, project) : [];
4804
+ await saveState(root, {
4805
+ hash,
4806
+ stage
4807
+ });
4808
+ return {
4809
+ ...result,
4810
+ ...credentials === options.credentials ? {} : { env: envWith(credentials) },
4811
+ credentials,
4812
+ logins: checked.outcomes,
4813
+ provisioned,
4814
+ changed: true
4815
+ };
4106
4816
  }
4107
4817
  /**
4108
- * Apply the plan's Postgres statements.
4818
+ * Create the databases, schemas, buckets, topics and queues the plan names.
4109
4819
  *
4110
- * Each is checked before it is run, so this converges rather than erroring on a
4111
- * second pass.
4820
+ * Idempotent throughout, so this runs on every non-converged reconcile rather
4821
+ * than being something to remember.
4112
4822
  */
4113
- async function applyPostgres(client, statements) {
4823
+ async function create(plan, ports, credentials, sql, buckets, emulatorCarriers, project) {
4114
4824
  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
- });
4825
+ const postgresPort = ports[primaryPortKey("postgres")];
4826
+ const statements = postgresStatements(plan, project, credentials.seed);
4827
+ if (postgresPort !== void 0 && statements.length > 0) applied.push(...await applyPostgres(sql(postgresPort, credentials.postgres), statements));
4828
+ const minioPort = ports[primaryPortKey("minio")];
4829
+ const names = bucketNames(plan);
4830
+ const policies = bucketPolicies(plan);
4831
+ if (minioPort !== void 0 && (names.length > 0 || policies.length > 0)) {
4832
+ const client = buckets(minioPort, credentials.minio);
4833
+ if (names.length > 0) applied.push(...await applyBuckets(client, names));
4834
+ if (policies.length > 0) applied.push(...await applyPolicies(client, policies));
4139
4835
  }
4836
+ const emulatorPort = ports[primaryPortKey("localstack")];
4837
+ const carriers = carrierNames(plan);
4838
+ if (emulatorPort !== void 0 && carriers.topics.length + carriers.queues.length > 0) applied.push(...await applyCarriers(emulatorCarriers(emulatorPort, credentials.emulator), carriers));
4140
4839
  return applied;
4141
4840
  }
4142
4841
  /**
4143
- * What each bucket's open paths are, if anything serves it.
4842
+ * The host ports of containers already running.
4144
4843
  *
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.
4844
+ * Keyed the same way allocation keys them, so the two merge without either
4845
+ * knowing about the other.
4148
4846
  */
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);
4847
+ async function observedPorts(docker, composePath, containers, fakes) {
4848
+ const observed = {};
4849
+ for (const container of containers) for (const port of portsOf(container, fakes)) {
4850
+ const running = await docker.publishedPort(composePath, container, port.inside);
4851
+ if (running !== void 0) observed[port.key] = running;
4158
4852
  }
4159
- return [...byBucket].map(([bucket, open]) => ({
4160
- bucket,
4161
- open: [...open].sort()
4162
- })).sort((a, b) => a.bucket.localeCompare(b.bucket));
4853
+ return observed;
4163
4854
  }
4855
+ /** Write a file only when its content would change, so mtimes stay meaningful. */
4164
4856
  /**
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.
4857
+ * Remove the routes of stages this project no longer reconciles.
4170
4858
  *
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.
4859
+ * Only the local stage and `test` are ever written, so any other file is left
4860
+ * over from a stage that was renamed — `development` becoming `dev`. Caddy
4861
+ * imports every file, and the leftover declares the same hosts as its
4862
+ * replacement: it refuses the whole config as ambiguous, and every URL behind
4863
+ * the edge goes with it.
4178
4864
  */
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
- });
4865
+ async function pruneCaddySites(root, stages) {
4866
+ const dir = dirname(join(root, caddySitesPath(TEST_STAGE)));
4867
+ const keep = new Set(stages.map((stage) => stage && `${stage}.caddy`));
4868
+ for (const file of await readdir(dir)) if (file.endsWith(".caddy") && !keep.has(file)) await rm(join(dir, file));
4869
+ }
4870
+ async function write(path, content, mode) {
4871
+ try {
4872
+ if (await readFile(path, "utf-8") === content) {
4873
+ if (mode !== void 0) await chmod(path, mode);
4874
+ return;
4875
+ }
4876
+ } catch {}
4877
+ await mkdir(dirname(path), { recursive: true });
4878
+ await writeFile(path, content, mode === void 0 ? {} : { mode });
4879
+ if (mode !== void 0) await chmod(path, mode);
4190
4880
  }
4881
+ //#endregion
4882
+ //#region src/deploy/otel.ts
4191
4883
  /**
4192
- * The topics and queues to create on the AWS emulator.
4884
+ * The OpenTelemetry variables a stage's secrets pass to every backend.
4193
4885
  *
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.
4886
+ * A production server starts OpenTelemetry when `OTEL_EXPORTER_OTLP_ENDPOINT`
4887
+ * is set (`generators/telemetry.ts`), and the SDK reads the rest of its
4888
+ * configuration from the standard `OTEL_*` variables. No construct declares
4889
+ * them — they say where the telemetry goes, not what the app is — so a target
4890
+ * that writes each backend only the keys it reads would never hand them over.
4891
+ * Every target that runs backends asks this module instead.
4892
+ *
4893
+ * Which keys pass is a pattern over the `OTEL_` prefix rather than the prefix
4894
+ * alone or a list:
4895
+ *
4896
+ * - The bare prefix would forward anything a stage happened to name `OTEL_*`
4897
+ * — `OTEL_LOG_LEVEL=debug`, `OTEL_SDK_DISABLED` — to every backend, without
4898
+ * anyone choosing to.
4899
+ * - A list would have to spell out the exporter's five settings once for all
4900
+ * signals and once per signal — twenty keys — and would miss the next one.
4901
+ *
4902
+ * The pattern names the families the server uses: the OTLP exporter's
4903
+ * endpoint, headers, protocol, timeout and compression, for every signal or
4904
+ * for one (`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`); sampling; and the resource's
4905
+ * identity.
4906
+ *
4907
+ * Sites get none of it: a site's environment is inlined into a bundle that
4908
+ * every browser downloads, and an exporter's headers are a credential.
4197
4909
  */
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;
4910
+ /** The `OTEL_*` variables a backend is handed from the stage's secrets. */
4911
+ const OTEL_PASSTHROUGH = /^OTEL_(?:EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION)|TRACES_SAMPLER(?:_ARG)?|RESOURCE_ATTRIBUTES|SERVICE_NAME)$/;
4912
+ /** Whether a stage secret is one of the passed-through `OTEL_*` variables. */
4913
+ function isOtelVariable(key) {
4914
+ return OTEL_PASSTHROUGH.test(key);
4231
4915
  }
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);
4916
+ /** Whether a variable says where an OTLP exporter sends, or how it signs. */
4917
+ function isOtlpDestination(key) {
4918
+ return /^OTEL_EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS)$/.test(key);
4235
4919
  }
4236
4920
  /**
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.
4921
+ * The `OTEL_*` variables one backend runs with: every one the stage's secrets
4922
+ * hold, and `OTEL_SERVICE_NAME` — the app's name — where the stage set
4923
+ * telemetry up without naming the service. Empty when the stage set none.
4241
4924
  */
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
- }
4925
+ function otelEnv(custom, app) {
4926
+ const env = {};
4927
+ for (const [key, value] of Object.entries(custom)) if (isOtelVariable(key) && value !== "") env[key] = value;
4928
+ if (Object.keys(env).length > 0 && !env.OTEL_SERVICE_NAME) env.OTEL_SERVICE_NAME = app;
4929
+ return env;
4250
4930
  }
4931
+ //#endregion
4932
+ //#region src/target/dokploy/domain.ts
4933
+ /** A stage was deployed that `deploy.domains` gives no domain. */
4934
+ var NoDomainForStage = class extends Error {
4935
+ stage;
4936
+ constructor(stage) {
4937
+ super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
4938
+ this.stage = stage;
4939
+ this.name = "NoDomainForStage";
4940
+ }
4941
+ };
4251
4942
  /**
4252
- * Apply each served bucket's open-path policy, skipping those already correct.
4943
+ * The hostname an app answers on for a stage.
4253
4944
  *
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.
4945
+ * 1. An explicit `app.domain` — a string, or one per stage — wins.
4946
+ * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
4947
+ * answers on the domain itself, and every other app on
4948
+ * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
4949
+ *
4950
+ * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
4257
4951
  */
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
- });
4952
+ function resolveHost(appName, app, stage, domains, isMainFrontend) {
4953
+ if (app.domain) {
4954
+ if (typeof app.domain === "string") return app.domain;
4955
+ if (app.domain[stage]) return app.domain[stage];
4277
4956
  }
4278
- return applied;
4957
+ const baseDomain = domains?.[stage];
4958
+ if (!baseDomain) throw new NoDomainForStage(stage);
4959
+ if (isMainFrontend) return baseDomain;
4960
+ return `${app.subdomain ?? appName}.${baseDomain}`;
4279
4961
  }
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
- }
4962
+ /**
4963
+ * Whether this app is the site the base domain points at — see `rootSite`,
4964
+ * which the local edge asks too.
4965
+ *
4966
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
4967
+ */
4968
+ function isMainFrontendApp(appName, app, allApps) {
4969
+ if (app.type !== "web") return false;
4970
+ return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
4971
+ name,
4972
+ root: a.root
4973
+ }))) === appName;
4288
4974
  }
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;
4975
+ /**
4976
+ * Get public URL arg names from app dependencies.
4977
+ *
4978
+ * @param app - The web/mobile app configuration
4979
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
4980
+ * without a public prefix (e.g. Remix).
4981
+ */
4982
+ function getPublicUrlArgNames(app) {
4983
+ const prefix = getPublicEnvPrefix(app.framework);
4984
+ if (!prefix) return [];
4985
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
4309
4986
  }
4310
4987
  //#endregion
4311
- //#region src/reconcile/state.ts
4988
+ //#region src/compose/logs.ts
4312
4989
  /**
4313
- * What reconcile records, and the hash that lets it do nothing.
4990
+ * The stack's log UI: OpenObserve, run beside the apps when
4991
+ * `deploy.compose.logs` asks for it, and every backend's OpenTelemetry
4992
+ * pointed at it.
4314
4993
  *
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.
4994
+ * The production server already exports its traces and pino logs over
4995
+ * OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. OpenObserve takes OTLP
4996
+ * at `/api/<org>/v1/{logs,traces}` with HTTP basic auth, so wiring an app is
4997
+ * two variables: the endpoint, `http://openobserve:5080/api/default`, and
4998
+ * the header that signs in as the root user.
4319
4999
  *
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.
5000
+ * It is reached, by default, through an SSH tunnel: published on 127.0.0.1
5001
+ * only, because a port Docker publishes on every interface is opened in the
5002
+ * firewall by Docker itself, past ufw's rules. Served publicly, it is a host
5003
+ * on the stack's Caddy that refuses every address not allowed.
5004
+ *
5005
+ * Pure: the stack decides it, and the phases print and check it.
4323
5006
  */
4324
- const STATE_DIR = ".gkm/reconcile";
4325
5007
  /**
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.
5008
+ * The image, pinned. A log store's on-disk format is the thing that must not
5009
+ * move under a running stack, so a new version is a change to this line.
4336
5010
  */
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;
5011
+ const OPENOBSERVE_IMAGE = "public.ecr.aws/zinclabs/openobserve:v1.0.4";
5012
+ /** Its compose service name — the host the apps send to. */
5013
+ const LOGS_SERVICE = "openobserve";
5014
+ /** The organisation every app's telemetry lands in. */
5015
+ const LOGS_ORG = "default";
5016
+ /** The stage secrets that sign in as OpenObserve's root user. */
5017
+ const LOGS_EMAIL_KEY = "ZO_ROOT_USER_EMAIL";
5018
+ const LOGS_PASSWORD_KEY = "ZO_ROOT_USER_PASSWORD";
5019
+ /**
5020
+ * The local stage's root user. Its password is generated per machine with
5021
+ * the other local logins — see `reconcile/localCredentials.ts`.
5022
+ */
5023
+ const LOCAL_LOGS_EMAIL = "admin@gkm.localhost";
5024
+ /** The stage sends its telemetry somewhere already, and logs are on too. */
5025
+ var LogsEndpointConflict = class extends Error {
5026
+ stage;
5027
+ keys;
5028
+ constructor(stage, keys) {
5029
+ 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.`);
5030
+ this.stage = stage;
5031
+ this.keys = keys;
5032
+ this.name = "LogsEndpointConflict";
5033
+ }
5034
+ };
5035
+ /** A root password OpenObserve would refuse to start with. */
5036
+ var LogsPasswordWeak = class extends Error {
5037
+ stage;
5038
+ constructor(stage) {
5039
+ 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.`);
5040
+ this.stage = stage;
5041
+ this.name = "LogsPasswordWeak";
5042
+ }
5043
+ };
5044
+ /** A deployed stage with logs on and no root password generated yet. */
5045
+ var LogsPasswordMissing = class extends Error {
5046
+ stage;
5047
+ constructor(stage) {
5048
+ 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}`);
5049
+ this.stage = stage;
5050
+ this.name = "LogsPasswordMissing";
4353
5051
  }
5052
+ };
5053
+ /** Whether OpenObserve accepts `password` for its root user. */
5054
+ function isStrongLogsPassword(password) {
5055
+ 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
5056
  }
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`);
5057
+ /**
5058
+ * A root password: 192 random bits, with a fixed tail so every class
5059
+ * OpenObserve requires is present whatever the random part drew.
5060
+ */
5061
+ function generateLogsPassword() {
5062
+ return `${randomBytes(24).toString("base64url")}-Zo1`;
4360
5063
  }
4361
5064
  /**
4362
- * Forget what was applied, so the next reconcile of this stage converges from
4363
- * nothing — creating again whatever was dropped underneath the record.
5065
+ * The stage's secrets with a generated root password, where it has none.
5066
+ * Pure, like `withGeneratedSecrets`: the caller keeps the result once the
5067
+ * run goes ahead.
4364
5068
  */
4365
- async function forgetState(root, stage) {
4366
- await rm(statePath(root, stage), { force: true });
5069
+ function withLogsPassword(secrets) {
5070
+ if (secrets.custom?.["ZO_ROOT_USER_PASSWORD"]) return {
5071
+ secrets,
5072
+ generated: []
5073
+ };
5074
+ return {
5075
+ secrets: {
5076
+ ...secrets,
5077
+ custom: {
5078
+ ...secrets.custom,
5079
+ [LOGS_PASSWORD_KEY]: generateLogsPassword()
5080
+ },
5081
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5082
+ },
5083
+ generated: [LOGS_PASSWORD_KEY]
5084
+ };
4367
5085
  }
4368
- function statePath(root, stage) {
4369
- return join(root, STATE_DIR, `${stage}.json`);
5086
+ /**
5087
+ * `OTEL_EXPORTER_OTLP_HEADERS` signing in as `email`.
5088
+ *
5089
+ * The variable's values are percent-decoded by the SDK (they follow the W3C
5090
+ * baggage format), so the space after `Basic` is written `%20`. Base64's own
5091
+ * `+`, `/` and `=` pass through decoding unchanged.
5092
+ */
5093
+ function otlpHeaders(email, password) {
5094
+ return `Authorization=Basic%20${Buffer.from(`${email}:${password}`).toString("base64")}`;
5095
+ }
5096
+ /** The stack's OpenObserve for one stage. */
5097
+ function stackLogs(options) {
5098
+ const { config, stage, local, custom } = options;
5099
+ const conflicting = Object.keys(custom).filter(isOtlpDestination).sort();
5100
+ if (conflicting.length > 0) throw new LogsEndpointConflict(stage, conflicting);
5101
+ if (!local && !options.domain) throw new NoDomainForStage(stage);
5102
+ const email = custom["ZO_ROOT_USER_EMAIL"] ?? (local ? options.localLogin?.email ?? "admin@gkm.localhost" : `admin@${options.domain}`);
5103
+ const set = custom[LOGS_PASSWORD_KEY];
5104
+ const password = set ?? (local ? options.localLogin?.password : void 0);
5105
+ if (!password) throw new LogsPasswordMissing(stage);
5106
+ if (!isStrongLogsPassword(password)) throw new LogsPasswordWeak(stage);
5107
+ const host = config.public ? local ? `logs.${options.project}.localhost` : `logs.${options.domain}` : void 0;
5108
+ const url = host ? `https://${host}${local && options.https !== 443 ? `:${options.https}` : ""}` : `http://localhost:${config.port}`;
5109
+ return {
5110
+ ...config,
5111
+ email,
5112
+ password,
5113
+ passwordFromSecrets: Boolean(set),
5114
+ url,
5115
+ ...host ? { host } : {},
5116
+ env: {
5117
+ [LOGS_EMAIL_KEY]: email,
5118
+ [LOGS_PASSWORD_KEY]: password,
5119
+ ZO_DATA_DIR: "/data",
5120
+ ZO_COMPACT_DATA_RETENTION_DAYS: String(config.retentionDays),
5121
+ ZO_TELEMETRY: "false"
5122
+ },
5123
+ appEnv: {
5124
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://${LOGS_SERVICE}:${LOGS_PORT}/api/${LOGS_ORG}`,
5125
+ OTEL_EXPORTER_OTLP_HEADERS: otlpHeaders(email, password)
5126
+ }
5127
+ };
5128
+ }
5129
+ /**
5130
+ * OpenObserve's service. The image has no shell, `wget` or `curl`, so its
5131
+ * health check is its own binary asking the running server for its status —
5132
+ * which exits non-zero when nothing answers on its port.
5133
+ */
5134
+ function logsService(logs) {
5135
+ return {
5136
+ image: OPENOBSERVE_IMAGE,
5137
+ restart: "unless-stopped",
5138
+ env_file: [{
5139
+ path: `./${LOGS_SERVICE}.env`,
5140
+ format: "raw"
5141
+ }],
5142
+ volumes: ["openobserve-data:/data"],
5143
+ ...logs.public ? {} : { ports: [`127.0.0.1:${logs.port}:${LOGS_PORT}`] },
5144
+ healthcheck: {
5145
+ test: [
5146
+ "CMD",
5147
+ "/openobserve",
5148
+ "node",
5149
+ "status"
5150
+ ],
5151
+ interval: "10s",
5152
+ timeout: "5s",
5153
+ retries: 12,
5154
+ start_period: "20s"
5155
+ }
5156
+ };
5157
+ }
5158
+ /** What `gkm compose` prints once the stack is up: how to open the logs. */
5159
+ function logsAccess(logs, ctx) {
5160
+ const login = `login: ${logs.email}, password: ${ctx.local && !logs.passwordFromSecrets ? logs.password : `gkm secrets:show --stage ${ctx.stage} --reveal → ${LOGS_PASSWORD_KEY}`}`;
5161
+ if (logs.public) return [`📜 Logs (OpenObserve): ${logs.url} (${login})`, ` answered only for ${logs.public.allow.join(", ")}; every other address gets 403`];
5162
+ return [
5163
+ `📜 Logs (OpenObserve) on 127.0.0.1:${logs.port} — from your computer:`,
5164
+ ...ctx.local ? [` on this machine, open http://localhost:${logs.port} directly; from another:`] : [],
5165
+ ` ssh -N -L ${logs.port}:localhost:${logs.port} ${ctx.user}@${ctx.hostname} (user and host are guesses: this machine's)`,
5166
+ ` then open http://localhost:${logs.port} (${login})`,
5167
+ `⚠️ 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.`
5168
+ ];
4370
5169
  }
4371
5170
  //#endregion
4372
- //#region src/reconcile/index.ts
5171
+ //#region src/compose/redis.ts
5172
+ /**
5173
+ * The stack's Redis: what every declared cache lives in when a workspace runs
5174
+ * as a `gkm compose` stack, on the local stage and on a deployed one alike.
5175
+ *
5176
+ * A cache is the one store whose contents can always be rebuilt, so unlike
5177
+ * Mailpit and MinIO it is a production service, not a dev stand-in: it runs
5178
+ * for every stage, on the compose network only, with nothing published. It
5179
+ * is bounded (`maxmemory`, evicting the least recently used key), persisted
5180
+ * (an append-only file on a named volume, kept by `--down`) and password
5181
+ * protected — the stage's own password, generated once like its seed.
5182
+ *
5183
+ * A stage that sets a cache's URL in its secrets — a managed Redis — keeps
5184
+ * it, and a stack whose every cache is set that way runs no Redis at all.
5185
+ *
5186
+ * Pure: the stack decides it, and the phases keep the password.
5187
+ */
5188
+ /**
5189
+ * Where a stack's caches live: its own Redis, whatever the deploy target's
5190
+ * default is. A stack is one machine running containers, and a Redis beside
5191
+ * the apps is cheaper for a cache than a table in the database they share.
5192
+ */
5193
+ const STACK_CACHE = "redis";
5194
+ /** Its compose service name — the host every cache URL points at. */
5195
+ const REDIS_SERVICE = "redis";
5196
+ /** The port it answers on, inside the network. */
5197
+ const REDIS_PORT = 6379;
5198
+ /** Reconcile's pin, so `gkm dev` and the stack run one Redis. */
5199
+ const REDIS_IMAGE = DEFAULT_IMAGES.redis;
5200
+ /**
5201
+ * How much it may hold before it evicts. A cache that grows until the box
5202
+ * runs out of memory takes the apps with it; 256 MB is a lot of sessions and
5203
+ * little of a small server. A project's `docker-compose.<stage>.yml` can
5204
+ * replace the command to change it.
5205
+ */
5206
+ const REDIS_MAXMEMORY = "256mb";
5207
+ /** The stage secret holding its password. */
5208
+ const REDIS_PASSWORD_KEY = "REDIS_PASSWORD";
5209
+ /** Redis's own count of logical databases, before `--databases` raises it. */
5210
+ const REDIS_DATABASES = 16;
5211
+ /** A deployed stage that runs the stack's Redis and has no password yet. */
5212
+ var RedisPasswordMissing = class extends Error {
5213
+ stage;
5214
+ constructor(stage) {
5215
+ 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}`);
5216
+ this.stage = stage;
5217
+ this.name = "RedisPasswordMissing";
5218
+ }
5219
+ };
4373
5220
  /**
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.
5221
+ * Whether a stage's stack runs Redis: a cache is declared and the stage's
5222
+ * secrets do not set the URL of every one.
4391
5223
  */
5224
+ function runsRedis(manifest, custom) {
5225
+ return Object.values(manifest).some((declaration) => declaration?.kind === "cache" && (declaration.provides ?? []).some((key) => custom[key] === void 0));
5226
+ }
5227
+ /** A password: 256 random bits, URL-safe, so it sits in a URL unescaped. */
5228
+ function generateRedisPassword() {
5229
+ return randomBytes(32).toString("base64url");
5230
+ }
4392
5231
  /**
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.
5232
+ * The stage's secrets with a generated Redis password, where it has none.
5233
+ * Pure, like `withGeneratedSecrets`: the caller keeps the result once the run
5234
+ * goes ahead.
4399
5235
  */
4400
- const COMPOSE_PATH = "docker-compose.constructs.yml";
5236
+ function withRedisPassword(secrets) {
5237
+ if (secrets.custom?.["REDIS_PASSWORD"]) return {
5238
+ secrets,
5239
+ generated: []
5240
+ };
5241
+ return {
5242
+ secrets: {
5243
+ ...secrets,
5244
+ custom: {
5245
+ ...secrets.custom,
5246
+ [REDIS_PASSWORD_KEY]: generateRedisPassword()
5247
+ },
5248
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5249
+ },
5250
+ generated: [REDIS_PASSWORD_KEY]
5251
+ };
5252
+ }
4401
5253
  /**
4402
- * The local edge's config, beside the compose file that mounts it.
5254
+ * The stack's Redis for one stage, or nothing where no cache needs it.
4403
5255
  *
4404
- * Relative in the compose volume, so it resolves against `.gkm/` wherever the
4405
- * project lives.
5256
+ * Each cache it holds gets a logical database of its own, by the order of
5257
+ * their ids, so two caches never read each other's keys.
4406
5258
  */
4407
- const CADDYFILE_PATH = ".gkm/Caddyfile";
5259
+ function stackRedis(options) {
5260
+ const { plan, stage, local, custom } = options;
5261
+ const caches = plan.resources.filter((r) => r.kind === "cache" && custom[r.envKey] === void 0).sort((a, b) => a.id.localeCompare(b.id));
5262
+ if (caches.length === 0) return void 0;
5263
+ const set = custom[REDIS_PASSWORD_KEY];
5264
+ const password = set ?? (local ? options.localPassword : void 0);
5265
+ if (!password) throw new RedisPasswordMissing(stage);
5266
+ const auth = encodeURIComponent(password);
5267
+ return {
5268
+ password,
5269
+ passwordFromSecrets: Boolean(set),
5270
+ urls: Object.fromEntries(caches.map((cache, index) => [cache.envKey, `redis://:${auth}@${REDIS_SERVICE}:${REDIS_PORT}/${index}`])),
5271
+ env: {
5272
+ [REDIS_PASSWORD_KEY]: password,
5273
+ REDISCLI_AUTH: password
5274
+ },
5275
+ databases: Math.max(REDIS_DATABASES, caches.length)
5276
+ };
5277
+ }
4408
5278
  /**
4409
- * Where one stage's routes live, imported by the file above.
5279
+ * Redis's service: bounded, persisted and password protected, on the
5280
+ * network alone.
4410
5281
  *
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.
5282
+ * The command runs the image's own entrypoint with `redis-server` first, so
5283
+ * it still drops to the `redis` user and owns `/data`; the password is read
5284
+ * from the env file by the shell (`$$` is compose's escape for `$`), so
5285
+ * neither the compose file nor the container's command holds it.
4414
5286
  */
4415
- const caddySitesPath = (stage) => `.gkm/caddy-sites/${stage}.caddy`;
5287
+ function redisService(redis) {
5288
+ const flags = [
5289
+ `--requirepass "$$${REDIS_PASSWORD_KEY}"`,
5290
+ `--maxmemory ${REDIS_MAXMEMORY}`,
5291
+ "--maxmemory-policy allkeys-lru",
5292
+ "--appendonly yes",
5293
+ ...redis.databases > REDIS_DATABASES ? [`--databases ${redis.databases}`] : []
5294
+ ];
5295
+ return {
5296
+ image: REDIS_IMAGE,
5297
+ restart: "unless-stopped",
5298
+ command: [
5299
+ "sh",
5300
+ "-c",
5301
+ `exec docker-entrypoint.sh redis-server ${flags.join(" ")}`
5302
+ ],
5303
+ env_file: [{
5304
+ path: `./${REDIS_SERVICE}.env`,
5305
+ format: "raw"
5306
+ }],
5307
+ volumes: ["redis-data:/data"],
5308
+ healthcheck: {
5309
+ test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"],
5310
+ interval: "5s",
5311
+ timeout: "3s",
5312
+ retries: 10
5313
+ }
5314
+ };
5315
+ }
5316
+ //#endregion
5317
+ //#region src/deploy/generated.ts
4416
5318
  /**
4417
- * Where the local edge's root certificate is copied to.
5319
+ * The values a deployed stage generates once and keeps: what nothing can
5320
+ * provision and nobody else issues.
4418
5321
  *
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.
5322
+ * - **A seed**, which salts every password a deploy derives — a database's
5323
+ * master and roles, a bucket's root user. Derived from repo facts alone
5324
+ * (project, stage, construct id), those passwords were computable by anyone
5325
+ * who could read the repo.
5326
+ * - **Each `secret` construct's value** — an auth server's signing secret —
5327
+ * random, rather than derived.
5328
+ * - **Each `encryption` construct's keyring** — one random key and an index
5329
+ * key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
5330
+ *
5331
+ * Both are generated on the first deploy that needs them and written to the
5332
+ * stage's store, so every later deploy reads the same values: sessions survive
5333
+ * a redeploy, and a role's password does not move. A value set by hand with
5334
+ * `gkm secrets:set` is kept, never replaced.
4424
5335
  */
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
5336
  /**
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.
5337
+ * The stage's secrets with its seed and every declared `secret`'s value
5338
+ * present, generating what is missing. Pure: the caller writes the result
5339
+ * back to the stage's store when `generated` is not empty.
4434
5340
  */
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
- });
5341
+ function withGeneratedSecrets(secrets, manifest) {
5342
+ const generated = [];
5343
+ const custom = { ...secrets.custom };
5344
+ for (const declaration of Object.values(manifest)) {
5345
+ if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
5346
+ const key = declaration.provides?.[0];
5347
+ if (!key || custom[key]) continue;
5348
+ custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
5349
+ generated.push(key);
5350
+ }
5351
+ const seed = secrets.seed ?? generateSeed();
5352
+ if (!secrets.seed) generated.push("seed");
4506
5353
  return {
4507
- ...result,
4508
- provisioned,
4509
- changed: true
5354
+ secrets: generated.length ? {
5355
+ ...secrets,
5356
+ seed,
5357
+ custom,
5358
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5359
+ } : secrets,
5360
+ generated
4510
5361
  };
4511
5362
  }
5363
+ /** A stage's seed: 256 random bits, URL-safe. */
5364
+ function generateSeed() {
5365
+ return random();
5366
+ }
5367
+ /** 256 bits, URL-safe. */
5368
+ function random() {
5369
+ return randomBytes(32).toString("base64url");
5370
+ }
5371
+ /** MinIO's root user, the same for every workspace: it is not a secret. */
5372
+ const LOCAL_MINIO_USER = "minio";
5373
+ /** RabbitMQ's administrator, the same way. */
5374
+ const LOCAL_RABBITMQ_USER = "rabbitmq";
4512
5375
  /**
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.
5376
+ * The Postgres superuser for a workspace: its name as an identifier, with
5377
+ * `_admin` — never the bare name, which a database construct of the same
5378
+ * name would claim as its runtime role.
4517
5379
  */
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;
5380
+ function postgresSuperuser(project) {
5381
+ return `${project.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/^(\d)/, "_$1") || "gkm"}_admin`;
5382
+ }
5383
+ /** 256 random bits, URL-safe. */
5384
+ function token() {
5385
+ return randomBytes(32).toString("base64url");
5386
+ }
5387
+ /** `length` characters of letters and digits, which every client takes as is. */
5388
+ function alphanumeric(length) {
5389
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
5390
+ let out = "";
5391
+ for (let i = 0; i < length; i++) out += alphabet[randomInt(62)];
5392
+ return out;
5393
+ }
5394
+ /** A fresh set of local logins for a workspace. */
5395
+ function generateLocalCredentials(project) {
5396
+ return {
5397
+ seed: generateSeed(),
5398
+ postgres: {
5399
+ user: postgresSuperuser(project),
5400
+ password: token()
5401
+ },
5402
+ minio: {
5403
+ user: LOCAL_MINIO_USER,
5404
+ password: alphanumeric(32)
5405
+ },
5406
+ rabbitmq: {
5407
+ user: LOCAL_RABBITMQ_USER,
5408
+ password: token()
5409
+ },
5410
+ redis: { password: generateRedisPassword() },
5411
+ cacheToken: token(),
5412
+ emulator: {
5413
+ accessKeyId: EMULATOR_ACCESS_KEY_ID,
5414
+ secretAccessKey: alphanumeric(40)
5415
+ },
5416
+ logs: {
5417
+ email: LOCAL_LOGS_EMAIL,
5418
+ password: generateLogsPassword()
5419
+ }
5420
+ };
4535
5421
  }
4536
5422
  /**
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.
5423
+ * The stored logins with anything missing generated — a file an older gkm
5424
+ * wrote before a service existed. Pure: the caller writes the result back
5425
+ * when `generated` is not empty.
4541
5426
  */
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;
5427
+ function withLocalCredentials(stored, project) {
5428
+ const fresh = generateLocalCredentials(project);
5429
+ const generated = Object.keys(fresh).filter((key) => stored?.[key] === void 0);
5430
+ return {
5431
+ credentials: {
5432
+ ...fresh,
5433
+ ...stored
5434
+ },
5435
+ generated
5436
+ };
5437
+ }
5438
+ /** Where a workspace's local logins are kept on this machine. */
5439
+ function localCredentialsPath(project) {
5440
+ const keys = getKeystoreDir(project);
5441
+ const name = keys.split(/[\\/]/).at(-1);
5442
+ const namespace = keys.split(/[\\/]/).at(-2);
5443
+ return join(project.home ?? gkmHome(), "local", namespace, `${name}.json`);
5444
+ }
5445
+ /** The local stage's logins file is there, and its key is not. */
5446
+ var LocalCredentialsKeyMissing = class extends Error {
5447
+ path;
5448
+ stage;
5449
+ constructor(path, stage) {
5450
+ 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.`);
5451
+ this.path = path;
5452
+ this.stage = stage;
5453
+ this.name = "LocalCredentialsKeyMissing";
4547
5454
  }
4548
- return observed;
5455
+ };
5456
+ /** Another gkm held the logins file's lock for longer than any write takes. */
5457
+ var LocalCredentialsLocked = class extends Error {
5458
+ lock;
5459
+ constructor(lock) {
5460
+ 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.`);
5461
+ this.lock = lock;
5462
+ this.name = "LocalCredentialsLocked";
5463
+ }
5464
+ };
5465
+ /** Read the stored logins, or `null` where none were generated yet. */
5466
+ async function readLocalCredentials(workspace, home) {
5467
+ const project = keystoreProject(workspace, home);
5468
+ const path = localCredentialsPath(project);
5469
+ if (!existsSync(path)) return null;
5470
+ const stage = workspace.stages.local;
5471
+ const key = await readKey(stage, project);
5472
+ if (!key) throw new LocalCredentialsKeyMissing(path, stage);
5473
+ return decrypt(JSON.parse(await readFile(path, "utf-8")), key);
5474
+ }
5475
+ /** Write the logins, encrypted with the local stage's key, owner-only. */
5476
+ async function saveLocalCredentials(workspace, credentials, home) {
5477
+ const project = keystoreProject(workspace, home);
5478
+ const path = localCredentialsPath(project);
5479
+ const key = await getOrCreateKey(workspace.stages.local, project);
5480
+ await mkdir(dirname(path), {
5481
+ recursive: true,
5482
+ mode: 448
5483
+ });
5484
+ await writeFile(path, JSON.stringify(encrypt(credentials, key), null, 2), { mode: 384 });
4549
5485
  }
4550
- /** Write a file only when its content would change, so mtimes stay meaningful. */
4551
5486
  /**
4552
- * Remove the routes of stages this project no longer reconciles.
5487
+ * The workspace's local logins: read back, or generated and kept the first
5488
+ * time anything needs them.
4553
5489
  *
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.
5490
+ * Under a lock, because a workspace's `gkm dev` starts one per app and the
5491
+ * first run of each would otherwise generate — and keep — its own.
4559
5492
  */
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));
5493
+ async function loadLocalCredentials(workspace, home) {
5494
+ return withLock(`${localCredentialsPath(keystoreProject(workspace, home))}.lock`, async () => {
5495
+ const result = withLocalCredentials(await readLocalCredentials(workspace, home), workspace.name);
5496
+ if (result.generated.length > 0) await saveLocalCredentials(workspace, result.credentials, home);
5497
+ return result;
5498
+ });
4564
5499
  }
4565
- async function write(path, content) {
5500
+ /** How long a lock may be held before it is someone else's problem. */
5501
+ const LOCK_TIMEOUT_MS = 15e3;
5502
+ async function withLock(lock, body) {
5503
+ await mkdir(dirname(lock), {
5504
+ recursive: true,
5505
+ mode: 448
5506
+ });
5507
+ const started = Date.now();
5508
+ for (;;) try {
5509
+ await mkdir(lock);
5510
+ break;
5511
+ } catch (error) {
5512
+ if (error.code !== "EEXIST") throw error;
5513
+ if (await stat(lock).then((s) => Date.now() - s.mtimeMs).catch(() => 0) > LOCK_TIMEOUT_MS) {
5514
+ await rm(lock, {
5515
+ recursive: true,
5516
+ force: true
5517
+ });
5518
+ continue;
5519
+ }
5520
+ if (Date.now() - started > LOCK_TIMEOUT_MS) throw new LocalCredentialsLocked(lock);
5521
+ await new Promise((resolve) => setTimeout(resolve, 50));
5522
+ }
4566
5523
  try {
4567
- if (await readFile(path, "utf-8") === content) return;
4568
- } catch {}
4569
- await mkdir(dirname(path), { recursive: true });
4570
- await writeFile(path, content);
5524
+ return await body();
5525
+ } finally {
5526
+ await rm(lock, {
5527
+ recursive: true,
5528
+ force: true
5529
+ });
5530
+ }
4571
5531
  }
4572
5532
  //#endregion
4573
5533
  //#region src/reconcile/workspace.ts
@@ -4657,7 +5617,9 @@ async function reconcileWorkspace(workspace, options) {
4657
5617
  });
4658
5618
  const manifest = options.manifest ?? discovered;
4659
5619
  const fakes = fakesApply(options.stage, options.fake) ? await readFakes(workspace.root, manifest) : {};
5620
+ const { credentials } = await loadLocalCredentials(workspace);
4660
5621
  const result = await reconcile({
5622
+ credentials,
4661
5623
  root: workspace.root,
4662
5624
  project: workspace.name,
4663
5625
  manifest,
@@ -4668,11 +5630,13 @@ async function reconcileWorkspace(workspace, options) {
4668
5630
  addresses: surfaceAddresses(workspace, manifest),
4669
5631
  metroPorts: metroPorts(workspace, manifest),
4670
5632
  fakes,
4671
- apps: (containers) => appServices(workspace, manifest, containers, runnables, fakes),
5633
+ apps: (containers) => appServices(workspace, manifest, containers, credentials, runnables, fakes),
4672
5634
  ...options.start === void 0 ? {} : { start: options.start },
4673
5635
  ...options.progress ? { progress: options.progress } : {}
4674
5636
  });
4675
5637
  await savePortState(workspace.root, { ...result.ports });
5638
+ if (result.credentials !== credentials) await saveLocalCredentials(workspace, result.credentials);
5639
+ for (const outcome of result.logins) if (outcome.notice) console.log(outcome.notice);
4676
5640
  return {
4677
5641
  ...result,
4678
5642
  manifest
@@ -5771,112 +6735,6 @@ function reportDevServices(ctx, uses) {
5771
6735
  }
5772
6736
  }
5773
6737
  //#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
6738
  //#region src/docker/imageRef.ts
5881
6739
  /**
5882
6740
  * An image reference, checked before it reaches `docker`.
@@ -6186,57 +7044,6 @@ function printRunInstructions(workspace) {
6186
7044
  logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
6187
7045
  }
6188
7046
  //#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
7047
  //#region src/deploy/journal.ts
6241
7048
  var DeployJournal = class DeployJournal {
6242
7049
  store;
@@ -6635,6 +7442,6 @@ function reportPlannedSeeds(planned, log) {
6635
7442
  for (const { folder, seeds } of planned) log(`🌱 ${folder}: would run ${seeds.length} (${seeds.join(", ")})`);
6636
7443
  }
6637
7444
  //#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 };
7445
+ 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
7446
 
6640
- //# sourceMappingURL=seeds-BdkFpW0P.mjs.map
7447
+ //# sourceMappingURL=seeds-B4PiO_48.mjs.map