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

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 (169) hide show
  1. package/dist/{EndpointGenerator-DtnIGafh.mjs → EndpointGenerator-CVc6Oi1Y.mjs} +185 -82
  2. package/dist/EndpointGenerator-CVc6Oi1Y.mjs.map +1 -0
  3. package/dist/{EndpointGenerator-cKlnllIu.cjs → EndpointGenerator-CwLjBCFE.cjs} +203 -82
  4. package/dist/EndpointGenerator-CwLjBCFE.cjs.map +1 -0
  5. package/dist/{LegacyStateStore-Ceq7A_75.mjs → LegacyStateStore-CTsUFLNQ.mjs} +2 -2
  6. package/dist/{LegacyStateStore-Ceq7A_75.mjs.map → LegacyStateStore-CTsUFLNQ.mjs.map} +1 -1
  7. package/dist/{LegacyStateStore-Z-EKUGSX.cjs → LegacyStateStore-EDT2TUSe.cjs} +2 -2
  8. package/dist/{LegacyStateStore-Z-EKUGSX.cjs.map → LegacyStateStore-EDT2TUSe.cjs.map} +1 -1
  9. package/dist/{LocalStateStore-Bocb8dmZ.cjs → LocalStateStore-CdpzTvPn.cjs} +2 -2
  10. package/dist/{LocalStateStore-Bocb8dmZ.cjs.map → LocalStateStore-CdpzTvPn.cjs.map} +1 -1
  11. package/dist/{LocalStateStore-C72bwpG2.mjs → LocalStateStore-D1VfxRuK.mjs} +2 -2
  12. package/dist/{LocalStateStore-C72bwpG2.mjs.map → LocalStateStore-D1VfxRuK.mjs.map} +1 -1
  13. package/dist/{OpenApiTsGenerator-CdUM6Hvv.cjs → OpenApiTsGenerator-B--HV-sW.cjs} +2 -2
  14. package/dist/{OpenApiTsGenerator-CdUM6Hvv.cjs.map → OpenApiTsGenerator-B--HV-sW.cjs.map} +1 -1
  15. package/dist/{OpenApiTsGenerator-ByRPMbBN.mjs → OpenApiTsGenerator-CkvsPdgg.mjs} +2 -2
  16. package/dist/{OpenApiTsGenerator-ByRPMbBN.mjs.map → OpenApiTsGenerator-CkvsPdgg.mjs.map} +1 -1
  17. package/dist/{S3StateStore-35YfcdL_.cjs → S3StateStore-BPM74zwv.cjs} +2 -2
  18. package/dist/{S3StateStore-35YfcdL_.cjs.map → S3StateStore-BPM74zwv.cjs.map} +1 -1
  19. package/dist/{S3StateStore-C9upSW5o.mjs → S3StateStore-DVZ1YuGJ.mjs} +2 -2
  20. package/dist/{S3StateStore-C9upSW5o.mjs.map → S3StateStore-DVZ1YuGJ.mjs.map} +1 -1
  21. package/dist/{SSMStateStore-dOmFO6g1.mjs → SSMStateStore-DLvnGyyl.mjs} +2 -2
  22. package/dist/{SSMStateStore-dOmFO6g1.mjs.map → SSMStateStore-DLvnGyyl.mjs.map} +1 -1
  23. package/dist/{SSMStateStore-W-Ps22AQ.cjs → SSMStateStore-qerm5c36.cjs} +2 -2
  24. package/dist/{SSMStateStore-W-Ps22AQ.cjs.map → SSMStateStore-qerm5c36.cjs.map} +1 -1
  25. package/dist/{SubscriberGenerator-DQZeGkDW.mjs → SubscriberGenerator-BPqQf8fB.mjs} +2 -2
  26. package/dist/{SubscriberGenerator-DQZeGkDW.mjs.map → SubscriberGenerator-BPqQf8fB.mjs.map} +1 -1
  27. package/dist/{SubscriberGenerator-CYriX5BV.cjs → SubscriberGenerator-BVn7cQ-2.cjs} +2 -2
  28. package/dist/{SubscriberGenerator-CYriX5BV.cjs.map → SubscriberGenerator-BVn7cQ-2.cjs.map} +1 -1
  29. package/dist/{add-1T3y6E4M.cjs → add-BQ8v1ZII.cjs} +3 -3
  30. package/dist/{add-1T3y6E4M.cjs.map → add-BQ8v1ZII.cjs.map} +1 -1
  31. package/dist/{add-DnYHJOap.mjs → add-DbnzC7Kl.mjs} +3 -3
  32. package/dist/{add-DnYHJOap.mjs.map → add-DbnzC7Kl.mjs.map} +1 -1
  33. package/dist/{bundler-DlKdxCGh.mjs → bundler-D4030Afw.mjs} +5 -5
  34. package/dist/{bundler-DlKdxCGh.mjs.map → bundler-D4030Afw.mjs.map} +1 -1
  35. package/dist/{bundler-YNvLdvlh.cjs → bundler-DjrRffko.cjs} +5 -5
  36. package/dist/{bundler-YNvLdvlh.cjs.map → bundler-DjrRffko.cjs.map} +1 -1
  37. package/dist/{cli-CWee1-wU.cjs → cli-D8YQAYUX.cjs} +3 -3
  38. package/dist/{cli-CWee1-wU.cjs.map → cli-D8YQAYUX.cjs.map} +1 -1
  39. package/dist/{cli-qMszGvDu.mjs → cli-DwTa9d_8.mjs} +3 -3
  40. package/dist/{cli-qMszGvDu.mjs.map → cli-DwTa9d_8.mjs.map} +1 -1
  41. package/dist/{compose-D4sD4Lgr.mjs → compose-C5wz8mf9.mjs} +64 -24
  42. package/dist/compose-C5wz8mf9.mjs.map +1 -0
  43. package/dist/{compose-qnDyPJNp.cjs → compose-DqWh1TaO.cjs} +64 -24
  44. package/dist/compose-DqWh1TaO.cjs.map +1 -0
  45. package/dist/{config-Mr4rcXFC.d.cts → config-BzIfP8a-.d.cts} +16 -3
  46. package/dist/config-BzIfP8a-.d.cts.map +1 -0
  47. package/dist/{config-L_6NivCp.d.mts → config-DhdzmndX.d.mts} +16 -3
  48. package/dist/config-DhdzmndX.d.mts.map +1 -0
  49. package/dist/config.cjs +1 -1
  50. package/dist/config.d.cts +3 -3
  51. package/dist/config.d.mts +3 -3
  52. package/dist/config.mjs +1 -1
  53. package/dist/deploy/public.cjs +6 -6
  54. package/dist/deploy/public.d.cts +3 -3
  55. package/dist/deploy/public.d.cts.map +1 -1
  56. package/dist/deploy/public.d.mts +3 -3
  57. package/dist/deploy/public.d.mts.map +1 -1
  58. package/dist/deploy/public.mjs +6 -6
  59. package/dist/{deploy-C00DAKOt.mjs → deploy-BwIKGxWH.mjs} +14 -5
  60. package/dist/deploy-BwIKGxWH.mjs.map +1 -0
  61. package/dist/{deploy-CXP2fXvK.cjs → deploy-C8sWg81_.cjs} +14 -5
  62. package/dist/deploy-C8sWg81_.cjs.map +1 -0
  63. package/dist/{dokploy-BHcSmghb.cjs → dokploy-BkRxJbfi.cjs} +2 -2
  64. package/dist/{dokploy-BHcSmghb.cjs.map → dokploy-BkRxJbfi.cjs.map} +1 -1
  65. package/dist/{dokploy-B3zoCmJb.mjs → dokploy-ijjms1-h.mjs} +2 -2
  66. package/dist/{dokploy-B3zoCmJb.mjs.map → dokploy-ijjms1-h.mjs.map} +1 -1
  67. package/dist/{drivers-B4RP2B2O.mjs → drivers-DTZzxeIl.mjs} +2 -2
  68. package/dist/{drivers-B4RP2B2O.mjs.map → drivers-DTZzxeIl.mjs.map} +1 -1
  69. package/dist/{drivers-CJx76mcb.cjs → drivers-LYSdQDmd.cjs} +2 -2
  70. package/dist/{drivers-CJx76mcb.cjs.map → drivers-LYSdQDmd.cjs.map} +1 -1
  71. package/dist/{encryption-DPFljXtM.mjs → encryption-BRcBAmjL.mjs} +3 -3
  72. package/dist/{encryption-DPFljXtM.mjs.map → encryption-BRcBAmjL.mjs.map} +1 -1
  73. package/dist/{encryption-DLin89I_.cjs → encryption-g6PzwwfL.cjs} +3 -3
  74. package/dist/{encryption-DLin89I_.cjs.map → encryption-g6PzwwfL.cjs.map} +1 -1
  75. package/dist/{engine-CgzgIg-_.mjs → engine-BP0CZ5fh.mjs} +33 -13
  76. package/dist/engine-BP0CZ5fh.mjs.map +1 -0
  77. package/dist/{engine-BS3iX9Mu.cjs → engine-DLEJsZ3i.cjs} +33 -13
  78. package/dist/engine-DLEJsZ3i.cjs.map +1 -0
  79. package/dist/{github-D8KVmNRg.cjs → github-B8YjwdLK.cjs} +3 -3
  80. package/dist/{github-D8KVmNRg.cjs.map → github-B8YjwdLK.cjs.map} +1 -1
  81. package/dist/{github-DL4zwJsR.mjs → github-Zs-dVJbR.mjs} +3 -3
  82. package/dist/{github-DL4zwJsR.mjs.map → github-Zs-dVJbR.mjs.map} +1 -1
  83. package/dist/{index-C0UmP_lg.d.mts → index-D1ODeM9s.d.cts} +7 -3
  84. package/dist/index-D1ODeM9s.d.cts.map +1 -0
  85. package/dist/{index-DTEPwLml.d.cts → index-f77HdqN5.d.mts} +7 -3
  86. package/dist/index-f77HdqN5.d.mts.map +1 -0
  87. package/dist/index.cjs +553 -134
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.mjs +553 -134
  90. package/dist/index.mjs.map +1 -1
  91. package/dist/{migrate-DIjG8-QR.cjs → migrate-BfZN1S32.cjs} +3 -3
  92. package/dist/{migrate-DIjG8-QR.cjs.map → migrate-BfZN1S32.cjs.map} +1 -1
  93. package/dist/{migrate-CCejlgU0.mjs → migrate-CdXcX0XR.mjs} +3 -3
  94. package/dist/{migrate-CCejlgU0.mjs.map → migrate-CdXcX0XR.mjs.map} +1 -1
  95. package/dist/{migrate-Cd3kcI7o.mjs → migrate-DOMU4uKe.mjs} +3 -3
  96. package/dist/{migrate-Cd3kcI7o.mjs.map → migrate-DOMU4uKe.mjs.map} +1 -1
  97. package/dist/{migrate-BdKhIJgT.cjs → migrate-FoL_mZIz.cjs} +3 -3
  98. package/dist/{migrate-BdKhIJgT.cjs.map → migrate-FoL_mZIz.cjs.map} +1 -1
  99. package/dist/openapi.cjs +3 -3
  100. package/dist/openapi.d.cts +1 -1
  101. package/dist/openapi.d.mts +1 -1
  102. package/dist/openapi.mjs +3 -3
  103. package/dist/{ready-4kx2pAhm.cjs → ready-BpNbORRB.cjs} +5 -5
  104. package/dist/{ready-4kx2pAhm.cjs.map → ready-BpNbORRB.cjs.map} +1 -1
  105. package/dist/{ready-DhZbZtFw.mjs → ready-b6vm0-W-.mjs} +5 -5
  106. package/dist/{ready-DhZbZtFw.mjs.map → ready-b6vm0-W-.mjs.map} +1 -1
  107. package/dist/reconcile/public.d.cts +1 -1
  108. package/dist/reconcile/public.d.mts +1 -1
  109. package/dist/{resolve-ApSUK_HB.cjs → resolve-T2I-5FcT.cjs} +3 -3
  110. package/dist/{resolve-ApSUK_HB.cjs.map → resolve-T2I-5FcT.cjs.map} +1 -1
  111. package/dist/{resolve-DY9TaVeV.mjs → resolve-r7PAs6ng.mjs} +3 -3
  112. package/dist/{resolve-DY9TaVeV.mjs.map → resolve-r7PAs6ng.mjs.map} +1 -1
  113. package/dist/{rollback-DXU6cr7q.mjs → rollback-BSdxnW3X.mjs} +5 -5
  114. package/dist/{rollback-DXU6cr7q.mjs.map → rollback-BSdxnW3X.mjs.map} +1 -1
  115. package/dist/{rollback-CdGhCk1d.cjs → rollback-CPSDjGse.cjs} +5 -5
  116. package/dist/{rollback-CdGhCk1d.cjs.map → rollback-CPSDjGse.cjs.map} +1 -1
  117. package/dist/{runtime-ekxfPNM-.d.mts → runtime-CNu2C2hB.d.mts} +2 -2
  118. package/dist/{runtime-D6A-C6zh.d.cts.map → runtime-CNu2C2hB.d.mts.map} +1 -1
  119. package/dist/{runtime-D6A-C6zh.d.cts → runtime-DMLgL26v.d.cts} +2 -2
  120. package/dist/{runtime-ekxfPNM-.d.mts.map → runtime-DMLgL26v.d.cts.map} +1 -1
  121. package/dist/{seeds-DbyqaXW4.mjs → seeds-BK8caVMm.mjs} +1707 -1612
  122. package/dist/seeds-BK8caVMm.mjs.map +1 -0
  123. package/dist/{seeds-BLzrSwEr.cjs → seeds-DQ0FG57Z.cjs} +2558 -2421
  124. package/dist/seeds-DQ0FG57Z.cjs.map +1 -0
  125. package/dist/{sniffer-ByWtH28i.mjs → sniffer-CqE_KTTp.mjs} +6 -6
  126. package/dist/{sniffer-ByWtH28i.mjs.map → sniffer-CqE_KTTp.mjs.map} +1 -1
  127. package/dist/{sniffer-BjG-1mlD.cjs → sniffer-hSf5mRgV.cjs} +6 -6
  128. package/dist/{sniffer-BjG-1mlD.cjs.map → sniffer-hSf5mRgV.cjs.map} +1 -1
  129. package/dist/stages-CS1sjcRb.cjs +174 -0
  130. package/dist/stages-CS1sjcRb.cjs.map +1 -0
  131. package/dist/stages-CSXt7N2b.mjs +174 -0
  132. package/dist/stages-CSXt7N2b.mjs.map +1 -0
  133. package/dist/target/public.cjs +1 -1
  134. package/dist/target/public.d.cts +2 -2
  135. package/dist/target/public.d.mts +2 -2
  136. package/dist/target/public.mjs +1 -1
  137. package/dist/{types-DZtj9jeI.d.cts → types-BZn-8ssD.d.mts} +75 -22
  138. package/dist/types-BZn-8ssD.d.mts.map +1 -0
  139. package/dist/{types-BzCavjGt.d.mts → types-DiZj6hqp.d.cts} +76 -21
  140. package/dist/types-DiZj6hqp.d.cts.map +1 -0
  141. package/dist/vitest.cjs +1 -1
  142. package/dist/vitest.mjs +1 -1
  143. package/dist/workspace/index.cjs +2 -1
  144. package/dist/workspace/index.d.cts +3 -3
  145. package/dist/workspace/index.d.mts +3 -3
  146. package/dist/workspace/index.mjs +2 -2
  147. package/dist/{workspace-Bp8FEkF-.cjs → workspace-Bic_R3tz.cjs} +345 -99
  148. package/dist/workspace-Bic_R3tz.cjs.map +1 -0
  149. package/dist/{workspace-BBdqmkch.mjs → workspace-Drz4jsvy.mjs} +314 -98
  150. package/dist/workspace-Drz4jsvy.mjs.map +1 -0
  151. package/package.json +15 -15
  152. package/dist/EndpointGenerator-DtnIGafh.mjs.map +0 -1
  153. package/dist/EndpointGenerator-cKlnllIu.cjs.map +0 -1
  154. package/dist/compose-D4sD4Lgr.mjs.map +0 -1
  155. package/dist/compose-qnDyPJNp.cjs.map +0 -1
  156. package/dist/config-L_6NivCp.d.mts.map +0 -1
  157. package/dist/config-Mr4rcXFC.d.cts.map +0 -1
  158. package/dist/deploy-C00DAKOt.mjs.map +0 -1
  159. package/dist/deploy-CXP2fXvK.cjs.map +0 -1
  160. package/dist/engine-BS3iX9Mu.cjs.map +0 -1
  161. package/dist/engine-CgzgIg-_.mjs.map +0 -1
  162. package/dist/index-C0UmP_lg.d.mts.map +0 -1
  163. package/dist/index-DTEPwLml.d.cts.map +0 -1
  164. package/dist/seeds-BLzrSwEr.cjs.map +0 -1
  165. package/dist/seeds-DbyqaXW4.mjs.map +0 -1
  166. package/dist/types-BzCavjGt.d.mts.map +0 -1
  167. package/dist/types-DZtj9jeI.d.cts.map +0 -1
  168. package/dist/workspace-BBdqmkch.mjs.map +0 -1
  169. package/dist/workspace-Bp8FEkF-.cjs.map +0 -1
@@ -2,11 +2,11 @@ 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 { 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";
5
+ import { I as stageSampleRate, L as telemetryEnv, R as LOGS_PORT, U as UnknownSecretsStoreProvider, g as getPublicEnvPrefix, h as PUBLIC_ENV_PREFIXES, k as TEST_STAGE, r as allConstructGlobs } from "./workspace-Drz4jsvy.mjs";
6
6
  import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
7
- import { n as runtimeFor, r as generateTelemetryModule, s as telemetryArgs } from "./EndpointGenerator-DtnIGafh.mjs";
8
- import { c as runtimeOf } from "./resolve-DY9TaVeV.mjs";
9
- import { a as DEFAULT_STORAGE, i as DEFAULT_EVENTS$1, n as readFakes, o as holdsEveryCache, r as DEFAULT_CACHE, t as SubscriberGenerator } from "./SubscriberGenerator-DQZeGkDW.mjs";
7
+ import { a as telemetryArgs, c as startTelemetryCode, n as runtimeFor, u as writeTelemetryModule } from "./EndpointGenerator-CVc6Oi1Y.mjs";
8
+ 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-BPqQf8fB.mjs";
9
+ import { c as runtimeOf } from "./resolve-r7PAs6ng.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
12
  import { appendFile, chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
@@ -14,11 +14,11 @@ import { homedir, networkInterfaces } from "node:os";
14
14
  import { promisify } from "node:util";
15
15
  import { Cron } from "@geekmidas/constructs/crons";
16
16
  import { Queue } from "@geekmidas/constructs/queue";
17
- import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
17
+ import { DEFAULT_POSTGRES_VERSION, TELEMETRY_KEYS, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
18
18
  import { createServer } from "node:net";
19
19
  import { execFile, spawn } from "node:child_process";
20
- import { config } from "dotenv";
21
20
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomInt } from "node:crypto";
21
+ import { config } from "dotenv";
22
22
  import { stringify } from "yaml";
23
23
  import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
24
24
  import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
@@ -29,425 +29,694 @@ import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-s
29
29
  import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
30
30
  import { Client } from "pg";
31
31
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
32
- //#region src/secrets/storage.ts
33
- var storage_exports = /* @__PURE__ */ __exportAll({
34
- initStageSecrets: () => initStageSecrets,
35
- maskPassword: () => maskPassword,
36
- toEmbeddableSecrets: () => toEmbeddableSecrets,
37
- validateEnvironmentVariables: () => validateEnvironmentVariables,
38
- withCustomSecret: () => withCustomSecret
39
- });
32
+ //#region src/workspace/rootSite.ts
40
33
  /**
41
- * Initialize an empty StageSecrets object for a stage.
34
+ * Which site the base domain points at — deployed and locally alike.
35
+ *
36
+ * Three rules, in order, and the last one is the point:
37
+ *
38
+ * 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
39
+ * 2. **Named `web`** — the convention wins, because people already rely on it.
40
+ * 3. **Declared `root: true`** — the site says so itself.
41
+ *
42
+ * Anything else throws. It used to take the *first* site it iterated, and that
43
+ * order came from the manifest, which is built in glob traversal order — so two
44
+ * sites with neither named `web` meant renaming a file could move the root
45
+ * domain, silently.
46
+ *
47
+ * One rule for both targets: deployed the root site gets the base domain, and
48
+ * locally it gets the project's bare host behind the edge. Before this was
49
+ * shared, the local edge put every site on a subdomain, so what a developer
50
+ * browsed was not the shape production served.
51
+ *
52
+ * @returns the root site's name, or `undefined` when there are no sites
53
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
42
54
  */
43
- function initStageSecrets(stage) {
44
- const now = (/* @__PURE__ */ new Date()).toISOString();
45
- return {
46
- stage,
47
- createdAt: now,
48
- updatedAt: now,
49
- services: {},
50
- urls: {},
51
- custom: {}
52
- };
55
+ function rootSite(sites) {
56
+ if (sites.length === 0) return void 0;
57
+ if (sites.length === 1) return sites[0].name;
58
+ if (sites.some(({ name }) => name === "web")) return "web";
59
+ const declared = sites.filter(({ root }) => root === true);
60
+ if (declared.length === 1) return declared[0].name;
61
+ throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
53
62
  }
63
+ /** Several sites could hold the base domain, and none of them says it does. */
64
+ var AmbiguousRootSite = class extends Error {
65
+ sites;
66
+ tooMany;
67
+ constructor(sites, tooMany = false) {
68
+ super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
69
+ this.sites = sites;
70
+ this.tooMany = tooMany;
71
+ this.name = "AmbiguousRootSite";
72
+ }
73
+ };
74
+ //#endregion
75
+ //#region src/target/dokploy/domain.ts
76
+ /** A stage was deployed that `deploy.domains` gives no domain. */
77
+ var NoDomainForStage = class extends Error {
78
+ stage;
79
+ constructor(stage) {
80
+ super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
81
+ this.stage = stage;
82
+ this.name = "NoDomainForStage";
83
+ }
84
+ };
54
85
  /**
55
- * Convert StageSecrets to embeddable format (flat key-value pairs).
56
- * This is what gets encrypted and embedded in the bundle.
86
+ * The hostname an app answers on for a stage.
87
+ *
88
+ * 1. An explicit `app.domain` — a string, or one per stage — wins.
89
+ * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
90
+ * answers on the domain itself, and every other app on
91
+ * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
92
+ *
93
+ * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
57
94
  */
58
- function toEmbeddableSecrets(secrets) {
59
- return {
60
- ...secrets.urls,
61
- ...secrets.custom,
62
- ...secrets.services.postgres && {
63
- POSTGRES_USER: secrets.services.postgres.username,
64
- POSTGRES_PASSWORD: secrets.services.postgres.password,
65
- POSTGRES_DB: secrets.services.postgres.database ?? "app",
66
- POSTGRES_HOST: secrets.services.postgres.host,
67
- POSTGRES_PORT: String(secrets.services.postgres.port)
68
- },
69
- ...secrets.services.redis && {
70
- REDIS_PASSWORD: secrets.services.redis.password,
71
- REDIS_HOST: secrets.services.redis.host,
72
- REDIS_PORT: String(secrets.services.redis.port)
73
- },
74
- ...secrets.services.minio && {
75
- STORAGE_ACCESS_KEY_ID: secrets.services.minio.username,
76
- STORAGE_SECRET_ACCESS_KEY: secrets.services.minio.password,
77
- STORAGE_BUCKET: secrets.services.minio.bucket ?? "app",
78
- STORAGE_REGION: "eu-west-1",
79
- STORAGE_FORCE_PATH_STYLE: "true"
80
- },
81
- ...secrets.services.mailpit && {
82
- SMTP_HOST: secrets.services.mailpit.host,
83
- SMTP_PORT: String(secrets.services.mailpit.port),
84
- SMTP_USER: secrets.services.mailpit.username,
85
- SMTP_PASS: secrets.services.mailpit.password,
86
- SMTP_SECURE: "false",
87
- MAIL_FROM: "noreply@localhost"
88
- },
89
- ...secrets.services.localstack && {
90
- AWS_ACCESS_KEY_ID: secrets.services.localstack.accessKeyId ?? secrets.services.localstack.username,
91
- AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
92
- AWS_REGION: secrets.services.localstack.region ?? "us-east-1",
93
- AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`
94
- }
95
- };
96
- }
97
- /** The same secrets with one custom value set, and `updatedAt` moved on. */
98
- function withCustomSecret(secrets, key, value) {
99
- return {
100
- ...secrets,
101
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
102
- custom: {
103
- ...secrets.custom,
104
- [key]: value
105
- }
106
- };
95
+ function resolveHost(appName, app, stage, domains, isMainFrontend) {
96
+ if (app.domain) {
97
+ if (typeof app.domain === "string") return app.domain;
98
+ if (app.domain[stage]) return app.domain[stage];
99
+ }
100
+ const baseDomain = domains?.[stage];
101
+ if (!baseDomain) throw new NoDomainForStage(stage);
102
+ if (isMainFrontend) return baseDomain;
103
+ return `${app.subdomain ?? appName}.${baseDomain}`;
107
104
  }
108
105
  /**
109
- * Mask a password for display (show first 4 and last 2 chars).
106
+ * Whether this app is the site the base domain points at — see `rootSite`,
107
+ * which the local edge asks too.
108
+ *
109
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
110
110
  */
111
- function maskPassword(password) {
112
- if (password.length <= 8) return "********";
113
- return `${password.slice(0, 4)}${"*".repeat(password.length - 6)}${password.slice(-2)}`;
111
+ function isMainFrontendApp(appName, app, allApps) {
112
+ if (app.type !== "web") return false;
113
+ return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
114
+ name,
115
+ root: a.root
116
+ }))) === appName;
114
117
  }
115
118
  /**
116
- * Validate that all required environment variables are present in secrets.
117
- *
118
- * @param requiredVars - Array of environment variable names required by the application
119
- * @param secrets - Stage secrets to validate against
120
- * @returns Validation result with missing and provided variables
121
- *
122
- * @example
123
- * ```typescript
124
- * const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
125
- * const secrets = await secretsStoreFor(workspace, 'production').then((s) => s.read('production'));
126
- * const result = validateEnvironmentVariables(required, secrets);
119
+ * Get public URL arg names from app dependencies.
127
120
  *
128
- * if (!result.valid) {
129
- * console.error(`Missing environment variables: ${result.missing.join(', ')}`);
130
- * }
131
- * ```
121
+ * @param app - The web/mobile app configuration
122
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
123
+ * without a public prefix (e.g. Remix).
132
124
  */
133
- function validateEnvironmentVariables(requiredVars, secrets) {
134
- const embeddable = toEmbeddableSecrets(secrets);
135
- const availableVars = new Set(Object.keys(embeddable));
136
- const missing = [];
137
- const provided = [];
138
- for (const varName of requiredVars) if (availableVars.has(varName)) provided.push(varName);
139
- else missing.push(varName);
140
- return {
141
- valid: missing.length === 0,
142
- missing: missing.sort(),
143
- provided: provided.sort(),
144
- required: [...requiredVars].sort()
145
- };
125
+ function getPublicUrlArgNames(app) {
126
+ const prefix = getPublicEnvPrefix(app.framework);
127
+ if (!prefix) return [];
128
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
146
129
  }
147
130
  //#endregion
148
- //#region src/deploy/identity.ts
131
+ //#region src/compose/logs.ts
149
132
  /**
150
- * Who a deploy is, on a target it shares with other workspaces.
133
+ * The stack's log UI: OpenObserve, run beside the apps as telemetry's
134
+ * self-hosted provider, and every process with a `Telemetry` edge pointed at
135
+ * it.
151
136
  *
152
- * A Dokploy server is one flat namespace of projects, images and Docker
153
- * service names. Deploys used to identify everything they made by the
154
- * workspace name alone, so two workspaces called `shop` — or `Shop` and
155
- * `shop` — deploying to one server landed in the same project, pushed over
156
- * each other's images and redeployed each other's applications. A
157
- * `DeployIdentity` is what a deploy names and claims its resources by instead.
137
+ * The production server already exports its traces and pino logs over
138
+ * OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. OpenObserve takes OTLP
139
+ * at `/api/<org>/v1/{logs,traces}` with HTTP basic auth, so wiring an app is
140
+ * two variables: the endpoint, `http://openobserve:5080/api/default`, and
141
+ * the header that signs in as the root user.
142
+ *
143
+ * It is reached, by default, through an SSH tunnel: published on 127.0.0.1
144
+ * only, because a port Docker publishes on every interface is opened in the
145
+ * firewall by Docker itself, past ufw's rules. Served publicly, it is a host
146
+ * on the stack's Caddy that refuses every address not allowed.
147
+ *
148
+ * Pure: the stack decides it, and the phases print and check it.
158
149
  */
159
- /** A namespace that cannot be part of a Dokploy name or an image path. */
160
- var DeployNamespaceInvalid = class extends Error {
161
- namespace;
162
- constructor(namespace) {
163
- super(`deploy.namespace '${namespace}' is not usable as a name: use lowercase letters, digits and single '-' between them (e.g. 'acme' or 'acme-platform') in gkm.config.ts.`);
164
- this.namespace = namespace;
165
- this.name = "DeployNamespaceInvalid";
150
+ /**
151
+ * The image, pinned. A log store's on-disk format is the thing that must not
152
+ * move under a running stack, so a new version is a change to this line.
153
+ */
154
+ const OPENOBSERVE_IMAGE = "public.ecr.aws/zinclabs/openobserve:v1.0.4";
155
+ /** Its compose service name — the host the apps send to. */
156
+ const LOGS_SERVICE = "openobserve";
157
+ /** The organisation every app's telemetry lands in. */
158
+ const LOGS_ORG = "default";
159
+ /** The stage secrets that sign in as OpenObserve's root user. */
160
+ const LOGS_EMAIL_KEY = "ZO_ROOT_USER_EMAIL";
161
+ const LOGS_PASSWORD_KEY = "ZO_ROOT_USER_PASSWORD";
162
+ /**
163
+ * The local stage's root user. Its password is generated per machine with
164
+ * the other local logins — see `reconcile/localCredentials.ts`.
165
+ */
166
+ const LOCAL_LOGS_EMAIL = "admin@gkm.localhost";
167
+ /** A root password OpenObserve would refuse to start with. */
168
+ var LogsPasswordWeak = class extends Error {
169
+ stage;
170
+ constructor(stage) {
171
+ 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.`);
172
+ this.stage = stage;
173
+ this.name = "LogsPasswordWeak";
166
174
  }
167
175
  };
168
- /** A workspace name with nothing a target can name a resource by. */
169
- var DeployProjectNameInvalid = class extends Error {
170
- workspaceName;
171
- constructor(workspaceName) {
172
- super(`The workspace name '${workspaceName}' has no letters or digits to name deployed resources by. Set "name" in gkm.config.ts.`);
173
- this.workspaceName = workspaceName;
174
- this.name = "DeployProjectNameInvalid";
176
+ /** A deployed stage with logs on and no root password generated yet. */
177
+ var LogsPasswordMissing = class extends Error {
178
+ stage;
179
+ constructor(stage) {
180
+ 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}`);
181
+ this.stage = stage;
182
+ this.name = "LogsPasswordMissing";
175
183
  }
176
184
  };
177
- const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
185
+ /** Whether OpenObserve accepts `password` for its root user. */
186
+ function isStrongLogsPassword(password) {
187
+ return password.length >= 8 && password.length <= 128 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password) && /[^A-Za-z0-9]/.test(password);
188
+ }
178
189
  /**
179
- * Lowercase `[a-z0-9-]`, runs of anything else collapsed to one `-`: valid as
180
- * a Docker image path component, a Docker service name and a Dokploy name.
190
+ * A root password: 192 random bits, with a fixed tail so every class
191
+ * OpenObserve requires is present whatever the random part drew.
181
192
  */
182
- function slug(value) {
183
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
184
- }
185
- /** The namespace a workspace is in when it does not name one. */
186
- function defaultNamespace(workspaceName) {
187
- return slug(kebabCase(workspaceName));
188
- }
189
- function deployIdentity(workspace, stage) {
190
- const project = slug(workspace.name);
191
- if (!project) throw new DeployProjectNameInvalid(workspace.name);
192
- const fallback = defaultNamespace(workspace.name);
193
- const namespace = workspace.deploy?.namespace ?? fallback;
194
- if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
195
- return {
196
- namespace,
197
- project,
198
- stage,
199
- key: `${namespace}/${project}`,
200
- scope: namespace === fallback ? project : `${namespace}-${project}`
201
- };
193
+ function generateLogsPassword() {
194
+ return `${randomBytes(24).toString("base64url")}-Zo1`;
202
195
  }
203
196
  /**
204
- * The token a Dokploy project's description carries to say which identity
205
- * created it. A project with the right name and no marker belongs to someone
206
- * else, and a deploy never adopts it.
197
+ * The stage's secrets with a generated root password, where it has none.
198
+ * Pure, like `withGeneratedSecrets`: the caller keeps the result once the
199
+ * run goes ahead.
207
200
  */
208
- function ownershipMarker(identity) {
209
- return `gkm:${identity.key}`;
210
- }
211
- /** Whether a project description carries `identity`'s marker. */
212
- function isOwnedBy(description, identity) {
213
- const marker = ownershipMarker(identity);
214
- return (description ?? "").split(/\s+/).includes(marker);
215
- }
216
- /** Any `gkm:` marker a description carries — whoever's it is. */
217
- function markerOf(description) {
218
- return (description ?? "").split(/\s+/).find((t) => t.startsWith("gkm:"));
219
- }
220
- /** What the Dokploy project is called. */
221
- function projectName(identity) {
222
- return identity.scope;
201
+ function withLogsPassword(secrets) {
202
+ if (secrets.custom?.["ZO_ROOT_USER_PASSWORD"]) return {
203
+ secrets,
204
+ generated: []
205
+ };
206
+ return {
207
+ secrets: {
208
+ ...secrets,
209
+ custom: {
210
+ ...secrets.custom,
211
+ [LOGS_PASSWORD_KEY]: generateLogsPassword()
212
+ },
213
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
214
+ },
215
+ generated: [LOGS_PASSWORD_KEY]
216
+ };
223
217
  }
224
218
  /**
225
- * The repository an app's image is pushed to, below the registry:
226
- * `<namespace>/<project>-<app>`.
219
+ * `OTEL_EXPORTER_OTLP_HEADERS` signing in as `email`.
227
220
  *
228
- * The namespace is a path segment of its own so that a registry that maps
229
- * paths to owners (an organisation's packages, a Harbor project) can grant it
230
- * as one; the app is joined to the project so the two read as one name.
221
+ * The variable's values are percent-decoded by the SDK (they follow the W3C
222
+ * baggage format), so the space after `Basic` is written `%20`. Base64's own
223
+ * `+`, `/` and `=` pass through decoding unchanged.
231
224
  */
232
- function imageName(identity, app) {
233
- return `${identity.namespace}/${identity.project}-${slug(kebabCase(app))}`;
225
+ function otlpHeaders(email, password) {
226
+ return `Authorization=Basic%20${Buffer.from(`${email}:${password}`).toString("base64")}`;
234
227
  }
235
- /** The full image ref an app is built, pushed and deployed as. */
236
- function imageRef(identity, app, registry, tag) {
237
- const name = imageName(identity, app);
238
- return registry ? `${registry.replace(/\/+$/, "")}/${name}:${tag}` : `${name}:${tag}`;
228
+ /** The stack's OpenObserve for one stage. */
229
+ function stackLogs(options) {
230
+ const { config, stage, local, custom } = options;
231
+ if (!local && !options.domain) throw new NoDomainForStage(stage);
232
+ const email = custom["ZO_ROOT_USER_EMAIL"] ?? (local ? options.localLogin?.email ?? "admin@gkm.localhost" : `admin@${options.domain}`);
233
+ const set = custom[LOGS_PASSWORD_KEY];
234
+ const password = set ?? (local ? options.localLogin?.password : void 0);
235
+ if (!password) throw new LogsPasswordMissing(stage);
236
+ if (!isStrongLogsPassword(password)) throw new LogsPasswordWeak(stage);
237
+ const host = config.public ? local ? `logs.${options.project}.localhost` : `logs.${options.domain}` : void 0;
238
+ const url = host ? `https://${host}${local && options.https !== 443 ? `:${options.https}` : ""}` : `http://localhost:${config.port}`;
239
+ return {
240
+ ...config,
241
+ email,
242
+ password,
243
+ passwordFromSecrets: Boolean(set),
244
+ url,
245
+ ...host ? { host } : {},
246
+ env: {
247
+ [LOGS_EMAIL_KEY]: email,
248
+ [LOGS_PASSWORD_KEY]: password,
249
+ ZO_DATA_DIR: "/data",
250
+ ZO_COMPACT_DATA_RETENTION_DAYS: String(config.retentionDays),
251
+ ZO_TELEMETRY: "false"
252
+ },
253
+ appEnv: {
254
+ OTEL_EXPORTER_OTLP_ENDPOINT: `http://${LOGS_SERVICE}:${LOGS_PORT}/api/${LOGS_ORG}`,
255
+ OTEL_EXPORTER_OTLP_HEADERS: otlpHeaders(email, password)
256
+ }
257
+ };
239
258
  }
240
259
  /**
241
- * What one application is called on the target.
242
- *
243
- * The same rule the constructs use, through the same `scopedName`, scoped by
244
- * the stage and the identity: the application beside
245
- * `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
246
- * Dokploy application's name is also its Docker service name, which is unique
247
- * on the whole server, so the namespace has to be in it too — `acme`'s and
248
- * `globex`'s `shop` would otherwise both run `production-shop-api`.
249
- *
250
- * The app id is dropped when it repeats the project, so a project named for its
251
- * one application is `production-kitchen-sink` rather than
252
- * `production-kitchen-sink-kitchen-sink`.
260
+ * OpenObserve's service. The image has no shell, `wget` or `curl`, so its
261
+ * health check is its own binary asking the running server for its status —
262
+ * which exits non-zero when nothing answers on its port.
253
263
  */
254
- function applicationName(identity, app) {
255
- return slug(app) === identity.project ? `${identity.stage}-${identity.scope}` : scopedName([identity.stage, identity.scope], app);
264
+ function logsService(logs) {
265
+ return {
266
+ image: OPENOBSERVE_IMAGE,
267
+ restart: "unless-stopped",
268
+ env_file: [{
269
+ path: `./${LOGS_SERVICE}.env`,
270
+ format: "raw"
271
+ }],
272
+ volumes: ["openobserve-data:/data"],
273
+ ...logs.public ? {} : { ports: [`127.0.0.1:${logs.port}:${LOGS_PORT}`] },
274
+ healthcheck: {
275
+ test: [
276
+ "CMD",
277
+ "/openobserve",
278
+ "node",
279
+ "status"
280
+ ],
281
+ interval: "10s",
282
+ timeout: "5s",
283
+ retries: 12,
284
+ start_period: "20s"
285
+ }
286
+ };
287
+ }
288
+ /** What `gkm compose` prints once the stack is up: how to open the logs. */
289
+ function logsAccess(logs, ctx) {
290
+ const login = `login: ${logs.email}, password: ${ctx.local && !logs.passwordFromSecrets ? logs.password : `gkm secrets:show --stage ${ctx.stage} --reveal → ${LOGS_PASSWORD_KEY}`}`;
291
+ if (logs.public) return [`📜 Logs (OpenObserve): ${logs.url} (${login})`, ` answered only for ${logs.public.allow.join(", ")}; every other address gets 403`];
292
+ return [
293
+ `📜 Logs (OpenObserve) on 127.0.0.1:${logs.port} — from your computer:`,
294
+ ...ctx.local ? [` on this machine, open http://localhost:${logs.port} directly; from another:`] : [],
295
+ ` ssh -N -L ${logs.port}:localhost:${logs.port} ${ctx.user}@${ctx.hostname} (user and host are guesses: this machine's)`,
296
+ ` then open http://localhost:${logs.port} (${login})`,
297
+ `⚠️ 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.`
298
+ ];
256
299
  }
257
300
  //#endregion
258
- //#region src/secrets/keystore.ts
259
- /**
260
- * The keys that open each stage's encrypted secrets file, kept outside the
261
- * project in the CLI's home.
262
- *
263
- * Keys were kept at `~/.gkm/<folder>/<stage>.key`, named after the project
264
- * directory — so two checkouts in folders with the same name (`~/work/api`
265
- * and `~/oss/api`) shared one key, and the second `gkm secrets:init`
266
- * overwrote the first project's. They are now kept under the project's
267
- * identity, `<home>/keys/<namespace>/<project>/<stage>.key`, the same
268
- * `<namespace>/<project>` a deploy claims its Dokploy project by. A key still
269
- * at the old place is copied to the new one the first time it is read; the
270
- * old file stays, so an older CLI on the same machine keeps working.
271
- */
272
- /** Key length for AES-256 encryption */
273
- const KEY_LENGTH = 32;
274
- /** A keystore project key that is not `<namespace>/<project>`. */
275
- var KeystoreProjectInvalid = class extends Error {
276
- key;
277
- constructor(key) {
278
- super(`'${key}' is not a keystore project: expected '<namespace>/<project>' in lowercase letters, digits and '-', as deployIdentity() produces. Pass the workspace's identity key.`);
279
- this.key = key;
280
- this.name = "KeystoreProjectInvalid";
281
- }
282
- };
283
- /** A stage has no key, here or at the place keys used to be kept. */
284
- var KeyNotFound = class extends Error {
285
- stage;
286
- project;
287
- path;
288
- constructor(stage, project, path) {
289
- super(`Encryption key not found for stage "${stage}" in project "${project}". Expected key at: ${path}. Run \`gkm secrets:init --stage ${stage}\` to create one, or copy the stage's key there.`);
290
- this.stage = stage;
291
- this.project = project;
292
- this.path = path;
293
- this.name = "KeyNotFound";
294
- }
295
- };
296
- const PROJECT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
301
+ //#region src/secrets/storage.ts
302
+ var storage_exports = /* @__PURE__ */ __exportAll({
303
+ initStageSecrets: () => initStageSecrets,
304
+ maskPassword: () => maskPassword,
305
+ toEmbeddableSecrets: () => toEmbeddableSecrets,
306
+ validateEnvironmentVariables: () => validateEnvironmentVariables,
307
+ withCustomSecret: () => withCustomSecret
308
+ });
297
309
  /**
298
- * The keystore key of a workspace: its deploy identity without the stage, so
299
- * a project's keys and its Dokploy project are named by the same thing.
310
+ * Initialize an empty StageSecrets object for a stage.
300
311
  */
301
- function projectKey(workspace) {
302
- return deployIdentity(workspace, "").key;
303
- }
304
- /** The keystore project of a loaded workspace. */
305
- function keystoreProject(workspace, home) {
306
- const key = projectKey(workspace);
307
- const unscoped = projectKey({ name: workspace.name });
312
+ function initStageSecrets(stage) {
313
+ const now = (/* @__PURE__ */ new Date()).toISOString();
308
314
  return {
309
- key,
310
- legacy: [.../* @__PURE__ */ new Set([folderName(workspace.root), workspace.name])].filter((name) => !!name),
311
- ...unscoped !== key ? { aliases: [unscoped] } : {},
312
- ...home ? { home } : {}
315
+ stage,
316
+ createdAt: now,
317
+ updatedAt: now,
318
+ services: {},
319
+ urls: {},
320
+ custom: {}
313
321
  };
314
322
  }
315
323
  /**
316
- * Whether `name` stays inside the home it is joined to. A workspace name was
317
- * joined as is — `@acme/shop` is `~/.gkm/@acme/shop` — but never upwards.
318
- */
319
- function isLegacyName(name) {
320
- return name.split(/[\\/]/).every((part) => part !== ".." && part !== "");
321
- }
322
- /** The last segment of a path, as the old keystore named projects. */
323
- function folderName(path) {
324
- const name = path.split(/[\\/]/).filter(Boolean).at(-1);
325
- return name && name !== "." && name !== ".." ? name : void 0;
326
- }
327
- /**
328
- * Get the keystore directory for a project:
329
- * `<home>/keys/<namespace>/<project>`.
324
+ * Convert StageSecrets to embeddable format (flat key-value pairs).
325
+ * This is what gets encrypted and embedded in the bundle.
330
326
  */
331
- function getKeystoreDir(project) {
332
- if (!PROJECT_KEY.test(project.key)) throw new KeystoreProjectInvalid(project.key);
333
- const [namespace, name] = project.key.split("/");
334
- return join(project.home ?? gkmHome(), "keys", namespace, name);
335
- }
336
- /** Get the path to a stage's encryption key. */
337
- function getKeyPath(stage, project) {
338
- return join(getKeystoreDir(project), `${stage}.key`);
327
+ function toEmbeddableSecrets(secrets) {
328
+ return {
329
+ ...secrets.urls,
330
+ ...secrets.custom,
331
+ ...secrets.services.postgres && {
332
+ POSTGRES_USER: secrets.services.postgres.username,
333
+ POSTGRES_PASSWORD: secrets.services.postgres.password,
334
+ POSTGRES_DB: secrets.services.postgres.database ?? "app",
335
+ POSTGRES_HOST: secrets.services.postgres.host,
336
+ POSTGRES_PORT: String(secrets.services.postgres.port)
337
+ },
338
+ ...secrets.services.redis && {
339
+ REDIS_PASSWORD: secrets.services.redis.password,
340
+ REDIS_HOST: secrets.services.redis.host,
341
+ REDIS_PORT: String(secrets.services.redis.port)
342
+ },
343
+ ...secrets.services.minio && {
344
+ STORAGE_ACCESS_KEY_ID: secrets.services.minio.username,
345
+ STORAGE_SECRET_ACCESS_KEY: secrets.services.minio.password,
346
+ STORAGE_BUCKET: secrets.services.minio.bucket ?? "app",
347
+ STORAGE_REGION: "eu-west-1",
348
+ STORAGE_FORCE_PATH_STYLE: "true"
349
+ },
350
+ ...secrets.services.mailpit && {
351
+ SMTP_HOST: secrets.services.mailpit.host,
352
+ SMTP_PORT: String(secrets.services.mailpit.port),
353
+ SMTP_USER: secrets.services.mailpit.username,
354
+ SMTP_PASS: secrets.services.mailpit.password,
355
+ SMTP_SECURE: "false",
356
+ MAIL_FROM: "noreply@localhost"
357
+ },
358
+ ...secrets.services.localstack && {
359
+ AWS_ACCESS_KEY_ID: secrets.services.localstack.accessKeyId ?? secrets.services.localstack.username,
360
+ AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
361
+ AWS_REGION: secrets.services.localstack.region ?? "us-east-1",
362
+ AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`
363
+ }
364
+ };
365
+ }
366
+ /** The same secrets with one custom value set, and `updatedAt` moved on. */
367
+ function withCustomSecret(secrets, key, value) {
368
+ return {
369
+ ...secrets,
370
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
371
+ custom: {
372
+ ...secrets.custom,
373
+ [key]: value
374
+ }
375
+ };
339
376
  }
340
377
  /**
341
- * Where else a stage's key may be, most likely first: under an alias of the
342
- * project, then where keys were kept before they were keyed by identity —
343
- * under the configured home, then under `~/.gkm`, which is where every CLI
344
- * before `GKM_HOME` wrote.
378
+ * Mask a password for display (show first 4 and last 2 chars).
345
379
  */
346
- function legacyKeyPaths(stage, project) {
347
- const aliases = (project.aliases ?? []).map((key) => getKeyPath(stage, {
348
- key,
349
- home: project.home
350
- }));
351
- const homes = [.../* @__PURE__ */ new Set([project.home ?? gkmHome(), join(homedir(), ".gkm")])];
352
- const names = (project.legacy ?? []).filter(isLegacyName);
353
- return [...aliases, ...names.flatMap((name) => homes.map((home) => join(home, name, `${stage}.key`)))];
380
+ function maskPassword(password) {
381
+ if (password.length <= 8) return "********";
382
+ return `${password.slice(0, 4)}${"*".repeat(password.length - 6)}${password.slice(-2)}`;
354
383
  }
355
384
  /**
356
- * Generate a new encryption key for a stage, stored owner-only at
357
- * `<home>/keys/<namespace>/<project>/<stage>.key`.
385
+ * Validate that all required environment variables are present in secrets.
386
+ *
387
+ * @param requiredVars - Array of environment variable names required by the application
388
+ * @param secrets - Stage secrets to validate against
389
+ * @returns Validation result with missing and provided variables
390
+ *
391
+ * @example
392
+ * ```typescript
393
+ * const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
394
+ * const secrets = await secretsStoreFor(workspace, 'production').then((s) => s.read('production'));
395
+ * const result = validateEnvironmentVariables(required, secrets);
396
+ *
397
+ * if (!result.valid) {
398
+ * console.error(`Missing environment variables: ${result.missing.join(', ')}`);
399
+ * }
400
+ * ```
358
401
  */
359
- async function generateKey(stage, project, flag = "w") {
360
- const keyDir = getKeystoreDir(project);
361
- const keyPath = getKeyPath(stage, project);
362
- await mkdir(keyDir, {
363
- recursive: true,
364
- mode: 448
365
- });
366
- const key = randomBytes(KEY_LENGTH).toString("hex");
367
- await writeFile(keyPath, key, {
368
- mode: 384,
369
- encoding: "utf-8",
370
- flag
371
- });
372
- await chmod(keyPath, 384);
373
- return key;
402
+ function validateEnvironmentVariables(requiredVars, secrets) {
403
+ const embeddable = toEmbeddableSecrets(secrets);
404
+ const availableVars = new Set(Object.keys(embeddable));
405
+ const missing = [];
406
+ const provided = [];
407
+ for (const varName of requiredVars) if (availableVars.has(varName)) provided.push(varName);
408
+ else missing.push(varName);
409
+ return {
410
+ valid: missing.length === 0,
411
+ missing: missing.sort(),
412
+ provided: provided.sort(),
413
+ required: [...requiredVars].sort()
414
+ };
374
415
  }
416
+ //#endregion
417
+ //#region src/deploy/identity.ts
375
418
  /**
376
- * Copy a key from where it used to be kept to where it is kept now.
419
+ * Who a deploy is, on a target it shares with other workspaces.
377
420
  *
378
- * Copied, not moved: an older CLI on the same machine still reads the old
379
- * place. Exclusive, so two processes migrating at once cannot overwrite a key
380
- * one of them already put there.
421
+ * A Dokploy server is one flat namespace of projects, images and Docker
422
+ * service names. Deploys used to identify everything they made by the
423
+ * workspace name alone, so two workspaces called `shop` — or `Shop` and
424
+ * `shop` — deploying to one server landed in the same project, pushed over
425
+ * each other's images and redeployed each other's applications. A
426
+ * `DeployIdentity` is what a deploy names and claims its resources by instead.
381
427
  */
382
- async function migrateLegacyKey(stage, project) {
383
- const legacy = legacyKeyPaths(stage, project).find((path) => existsSync(path));
384
- if (!legacy) return false;
385
- const keyPath = getKeyPath(stage, project);
386
- await mkdir(getKeystoreDir(project), {
387
- recursive: true,
388
- mode: 448
389
- });
390
- try {
391
- await copyFile(legacy, keyPath, constants.COPYFILE_EXCL);
392
- } catch (error) {
393
- if (error.code !== "EEXIST") throw error;
428
+ /** A namespace that cannot be part of a Dokploy name or an image path. */
429
+ var DeployNamespaceInvalid = class extends Error {
430
+ namespace;
431
+ constructor(namespace) {
432
+ super(`deploy.namespace '${namespace}' is not usable as a name: use lowercase letters, digits and single '-' between them (e.g. 'acme' or 'acme-platform') in gkm.config.ts.`);
433
+ this.namespace = namespace;
434
+ this.name = "DeployNamespaceInvalid";
394
435
  }
395
- await chmod(keyPath, 384);
396
- return true;
397
- }
436
+ };
437
+ /** A workspace name with nothing a target can name a resource by. */
438
+ var DeployProjectNameInvalid = class extends Error {
439
+ workspaceName;
440
+ constructor(workspaceName) {
441
+ super(`The workspace name '${workspaceName}' has no letters or digits to name deployed resources by. Set "name" in gkm.config.ts.`);
442
+ this.workspaceName = workspaceName;
443
+ this.name = "DeployProjectNameInvalid";
444
+ }
445
+ };
446
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
398
447
  /**
399
- * Read an encryption key for a stage, or `null` when there is none — after
400
- * copying it over from where keys used to be kept, if it is still there.
448
+ * Lowercase `[a-z0-9-]`, runs of anything else collapsed to one `-`: valid as
449
+ * a Docker image path component, a Docker service name and a Dokploy name.
401
450
  */
402
- async function readKey(stage, project) {
403
- const keyPath = getKeyPath(stage, project);
404
- if (!existsSync(keyPath) && !await migrateLegacyKey(stage, project)) return null;
405
- return (await readFile(keyPath, "utf-8")).trim();
451
+ function slug(value) {
452
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
453
+ }
454
+ /** The namespace a workspace is in when it does not name one. */
455
+ function defaultNamespace(workspaceName) {
456
+ return slug(kebabCase(workspaceName));
457
+ }
458
+ function deployIdentity(workspace, stage) {
459
+ const project = slug(workspace.name);
460
+ if (!project) throw new DeployProjectNameInvalid(workspace.name);
461
+ const fallback = defaultNamespace(workspace.name);
462
+ const namespace = workspace.deploy?.namespace ?? fallback;
463
+ if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
464
+ return {
465
+ namespace,
466
+ project,
467
+ stage,
468
+ key: `${namespace}/${project}`,
469
+ scope: namespace === fallback ? project : `${namespace}-${project}`
470
+ };
406
471
  }
407
472
  /**
408
- * Read an encryption key for a stage, throwing {@link KeyNotFound} if there
409
- * is none.
473
+ * The token a Dokploy project's description carries to say which identity
474
+ * created it. A project with the right name and no marker belongs to someone
475
+ * else, and a deploy never adopts it.
410
476
  */
411
- async function requireKey(stage, project) {
412
- const key = await readKey(stage, project);
413
- if (!key) throw new KeyNotFound(stage, project.key, getKeyPath(stage, project));
414
- return key;
477
+ function ownershipMarker(identity) {
478
+ return `gkm:${identity.key}`;
479
+ }
480
+ /** Whether a project description carries `identity`'s marker. */
481
+ function isOwnedBy(description, identity) {
482
+ const marker = ownershipMarker(identity);
483
+ return (description ?? "").split(/\s+/).includes(marker);
484
+ }
485
+ /** Any `gkm:` marker a description carries — whoever's it is. */
486
+ function markerOf(description) {
487
+ return (description ?? "").split(/\s+/).find((t) => t.startsWith("gkm:"));
488
+ }
489
+ /** What the Dokploy project is called. */
490
+ function projectName(identity) {
491
+ return identity.scope;
415
492
  }
416
493
  /**
417
- * Get or create a key for a stage.
418
- * If the key already exists, it is returned. Otherwise, a new key is generated.
494
+ * The repository an app's image is pushed to, below the registry:
495
+ * `<namespace>/<project>-<app>`.
496
+ *
497
+ * The namespace is a path segment of its own so that a registry that maps
498
+ * paths to owners (an organisation's packages, a Harbor project) can grant it
499
+ * as one; the app is joined to the project so the two read as one name.
419
500
  */
420
- async function getOrCreateKey(stage, project) {
421
- const existingKey = await readKey(stage, project);
422
- if (existingKey) return existingKey;
423
- try {
424
- return await generateKey(stage, project, "wx");
425
- } catch (error) {
426
- if (error.code !== "EEXIST") throw error;
427
- return requireKey(stage, project);
428
- }
501
+ function imageName(identity, app) {
502
+ return `${identity.namespace}/${identity.project}-${slug(kebabCase(app))}`;
503
+ }
504
+ /** The full image ref an app is built, pushed and deployed as. */
505
+ function imageRef(identity, app, registry, tag) {
506
+ const name = imageName(identity, app);
507
+ return registry ? `${registry.replace(/\/+$/, "")}/${name}:${tag}` : `${name}:${tag}`;
429
508
  }
430
- //#endregion
431
- //#region src/secrets/file.ts
432
- /** Where the files live, relative to the project root. */
433
- const SECRETS_DIR = ".gkm/secrets";
434
- /** AES-256-GCM configuration */
435
- const ALGORITHM = "aes-256-gcm";
436
- const IV_LENGTH = 12;
437
509
  /**
438
- * A stage's secrets as an encrypted file on this machine:
439
- * `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
440
- * `keys/<namespace>/<project>/<stage>.key`.
510
+ * What one application is called on the target.
441
511
  *
442
- * The local stage's store, always — its secrets belong to the machine running
443
- * `gkm dev` — and a deployed stage's when `secrets.store` is `'file'`.
444
- */
445
- var FileSecretsStore = class {
446
- root;
447
- name = "file";
448
- project;
449
- /**
450
- * @param project - Whose keys: a workspace's `keystoreProject()`. Outside a
512
+ * The same rule the constructs use, through the same `scopedName`, scoped by
513
+ * the stage and the identity: the application beside
514
+ * `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
515
+ * Dokploy application's name is also its Docker service name, which is unique
516
+ * on the whole server, so the namespace has to be in it too — `acme`'s and
517
+ * `globex`'s `shop` would otherwise both run `production-shop-api`.
518
+ *
519
+ * The app id is dropped when it repeats the project, so a project named for its
520
+ * one application is `production-kitchen-sink` rather than
521
+ * `production-kitchen-sink-kitchen-sink`.
522
+ */
523
+ function applicationName(identity, app) {
524
+ return slug(app) === identity.project ? `${identity.stage}-${identity.scope}` : scopedName([identity.stage, identity.scope], app);
525
+ }
526
+ //#endregion
527
+ //#region src/secrets/keystore.ts
528
+ /**
529
+ * The keys that open each stage's encrypted secrets file, kept outside the
530
+ * project in the CLI's home.
531
+ *
532
+ * Keys were kept at `~/.gkm/<folder>/<stage>.key`, named after the project
533
+ * directory — so two checkouts in folders with the same name (`~/work/api`
534
+ * and `~/oss/api`) shared one key, and the second `gkm secrets:init`
535
+ * overwrote the first project's. They are now kept under the project's
536
+ * identity, `<home>/keys/<namespace>/<project>/<stage>.key`, the same
537
+ * `<namespace>/<project>` a deploy claims its Dokploy project by. A key still
538
+ * at the old place is copied to the new one the first time it is read; the
539
+ * old file stays, so an older CLI on the same machine keeps working.
540
+ */
541
+ /** Key length for AES-256 encryption */
542
+ const KEY_LENGTH = 32;
543
+ /** A keystore project key that is not `<namespace>/<project>`. */
544
+ var KeystoreProjectInvalid = class extends Error {
545
+ key;
546
+ constructor(key) {
547
+ super(`'${key}' is not a keystore project: expected '<namespace>/<project>' in lowercase letters, digits and '-', as deployIdentity() produces. Pass the workspace's identity key.`);
548
+ this.key = key;
549
+ this.name = "KeystoreProjectInvalid";
550
+ }
551
+ };
552
+ /** A stage has no key, here or at the place keys used to be kept. */
553
+ var KeyNotFound = class extends Error {
554
+ stage;
555
+ project;
556
+ path;
557
+ constructor(stage, project, path) {
558
+ super(`Encryption key not found for stage "${stage}" in project "${project}". Expected key at: ${path}. Run \`gkm secrets:init --stage ${stage}\` to create one, or copy the stage's key there.`);
559
+ this.stage = stage;
560
+ this.project = project;
561
+ this.path = path;
562
+ this.name = "KeyNotFound";
563
+ }
564
+ };
565
+ const PROJECT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
566
+ /**
567
+ * The keystore key of a workspace: its deploy identity without the stage, so
568
+ * a project's keys and its Dokploy project are named by the same thing.
569
+ */
570
+ function projectKey(workspace) {
571
+ return deployIdentity(workspace, "").key;
572
+ }
573
+ /** The keystore project of a loaded workspace. */
574
+ function keystoreProject(workspace, home) {
575
+ const key = projectKey(workspace);
576
+ const unscoped = projectKey({ name: workspace.name });
577
+ return {
578
+ key,
579
+ legacy: [.../* @__PURE__ */ new Set([folderName(workspace.root), workspace.name])].filter((name) => !!name),
580
+ ...unscoped !== key ? { aliases: [unscoped] } : {},
581
+ ...home ? { home } : {}
582
+ };
583
+ }
584
+ /**
585
+ * Whether `name` stays inside the home it is joined to. A workspace name was
586
+ * joined as is — `@acme/shop` is `~/.gkm/@acme/shop` — but never upwards.
587
+ */
588
+ function isLegacyName(name) {
589
+ return name.split(/[\\/]/).every((part) => part !== ".." && part !== "");
590
+ }
591
+ /** The last segment of a path, as the old keystore named projects. */
592
+ function folderName(path) {
593
+ const name = path.split(/[\\/]/).filter(Boolean).at(-1);
594
+ return name && name !== "." && name !== ".." ? name : void 0;
595
+ }
596
+ /**
597
+ * Get the keystore directory for a project:
598
+ * `<home>/keys/<namespace>/<project>`.
599
+ */
600
+ function getKeystoreDir(project) {
601
+ if (!PROJECT_KEY.test(project.key)) throw new KeystoreProjectInvalid(project.key);
602
+ const [namespace, name] = project.key.split("/");
603
+ return join(project.home ?? gkmHome(), "keys", namespace, name);
604
+ }
605
+ /** Get the path to a stage's encryption key. */
606
+ function getKeyPath(stage, project) {
607
+ return join(getKeystoreDir(project), `${stage}.key`);
608
+ }
609
+ /**
610
+ * Where else a stage's key may be, most likely first: under an alias of the
611
+ * project, then where keys were kept before they were keyed by identity —
612
+ * under the configured home, then under `~/.gkm`, which is where every CLI
613
+ * before `GKM_HOME` wrote.
614
+ */
615
+ function legacyKeyPaths(stage, project) {
616
+ const aliases = (project.aliases ?? []).map((key) => getKeyPath(stage, {
617
+ key,
618
+ home: project.home
619
+ }));
620
+ const homes = [.../* @__PURE__ */ new Set([project.home ?? gkmHome(), join(homedir(), ".gkm")])];
621
+ const names = (project.legacy ?? []).filter(isLegacyName);
622
+ return [...aliases, ...names.flatMap((name) => homes.map((home) => join(home, name, `${stage}.key`)))];
623
+ }
624
+ /**
625
+ * Generate a new encryption key for a stage, stored owner-only at
626
+ * `<home>/keys/<namespace>/<project>/<stage>.key`.
627
+ */
628
+ async function generateKey(stage, project, flag = "w") {
629
+ const keyDir = getKeystoreDir(project);
630
+ const keyPath = getKeyPath(stage, project);
631
+ await mkdir(keyDir, {
632
+ recursive: true,
633
+ mode: 448
634
+ });
635
+ const key = randomBytes(KEY_LENGTH).toString("hex");
636
+ await writeFile(keyPath, key, {
637
+ mode: 384,
638
+ encoding: "utf-8",
639
+ flag
640
+ });
641
+ await chmod(keyPath, 384);
642
+ return key;
643
+ }
644
+ /**
645
+ * Copy a key from where it used to be kept to where it is kept now.
646
+ *
647
+ * Copied, not moved: an older CLI on the same machine still reads the old
648
+ * place. Exclusive, so two processes migrating at once cannot overwrite a key
649
+ * one of them already put there.
650
+ */
651
+ async function migrateLegacyKey(stage, project) {
652
+ const legacy = legacyKeyPaths(stage, project).find((path) => existsSync(path));
653
+ if (!legacy) return false;
654
+ const keyPath = getKeyPath(stage, project);
655
+ await mkdir(getKeystoreDir(project), {
656
+ recursive: true,
657
+ mode: 448
658
+ });
659
+ try {
660
+ await copyFile(legacy, keyPath, constants.COPYFILE_EXCL);
661
+ } catch (error) {
662
+ if (error.code !== "EEXIST") throw error;
663
+ }
664
+ await chmod(keyPath, 384);
665
+ return true;
666
+ }
667
+ /**
668
+ * Read an encryption key for a stage, or `null` when there is none — after
669
+ * copying it over from where keys used to be kept, if it is still there.
670
+ */
671
+ async function readKey(stage, project) {
672
+ const keyPath = getKeyPath(stage, project);
673
+ if (!existsSync(keyPath) && !await migrateLegacyKey(stage, project)) return null;
674
+ return (await readFile(keyPath, "utf-8")).trim();
675
+ }
676
+ /**
677
+ * Read an encryption key for a stage, throwing {@link KeyNotFound} if there
678
+ * is none.
679
+ */
680
+ async function requireKey(stage, project) {
681
+ const key = await readKey(stage, project);
682
+ if (!key) throw new KeyNotFound(stage, project.key, getKeyPath(stage, project));
683
+ return key;
684
+ }
685
+ /**
686
+ * Get or create a key for a stage.
687
+ * If the key already exists, it is returned. Otherwise, a new key is generated.
688
+ */
689
+ async function getOrCreateKey(stage, project) {
690
+ const existingKey = await readKey(stage, project);
691
+ if (existingKey) return existingKey;
692
+ try {
693
+ return await generateKey(stage, project, "wx");
694
+ } catch (error) {
695
+ if (error.code !== "EEXIST") throw error;
696
+ return requireKey(stage, project);
697
+ }
698
+ }
699
+ //#endregion
700
+ //#region src/secrets/file.ts
701
+ /** Where the files live, relative to the project root. */
702
+ const SECRETS_DIR = ".gkm/secrets";
703
+ /** AES-256-GCM configuration */
704
+ const ALGORITHM = "aes-256-gcm";
705
+ const IV_LENGTH = 12;
706
+ /**
707
+ * A stage's secrets as an encrypted file on this machine:
708
+ * `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
709
+ * `keys/<namespace>/<project>/<stage>.key`.
710
+ *
711
+ * The local stage's store, always — its secrets belong to the machine running
712
+ * `gkm dev` — and a deployed stage's when `secrets.store` is `'file'`.
713
+ */
714
+ var FileSecretsStore = class {
715
+ root;
716
+ name = "file";
717
+ project;
718
+ /**
719
+ * @param project - Whose keys: a workspace's `keystoreProject()`. Outside a
451
720
  * workspace there is no config to name the project, so it is named the
452
721
  * way a workspace without `name` is — its package name, else its folder.
453
722
  */
@@ -850,7 +1119,8 @@ const DEFAULT_IMAGES = {
850
1119
  "redis-http": "hiett/serverless-redis-http:latest",
851
1120
  rabbitmq: "rabbitmq:4-management-alpine",
852
1121
  localstack: "floci/floci:2.1.0",
853
- caddy: "caddy:2-alpine"
1122
+ caddy: "caddy:2-alpine",
1123
+ openobserve: OPENOBSERVE_IMAGE
854
1124
  };
855
1125
  /**
856
1126
  * The ports each container listens on.
@@ -914,6 +1184,12 @@ const PORTS = {
914
1184
  key: "caddy",
915
1185
  inside: 443,
916
1186
  label: "https edge"
1187
+ }],
1188
+ openobserve: [{
1189
+ key: "openobserve",
1190
+ inside: LOGS_PORT,
1191
+ label: "openobserve",
1192
+ web: true
917
1193
  }]
918
1194
  };
919
1195
  /** The port key — and container name, for an image — of an API's fake. */
@@ -985,50 +1261,630 @@ function volumeOf(container) {
985
1261
  redis: "redis-data",
986
1262
  rabbitmq: "rabbitmq-data",
987
1263
  localstack: "localstack-data",
988
- caddy: "caddy-data"
1264
+ caddy: "caddy-data",
1265
+ openobserve: "openobserve-data"
989
1266
  }[container];
990
1267
  }
991
1268
  //#endregion
992
- //#region src/reconcile/emulator.ts
1269
+ //#region src/telemetry/edges.ts
1270
+ /**
1271
+ * Which processes use a `Telemetry` node — read off the manifest's edges.
1272
+ *
1273
+ * A process uses telemetry when its own declaration names the node: a
1274
+ * surface's `telemetry`, a worker's. The server the build generates for a
1275
+ * surface whose endpoints a glob finds also runs the workers' crons and
1276
+ * consumers under `gkm dev`, and is handed their edges' keys by `appEnvKeys`,
1277
+ * so a worker's edge counts for it too — the same rule, read the same way.
1278
+ *
1279
+ * A site's edge is recorded and never followed here: a site is handed none of
1280
+ * the node's keys (see `PUBLIC.telemetry`).
1281
+ */
1282
+ /** The `OTEL_*` keys a `Telemetry` node provides. */
1283
+ const TELEMETRY_ENV_KEYS = TELEMETRY_KEYS;
1284
+ /** Whether `key` is one of the keys a `Telemetry` node provides. */
1285
+ function isTelemetryKey(key) {
1286
+ return TELEMETRY_ENV_KEYS.includes(key);
1287
+ }
1288
+ /** The node a declaration's `telemetry` edge names, when it names one. */
1289
+ function nodeOf(manifest, id) {
1290
+ if (!id) return void 0;
1291
+ const node = manifest[id];
1292
+ return node?.kind === "telemetry" ? node : void 0;
1293
+ }
1294
+ /**
1295
+ * The `Telemetry` node the process for `id` — a surface or a worker, by
1296
+ * construct id — emits through, or undefined when it has no edge to one.
1297
+ */
1298
+ function telemetryOf(manifest, id) {
1299
+ const declaration = manifest[id];
1300
+ if (!declaration) return void 0;
1301
+ if (declaration.kind === "worker") return nodeOf(manifest, declaration.telemetry);
1302
+ if (declaration.kind !== "rest-api") return void 0;
1303
+ const own = nodeOf(manifest, declaration.telemetry);
1304
+ if (own) return own;
1305
+ if (declaration.endpoints.length > 0) return void 0;
1306
+ for (const other of Object.values(manifest)) {
1307
+ if (other.kind !== "worker") continue;
1308
+ const node = nodeOf(manifest, other.telemetry);
1309
+ if (node) return node;
1310
+ }
1311
+ }
993
1312
  /**
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.
997
- *
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.
1313
+ * The `Telemetry` node the app `appName` emits through — its surface's, or
1314
+ * undefined for a site, an app without one, or one with no edge.
1001
1315
  */
1002
- /** The emulator's single account. */
1003
- const EMULATOR_ACCOUNT = "000000000000";
1004
- const EMULATOR_REGION = "us-east-1";
1316
+ function appTelemetry(manifest, appName) {
1317
+ const id = Object.entries(manifest).find(([id, d]) => d.kind === "rest-api" && appKey(id) === appName)?.[0];
1318
+ return id ? telemetryOf(manifest, id) : void 0;
1319
+ }
1320
+ /** Whether any process in the manifest emits through a `Telemetry` node. */
1321
+ function usesTelemetry(manifest) {
1322
+ return Object.entries(manifest).some(([id, d]) => (d.kind === "rest-api" || d.kind === "worker") && telemetryOf(manifest, id) !== void 0);
1323
+ }
1005
1324
  /**
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.
1325
+ * One process's share of the telemetry keys a stage resolved: all of them,
1326
+ * named for the process, when it has an edge to the node — and none
1327
+ * otherwise.
1328
+ *
1329
+ * `env` is everything else the process is handed; its `OTEL_*` keys are
1330
+ * replaced, never merged, so a process without the edge cannot inherit them.
1011
1331
  */
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}`;
1332
+ function scopeTelemetryEnv(env, options) {
1333
+ const scoped = Object.fromEntries(Object.entries(env).filter(([key]) => !isTelemetryKey(key)));
1334
+ if (!options.uses || !options.telemetry) return scoped;
1335
+ const values = Object.fromEntries(Object.entries(options.telemetry).filter(([key]) => isTelemetryKey(key)));
1336
+ if (!values.OTEL_EXPORTER_OTLP_ENDPOINT) return scoped;
1337
+ return {
1338
+ ...scoped,
1339
+ ...values,
1340
+ OTEL_SERVICE_NAME: options.serviceName
1341
+ };
1016
1342
  }
1017
- function emulatorTopicArn(name) {
1018
- return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
1343
+ /** Whether the site `siteId` has an edge to a `Telemetry` node. */
1344
+ function siteUsesTelemetry(manifest, siteId) {
1345
+ const site = manifest[siteId];
1346
+ return site?.kind === "site" && nodeOf(manifest, site.telemetry) !== void 0;
1019
1347
  }
1020
- function emulatorQueueUrl(name, port) {
1021
- return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
1348
+ /**
1349
+ * Whether the client of the surface `surfaceId` propagates trace context:
1350
+ * when a site that calls it has an edge to a `Telemetry` node. One client
1351
+ * serves every site that calls the surface on the host, so one with the
1352
+ * edge turns it on; a site's image generates its own, from its own edge.
1353
+ */
1354
+ function surfaceClientTraced(manifest, surfaceId) {
1355
+ return Object.entries(manifest).some(([id, d]) => d.kind === "site" && siteUsesTelemetry(manifest, id) && d.dependencies.some((edge) => edge.target === surfaceId));
1356
+ }
1357
+ /**
1358
+ * Whether the site app `appName`'s clients propagate trace context, and at
1359
+ * what rate: when the site has an edge to a `Telemetry` construct, at the
1360
+ * stage's sample rate — a public value, every trace locally — and not at all
1361
+ * for a stage that sends no telemetry.
1362
+ */
1363
+ function siteClientTelemetry(manifest, appName, workspace, stage) {
1364
+ const id = Object.entries(manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[0];
1365
+ if (!id || !siteUsesTelemetry(manifest, id)) return void 0;
1366
+ const sampleRate = stageSampleRate(workspace.deploy?.telemetry, stage ?? workspace.stages.local, !stage || stage === workspace.stages.local);
1367
+ return sampleRate === void 0 ? void 0 : { sampleRate };
1022
1368
  }
1023
1369
  //#endregion
1024
- //#region src/reconcile/compose.ts
1370
+ //#region src/reconcile/caddyfile.ts
1025
1371
  /**
1026
- * The compose document a plan implies.
1027
- *
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.
1372
+ * The kinds the edge serves.
1373
+ *
1374
+ * Everything that owns a *public address*. Deployed, each of these has a real
1375
+ * hostname and a certificate, so this is what makes the local shape the same
1376
+ * one rather than a near-enough one — and the application is indifferent
1377
+ * either way, because it reads whichever address was injected and never
1378
+ * composes one.
1379
+ *
1380
+ * Adding a kind here is the whole of adding it to the edge.
1381
+ */
1382
+ const EDGE_KINDS = {
1383
+ "file-server": true,
1384
+ "rest-api": true,
1385
+ site: true
1386
+ };
1387
+ /**
1388
+ * How the edge reaches the host, from inside its container.
1389
+ *
1390
+ * A surface and a site are served by processes `gkm dev` starts on the host,
1391
+ * not by containers — so the edge has to leave Docker's network to reach them.
1392
+ * Docker Desktop resolves this name already; on Linux the compose definition
1393
+ * maps it to the host gateway.
1394
+ */
1395
+ const HOST_GATEWAY = "host.docker.internal";
1396
+ /**
1397
+ * The suffix every local host shares.
1398
+ *
1399
+ * `.localhost` rather than a made-up TLD because it resolves to loopback
1400
+ * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
1401
+ * subdomains of it, and Caddy issues internal certificates for it by default.
1402
+ */
1403
+ const LOCAL_TLD = "localhost";
1404
+ /**
1405
+ * The sites a plan implies, one per file server.
1406
+ *
1407
+ * The leading label is the *server's* stage-scoped name rather than the
1408
+ * bucket's, which is the difference that matters: two servers over one bucket
1409
+ * are a legitimate arrangement — two cache behaviours, one origin — and naming
1410
+ * the host after the bucket would make them collide.
1411
+ */
1412
+ function sitesFor(plan, project, addresses = {}) {
1413
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
1414
+ const sites = [];
1415
+ for (const resource of plan.resources) {
1416
+ if (!EDGE_KINDS[resource.kind]) continue;
1417
+ const host = hostFor(resource, project);
1418
+ if (resource.kind === "file-server") {
1419
+ const origin = resource.of ? byId.get(resource.of) : void 0;
1420
+ if (!origin) continue;
1421
+ sites.push({
1422
+ host,
1423
+ upstream: "http://minio:9000",
1424
+ rewrite: `/${origin.name}{uri}`
1425
+ });
1426
+ continue;
1427
+ }
1428
+ const address = addresses[resource.id];
1429
+ if (!address) continue;
1430
+ const port = portOf(address);
1431
+ if (!port) continue;
1432
+ sites.push({
1433
+ host,
1434
+ upstream: `http://${HOST_GATEWAY}:${port}`
1435
+ });
1436
+ }
1437
+ return sites;
1438
+ }
1439
+ /** The port a local address answers on, defaulting by scheme. */
1440
+ function portOf(address) {
1441
+ try {
1442
+ const { port, protocol } = new URL(address);
1443
+ return port || (protocol === "https:" ? "443" : "80");
1444
+ } catch {
1445
+ return;
1446
+ }
1447
+ }
1448
+ /**
1449
+ * Where one file server answers, including the stage its name carries.
1450
+ *
1451
+ * The project is a label rather than a decoration: it is what keeps two
1452
+ * checkouts from answering on one address. Omitted when there is no project to
1453
+ * name, because an empty label makes `uploads..localhost` — a host nothing
1454
+ * resolves and no certificate can be issued for.
1455
+ */
1456
+ function hostFor(resource, project) {
1457
+ return [
1458
+ resource.root && project ? stageOf(resource) : labelOf(resource),
1459
+ project,
1460
+ LOCAL_TLD
1461
+ ].filter(Boolean).join(".");
1462
+ }
1463
+ /**
1464
+ * A resource's own label: its `subdomain` where it declared one — the label a
1465
+ * deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
1466
+ * still answer on different hosts.
1467
+ */
1468
+ function labelOf(resource) {
1469
+ if (!resource.subdomain) return resource.name;
1470
+ const stage = stageOf(resource);
1471
+ return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
1472
+ }
1473
+ /** The stage a resource's name carries, or nothing for the local stage's. */
1474
+ function stageOf(resource) {
1475
+ const base = resource.id.toLowerCase();
1476
+ return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
1477
+ }
1478
+ /**
1479
+ * The static root config, which only ever imports.
1480
+ *
1481
+ * Written once and identical for every stage, because the *sites* are what
1482
+ * differ and they live in their own files. That split is not tidiness: one
1483
+ * container serves every stage — the same way one Postgres holds `orders` and
1484
+ * `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
1485
+ * routes `gkm dev` is serving, and each command silently breaking the other.
1486
+ */
1487
+ function caddyfileRoot() {
1488
+ return `${GENERATED}
1489
+ {
1490
+ # No ACME, no public DNS: the CA is local and so are the names.
1491
+ local_certs
1492
+ auto_https disable_redirects
1493
+ }
1494
+
1495
+ # One file per stage, so two stages share this edge the way they already share
1496
+ # one Postgres and one MinIO.
1497
+ import /etc/caddy/sites/*.caddy
1498
+ `;
1499
+ }
1500
+ /**
1501
+ * One stage's sites, as the file imported above.
1502
+ *
1503
+ * `rewrite` rather than a path prefix on the address: the bucket is the
1504
+ * origin's business, and the client asks for the key it stored.
1505
+ *
1506
+ * `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
1507
+ * and signs on the Host header, so forwarding the requested hostname would make
1508
+ * it look for a bucket named after the domain.
1509
+ */
1510
+ function toCaddyfile(sites) {
1511
+ if (sites.length === 0) return `${GENERATED}
1512
+ # No file server is declared in this stage, so it routes nothing.
1513
+ `;
1514
+ const blocks = sites.map(({ host, upstream, rewrite }) => `
1515
+ https://${host} {
1516
+ tls internal
1517
+
1518
+ reverse_proxy ${upstream} {${rewrite ? `
1519
+ rewrite ${rewrite}
1520
+ # MinIO routes and signs on the Host header, so forwarding the requested
1521
+ # hostname would make it look for a bucket named after the domain.
1522
+ header_up Host {upstream_hostport}` : `
1523
+ # The application reads whatever address was injected, so it has to see
1524
+ # the one a caller used — that is what makes a redirect, a cookie domain
1525
+ # and a generated link point back here rather than at the upstream.
1526
+ header_up Host {host}
1527
+ header_up X-Forwarded-Proto {scheme}`}
1528
+ }
1529
+ }`);
1530
+ return `${GENERATED}${blocks.join("\n")}\n`;
1531
+ }
1532
+ const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
1533
+ #
1534
+ # One host per declared file server, in front of the object store that holds its
1535
+ # objects. This is the shape a file server has deployed — a domain serving a
1536
+ # bucket — which MinIO alone cannot produce: its virtual-host mode reads the
1537
+ # leading label as the bucket name.
1538
+ #
1539
+ # Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
1540
+ # root and point Node at it, so nothing has to be installed to use these URLs
1541
+ # from code; a browser needs the root trusted once.
1542
+ `;
1543
+ //#endregion
1544
+ //#region src/reconcile/plan.ts
1545
+ /**
1546
+ * The reconcile plan — what a manifest wants to exist, for one stage.
1547
+ *
1548
+ * Pure. Turning the plan into running containers and provisioned resources is
1549
+ * the applier's job; deciding *what* should exist is this, so the decisions can
1550
+ * be asserted as data rather than by starting Docker.
1551
+ *
1552
+ * One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
1553
+ * reconciles `test`; they differ only in what the resources are called, never in
1554
+ * what infrastructure exists. Roles are cluster-scoped in Postgres while grants
1555
+ * are per-object, so one container and one role pair serve both.
1556
+ */
1557
+ /**
1558
+ * Which container serves a kind.
1559
+ *
1560
+ * Derived kinds map to the same container as their parent — a schema tenant
1561
+ * lives in its parent's Postgres — so the set is deduplicated rather than
1562
+ * counted.
1563
+ */
1564
+ const CONTAINERS = {
1565
+ database: "postgres",
1566
+ "database-reader": "postgres",
1567
+ "database-schema": "postgres",
1568
+ objects: "minio",
1569
+ "file-server": "caddy",
1570
+ email: "mailpit"
1571
+ };
1572
+ /**
1573
+ * The container a cache backend needs locally.
1574
+ *
1575
+ * Each one matches the protocol its deployed form speaks, which is the property
1576
+ * worth having: a cache that behaves differently in the two places is worse than
1577
+ * a slower one.
1578
+ *
1579
+ * `db` is deliberately absent, the same way `pgboss` is absent from
1580
+ * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
1581
+ * declares, so mapping it here would start a container for a project that needs
1582
+ * none.
1583
+ */
1584
+ const CACHE_CONTAINERS = {
1585
+ upstash: "redis-http",
1586
+ elasticache: "redis",
1587
+ redis: "redis"
1588
+ };
1589
+ /**
1590
+ * Kinds that resolve a value without anything running.
1591
+ *
1592
+ * A secret is the case: it has no address, so there is nothing to start and
1593
+ * nothing to connect to — but it still appears in the plan, because the target
1594
+ * still has to resolve it.
1595
+ */
1596
+ const CONTAINERLESS = {
1597
+ secret: true,
1598
+ credential: true,
1599
+ encryption: true,
1600
+ "rest-api": true,
1601
+ site: true,
1602
+ "mobile-app": true,
1603
+ "external-api": true,
1604
+ telemetry: true
1605
+ };
1606
+ /** The container that collects telemetry locally, and serves its UI. */
1607
+ const TELEMETRY_CONTAINER = "openobserve";
1608
+ /**
1609
+ * Containers that cannot run alone.
1610
+ *
1611
+ * The one case is the cache: `serverless-redis-http` is a proxy and needs the
1612
+ * Redis it proxies. Kept here rather than in the compose definition so the plan
1613
+ * -- which is what decides what must be running -- still names everything.
1614
+ */
1615
+ const REQUIRES = { "redis-http": ["redis"] };
1616
+ /**
1617
+ * The role each kind's one key plays, where it is not a URL.
1618
+ *
1619
+ * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
1620
+ * construct declares, this is what the local target resolves, and both derive
1621
+ * the key from the same `provideKey` so they cannot disagree.
1622
+ */
1623
+ const ROLES = {
1624
+ queue: "publisherConnectionString",
1625
+ topic: "publisherConnectionString",
1626
+ credential: "credentials",
1627
+ "mobile-app": "scheme"
1628
+ };
1629
+ /** The kinds whose container is the events backend's rather than their own. */
1630
+ const EVENT_KINDS = {
1631
+ queue: true,
1632
+ topic: true
1633
+ };
1634
+ /**
1635
+ * Which kinds provision something *inside* their container.
1636
+ *
1637
+ * A database creates a database, a bucket creates a bucket; mail creates
1638
+ * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
1639
+ * to name. Both still appear in the plan, because both still resolve a URL; the
1640
+ * flag is the difference between "must be running" and "must be created".
1641
+ */
1642
+ const PROVISIONS = {
1643
+ database: true,
1644
+ "database-reader": true,
1645
+ "database-schema": true,
1646
+ objects: true,
1647
+ cache: true,
1648
+ "file-server": true
1649
+ };
1650
+ /**
1651
+ * The container each events backend needs, where it needs one of its own.
1652
+ *
1653
+ * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
1654
+ * schema tenant in a database the manifest already declares, so mapping it here
1655
+ * would start a Postgres for a project that declared no database at all.
1656
+ *
1657
+ * This one comes from config rather than from a declaration because there are
1658
+ * no `topic` or `queue` kinds yet. When they land the mapping moves to
1659
+ * {@link CONTAINERS} keyed by kind, and this constant goes with them.
1660
+ */
1661
+ const EVENT_CONTAINERS = {
1662
+ sns: "localstack",
1663
+ rabbitmq: "rabbitmq"
1664
+ };
1665
+ /** The backend a project gets when it declares queues or topics and says nothing. */
1666
+ const DEFAULT_EVENTS = "pgboss";
1667
+ /**
1668
+ * How a resource of each kind separates its stage suffix.
1669
+ *
1670
+ * Postgres identifiers are snake_case and a hyphen would need quoting
1671
+ * everywhere; buckets and queues are kebab and disallow underscores in some
1672
+ * providers. The suffix is the same word either way.
1673
+ */
1674
+ const SEPARATORS = {
1675
+ database: "_",
1676
+ "database-reader": "_",
1677
+ "database-schema": "_",
1678
+ objects: "-"
1679
+ };
1680
+ /**
1681
+ * The stage-scoped name for a resource.
1682
+ *
1683
+ * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
1684
+ * developer's psql history, saved connections, and muscle memory survive a
1685
+ * change of stage name. Every other stage is suffixed.
1686
+ */
1687
+ function resourceName(id, kind, stage, localStage) {
1688
+ const base = id.toLowerCase();
1689
+ if (stage === localStage) return base;
1690
+ return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
1691
+ }
1692
+ /**
1693
+ * The container a kind needs, or `undefined` if it needs none.
1694
+ *
1695
+ * Queues and topics have no container of their own — they live in whichever
1696
+ * broker the project selected, which is why the backend is an argument here
1697
+ * rather than a second pass that adds containers after the fact.
1698
+ */
1699
+ function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
1700
+ if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
1701
+ if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
1702
+ if (kind === "cache") {
1703
+ if (derived && !holdsEveryCache(cache)) return CONTAINERS.database;
1704
+ return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
1705
+ }
1706
+ return CONTAINERS[kind];
1707
+ }
1708
+ /**
1709
+ * Build the plan for a manifest and stage.
1710
+ *
1711
+ * `order` is the caller's provisioning order — parents before children — which
1712
+ * `provisionOrder` produces from the manifest. Passing it in keeps this
1713
+ * function free of the traversal and makes the ordering assertable on its own.
1714
+ */
1715
+ function planFor(manifest, stage, order, options = {}) {
1716
+ const containers = /* @__PURE__ */ new Set();
1717
+ const resources = [];
1718
+ const fakes = {};
1719
+ const events = options.events ?? "pgboss";
1720
+ const cache = options.cache ?? DEFAULT_CACHE.aws;
1721
+ for (const id of order) {
1722
+ const declaration = manifest[id];
1723
+ if (!declaration) continue;
1724
+ const local = declaration.kind === "external-api" ? options.fakes?.[id] : void 0;
1725
+ const fake = local && {
1726
+ key: fakeKey(id),
1727
+ ...local
1728
+ };
1729
+ if (fake) fakes[fake.key] = {
1730
+ id,
1731
+ ...fake.image ? {
1732
+ image: fake.image,
1733
+ port: fake.port
1734
+ } : {}
1735
+ };
1736
+ const detached = declaration.kind === "cache" && holdsEveryCache(cache);
1737
+ const container = fake?.image ? fake.key : declaration.kind === "telemetry" ? usesTelemetry(manifest) ? TELEMETRY_CONTAINER : void 0 : containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
1738
+ if (!container && !CONTAINERLESS[declaration.kind]) continue;
1739
+ if (container) {
1740
+ containers.add(container);
1741
+ for (const required of REQUIRES[container] ?? []) containers.add(required);
1742
+ }
1743
+ resources.push({
1744
+ id,
1745
+ kind: declaration.kind,
1746
+ ...container ? { container } : {},
1747
+ name: resourceName(id, declaration.kind, stage, options.localStage),
1748
+ envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
1749
+ provisions: PROVISIONS[declaration.kind] === true,
1750
+ ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
1751
+ ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
1752
+ ...declaration.kind === "mobile-app" ? {
1753
+ publicEnv: publicEnvFor(declaration, manifest),
1754
+ ...declaration.scheme ? { scheme: declaration.scheme } : {}
1755
+ } : {},
1756
+ ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
1757
+ ...(declaration.kind === "rest-api" || declaration.kind === "site") && declaration.subdomain ? { subdomain: declaration.subdomain } : {},
1758
+ ...fake ? { fake: {
1759
+ key: fake.key,
1760
+ credentials: fake.credentials
1761
+ } } : {},
1762
+ ...declaration.kind === "external-api" && !fake ? { url: declaration.url } : {},
1763
+ ..."of" in declaration && !detached ? { of: declaration.of } : {},
1764
+ ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
1765
+ ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
1766
+ ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
1767
+ ..."table" in declaration && declaration.table && !detached ? { table: declaration.table } : {}
1768
+ });
1769
+ }
1770
+ if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
1771
+ for (const extra of options.extraContainers ?? []) containers.add(extra);
1772
+ if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
1773
+ if (cache === "db") {
1774
+ const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
1775
+ if (placeless.length > 0) {
1776
+ const databases = resources.filter((r) => r.kind === "database");
1777
+ const [only] = databases;
1778
+ if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
1779
+ if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
1780
+ for (const resource of placeless) resource.of = only.id;
1781
+ }
1782
+ }
1783
+ const sites = resources.filter((r) => r.kind === "site");
1784
+ const root = rootSite(sites.map((r) => {
1785
+ const declared = manifest[r.id];
1786
+ return {
1787
+ name: appKey(r.id),
1788
+ ...declared?.kind === "site" && declared.root ? { root: true } : {}
1789
+ };
1790
+ }));
1791
+ for (const site of sites) if (appKey(site.id) === root) site.root = true;
1792
+ const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
1793
+ return {
1794
+ stage,
1795
+ events,
1796
+ cache,
1797
+ containers: [...containers],
1798
+ resources,
1799
+ ...Object.keys(fakes).length ? { fakes } : {},
1800
+ ...workerBroker ? { workerBroker: true } : {}
1801
+ };
1802
+ }
1803
+ /**
1804
+ * Queues or topics were declared with pg-boss and no database to hold them.
1805
+ *
1806
+ * Not a reconcile failure but a manifest one: the fix is a line of application
1807
+ * code either way — declare a database, or select a backend that brings its own
1808
+ * broker.
1809
+ */
1810
+ var CacheNeedsDatabase = class extends Error {
1811
+ ids;
1812
+ constructor(ids) {
1813
+ super(`A cache backed by the database needs a declared database, and none was declared, and on this deploy target that is where a cache lives. Declare one. Caches affected: ${ids.join(", ")}.`);
1814
+ this.ids = ids;
1815
+ this.name = "CacheNeedsDatabase";
1816
+ }
1817
+ };
1818
+ /**
1819
+ * A `'db'` cache — a server target's default — in an app that declares more
1820
+ * than one database.
1821
+ *
1822
+ * The target says *a* database and the graph offers several, so there is no
1823
+ * answer to give — and the wrong kind of failure would be to pick one, since a
1824
+ * cache silently landing in the wrong database is a bug that surfaces as
1825
+ * missing rows much later. The fix is a stronger statement in application code:
1826
+ * `orders.cache('Sessions')` names the database, which is a fact about the
1827
+ * application rather than a deployment choice.
1828
+ */
1829
+ var CacheIsAmbiguous = class extends Error {
1830
+ ids;
1831
+ databases;
1832
+ constructor(ids, databases) {
1833
+ super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than as a bare new Cache(). Caches affected: ${ids.join(", ")}.`);
1834
+ this.ids = ids;
1835
+ this.databases = databases;
1836
+ this.name = "CacheIsAmbiguous";
1837
+ }
1838
+ };
1839
+ var PgBossNeedsDatabase = class extends Error {
1840
+ ids;
1841
+ constructor(ids) {
1842
+ super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one: on this deploy target, events are carried by pg-boss. Queues and topics affected: ${ids.join(", ")}.`);
1843
+ this.ids = ids;
1844
+ this.name = "PgBossNeedsDatabase";
1845
+ }
1846
+ };
1847
+ //#endregion
1848
+ //#region src/reconcile/emulator.ts
1849
+ /**
1850
+ * The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
1851
+ * locally — the facts the compose file, the env writer and the applier must
1852
+ * agree on.
1853
+ *
1854
+ * The emulator has one account and answers with deterministic ARNs and queue
1855
+ * URLs, which is what lets a connection string be composed before the topic
1856
+ * or queue it names has been created.
1857
+ */
1858
+ /** The emulator's single account. */
1859
+ const EMULATOR_ACCOUNT = "000000000000";
1860
+ const EMULATOR_REGION = "us-east-1";
1861
+ /**
1862
+ * The emulator's access key id. The `LSIA` prefix is what LocalStack required
1863
+ * on issued keys, and LocalStack reads the account from the id — so it is
1864
+ * fixed, where the secret key beside it is generated per machine (see
1865
+ * `localCredentials.ts`). Floci does not care; keeping it means a project
1866
+ * that pins the old image still works.
1867
+ */
1868
+ const EMULATOR_ACCESS_KEY_ID = "LSIAQAAAAAAVNCBMPNSG";
1869
+ /** The emulator's address from the host, on the port it was published on. */
1870
+ function emulatorEndpoint(port) {
1871
+ return `http://localhost:${port}`;
1872
+ }
1873
+ function emulatorTopicArn(name) {
1874
+ return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
1875
+ }
1876
+ function emulatorQueueUrl(name, port) {
1877
+ return `${emulatorEndpoint(port)}/${EMULATOR_ACCOUNT}/${name}`;
1878
+ }
1879
+ //#endregion
1880
+ //#region src/reconcile/compose.ts
1881
+ /**
1882
+ * The compose document a plan implies.
1883
+ *
1884
+ * The third declaration of the same containers disappears here: today a
1885
+ * `services: ['postgres','minio']` block is kept in step with the code by hand,
1886
+ * which is the triplication this design exists to remove, in the one place you
1887
+ * touch every day. A `database` implies Postgres; the list is derived.
1032
1888
  *
1033
1889
  * Returns an object rather than a string for the same reason `plan` is pure —
1034
1890
  * what should exist is asserted as data, and turning it into YAML is one line at
@@ -1271,6 +2127,34 @@ function define(container, image, published, credentials) {
1271
2127
  start_period: "10s"
1272
2128
  }
1273
2129
  };
2130
+ case "openobserve": {
2131
+ const login = credentials.logs;
2132
+ return {
2133
+ image,
2134
+ restart: "unless-stopped",
2135
+ ports: published,
2136
+ environment: {
2137
+ [LOGS_EMAIL_KEY]: login.email,
2138
+ [LOGS_PASSWORD_KEY]: login.password,
2139
+ ZO_DATA_DIR: "/data",
2140
+ ZO_COMPACT_DATA_RETENTION_DAYS: String(30),
2141
+ ZO_TELEMETRY: "false"
2142
+ },
2143
+ volumes: ["openobserve-data:/data"],
2144
+ healthcheck: {
2145
+ test: [
2146
+ "CMD",
2147
+ "/openobserve",
2148
+ "node",
2149
+ "status"
2150
+ ],
2151
+ interval: "10s",
2152
+ timeout: "5s",
2153
+ retries: 12,
2154
+ start_period: "20s"
2155
+ }
2156
+ };
2157
+ }
1274
2158
  default: throw new UnknownContainer(container);
1275
2159
  }
1276
2160
  }
@@ -1881,987 +2765,470 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
1881
2765
  }
1882
2766
  /**
1883
2767
  * Generate a Dockerfile for Next.js frontend apps using standalone output.
1884
- *
1885
- * Pruned at the build root, Next traces from there, so the standalone
1886
- * server lands at `<appPath>/server.js` under `.next/standalone`.
1887
- * @internal Exported for testing
1888
- */
1889
- function generateNextjsDockerfile(options) {
1890
- const { port, appPath, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
1891
- return `# syntax=docker/dockerfile:1
1892
- # Next.js standalone Dockerfile, built from a turbo-pruned slice
1893
-
1894
- ${prunerStage(options)}
1895
-
1896
- ${depsStage(options)}
1897
-
1898
- ${builderStage(options, { args: publicUrlArgs })}
1899
-
1900
- ${siteWorkspaceSteps(options)}
1901
-
1902
- # Ensure public directory exists (may be empty for scaffolded projects)
1903
- RUN mkdir -p ${appPath}/public
1904
-
1905
- ENV NEXT_TELEMETRY_DISABLED=1
1906
-
1907
- # Build the application
1908
- ${siteBuild(options)}
1909
-
1910
- # The runner needs the standalone server; without it there is nothing to run.
1911
- RUN if [ ! -d ${appPath}/.next/standalone ]; then \\
1912
- echo "${appPath}/.next/standalone was not built: set output: 'standalone' in next.config so the image can run the app without node_modules." >&2; \\
1913
- exit 1; \\
1914
- fi
1915
-
1916
- # Stage 4: Production
1917
- FROM ${options.baseImage} AS runner
1918
-
1919
- WORKDIR /app
1920
-
1921
- # Install tini for proper signal handling
1922
- RUN apk add --no-cache tini
1923
-
1924
- # Create non-root user
1925
- RUN addgroup --system --gid 1001 nodejs && \\
1926
- adduser --system --uid 1001 nextjs
1927
-
1928
- # Set environment
1929
- ENV NODE_ENV=production
1930
- ENV NEXT_TELEMETRY_DISABLED=1
1931
- ENV PORT=${port}
1932
- ENV HOSTNAME="0.0.0.0"
1933
-
1934
- # The standalone server, with its static files and public directory beside it
1935
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
1936
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
1937
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
1938
-
1939
- USER nextjs
1940
-
1941
- EXPOSE ${port}
1942
-
1943
- ${siteHealthcheck(port)}
1944
-
1945
- ENTRYPOINT ["/sbin/tini", "--"]
1946
- CMD ["node", "${appPath}/server.js"]
1947
- `;
1948
- }
1949
- /**
1950
- * Generate a Dockerfile for a gkm backend: pruned, installed, and bundled by
1951
- * `gkm build --provider server --production` inside the image. The runner is
1952
- * `server.mjs` alone — plus any package the bundle leaves external.
1953
- * @internal Exported for testing
1954
- */
1955
- function generateBackendDockerfile(options) {
1956
- return gkmBundleDockerfile(options, {
1957
- title: "Backend Dockerfile",
1958
- bundle: "server.mjs",
1959
- as: "server.mjs",
1960
- expose: true
1961
- });
1962
- }
1963
- /** The port a worker answers its health check on, inside its container. */
1964
- const WORKER_PORT = 3e3;
1965
- /**
1966
- * A Worker's image: the same build as its host app's backend — a pruned
1967
- * slice, `gkm build --provider server --production` in the image, with the
1968
- * encrypted credentials from the `gkm_credentials` secret — and a runner that
1969
- * is the worker's bundle alone. It publishes nothing: its one route is the
1970
- * health check the HEALTHCHECK asks.
1971
- * @internal Exported for testing
1972
- */
1973
- function generateWorkerDockerfile(options) {
1974
- return gkmBundleDockerfile(options, {
1975
- title: `Worker Dockerfile (${options.worker})`,
1976
- bundle: options.bundle,
1977
- as: "worker.mjs",
1978
- expose: false
1979
- });
1980
- }
1981
- function gkmBundleDockerfile(options, runner) {
1982
- const { port, healthCheckPath = "/health" } = options;
1983
- const { app } = layout(options);
1984
- const externals = externalsStages(options, app);
1985
- return `# syntax=docker/dockerfile:1
1986
- # ${runner.title}: a turbo-pruned slice, bundled by gkm build in the image
1987
-
1988
- ${prunerStage(options)}
1989
-
1990
- ${depsStage(options)}
1991
-
1992
- ${builderStage(options)}
1993
-
1994
- # Bundle the production server, with the encrypted credentials embedded
1995
- ${credentialsStep(app, `cd ${app} && ${gkmCommand(`build --provider server --production${options.cache ? ` --cache ${options.cache}` : ""}`)}`)}
1996
- ${externals.stage}
1997
- # Production
1998
- FROM ${options.baseImage} AS runner
1999
-
2000
- WORKDIR /app
2001
-
2002
- RUN apk add --no-cache tini
2003
-
2004
- RUN addgroup --system --gid 1001 nodejs && \\
2005
- adduser --system --uid 1001 hono
2006
-
2007
- # The bundled ${runner.as === "server.mjs" ? "server" : "worker"}
2008
- COPY --from=builder --chown=hono:nodejs ${app}/.gkm/server/dist/${runner.bundle} ./${runner.as === runner.bundle ? "" : runner.as}${externals.copy}
2009
-
2010
- ENV NODE_ENV=production
2011
- ENV PORT=${port}
2012
-
2013
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
2014
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
2015
-
2016
- USER hono
2017
- ${runner.expose ? `\nEXPOSE ${port}\n` : ""}
2018
- ENTRYPOINT ["/sbin/tini", "--"]
2019
- CMD ["node", "${runner.as}"]
2020
- `;
2021
- }
2022
- /**
2023
- * Generate a Dockerfile for apps with a custom entry point.
2024
- * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
2025
- * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
2026
- * @internal Exported for testing
2027
- */
2028
- function generateEntryDockerfile(options) {
2029
- const { port, entry, healthCheckPath = "/health" } = options;
2030
- const { app } = layout(options);
2031
- const esbuild = (defines) => `esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \\
2032
- --outfile=dist/index.mjs --packages=bundle \\
2033
- --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'${defines}`;
2034
- return `# syntax=docker/dockerfile:1
2035
- # Entry-based Dockerfile: a turbo-pruned slice, bundled by esbuild in the image
2036
-
2037
- ${prunerStage(options)}
2038
-
2039
- ${depsStage(options)}
2040
-
2041
- ${builderStage(options)}
2042
-
2043
- # Bundle the entry point with every dependency (dist/index.mjs), embedding the
2044
- # encrypted credentials when there are any.
2045
- ARG GKM_CIPHERTEXT_HASH=""
2046
- RUN --mount=type=secret,id=gkm_credentials,required=false \\
2047
- echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
2048
- cd ${app} && export ${binPath(options)} && \\
2049
- if [ -s /run/secrets/gkm_credentials ]; then \\
2050
- CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \\
2051
- IV=$(sed -n 2p /run/secrets/gkm_credentials) && \\
2052
- ${esbuild(` \\
2053
- --define:__GKM_ENCRYPTED_CREDENTIALS__="\\"$CREDS\\"" \\
2054
- --define:__GKM_CREDENTIALS_IV__="\\"$IV\\""`)}; \\
2055
- else \\
2056
- ${esbuild("")}; \\
2057
- fi
2058
-
2059
- # Production
2060
- FROM ${options.baseImage} AS runner
2061
-
2062
- WORKDIR /app
2063
-
2064
- RUN apk add --no-cache tini
2065
-
2066
- RUN addgroup --system --gid 1001 nodejs && \\
2067
- adduser --system --uid 1001 app
2068
-
2069
- # Copy bundled output only (no node_modules needed - fully bundled)
2070
- COPY --from=builder --chown=app:nodejs ${app}/dist/index.mjs ./
2071
-
2072
- ENV NODE_ENV=production
2073
- ENV PORT=${port}
2074
-
2075
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
2076
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
2077
-
2078
- USER app
2079
-
2080
- EXPOSE ${port}
2081
-
2082
- ENTRYPOINT ["/sbin/tini", "--"]
2083
- CMD ["node", "index.mjs"]
2084
- `;
2085
- }
2086
- /**
2087
- * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
2088
- * Remix). Builds with turbo, then runs the framework's production start
2089
- * script.
2090
- *
2091
- * Public URL build args are declared so framework bundlers (Vite, Vinxi)
2092
- * can inline them at build time. Per-framework prefix is the caller's job.
2093
- * @internal Exported for testing
2094
- */
2095
- function generateNodeWebDockerfile(options) {
2096
- const { port, appPath } = options;
2097
- return `# syntax=docker/dockerfile:1
2098
- # Node SSR web Dockerfile (TanStack Start / Remix), from a turbo-pruned slice
2099
-
2100
- ${prunerStage(options)}
2101
-
2102
- ${depsStage(options)}
2103
-
2104
- ${builderStage(options, { args: options.publicUrlArgs ?? [] })}
2105
-
2106
- ${siteWorkspaceSteps(options)}
2107
-
2108
- ${siteBuild(options)}
2109
-
2110
- FROM ${options.baseImage} AS runner
2111
- WORKDIR /app
2112
- RUN apk add --no-cache tini
2113
- RUN addgroup --system --gid 1001 nodejs && \\
2114
- adduser --system --uid 1001 app
2115
-
2116
- # Copy the whole built workspace — frameworks differ on output paths
2117
- COPY --from=builder --chown=app:nodejs /app/ ./
2118
-
2119
- ENV NODE_ENV=production
2120
- ENV PORT=${port}
2121
- ENV HOSTNAME="0.0.0.0"
2122
-
2123
- USER app
2124
- EXPOSE ${port}
2125
-
2126
- ${siteHealthcheck(port)}
2127
-
2128
- ENTRYPOINT ["/sbin/tini", "--"]
2129
- # npm ships with node, and runs the start script whichever package manager
2130
- # installed it: the runner needs no other.
2131
- CMD ["sh", "-c", "cd ${appPath} && npm start"]
2132
- `;
2133
- }
2134
- /**
2135
- * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
2136
- * turbo, then serves it with Caddy: Vite's hashed `/assets/*` cached for a
2137
- * year, everything else — `index.html`, and every deep link that falls back
2138
- * to it — revalidated on each request.
2768
+ *
2769
+ * Pruned at the build root, Next traces from there, so the standalone
2770
+ * server lands at `<appPath>/server.js` under `.next/standalone`.
2139
2771
  * @internal Exported for testing
2140
2772
  */
2141
- function generateViteStaticDockerfile(options) {
2142
- const { port, appPath } = options;
2773
+ function generateNextjsDockerfile(options) {
2774
+ const { port, appPath, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
2143
2775
  return `# syntax=docker/dockerfile:1
2144
- # Vite SPA Dockerfile — a turbo-pruned slice built to static files, served by Caddy
2776
+ # Next.js standalone Dockerfile, built from a turbo-pruned slice
2145
2777
 
2146
2778
  ${prunerStage(options)}
2147
2779
 
2148
2780
  ${depsStage(options)}
2149
2781
 
2150
- ${builderStage(options, { args: options.publicUrlArgs ?? [] })}
2782
+ ${builderStage(options, { args: publicUrlArgs })}
2151
2783
 
2152
2784
  ${siteWorkspaceSteps(options)}
2153
2785
 
2786
+ # Ensure public directory exists (may be empty for scaffolded projects)
2787
+ RUN mkdir -p ${appPath}/public
2788
+
2789
+ ENV NEXT_TELEMETRY_DISABLED=1
2790
+
2791
+ # Build the application
2154
2792
  ${siteBuild(options)}
2155
2793
 
2156
- FROM ${STATIC_SITE_IMAGE} AS runner
2794
+ # The runner needs the standalone server; without it there is nothing to run.
2795
+ RUN if [ ! -d ${appPath}/.next/standalone ]; then \\
2796
+ echo "${appPath}/.next/standalone was not built: set output: 'standalone' in next.config so the image can run the app without node_modules." >&2; \\
2797
+ exit 1; \\
2798
+ fi
2157
2799
 
2158
- COPY <<'EOF' /etc/caddy/Caddyfile
2159
- {
2160
- admin off
2161
- auto_https off
2162
- persist_config off
2163
- }
2800
+ # Stage 4: Production
2801
+ FROM ${options.baseImage} AS runner
2164
2802
 
2165
- :${port} {
2166
- root * /srv
2167
- encode zstd gzip
2803
+ WORKDIR /app
2168
2804
 
2169
- # Vite names every built asset by its content hash: a changed file is a
2170
- # new URL, so the old one can be cached for good.
2171
- handle /assets/* {
2172
- header Cache-Control "public, max-age=31536000, immutable"
2173
- file_server
2174
- }
2805
+ # Install tini for proper signal handling
2806
+ RUN apk add --no-cache tini
2175
2807
 
2176
- # index.html, and every client-side route that falls back to it, is
2177
- # asked for again each time so a deploy is seen at once.
2178
- handle {
2179
- header Cache-Control "no-cache"
2180
- try_files {path} /index.html
2181
- file_server
2182
- }
2183
- }
2184
- EOF
2808
+ # Create non-root user
2809
+ RUN addgroup --system --gid 1001 nodejs && \\
2810
+ adduser --system --uid 1001 nextjs
2185
2811
 
2186
- COPY --from=builder /app/${appPath}/dist /srv
2812
+ # Set environment
2813
+ ENV NODE_ENV=production
2814
+ ENV NEXT_TELEMETRY_DISABLED=1
2815
+ ENV PORT=${port}
2816
+ ENV HOSTNAME="0.0.0.0"
2187
2817
 
2188
- RUN addgroup -S -g 1001 site && adduser -S -u 1001 -G site site && \\
2189
- chown -R site:site /data /config
2818
+ # The standalone server, with its static files and public directory beside it
2819
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
2820
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
2821
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
2190
2822
 
2191
- USER site
2823
+ USER nextjs
2192
2824
 
2193
2825
  EXPOSE ${port}
2194
2826
 
2195
2827
  ${siteHealthcheck(port)}
2196
2828
 
2197
- CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
2829
+ ENTRYPOINT ["/sbin/tini", "--"]
2830
+ CMD ["node", "${appPath}/server.js"]
2198
2831
  `;
2199
2832
  }
2200
- //#endregion
2201
- //#region src/docker/layout.ts
2202
- /**
2203
- * Where a workspace's images are built from, and what each is built with.
2204
- *
2205
- * Every builder of images — `gkm docker`, `gkm compose`, a Dokploy deploy —
2206
- * builds from the same place with the same templates: the build root (the
2207
- * package manager's root, at or above the gkm workspace), cut by `turbo
2208
- * prune` to one app's slice. Nothing is built on the host.
2209
- */
2210
- /** A workspace of packages with no turbo.json, which every image prunes with. */
2211
- var MonorepoNeedsTurbo = class extends Error {
2212
- buildRoot;
2213
- constructor(buildRoot) {
2214
- super(`${buildRoot} is a workspace of packages, and its images are built from a turbo-pruned slice of it — but it has no turbo.json.\n\nTo fix this:
2215
- 1. Install turbo: pnpm add -Dw turbo
2216
- 2. Create turbo.json in ${buildRoot}\n 3. Run this command again
2217
-
2218
- See: https://turbo.build/repo/docs/guides/tools/docker`);
2219
- this.buildRoot = buildRoot;
2220
- this.name = "MonorepoNeedsTurbo";
2221
- }
2222
- };
2223
- /**
2224
- * Where a workspace's images are built from.
2225
- *
2226
- * Refuses a workspace of packages with no turbo.json — every image prunes
2227
- * with turbo — so only a caller about to generate or build images asks.
2228
- */
2229
- function imageLayout(workspace) {
2230
- const buildRoot = findBuildRoot(workspace.root);
2231
- const tools = resolveBuildTools(buildRoot);
2232
- if (tools.monorepo && !hasTurboConfig(buildRoot)) throw new MonorepoNeedsTurbo(buildRoot);
2233
- const gkmRoot = toPosix(relative(buildRoot, workspace.root)) || ".";
2234
- const workspacePackage = gkmRoot !== "." ? packageName(workspace.root) : void 0;
2235
- return {
2236
- buildRoot,
2237
- gkmRoot,
2238
- tools,
2239
- ...workspacePackage ? { workspacePackage } : {},
2240
- gkmPaths: ["gkm.config.*", ...constructRoots(workspace)]
2241
- };
2242
- }
2243
- /**
2244
- * How a compose file builds an app: the build root as the context, relative
2245
- * to the directory the compose file is in, and the Dockerfile — written under
2246
- * the gkm root — relative to that context, which is what compose resolves
2247
- * `dockerfile` against.
2248
- */
2249
- function composeBuildPaths(options) {
2250
- const { composeDir, buildRoot, workspaceRoot, dockerfile } = options;
2251
- return {
2252
- context: toPosix(relative(composeDir, buildRoot)) || ".",
2253
- dockerfile: toPosix(join(relative(buildRoot, workspaceRoot), dockerfile))
2254
- };
2255
- }
2256
- /** A path under the gkm root, as the build root sees it. */
2257
- function fromBuildRoot(layout, path) {
2258
- if (layout.gkmRoot === ".") return toPosix(path);
2259
- const joined = toPosix(join(layout.gkmRoot, path));
2260
- return joined === "" ? "." : joined;
2261
- }
2262
- /** Everything a template needs about one app, relative to the build root. */
2263
- function appImageOptions(layout, appName, app, workspaceRoot, apps = {}) {
2264
- const turboPackage = packageName(isAbsolute(app.path) ? app.path : join(workspaceRoot, app.path)) ?? appName;
2265
- return {
2266
- imageName: appName,
2267
- baseImage: "node:22-alpine",
2268
- port: app.port,
2269
- appPath: fromBuildRoot(layout, app.path),
2270
- turboPackage,
2271
- packageManager: layout.tools.packageManager,
2272
- ...layout.tools.packageManagerVersion ? { packageManagerVersion: layout.tools.packageManagerVersion } : {},
2273
- turboVersion: layout.tools.turboVersion,
2274
- monorepo: layout.tools.monorepo,
2275
- gkmRoot: layout.gkmRoot,
2276
- ...app.type === "backend" ? {
2277
- gkmPaths: layout.gkmPaths,
2278
- ...layout.workspacePackage && layout.workspacePackage !== turboPackage ? { prunePackages: [layout.workspacePackage] } : {}
2279
- } : app.type === "web" ? siteWorkspace(layout, app, apps, workspaceRoot, turboPackage) : {}
2280
- };
2281
- }
2282
- /**
2283
- * What a site's image carries of the gkm workspace: its config and construct
2284
- * directories, the workspace's own package where it is nested in a monorepo
2285
- * (it holds the CLI), and each backend the site calls — whose endpoints its
2286
- * client is generated from, and whose package their imports resolve in.
2287
- */
2288
- function siteWorkspace(layout, site, apps, workspaceRoot, turboPackage) {
2289
- const clients = siteClients(site, apps);
2290
- const packages = /* @__PURE__ */ new Set();
2291
- if (layout.workspacePackage) packages.add(layout.workspacePackage);
2292
- for (const { app } of clients) {
2293
- const backend = apps[app];
2294
- const name = packageName(isAbsolute(backend.path) ? backend.path : join(workspaceRoot, backend.path));
2295
- if (name) packages.add(name);
2296
- }
2297
- packages.delete(turboPackage);
2298
- return {
2299
- gkmPaths: layout.gkmPaths,
2300
- ...packages.size > 0 ? { prunePackages: [...packages].sort() } : {},
2301
- ...clients.length > 0 ? { clients: clients.map(({ app }) => ({
2302
- app,
2303
- path: fromBuildRoot(layout, apps[app].path)
2304
- })) } : {}
2305
- };
2306
- }
2307
- /**
2308
- * The backends whose clients a site imports: each it depends on that gkm
2309
- * builds from endpoints — not one with an entry of its own, and not one that
2310
- * turned OpenAPI off.
2311
- */
2312
- function siteClients(site, apps) {
2313
- return site.dependencies.filter((name) => {
2314
- const app = apps[name];
2315
- if (!app || app.type !== "backend" || app.entry) return false;
2316
- if (app.openapi === false) return false;
2317
- if (typeof app.openapi === "object" && app.openapi.enabled === false) return false;
2318
- return true;
2319
- }).sort().map((app) => ({ app }));
2320
- }
2321
- /**
2322
- * The directories the workspace's construct globs start in, relative to its
2323
- * root: `./constructs/**\/*.ts` reads from `constructs`.
2324
- */
2325
- function constructRoots(workspace) {
2326
- const roots = /* @__PURE__ */ new Set();
2327
- for (const glob of allConstructGlobs(workspace)) {
2328
- const rel = toPosix(relative(workspace.root, glob));
2329
- if (!rel || rel.startsWith("..")) continue;
2330
- const first = rel.split("/")[0];
2331
- if (/[*?{}[\]!]/.test(first)) continue;
2332
- roots.add(first);
2333
- }
2334
- return [...roots].sort();
2335
- }
2336
- function packageName(dir) {
2337
- try {
2338
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
2339
- return typeof pkg.name === "string" ? pkg.name : void 0;
2340
- } catch {
2341
- return;
2342
- }
2343
- }
2344
- function toPosix(path) {
2345
- return path.split(sep).join("/");
2346
- }
2347
- //#endregion
2348
- //#region src/reconcile/caddyfile.ts
2349
- /**
2350
- * The kinds the edge serves.
2351
- *
2352
- * Everything that owns a *public address*. Deployed, each of these has a real
2353
- * hostname and a certificate, so this is what makes the local shape the same
2354
- * one rather than a near-enough one — and the application is indifferent
2355
- * either way, because it reads whichever address was injected and never
2356
- * composes one.
2357
- *
2358
- * Adding a kind here is the whole of adding it to the edge.
2359
- */
2360
- const EDGE_KINDS = {
2361
- "file-server": true,
2362
- "rest-api": true,
2363
- site: true
2364
- };
2365
- /**
2366
- * How the edge reaches the host, from inside its container.
2367
- *
2368
- * A surface and a site are served by processes `gkm dev` starts on the host,
2369
- * not by containers — so the edge has to leave Docker's network to reach them.
2370
- * Docker Desktop resolves this name already; on Linux the compose definition
2371
- * maps it to the host gateway.
2372
- */
2373
- const HOST_GATEWAY = "host.docker.internal";
2374
2833
  /**
2375
- * The suffix every local host shares.
2376
- *
2377
- * `.localhost` rather than a made-up TLD because it resolves to loopback
2378
- * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
2379
- * subdomains of it, and Caddy issues internal certificates for it by default.
2834
+ * Generate a Dockerfile for a gkm backend: pruned, installed, and bundled by
2835
+ * `gkm build --provider server --production` inside the image. The runner is
2836
+ * `server.mjs` alone — plus any package the bundle leaves external.
2837
+ * @internal Exported for testing
2380
2838
  */
2381
- const LOCAL_TLD = "localhost";
2839
+ function generateBackendDockerfile(options) {
2840
+ return gkmBundleDockerfile(options, {
2841
+ title: "Backend Dockerfile",
2842
+ bundle: "server.mjs",
2843
+ as: "server.mjs",
2844
+ expose: true
2845
+ });
2846
+ }
2847
+ /** The port a worker answers its health check on, inside its container. */
2848
+ const WORKER_PORT = 3e3;
2382
2849
  /**
2383
- * The sites a plan implies, one per file server.
2384
- *
2385
- * The leading label is the *server's* stage-scoped name rather than the
2386
- * bucket's, which is the difference that matters: two servers over one bucket
2387
- * are a legitimate arrangement — two cache behaviours, one origin — and naming
2388
- * the host after the bucket would make them collide.
2850
+ * A Worker's image: the same build as its host app's backend — a pruned
2851
+ * slice, `gkm build --provider server --production` in the image, with the
2852
+ * encrypted credentials from the `gkm_credentials` secret — and a runner that
2853
+ * is the worker's bundle alone. It publishes nothing: its one route is the
2854
+ * health check the HEALTHCHECK asks.
2855
+ * @internal Exported for testing
2389
2856
  */
2390
- function sitesFor(plan, project, addresses = {}) {
2391
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
2392
- const sites = [];
2393
- for (const resource of plan.resources) {
2394
- if (!EDGE_KINDS[resource.kind]) continue;
2395
- const host = hostFor(resource, project);
2396
- if (resource.kind === "file-server") {
2397
- const origin = resource.of ? byId.get(resource.of) : void 0;
2398
- if (!origin) continue;
2399
- sites.push({
2400
- host,
2401
- upstream: "http://minio:9000",
2402
- rewrite: `/${origin.name}{uri}`
2403
- });
2404
- continue;
2405
- }
2406
- const address = addresses[resource.id];
2407
- if (!address) continue;
2408
- const port = portOf(address);
2409
- if (!port) continue;
2410
- sites.push({
2411
- host,
2412
- upstream: `http://${HOST_GATEWAY}:${port}`
2413
- });
2414
- }
2415
- return sites;
2857
+ function generateWorkerDockerfile(options) {
2858
+ return gkmBundleDockerfile(options, {
2859
+ title: `Worker Dockerfile (${options.worker})`,
2860
+ bundle: options.bundle,
2861
+ as: "worker.mjs",
2862
+ expose: false
2863
+ });
2416
2864
  }
2417
- /** The port a local address answers on, defaulting by scheme. */
2418
- function portOf(address) {
2419
- try {
2420
- const { port, protocol } = new URL(address);
2421
- return port || (protocol === "https:" ? "443" : "80");
2422
- } catch {
2423
- return;
2424
- }
2865
+ function gkmBundleDockerfile(options, runner) {
2866
+ const { port, healthCheckPath = "/health" } = options;
2867
+ const { app } = layout(options);
2868
+ const externals = externalsStages(options, app);
2869
+ return `# syntax=docker/dockerfile:1
2870
+ # ${runner.title}: a turbo-pruned slice, bundled by gkm build in the image
2871
+
2872
+ ${prunerStage(options)}
2873
+
2874
+ ${depsStage(options)}
2875
+
2876
+ ${builderStage(options)}
2877
+
2878
+ # Bundle the production server, with the encrypted credentials embedded
2879
+ ${credentialsStep(app, `cd ${app} && ${gkmCommand(`build --provider server --production${options.cache ? ` --cache ${options.cache}` : ""}`)}`)}
2880
+ ${externals.stage}
2881
+ # Production
2882
+ FROM ${options.baseImage} AS runner
2883
+
2884
+ WORKDIR /app
2885
+
2886
+ RUN apk add --no-cache tini
2887
+
2888
+ RUN addgroup --system --gid 1001 nodejs && \\
2889
+ adduser --system --uid 1001 hono
2890
+
2891
+ # The bundled ${runner.as === "server.mjs" ? "server" : "worker"}
2892
+ COPY --from=builder --chown=hono:nodejs ${app}/.gkm/server/dist/${runner.bundle} ./${runner.as === runner.bundle ? "" : runner.as}${externals.copy}
2893
+
2894
+ ENV NODE_ENV=production
2895
+ ENV PORT=${port}
2896
+
2897
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
2898
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
2899
+
2900
+ USER hono
2901
+ ${runner.expose ? `\nEXPOSE ${port}\n` : ""}
2902
+ ENTRYPOINT ["/sbin/tini", "--"]
2903
+ CMD ["node", "${runner.as}"]
2904
+ `;
2425
2905
  }
2426
2906
  /**
2427
- * Where one file server answers, including the stage its name carries.
2428
- *
2429
- * The project is a label rather than a decoration: it is what keeps two
2430
- * checkouts from answering on one address. Omitted when there is no project to
2431
- * name, because an empty label makes `uploads..localhost` — a host nothing
2432
- * resolves and no certificate can be issued for.
2907
+ * Generate a Dockerfile for apps with a custom entry point.
2908
+ * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
2909
+ * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
2910
+ * @internal Exported for testing
2433
2911
  */
2434
- function hostFor(resource, project) {
2435
- return [
2436
- resource.root && project ? stageOf(resource) : labelOf(resource),
2437
- project,
2438
- LOCAL_TLD
2439
- ].filter(Boolean).join(".");
2912
+ function generateEntryDockerfile(options) {
2913
+ const { port, entry, healthCheckPath = "/health" } = options;
2914
+ const { app } = layout(options);
2915
+ const esbuild = (defines) => `esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \\
2916
+ --outfile=dist/index.mjs --packages=bundle \\
2917
+ --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'${defines}`;
2918
+ return `# syntax=docker/dockerfile:1
2919
+ # Entry-based Dockerfile: a turbo-pruned slice, bundled by esbuild in the image
2920
+
2921
+ ${prunerStage(options)}
2922
+
2923
+ ${depsStage(options)}
2924
+
2925
+ ${builderStage(options)}
2926
+
2927
+ # Bundle the entry point with every dependency (dist/index.mjs), embedding the
2928
+ # encrypted credentials when there are any.
2929
+ ARG GKM_CIPHERTEXT_HASH=""
2930
+ RUN --mount=type=secret,id=gkm_credentials,required=false \\
2931
+ echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
2932
+ cd ${app} && export ${binPath(options)} && \\
2933
+ if [ -s /run/secrets/gkm_credentials ]; then \\
2934
+ CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \\
2935
+ IV=$(sed -n 2p /run/secrets/gkm_credentials) && \\
2936
+ ${esbuild(` \\
2937
+ --define:__GKM_ENCRYPTED_CREDENTIALS__="\\"$CREDS\\"" \\
2938
+ --define:__GKM_CREDENTIALS_IV__="\\"$IV\\""`)}; \\
2939
+ else \\
2940
+ ${esbuild("")}; \\
2941
+ fi
2942
+
2943
+ # Production
2944
+ FROM ${options.baseImage} AS runner
2945
+
2946
+ WORKDIR /app
2947
+
2948
+ RUN apk add --no-cache tini
2949
+
2950
+ RUN addgroup --system --gid 1001 nodejs && \\
2951
+ adduser --system --uid 1001 app
2952
+
2953
+ # Copy bundled output only (no node_modules needed - fully bundled)
2954
+ COPY --from=builder --chown=app:nodejs ${app}/dist/index.mjs ./
2955
+
2956
+ ENV NODE_ENV=production
2957
+ ENV PORT=${port}
2958
+
2959
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
2960
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
2961
+
2962
+ USER app
2963
+
2964
+ EXPOSE ${port}
2965
+
2966
+ ENTRYPOINT ["/sbin/tini", "--"]
2967
+ CMD ["node", "index.mjs"]
2968
+ `;
2440
2969
  }
2441
2970
  /**
2442
- * A resource's own label: its `subdomain` where it declared one — the label a
2443
- * deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
2444
- * still answer on different hosts.
2971
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
2972
+ * Remix). Builds with turbo, then runs the framework's production start
2973
+ * script.
2974
+ *
2975
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
2976
+ * can inline them at build time. Per-framework prefix is the caller's job.
2977
+ * @internal Exported for testing
2445
2978
  */
2446
- function labelOf(resource) {
2447
- if (!resource.subdomain) return resource.name;
2448
- const stage = stageOf(resource);
2449
- return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
2450
- }
2451
- /** The stage a resource's name carries, or nothing for the local stage's. */
2452
- function stageOf(resource) {
2453
- const base = resource.id.toLowerCase();
2454
- return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
2979
+ function generateNodeWebDockerfile(options) {
2980
+ const { port, appPath } = options;
2981
+ return `# syntax=docker/dockerfile:1
2982
+ # Node SSR web Dockerfile (TanStack Start / Remix), from a turbo-pruned slice
2983
+
2984
+ ${prunerStage(options)}
2985
+
2986
+ ${depsStage(options)}
2987
+
2988
+ ${builderStage(options, { args: options.publicUrlArgs ?? [] })}
2989
+
2990
+ ${siteWorkspaceSteps(options)}
2991
+
2992
+ ${siteBuild(options)}
2993
+
2994
+ FROM ${options.baseImage} AS runner
2995
+ WORKDIR /app
2996
+ RUN apk add --no-cache tini
2997
+ RUN addgroup --system --gid 1001 nodejs && \\
2998
+ adduser --system --uid 1001 app
2999
+
3000
+ # Copy the whole built workspace — frameworks differ on output paths
3001
+ COPY --from=builder --chown=app:nodejs /app/ ./
3002
+
3003
+ ENV NODE_ENV=production
3004
+ ENV PORT=${port}
3005
+ ENV HOSTNAME="0.0.0.0"
3006
+
3007
+ USER app
3008
+ EXPOSE ${port}
3009
+
3010
+ ${siteHealthcheck(port)}
3011
+
3012
+ ENTRYPOINT ["/sbin/tini", "--"]
3013
+ # npm ships with node, and runs the start script whichever package manager
3014
+ # installed it: the runner needs no other.
3015
+ CMD ["sh", "-c", "cd ${appPath} && npm start"]
3016
+ `;
2455
3017
  }
2456
3018
  /**
2457
- * The static root config, which only ever imports.
2458
- *
2459
- * Written once and identical for every stage, because the *sites* are what
2460
- * differ and they live in their own files. That split is not tidiness: one
2461
- * container serves every stage — the same way one Postgres holds `orders` and
2462
- * `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
2463
- * routes `gkm dev` is serving, and each command silently breaking the other.
3019
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
3020
+ * turbo, then serves it with Caddy: Vite's hashed `/assets/*` cached for a
3021
+ * year, everything else — `index.html`, and every deep link that falls back
3022
+ * to it — revalidated on each request.
3023
+ * @internal Exported for testing
2464
3024
  */
2465
- function caddyfileRoot() {
2466
- return `${GENERATED}
3025
+ function generateViteStaticDockerfile(options) {
3026
+ const { port, appPath } = options;
3027
+ return `# syntax=docker/dockerfile:1
3028
+ # Vite SPA Dockerfile — a turbo-pruned slice built to static files, served by Caddy
3029
+
3030
+ ${prunerStage(options)}
3031
+
3032
+ ${depsStage(options)}
3033
+
3034
+ ${builderStage(options, { args: options.publicUrlArgs ?? [] })}
3035
+
3036
+ ${siteWorkspaceSteps(options)}
3037
+
3038
+ ${siteBuild(options)}
3039
+
3040
+ FROM ${STATIC_SITE_IMAGE} AS runner
3041
+
3042
+ COPY <<'EOF' /etc/caddy/Caddyfile
2467
3043
  {
2468
- # No ACME, no public DNS: the CA is local and so are the names.
2469
- local_certs
2470
- auto_https disable_redirects
3044
+ admin off
3045
+ auto_https off
3046
+ persist_config off
2471
3047
  }
2472
3048
 
2473
- # One file per stage, so two stages share this edge the way they already share
2474
- # one Postgres and one MinIO.
2475
- import /etc/caddy/sites/*.caddy
2476
- `;
2477
- }
2478
- /**
2479
- * One stage's sites, as the file imported above.
2480
- *
2481
- * `rewrite` rather than a path prefix on the address: the bucket is the
2482
- * origin's business, and the client asks for the key it stored.
2483
- *
2484
- * `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
2485
- * and signs on the Host header, so forwarding the requested hostname would make
2486
- * it look for a bucket named after the domain.
2487
- */
2488
- function toCaddyfile(sites) {
2489
- if (sites.length === 0) return `${GENERATED}
2490
- # No file server is declared in this stage, so it routes nothing.
2491
- `;
2492
- const blocks = sites.map(({ host, upstream, rewrite }) => `
2493
- https://${host} {
2494
- tls internal
3049
+ :${port} {
3050
+ root * /srv
3051
+ encode zstd gzip
2495
3052
 
2496
- reverse_proxy ${upstream} {${rewrite ? `
2497
- rewrite ${rewrite}
2498
- # MinIO routes and signs on the Host header, so forwarding the requested
2499
- # hostname would make it look for a bucket named after the domain.
2500
- header_up Host {upstream_hostport}` : `
2501
- # The application reads whatever address was injected, so it has to see
2502
- # the one a caller used — that is what makes a redirect, a cookie domain
2503
- # and a generated link point back here rather than at the upstream.
2504
- header_up Host {host}
2505
- header_up X-Forwarded-Proto {scheme}`}
3053
+ # Vite names every built asset by its content hash: a changed file is a
3054
+ # new URL, so the old one can be cached for good.
3055
+ handle /assets/* {
3056
+ header Cache-Control "public, max-age=31536000, immutable"
3057
+ file_server
3058
+ }
3059
+
3060
+ # index.html, and every client-side route that falls back to it, is
3061
+ # asked for again each time so a deploy is seen at once.
3062
+ handle {
3063
+ header Cache-Control "no-cache"
3064
+ try_files {path} /index.html
3065
+ file_server
2506
3066
  }
2507
- }`);
2508
- return `${GENERATED}${blocks.join("\n")}\n`;
2509
3067
  }
2510
- const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
2511
- #
2512
- # One host per declared file server, in front of the object store that holds its
2513
- # objects. This is the shape a file server has deployed — a domain serving a
2514
- # bucket — which MinIO alone cannot produce: its virtual-host mode reads the
2515
- # leading label as the bucket name.
2516
- #
2517
- # Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
2518
- # root and point Node at it, so nothing has to be installed to use these URLs
2519
- # from code; a browser needs the root trusted once.
3068
+ EOF
3069
+
3070
+ COPY --from=builder /app/${appPath}/dist /srv
3071
+
3072
+ RUN addgroup -S -g 1001 site && adduser -S -u 1001 -G site site && \\
3073
+ chown -R site:site /data /config
3074
+
3075
+ USER site
3076
+
3077
+ EXPOSE ${port}
3078
+
3079
+ ${siteHealthcheck(port)}
3080
+
3081
+ CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
2520
3082
  `;
2521
- //#endregion
2522
- //#region src/workspace/rootSite.ts
2523
- /**
2524
- * Which site the base domain points at — deployed and locally alike.
2525
- *
2526
- * Three rules, in order, and the last one is the point:
2527
- *
2528
- * 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
2529
- * 2. **Named `web`** — the convention wins, because people already rely on it.
2530
- * 3. **Declared `root: true`** — the site says so itself.
2531
- *
2532
- * Anything else throws. It used to take the *first* site it iterated, and that
2533
- * order came from the manifest, which is built in glob traversal order — so two
2534
- * sites with neither named `web` meant renaming a file could move the root
2535
- * domain, silently.
2536
- *
2537
- * One rule for both targets: deployed the root site gets the base domain, and
2538
- * locally it gets the project's bare host behind the edge. Before this was
2539
- * shared, the local edge put every site on a subdomain, so what a developer
2540
- * browsed was not the shape production served.
2541
- *
2542
- * @returns the root site's name, or `undefined` when there are no sites
2543
- * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
2544
- */
2545
- function rootSite(sites) {
2546
- if (sites.length === 0) return void 0;
2547
- if (sites.length === 1) return sites[0].name;
2548
- if (sites.some(({ name }) => name === "web")) return "web";
2549
- const declared = sites.filter(({ root }) => root === true);
2550
- if (declared.length === 1) return declared[0].name;
2551
- throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
2552
3083
  }
2553
- /** Several sites could hold the base domain, and none of them says it does. */
2554
- var AmbiguousRootSite = class extends Error {
2555
- sites;
2556
- tooMany;
2557
- constructor(sites, tooMany = false) {
2558
- super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
2559
- this.sites = sites;
2560
- this.tooMany = tooMany;
2561
- this.name = "AmbiguousRootSite";
2562
- }
2563
- };
2564
3084
  //#endregion
2565
- //#region src/reconcile/plan.ts
2566
- /**
2567
- * The reconcile plan — what a manifest wants to exist, for one stage.
2568
- *
2569
- * Pure. Turning the plan into running containers and provisioned resources is
2570
- * the applier's job; deciding *what* should exist is this, so the decisions can
2571
- * be asserted as data rather than by starting Docker.
2572
- *
2573
- * One plan serves every stage. `gkm dev` reconciles `development` and `gkm test`
2574
- * reconciles `test`; they differ only in what the resources are called, never in
2575
- * what infrastructure exists. Roles are cluster-scoped in Postgres while grants
2576
- * are per-object, so one container and one role pair serve both.
2577
- */
2578
- /**
2579
- * Which container serves a kind.
2580
- *
2581
- * Derived kinds map to the same container as their parent — a schema tenant
2582
- * lives in its parent's Postgres — so the set is deduplicated rather than
2583
- * counted.
2584
- */
2585
- const CONTAINERS = {
2586
- database: "postgres",
2587
- "database-reader": "postgres",
2588
- "database-schema": "postgres",
2589
- objects: "minio",
2590
- "file-server": "caddy",
2591
- email: "mailpit"
2592
- };
2593
- /**
2594
- * The container a cache backend needs locally.
2595
- *
2596
- * Each one matches the protocol its deployed form speaks, which is the property
2597
- * worth having: a cache that behaves differently in the two places is worse than
2598
- * a slower one.
2599
- *
2600
- * `db` is deliberately absent, the same way `pgboss` is absent from
2601
- * `EVENT_CONTAINERS`: the cache is a table in a database the manifest already
2602
- * declares, so mapping it here would start a container for a project that needs
2603
- * none.
2604
- */
2605
- const CACHE_CONTAINERS = {
2606
- upstash: "redis-http",
2607
- elasticache: "redis",
2608
- redis: "redis"
2609
- };
2610
- /**
2611
- * Kinds that resolve a value without anything running.
2612
- *
2613
- * A secret is the case: it has no address, so there is nothing to start and
2614
- * nothing to connect to — but it still appears in the plan, because the target
2615
- * still has to resolve it.
2616
- */
2617
- const CONTAINERLESS = {
2618
- secret: true,
2619
- credential: true,
2620
- encryption: true,
2621
- "rest-api": true,
2622
- site: true,
2623
- "mobile-app": true,
2624
- "external-api": true
2625
- };
2626
- /**
2627
- * Containers that cannot run alone.
2628
- *
2629
- * The one case is the cache: `serverless-redis-http` is a proxy and needs the
2630
- * Redis it proxies. Kept here rather than in the compose definition so the plan
2631
- * -- which is what decides what must be running -- still names everything.
2632
- */
2633
- const REQUIRES = { "redis-http": ["redis"] };
2634
- /**
2635
- * The role each kind's one key plays, where it is not a URL.
2636
- *
2637
- * The twin of `ProvidesByKind` in `@geekmidas/manifest`: that is what a
2638
- * construct declares, this is what the local target resolves, and both derive
2639
- * the key from the same `provideKey` so they cannot disagree.
2640
- */
2641
- const ROLES = {
2642
- queue: "publisherConnectionString",
2643
- topic: "publisherConnectionString",
2644
- credential: "credentials",
2645
- "mobile-app": "scheme"
2646
- };
2647
- /** The kinds whose container is the events backend's rather than their own. */
2648
- const EVENT_KINDS = {
2649
- queue: true,
2650
- topic: true
2651
- };
2652
- /**
2653
- * Which kinds provision something *inside* their container.
2654
- *
2655
- * A database creates a database, a bucket creates a bucket; mail creates
2656
- * nothing — Mailpit accepts whatever is sent to it and has no per-stage object
2657
- * to name. Both still appear in the plan, because both still resolve a URL; the
2658
- * flag is the difference between "must be running" and "must be created".
2659
- */
2660
- const PROVISIONS = {
2661
- database: true,
2662
- "database-reader": true,
2663
- "database-schema": true,
2664
- objects: true,
2665
- cache: true,
2666
- "file-server": true
2667
- };
2668
- /**
2669
- * The container each events backend needs, where it needs one of its own.
2670
- *
2671
- * `pgboss` is deliberately absent rather than mapped to `postgres`: it is a
2672
- * schema tenant in a database the manifest already declares, so mapping it here
2673
- * would start a Postgres for a project that declared no database at all.
2674
- *
2675
- * This one comes from config rather than from a declaration because there are
2676
- * no `topic` or `queue` kinds yet. When they land the mapping moves to
2677
- * {@link CONTAINERS} keyed by kind, and this constant goes with them.
2678
- */
2679
- const EVENT_CONTAINERS = {
2680
- sns: "localstack",
2681
- rabbitmq: "rabbitmq"
2682
- };
2683
- /** The backend a project gets when it declares queues or topics and says nothing. */
2684
- const DEFAULT_EVENTS = "pgboss";
3085
+ //#region src/docker/layout.ts
2685
3086
  /**
2686
- * How a resource of each kind separates its stage suffix.
3087
+ * Where a workspace's images are built from, and what each is built with.
2687
3088
  *
2688
- * Postgres identifiers are snake_case and a hyphen would need quoting
2689
- * everywhere; buckets and queues are kebab and disallow underscores in some
2690
- * providers. The suffix is the same word either way.
3089
+ * Every builder of images — `gkm docker`, `gkm compose`, a Dokploy deploy —
3090
+ * builds from the same place with the same templates: the build root (the
3091
+ * package manager's root, at or above the gkm workspace), cut by `turbo
3092
+ * prune` to one app's slice. Nothing is built on the host.
2691
3093
  */
2692
- const SEPARATORS = {
2693
- database: "_",
2694
- "database-reader": "_",
2695
- "database-schema": "_",
2696
- objects: "-"
3094
+ /** A workspace of packages with no turbo.json, which every image prunes with. */
3095
+ var MonorepoNeedsTurbo = class extends Error {
3096
+ buildRoot;
3097
+ constructor(buildRoot) {
3098
+ super(`${buildRoot} is a workspace of packages, and its images are built from a turbo-pruned slice of it — but it has no turbo.json.\n\nTo fix this:
3099
+ 1. Install turbo: pnpm add -Dw turbo
3100
+ 2. Create turbo.json in ${buildRoot}\n 3. Run this command again
3101
+
3102
+ See: https://turbo.build/repo/docs/guides/tools/docker`);
3103
+ this.buildRoot = buildRoot;
3104
+ this.name = "MonorepoNeedsTurbo";
3105
+ }
2697
3106
  };
2698
3107
  /**
2699
- * The stage-scoped name for a resource.
3108
+ * Where a workspace's images are built from.
2700
3109
  *
2701
- * The local stage carries no suffix, so `gkm dev` keeps using `orders` and a
2702
- * developer's psql history, saved connections, and muscle memory survive a
2703
- * change of stage name. Every other stage is suffixed.
3110
+ * Refuses a workspace of packages with no turbo.json — every image prunes
3111
+ * with turbo — so only a caller about to generate or build images asks.
2704
3112
  */
2705
- function resourceName(id, kind, stage, localStage) {
2706
- const base = id.toLowerCase();
2707
- if (stage === localStage) return base;
2708
- return `${base}${SEPARATORS[kind] ?? "-"}${stage}`;
3113
+ function imageLayout(workspace) {
3114
+ const buildRoot = findBuildRoot(workspace.root);
3115
+ const tools = resolveBuildTools(buildRoot);
3116
+ if (tools.monorepo && !hasTurboConfig(buildRoot)) throw new MonorepoNeedsTurbo(buildRoot);
3117
+ const gkmRoot = toPosix(relative(buildRoot, workspace.root)) || ".";
3118
+ const workspacePackage = gkmRoot !== "." ? packageName(workspace.root) : void 0;
3119
+ return {
3120
+ buildRoot,
3121
+ gkmRoot,
3122
+ tools,
3123
+ ...workspacePackage ? { workspacePackage } : {},
3124
+ gkmPaths: ["gkm.config.*", ...constructRoots(workspace)]
3125
+ };
2709
3126
  }
2710
3127
  /**
2711
- * The container a kind needs, or `undefined` if it needs none.
2712
- *
2713
- * Queues and topics have no container of their own — they live in whichever
2714
- * broker the project selected, which is why the backend is an argument here
2715
- * rather than a second pass that adds containers after the fact.
3128
+ * How a compose file builds an app: the build root as the context, relative
3129
+ * to the directory the compose file is in, and the Dockerfile — written under
3130
+ * the gkm root — relative to that context, which is what compose resolves
3131
+ * `dockerfile` against.
2716
3132
  */
2717
- function containerFor(kind, events = DEFAULT_EVENTS, cache = DEFAULT_CACHE.aws, derived = false, edge = true) {
2718
- if (EVENT_KINDS[kind]) return EVENT_CONTAINERS[events] ?? CONTAINERS.database;
2719
- if (kind === "file-server") return edge ? "caddy" : CONTAINERS.objects;
2720
- if (kind === "cache") {
2721
- if (derived && !holdsEveryCache(cache)) return CONTAINERS.database;
2722
- return CACHE_CONTAINERS[cache] ?? CONTAINERS.database;
2723
- }
2724
- return CONTAINERS[kind];
3133
+ function composeBuildPaths(options) {
3134
+ const { composeDir, buildRoot, workspaceRoot, dockerfile } = options;
3135
+ return {
3136
+ context: toPosix(relative(composeDir, buildRoot)) || ".",
3137
+ dockerfile: toPosix(join(relative(buildRoot, workspaceRoot), dockerfile))
3138
+ };
3139
+ }
3140
+ /** A path under the gkm root, as the build root sees it. */
3141
+ function fromBuildRoot(layout, path) {
3142
+ if (layout.gkmRoot === ".") return toPosix(path);
3143
+ const joined = toPosix(join(layout.gkmRoot, path));
3144
+ return joined === "" ? "." : joined;
3145
+ }
3146
+ /** Everything a template needs about one app, relative to the build root. */
3147
+ function appImageOptions(layout, appName, app, workspaceRoot, apps = {}, clientTelemetry) {
3148
+ const turboPackage = packageName(isAbsolute(app.path) ? app.path : join(workspaceRoot, app.path)) ?? appName;
3149
+ return {
3150
+ imageName: appName,
3151
+ baseImage: "node:22-alpine",
3152
+ port: app.port,
3153
+ appPath: fromBuildRoot(layout, app.path),
3154
+ turboPackage,
3155
+ packageManager: layout.tools.packageManager,
3156
+ ...layout.tools.packageManagerVersion ? { packageManagerVersion: layout.tools.packageManagerVersion } : {},
3157
+ turboVersion: layout.tools.turboVersion,
3158
+ monorepo: layout.tools.monorepo,
3159
+ gkmRoot: layout.gkmRoot,
3160
+ ...app.type === "backend" ? {
3161
+ gkmPaths: layout.gkmPaths,
3162
+ ...layout.workspacePackage && layout.workspacePackage !== turboPackage ? { prunePackages: [layout.workspacePackage] } : {}
3163
+ } : app.type === "web" ? siteWorkspace(layout, app, apps, workspaceRoot, turboPackage, clientTelemetry) : {}
3164
+ };
2725
3165
  }
2726
3166
  /**
2727
- * Build the plan for a manifest and stage.
2728
- *
2729
- * `order` is the caller's provisioning order — parents before children — which
2730
- * `provisionOrder` produces from the manifest. Passing it in keeps this
2731
- * function free of the traversal and makes the ordering assertable on its own.
3167
+ * What a site's image carries of the gkm workspace: its config and construct
3168
+ * directories, the workspace's own package where it is nested in a monorepo
3169
+ * (it holds the CLI), and each backend the site calls — whose endpoints its
3170
+ * client is generated from, and whose package their imports resolve in.
2732
3171
  */
2733
- function planFor(manifest, stage, order, options = {}) {
2734
- const containers = /* @__PURE__ */ new Set();
2735
- const resources = [];
2736
- const fakes = {};
2737
- const events = options.events ?? "pgboss";
2738
- const cache = options.cache ?? DEFAULT_CACHE.aws;
2739
- for (const id of order) {
2740
- const declaration = manifest[id];
2741
- if (!declaration) continue;
2742
- const local = declaration.kind === "external-api" ? options.fakes?.[id] : void 0;
2743
- const fake = local && {
2744
- key: fakeKey(id),
2745
- ...local
2746
- };
2747
- if (fake) fakes[fake.key] = {
2748
- id,
2749
- ...fake.image ? {
2750
- image: fake.image,
2751
- port: fake.port
2752
- } : {}
2753
- };
2754
- const detached = declaration.kind === "cache" && holdsEveryCache(cache);
2755
- const container = fake?.image ? fake.key : containerFor(declaration.kind, events, cache, "of" in declaration && typeof declaration.of === "string", options.edge !== false);
2756
- if (!container && !CONTAINERLESS[declaration.kind]) continue;
2757
- if (container) {
2758
- containers.add(container);
2759
- for (const required of REQUIRES[container] ?? []) containers.add(required);
2760
- }
2761
- resources.push({
2762
- id,
2763
- kind: declaration.kind,
2764
- ...container ? { container } : {},
2765
- name: resourceName(id, declaration.kind, stage, options.localStage),
2766
- envKey: providedKeyFor(id, declaration.kind, ROLES[declaration.kind] ?? "url"),
2767
- provisions: PROVISIONS[declaration.kind] === true,
2768
- ...declaration.kind === "rest-api" ? { callers: dependentsOf(manifest, id) } : {},
2769
- ...declaration.kind === "site" ? { publicEnv: publicEnvFor(declaration, manifest) } : {},
2770
- ...declaration.kind === "mobile-app" ? {
2771
- publicEnv: publicEnvFor(declaration, manifest),
2772
- ...declaration.scheme ? { scheme: declaration.scheme } : {}
2773
- } : {},
2774
- ...declaration.kind === "file-server" && declaration.open?.length ? { open: declaration.open } : {},
2775
- ...(declaration.kind === "rest-api" || declaration.kind === "site") && declaration.subdomain ? { subdomain: declaration.subdomain } : {},
2776
- ...fake ? { fake: {
2777
- key: fake.key,
2778
- credentials: fake.credentials
2779
- } } : {},
2780
- ...declaration.kind === "external-api" && !fake ? { url: declaration.url } : {},
2781
- ..."of" in declaration && !detached ? { of: declaration.of } : {},
2782
- ..."schema" in declaration && declaration.schema ? { schema: declaration.schema } : {},
2783
- ..."version" in declaration && declaration.version ? { version: declaration.version } : {},
2784
- ..."roles" in declaration && declaration.roles === false ? { roles: false } : {},
2785
- ..."table" in declaration && declaration.table && !detached ? { table: declaration.table } : {}
2786
- });
2787
- }
2788
- if (options.edge !== false && resources.some((r) => EDGE_KINDS[r.kind] === true)) containers.add("caddy");
2789
- for (const extra of options.extraContainers ?? []) containers.add(extra);
2790
- if (events === "pgboss" && resources.some((r) => EVENT_KINDS[r.kind]) && !resources.some((r) => r.kind === "database")) throw new PgBossNeedsDatabase(resources.filter((r) => EVENT_KINDS[r.kind]).map((r) => r.id));
2791
- if (cache === "db") {
2792
- const placeless = resources.filter((r) => r.kind === "cache" && !r.of);
2793
- if (placeless.length > 0) {
2794
- const databases = resources.filter((r) => r.kind === "database");
2795
- const [only] = databases;
2796
- if (!only) throw new CacheNeedsDatabase(resources.filter((r) => r.kind === "cache").map((r) => r.id));
2797
- if (databases.length > 1) throw new CacheIsAmbiguous(placeless.map((r) => r.id), databases.map((r) => r.id));
2798
- for (const resource of placeless) resource.of = only.id;
2799
- }
3172
+ function siteWorkspace(layout, site, apps, workspaceRoot, turboPackage, clientTelemetry) {
3173
+ const clients = siteClients(site, apps);
3174
+ const packages = /* @__PURE__ */ new Set();
3175
+ if (layout.workspacePackage) packages.add(layout.workspacePackage);
3176
+ for (const { app } of clients) {
3177
+ const backend = apps[app];
3178
+ const name = packageName(isAbsolute(backend.path) ? backend.path : join(workspaceRoot, backend.path));
3179
+ if (name) packages.add(name);
2800
3180
  }
2801
- const sites = resources.filter((r) => r.kind === "site");
2802
- const root = rootSite(sites.map((r) => {
2803
- const declared = manifest[r.id];
2804
- return {
2805
- name: appKey(r.id),
2806
- ...declared?.kind === "site" && declared.root ? { root: true } : {}
2807
- };
2808
- }));
2809
- for (const site of sites) if (appKey(site.id) === root) site.root = true;
2810
- const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
3181
+ packages.delete(turboPackage);
2811
3182
  return {
2812
- stage,
2813
- events,
2814
- cache,
2815
- containers: [...containers],
2816
- resources,
2817
- ...Object.keys(fakes).length ? { fakes } : {},
2818
- ...workerBroker ? { workerBroker: true } : {}
3183
+ gkmPaths: layout.gkmPaths,
3184
+ ...packages.size > 0 ? { prunePackages: [...packages].sort() } : {},
3185
+ ...clients.length > 0 ? { clients: clients.map(({ app }) => ({
3186
+ app,
3187
+ path: fromBuildRoot(layout, apps[app].path),
3188
+ ...clientTelemetry ? { telemetry: clientTelemetry } : {}
3189
+ })) } : {}
2819
3190
  };
2820
3191
  }
2821
3192
  /**
2822
- * Queues or topics were declared with pg-boss and no database to hold them.
2823
- *
2824
- * Not a reconcile failure but a manifest one: the fix is a line of application
2825
- * code either way — declare a database, or select a backend that brings its own
2826
- * broker.
3193
+ * The backends whose clients a site imports: each it depends on that gkm
3194
+ * builds from endpoints — not one with an entry of its own, and not one that
3195
+ * turned OpenAPI off.
2827
3196
  */
2828
- var CacheNeedsDatabase = class extends Error {
2829
- ids;
2830
- constructor(ids) {
2831
- super(`A cache backed by the database needs a declared database, and none was declared, and on this deploy target that is where a cache lives. Declare one. Caches affected: ${ids.join(", ")}.`);
2832
- this.ids = ids;
2833
- this.name = "CacheNeedsDatabase";
2834
- }
2835
- };
3197
+ function siteClients(site, apps) {
3198
+ return site.dependencies.filter((name) => {
3199
+ const app = apps[name];
3200
+ if (!app || app.type !== "backend" || app.entry) return false;
3201
+ if (app.openapi === false) return false;
3202
+ if (typeof app.openapi === "object" && app.openapi.enabled === false) return false;
3203
+ return true;
3204
+ }).sort().map((app) => ({ app }));
3205
+ }
2836
3206
  /**
2837
- * A `'db'` cache — a server target's default — in an app that declares more
2838
- * than one database.
2839
- *
2840
- * The target says *a* database and the graph offers several, so there is no
2841
- * answer to give — and the wrong kind of failure would be to pick one, since a
2842
- * cache silently landing in the wrong database is a bug that surfaces as
2843
- * missing rows much later. The fix is a stronger statement in application code:
2844
- * `orders.cache('Sessions')` names the database, which is a fact about the
2845
- * application rather than a deployment choice.
3207
+ * The directories the workspace's construct globs start in, relative to its
3208
+ * root: `./constructs/**\/*.ts` reads from `constructs`.
2846
3209
  */
2847
- var CacheIsAmbiguous = class extends Error {
2848
- ids;
2849
- databases;
2850
- constructor(ids, databases) {
2851
- super(`A cache backed by the database needs to know which one, and this app declares ${databases.length}: ${databases.join(", ")}. Declare the cache from its database — e.g. ${databases[0]}.cache('Sessions') — rather than as a bare new Cache(). Caches affected: ${ids.join(", ")}.`);
2852
- this.ids = ids;
2853
- this.databases = databases;
2854
- this.name = "CacheIsAmbiguous";
3210
+ function constructRoots(workspace) {
3211
+ const roots = /* @__PURE__ */ new Set();
3212
+ for (const glob of allConstructGlobs(workspace)) {
3213
+ const rel = toPosix(relative(workspace.root, glob));
3214
+ if (!rel || rel.startsWith("..")) continue;
3215
+ const first = rel.split("/")[0];
3216
+ if (/[*?{}[\]!]/.test(first)) continue;
3217
+ roots.add(first);
2855
3218
  }
2856
- };
2857
- var PgBossNeedsDatabase = class extends Error {
2858
- ids;
2859
- constructor(ids) {
2860
- super(`pg-boss keeps its queues in a declared database, and none was declared. Declare one: on this deploy target, events are carried by pg-boss. Queues and topics affected: ${ids.join(", ")}.`);
2861
- this.ids = ids;
2862
- this.name = "PgBossNeedsDatabase";
3219
+ return [...roots].sort();
3220
+ }
3221
+ function packageName(dir) {
3222
+ try {
3223
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
3224
+ return typeof pkg.name === "string" ? pkg.name : void 0;
3225
+ } catch {
3226
+ return;
2863
3227
  }
2864
- };
3228
+ }
3229
+ function toPosix(path) {
3230
+ return path.split(sep).join("/");
3231
+ }
2865
3232
  //#endregion
2866
3233
  //#region src/reconcile/env.ts
2867
3234
  /**
@@ -2938,6 +3305,13 @@ function envFor(plan, options) {
2938
3305
  if (owner) env[provideKey(resource.id, "ownerUrl")] = owner;
2939
3306
  }
2940
3307
  if (resource.kind === "external-api" && url && resource.fake) env[provideKey(resource.id, "credentials")] = resource.fake.credentials;
3308
+ if (resource.kind === "telemetry" && url) {
3309
+ const login = options.credentials.logs;
3310
+ Object.assign(env, telemetryEnv({
3311
+ endpoint: url,
3312
+ headers: otlpHeaders(login.email, login.password)
3313
+ }, 1));
3314
+ }
2941
3315
  if (resource.kind === "email" && url) {
2942
3316
  env[provideKey(resource.id, "from")] = options.mailFrom ?? `noreply@${LOCAL_HOST}`;
2943
3317
  const inbox = options.ports[MAILPIT_INBOX_PORT];
@@ -3118,6 +3492,7 @@ function urlFor(resource, plan, ports, project, addresses, credential) {
3118
3492
  password: localRolePassword(project, plan, role, credential.seed)
3119
3493
  });
3120
3494
  }
3495
+ case "telemetry": return `http://${LOCAL_HOST}:${port}/api/${LOGS_ORG}`;
3121
3496
  case "email": return `smtp://${LOCAL_HOST}:${port}`;
3122
3497
  case "objects": return `s3://${resource.name}?region=${LOCAL_REGION}&endpoint=http://${LOCAL_HOST}:${port}&forcePathStyle=true`;
3123
3498
  case "file-server": {
@@ -3429,7 +3804,10 @@ function appEnvKeys(manifest, appName, runnables = {}) {
3429
3804
  }
3430
3805
  }
3431
3806
  addEdgeKeys(manifest, edges, keys);
3432
- if (declaration.kind === "site" || declaration.kind === "mobile-app") for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
3807
+ if (declaration.kind === "site" || declaration.kind === "mobile-app") {
3808
+ for (const key of TELEMETRY_KEYS) keys.delete(key);
3809
+ for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
3810
+ }
3433
3811
  return keys;
3434
3812
  }
3435
3813
  /**
@@ -3519,7 +3897,11 @@ function appServices(workspace, manifest, containers, credentials, runnables = {
3519
3897
  continue;
3520
3898
  }
3521
3899
  const allowed = appEnvKeys(manifest, name, runnables);
3522
- const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
3900
+ const own = scopeTelemetryEnv(Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey))), {
3901
+ uses: appTelemetry(manifest, name) !== void 0,
3902
+ serviceName: name,
3903
+ telemetry: env
3904
+ });
3523
3905
  services[key] = {
3524
3906
  image: `${key}:\${TAG:-latest}`,
3525
3907
  build,
@@ -4732,6 +5114,8 @@ async function reconcile(options) {
4732
5114
  ...options.fakes ? { fakes: options.fakes } : {},
4733
5115
  ...options.edge === void 0 ? {} : { edge: options.edge }
4734
5116
  });
5117
+ const exports = stage !== TEST_STAGE;
5118
+ const running = exports ? plan.containers : plan.containers.filter((c) => c !== TELEMETRY_CONTAINER);
4735
5119
  const composePath = join(root, COMPOSE_PATH);
4736
5120
  const observed = await observedPorts(docker, composePath, plan.containers, plan.fakes);
4737
5121
  const ports = await allocate(project, portKeys(plan.containers, plan.fakes), keptPorts(options.saved, observed), probe);
@@ -4746,10 +5130,10 @@ async function reconcile(options) {
4746
5130
  caddyfile,
4747
5131
  postgres: postgresStatements(plan, project, seed).map((statement) => statement.create).join(";\n")
4748
5132
  });
4749
- const services = serviceAddresses(plan.containers, ports, plan.fakes);
5133
+ const services = serviceAddresses(running, ports, plan.fakes);
4750
5134
  const lan = plan.resources.some((r) => r.kind === "mobile-app") ? options.lanAddress === void 0 ? lanAddress() : options.lanAddress ?? void 0 : void 0;
4751
5135
  const envWith = (credentials) => {
4752
- const env = envFor(plan, {
5136
+ const resolved = envFor(plan, {
4753
5137
  ports,
4754
5138
  project,
4755
5139
  credentials,
@@ -4759,6 +5143,10 @@ async function reconcile(options) {
4759
5143
  ...lan ? { lanAddress: lan } : {},
4760
5144
  ...options.metroPorts ? { metroPorts: options.metroPorts } : {}
4761
5145
  });
5146
+ const env = exports ? resolved : scopeTelemetryEnv(resolved, {
5147
+ uses: false,
5148
+ serviceName: project
5149
+ });
4762
5150
  if (plan.containers.includes("caddy")) env.NODE_EXTRA_CA_CERTS = join(root, LOCAL_CA_PATH);
4763
5151
  return env;
4764
5152
  };
@@ -4778,7 +5166,7 @@ async function reconcile(options) {
4778
5166
  };
4779
5167
  const recorded = await loadState(root, stage);
4780
5168
  const postgresPort = ports[primaryPortKey("postgres")];
4781
- if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, plan.containers)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort, options.credentials.postgres), postgresDatabaseNames(plan)))) return result;
5169
+ if (recorded?.hash === hash && existsSync(composePath) && (!start || await docker.healthy(composePath, running)) && (!start || !provision || postgresPort === void 0 || await databasesExist(sql(postgresPort, options.credentials.postgres), postgresDatabaseNames(plan)))) return result;
4782
5170
  await write(composePath, toYaml(compose), 384);
4783
5171
  if (plan.containers.includes("caddy")) {
4784
5172
  await write(join(root, CADDYFILE_PATH), caddyfileRoot());
@@ -4789,9 +5177,9 @@ async function reconcile(options) {
4789
5177
  TEST_STAGE
4790
5178
  ]);
4791
5179
  }
4792
- if (start && plan.containers.length > 0) {
4793
- options.progress?.(`🐳 Starting ${plan.containers.join(", ")} — the first start pulls each image, which can take a few minutes`);
4794
- await docker.up(composePath, plan.containers, options.progress ? { show: true } : {});
5180
+ if (start && running.length > 0) {
5181
+ options.progress?.(`🐳 Starting ${running.join(", ")} — the first start pulls each image, which can take a few minutes`);
5182
+ await docker.up(composePath, running, options.progress ? { show: true } : {});
4795
5183
  }
4796
5184
  if (start && plan.containers.includes("caddy")) await docker.reload(composePath, "caddy").catch(() => {});
4797
5185
  if (start && plan.containers.includes("caddy")) await docker.copyOut(composePath, "caddy", CADDY_ROOT_IN_CONTAINER, join(root, LOCAL_CA_PATH)).catch(() => {});
@@ -4888,295 +5276,6 @@ async function write(path, content, mode) {
4888
5276
  if (mode !== void 0) await chmod(path, mode);
4889
5277
  }
4890
5278
  //#endregion
4891
- //#region src/deploy/otel.ts
4892
- /**
4893
- * The OpenTelemetry variables a stage's secrets pass to every backend.
4894
- *
4895
- * A production server starts OpenTelemetry when `OTEL_EXPORTER_OTLP_ENDPOINT`
4896
- * is set (`generators/telemetry.ts`), and the SDK reads the rest of its
4897
- * configuration from the standard `OTEL_*` variables. No construct declares
4898
- * them — they say where the telemetry goes, not what the app is — so a target
4899
- * that writes each backend only the keys it reads would never hand them over.
4900
- * Every target that runs backends asks this module instead.
4901
- *
4902
- * Which keys pass is a pattern over the `OTEL_` prefix rather than the prefix
4903
- * alone or a list:
4904
- *
4905
- * - The bare prefix would forward anything a stage happened to name `OTEL_*`
4906
- * — `OTEL_LOG_LEVEL=debug`, `OTEL_SDK_DISABLED` — to every backend, without
4907
- * anyone choosing to.
4908
- * - A list would have to spell out the exporter's five settings once for all
4909
- * signals and once per signal — twenty keys — and would miss the next one.
4910
- *
4911
- * The pattern names the families the server uses: the OTLP exporter's
4912
- * endpoint, headers, protocol, timeout and compression, for every signal or
4913
- * for one (`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`); sampling; and the resource's
4914
- * identity.
4915
- *
4916
- * Sites get none of it: a site's environment is inlined into a bundle that
4917
- * every browser downloads, and an exporter's headers are a credential.
4918
- */
4919
- /** The `OTEL_*` variables a backend is handed from the stage's secrets. */
4920
- const OTEL_PASSTHROUGH = /^OTEL_(?:EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION)|TRACES_SAMPLER(?:_ARG)?|RESOURCE_ATTRIBUTES|SERVICE_NAME)$/;
4921
- /** Whether a stage secret is one of the passed-through `OTEL_*` variables. */
4922
- function isOtelVariable(key) {
4923
- return OTEL_PASSTHROUGH.test(key);
4924
- }
4925
- /** Whether a variable says where an OTLP exporter sends, or how it signs. */
4926
- function isOtlpDestination(key) {
4927
- return /^OTEL_EXPORTER_OTLP_(?:(?:TRACES|LOGS|METRICS)_)?(?:ENDPOINT|HEADERS)$/.test(key);
4928
- }
4929
- /**
4930
- * The `OTEL_*` variables one backend runs with: every one the stage's secrets
4931
- * hold, and `OTEL_SERVICE_NAME` — the app's name — where the stage set
4932
- * telemetry up without naming the service. Empty when the stage set none.
4933
- */
4934
- function otelEnv(custom, app) {
4935
- const env = {};
4936
- for (const [key, value] of Object.entries(custom)) if (isOtelVariable(key) && value !== "") env[key] = value;
4937
- if (Object.keys(env).length > 0 && !env.OTEL_SERVICE_NAME) env.OTEL_SERVICE_NAME = app;
4938
- return env;
4939
- }
4940
- //#endregion
4941
- //#region src/target/dokploy/domain.ts
4942
- /** A stage was deployed that `deploy.domains` gives no domain. */
4943
- var NoDomainForStage = class extends Error {
4944
- stage;
4945
- constructor(stage) {
4946
- super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
4947
- this.stage = stage;
4948
- this.name = "NoDomainForStage";
4949
- }
4950
- };
4951
- /**
4952
- * The hostname an app answers on for a stage.
4953
- *
4954
- * 1. An explicit `app.domain` — a string, or one per stage — wins.
4955
- * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
4956
- * answers on the domain itself, and every other app on
4957
- * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
4958
- *
4959
- * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
4960
- */
4961
- function resolveHost(appName, app, stage, domains, isMainFrontend) {
4962
- if (app.domain) {
4963
- if (typeof app.domain === "string") return app.domain;
4964
- if (app.domain[stage]) return app.domain[stage];
4965
- }
4966
- const baseDomain = domains?.[stage];
4967
- if (!baseDomain) throw new NoDomainForStage(stage);
4968
- if (isMainFrontend) return baseDomain;
4969
- return `${app.subdomain ?? appName}.${baseDomain}`;
4970
- }
4971
- /**
4972
- * Whether this app is the site the base domain points at — see `rootSite`,
4973
- * which the local edge asks too.
4974
- *
4975
- * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
4976
- */
4977
- function isMainFrontendApp(appName, app, allApps) {
4978
- if (app.type !== "web") return false;
4979
- return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
4980
- name,
4981
- root: a.root
4982
- }))) === appName;
4983
- }
4984
- /**
4985
- * Get public URL arg names from app dependencies.
4986
- *
4987
- * @param app - The web/mobile app configuration
4988
- * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
4989
- * without a public prefix (e.g. Remix).
4990
- */
4991
- function getPublicUrlArgNames(app) {
4992
- const prefix = getPublicEnvPrefix(app.framework);
4993
- if (!prefix) return [];
4994
- return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
4995
- }
4996
- //#endregion
4997
- //#region src/compose/logs.ts
4998
- /**
4999
- * The stack's log UI: OpenObserve, run beside the apps when
5000
- * `deploy.compose.logs` asks for it, and every backend's OpenTelemetry
5001
- * pointed at it.
5002
- *
5003
- * The production server already exports its traces and pino logs over
5004
- * OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. OpenObserve takes OTLP
5005
- * at `/api/<org>/v1/{logs,traces}` with HTTP basic auth, so wiring an app is
5006
- * two variables: the endpoint, `http://openobserve:5080/api/default`, and
5007
- * the header that signs in as the root user.
5008
- *
5009
- * It is reached, by default, through an SSH tunnel: published on 127.0.0.1
5010
- * only, because a port Docker publishes on every interface is opened in the
5011
- * firewall by Docker itself, past ufw's rules. Served publicly, it is a host
5012
- * on the stack's Caddy that refuses every address not allowed.
5013
- *
5014
- * Pure: the stack decides it, and the phases print and check it.
5015
- */
5016
- /**
5017
- * The image, pinned. A log store's on-disk format is the thing that must not
5018
- * move under a running stack, so a new version is a change to this line.
5019
- */
5020
- const OPENOBSERVE_IMAGE = "public.ecr.aws/zinclabs/openobserve:v1.0.4";
5021
- /** Its compose service name — the host the apps send to. */
5022
- const LOGS_SERVICE = "openobserve";
5023
- /** The organisation every app's telemetry lands in. */
5024
- const LOGS_ORG = "default";
5025
- /** The stage secrets that sign in as OpenObserve's root user. */
5026
- const LOGS_EMAIL_KEY = "ZO_ROOT_USER_EMAIL";
5027
- const LOGS_PASSWORD_KEY = "ZO_ROOT_USER_PASSWORD";
5028
- /**
5029
- * The local stage's root user. Its password is generated per machine with
5030
- * the other local logins — see `reconcile/localCredentials.ts`.
5031
- */
5032
- const LOCAL_LOGS_EMAIL = "admin@gkm.localhost";
5033
- /** The stage sends its telemetry somewhere already, and logs are on too. */
5034
- var LogsEndpointConflict = class extends Error {
5035
- stage;
5036
- keys;
5037
- constructor(stage, keys) {
5038
- super(`The stage '${stage}' sets ${keys.join(", ")} in its secrets, and deploy.compose.logs also runs OpenObserve for it — each backend can send its telemetry to one place. Either keep your own OTLP backend: remove logs from deploy.compose in gkm.config.ts; or use the bundled OpenObserve: remove ${keys.join(", ")} from the stage's secrets, and every backend is pointed at it.`);
5039
- this.stage = stage;
5040
- this.keys = keys;
5041
- this.name = "LogsEndpointConflict";
5042
- }
5043
- };
5044
- /** A root password OpenObserve would refuse to start with. */
5045
- var LogsPasswordWeak = class extends Error {
5046
- stage;
5047
- constructor(stage) {
5048
- super(`The stage '${stage}' sets ${LOGS_PASSWORD_KEY}, and OpenObserve refuses it: a root password must be 8 to 128 characters with a lowercase letter, an uppercase letter, a digit and a symbol. Set another with gkm secrets:set ${LOGS_PASSWORD_KEY} '…' --stage ${stage}, or unset it to have one generated.`);
5049
- this.stage = stage;
5050
- this.name = "LogsPasswordWeak";
5051
- }
5052
- };
5053
- /** A deployed stage with logs on and no root password generated yet. */
5054
- var LogsPasswordMissing = class extends Error {
5055
- stage;
5056
- constructor(stage) {
5057
- super(`The stage '${stage}' runs OpenObserve and has no ${LOGS_PASSWORD_KEY} in its secrets. Run gkm compose --stage ${stage} without --dry-run once to generate it, or set one: gkm secrets:set ${LOGS_PASSWORD_KEY} '…' --stage ${stage}`);
5058
- this.stage = stage;
5059
- this.name = "LogsPasswordMissing";
5060
- }
5061
- };
5062
- /** Whether OpenObserve accepts `password` for its root user. */
5063
- function isStrongLogsPassword(password) {
5064
- return password.length >= 8 && password.length <= 128 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password) && /[^A-Za-z0-9]/.test(password);
5065
- }
5066
- /**
5067
- * A root password: 192 random bits, with a fixed tail so every class
5068
- * OpenObserve requires is present whatever the random part drew.
5069
- */
5070
- function generateLogsPassword() {
5071
- return `${randomBytes(24).toString("base64url")}-Zo1`;
5072
- }
5073
- /**
5074
- * The stage's secrets with a generated root password, where it has none.
5075
- * Pure, like `withGeneratedSecrets`: the caller keeps the result once the
5076
- * run goes ahead.
5077
- */
5078
- function withLogsPassword(secrets) {
5079
- if (secrets.custom?.["ZO_ROOT_USER_PASSWORD"]) return {
5080
- secrets,
5081
- generated: []
5082
- };
5083
- return {
5084
- secrets: {
5085
- ...secrets,
5086
- custom: {
5087
- ...secrets.custom,
5088
- [LOGS_PASSWORD_KEY]: generateLogsPassword()
5089
- },
5090
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5091
- },
5092
- generated: [LOGS_PASSWORD_KEY]
5093
- };
5094
- }
5095
- /**
5096
- * `OTEL_EXPORTER_OTLP_HEADERS` signing in as `email`.
5097
- *
5098
- * The variable's values are percent-decoded by the SDK (they follow the W3C
5099
- * baggage format), so the space after `Basic` is written `%20`. Base64's own
5100
- * `+`, `/` and `=` pass through decoding unchanged.
5101
- */
5102
- function otlpHeaders(email, password) {
5103
- return `Authorization=Basic%20${Buffer.from(`${email}:${password}`).toString("base64")}`;
5104
- }
5105
- /** The stack's OpenObserve for one stage. */
5106
- function stackLogs(options) {
5107
- const { config, stage, local, custom } = options;
5108
- const conflicting = Object.keys(custom).filter(isOtlpDestination).sort();
5109
- if (conflicting.length > 0) throw new LogsEndpointConflict(stage, conflicting);
5110
- if (!local && !options.domain) throw new NoDomainForStage(stage);
5111
- const email = custom["ZO_ROOT_USER_EMAIL"] ?? (local ? options.localLogin?.email ?? "admin@gkm.localhost" : `admin@${options.domain}`);
5112
- const set = custom[LOGS_PASSWORD_KEY];
5113
- const password = set ?? (local ? options.localLogin?.password : void 0);
5114
- if (!password) throw new LogsPasswordMissing(stage);
5115
- if (!isStrongLogsPassword(password)) throw new LogsPasswordWeak(stage);
5116
- const host = config.public ? local ? `logs.${options.project}.localhost` : `logs.${options.domain}` : void 0;
5117
- const url = host ? `https://${host}${local && options.https !== 443 ? `:${options.https}` : ""}` : `http://localhost:${config.port}`;
5118
- return {
5119
- ...config,
5120
- email,
5121
- password,
5122
- passwordFromSecrets: Boolean(set),
5123
- url,
5124
- ...host ? { host } : {},
5125
- env: {
5126
- [LOGS_EMAIL_KEY]: email,
5127
- [LOGS_PASSWORD_KEY]: password,
5128
- ZO_DATA_DIR: "/data",
5129
- ZO_COMPACT_DATA_RETENTION_DAYS: String(config.retentionDays),
5130
- ZO_TELEMETRY: "false"
5131
- },
5132
- appEnv: {
5133
- OTEL_EXPORTER_OTLP_ENDPOINT: `http://${LOGS_SERVICE}:${LOGS_PORT}/api/${LOGS_ORG}`,
5134
- OTEL_EXPORTER_OTLP_HEADERS: otlpHeaders(email, password)
5135
- }
5136
- };
5137
- }
5138
- /**
5139
- * OpenObserve's service. The image has no shell, `wget` or `curl`, so its
5140
- * health check is its own binary asking the running server for its status —
5141
- * which exits non-zero when nothing answers on its port.
5142
- */
5143
- function logsService(logs) {
5144
- return {
5145
- image: OPENOBSERVE_IMAGE,
5146
- restart: "unless-stopped",
5147
- env_file: [{
5148
- path: `./${LOGS_SERVICE}.env`,
5149
- format: "raw"
5150
- }],
5151
- volumes: ["openobserve-data:/data"],
5152
- ...logs.public ? {} : { ports: [`127.0.0.1:${logs.port}:${LOGS_PORT}`] },
5153
- healthcheck: {
5154
- test: [
5155
- "CMD",
5156
- "/openobserve",
5157
- "node",
5158
- "status"
5159
- ],
5160
- interval: "10s",
5161
- timeout: "5s",
5162
- retries: 12,
5163
- start_period: "20s"
5164
- }
5165
- };
5166
- }
5167
- /** What `gkm compose` prints once the stack is up: how to open the logs. */
5168
- function logsAccess(logs, ctx) {
5169
- const login = `login: ${logs.email}, password: ${ctx.local && !logs.passwordFromSecrets ? logs.password : `gkm secrets:show --stage ${ctx.stage} --reveal → ${LOGS_PASSWORD_KEY}`}`;
5170
- if (logs.public) return [`📜 Logs (OpenObserve): ${logs.url} (${login})`, ` answered only for ${logs.public.allow.join(", ")}; every other address gets 403`];
5171
- return [
5172
- `📜 Logs (OpenObserve) on 127.0.0.1:${logs.port} — from your computer:`,
5173
- ...ctx.local ? [` on this machine, open http://localhost:${logs.port} directly; from another:`] : [],
5174
- ` ssh -N -L ${logs.port}:localhost:${logs.port} ${ctx.user}@${ctx.hostname} (user and host are guesses: this machine's)`,
5175
- ` then open http://localhost:${logs.port} (${login})`,
5176
- `⚠️ Docker-published ports bypass ufw, so OpenObserve is bound to 127.0.0.1 only — reach it through the tunnel, not by opening the port.`
5177
- ];
5178
- }
5179
- //#endregion
5180
5279
  //#region src/compose/redis.ts
5181
5280
  /**
5182
5281
  * The stack's Redis: what every declared cache lives in when a workspace runs
@@ -6212,10 +6311,7 @@ var WorkerGenerator = class {
6212
6311
  this.generators.subscriber.build(context, input.subscribers, outputDir, options)
6213
6312
  ]);
6214
6313
  await writeFile(join(outputDir, "app.ts"), workerApp(context, workerId, outputDir));
6215
- await writeFile(join(outputDir, "telemetry.ts"), generateTelemetryModule(context.telemetry ? {
6216
- ...context.telemetry,
6217
- serviceName: workerId
6218
- } : void 0));
6314
+ await writeTelemetryModule(outputDir, input.telemetry);
6219
6315
  const entry = join(outputDir, "worker.ts");
6220
6316
  await writeFile(entry, workerEntry(context.production?.healthCheck ?? "/health"));
6221
6317
  return entry;
@@ -6339,11 +6435,8 @@ function workerEntry(healthCheck) {
6339
6435
  */
6340
6436
  import { createServer } from 'node:http';
6341
6437
  import { runShutdownHooks } from '@geekmidas/constructs';
6342
- import { startTelemetry } from './telemetry.js';
6343
-
6344
- // Before the worker is imported, so the libraries it loads are instrumented.
6345
- await startTelemetry({ ignorePaths: [${JSON.stringify(healthCheck)}] });
6346
6438
 
6439
+ ${startTelemetryCode([healthCheck])}
6347
6440
  const { startWorker, logger } = await import('./app.js');
6348
6441
 
6349
6442
  const port = Number(process.env.PORT) || 3000;
@@ -6816,7 +6909,7 @@ async function dockerCommand(options) {
6816
6909
  const cwd = options.cwd ?? process.cwd();
6817
6910
  const loaded = await loadWorkspaceConfig(cwd);
6818
6911
  const names = loaded.type === "single" ? Object.fromEntries(Object.keys(loaded.workspace.apps).map((name) => [name, resolveDockerConfig(loaded.raw).imageName])) : {};
6819
- const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names, loaded.background);
6912
+ const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names, loaded.background, options.stage);
6820
6913
  if (options.build) for (const app of result.apps) await buildDockerImage(app, result.buildRoot, options);
6821
6914
  if (options.push) for (const app of result.apps) await pushDockerImage(app.imageName, options, result.buildRoot);
6822
6915
  return result;
@@ -6868,7 +6961,7 @@ async function pushDockerImage(imageName, options, cwd) {
6868
6961
  * Generate Dockerfiles for all apps in a workspace.
6869
6962
  * @internal Exported for testing
6870
6963
  */
6871
- async function workspaceDockerCommand(workspace, manifest, imageNames = {}, background = {}) {
6964
+ async function workspaceDockerCommand(workspace, manifest, imageNames = {}, background = {}, stage) {
6872
6965
  const results = [];
6873
6966
  const layout = imageLayout(workspace);
6874
6967
  logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
@@ -6885,7 +6978,9 @@ async function workspaceDockerCommand(workspace, manifest, imageNames = {}, back
6885
6978
  layout,
6886
6979
  workspaceRoot: workspace.root,
6887
6980
  apps: workspace.apps,
6888
- ...manifest ? { manifest } : {}
6981
+ ...manifest ? { manifest } : {},
6982
+ workspace,
6983
+ ...stage ? { stage } : {}
6889
6984
  });
6890
6985
  const path = dockerfileOf(appName, app.path);
6891
6986
  const dockerfilePath = join(workspace.root, path);
@@ -6940,7 +7035,7 @@ async function workspaceDockerCommand(workspace, manifest, imageNames = {}, back
6940
7035
  * framework. The one choice every builder of images makes.
6941
7036
  */
6942
7037
  function appDockerfile(appName, app, options) {
6943
- const image = appImageOptions(options.layout, appName, app, options.workspaceRoot, options.apps);
7038
+ const image = appImageOptions(options.layout, appName, app, options.workspaceRoot, options.apps, app.type === "web" && options.manifest && options.workspace ? siteClientTelemetry(options.manifest, appName, options.workspace, options.stage) : void 0);
6944
7039
  if (app.type === "web") return siteDockerfile(appName, app, {
6945
7040
  image,
6946
7041
  ...options.manifest ? { manifest: options.manifest } : {},
@@ -7451,6 +7546,6 @@ function reportPlannedSeeds(planned, log) {
7451
7546
  for (const { folder, seeds } of planned) log(`🌱 ${folder}: would run ${seeds.length} (${seeds.join(", ")})`);
7452
7547
  }
7453
7548
  //#endregion
7454
- export { postgresSuperuser as $, EMULATOR_ACCESS_KEY_ID as $t, UnknownDevService as A, projectName as An, appEnvKeys as At, WorkerGenerator as B, imageLayout as Bt, workerDockerfile as C, applicationName as Cn, postgresDatabaseNames as Ct, DevServicesNeedServerTarget as D, isOwnedBy as Dn, ensurePostgresLogin as Dt, DEV_SERVICES as E, imageRef as En, LEGACY_LOCAL_LOGIN as Et, parseDevServices as F, withCustomSecret as Fn, localRolePassword as Ft, FAKE_ENV as G, imageBuildCredentials as Gt, workerEntryDir as H, WORKER_PORT as Ht, reportDevServices as I, planFor as It, derivedContainers as J, eventsBackendFor as Jt, backendsOf as K, isImageBuild as Kt, requiredStageKeys as L, resourceName as Lt, devServicesUsed as M, maskPassword as Mn, networkEnv as Mt, externalServices as N, storage_exports as Nn, workerEnvKeys as Nt, ExternalServicesNotConfigured as O, markerOf as On, bucketClient as Ot, manifestServiceDeclarations as P, toEmbeddableSecrets as Pn, envFor as Pt, loadLocalCredentials as Q, composeFor as Qt, suppliedOnly as R, hostFor as Rt, ensureDockerignore as S, readKey as Sn, bucketPolicies as St, validateImageRef as T, imageName as Tn, quoteIdentifier as Tt, QueueGenerator as U, detectPackageManager as Ut, workerBundleName as V, TURBO_VERSION as Vt, CronGenerator as W, findBuildRoot as Wt, workspace_exports as X, storageBackendFor as Xt, reconcileWorkspace as Y, providerOf as Yt, LOCAL_RABBITMQ_USER as Z, CLUSTER_DATABASE as Zt, DeployJournal as _, store_exports as _n, LOCAL_CA_PATH as _t, createEmptyState as a, primaryPortKey as an, runsRedis as at, appDockerfile as b, keystoreProject as bn, applyPolicies as bt, isDnsVerified as c, findAvailablePort as cn, LOGS_SERVICE as ct, setApplicationId as d, loadPortState as dn, stackLogs as dt, DEFAULT_IMAGES as en, readLocalCredentials as et, setBackupState as f, loadSecretsForApp as fn, withLogsPassword as ft, setPostgresBackupId as g, storeFromConfig as gn, otelEnv as gt, setGeneratedSecret as h, secretsStoreFor as hn, resolveHost as ht, assertNoStaleSecrets as i, portsOf as in, redisService as it, assertExternalServices as j, initStageSecrets as jn, dockerfileOf as jt, STORAGE_KEY_PAIR as k, ownershipMarker as kn, pgClient as kt, recordRelease as l, isPortAvailable as ln, logsAccess as lt, setDnsVerification as m, isRemoteStore as mn, isMainFrontendApp as mt, reportDatabaseRuns as n, fakeKey as nn, REDIS_SERVICE as nt, getBackupState as o, createCredentialsPreload as on, stackRedis as ot, setDnsRecord as p, prepareEntryCredentials as pn, NoDomainForStage as pt, constructGlobs as q, cacheBackendFor as qt, reportPlannedSeeds as r, portKeys as rn, STACK_CACHE as rt, getGeneratedSecret as s, createEntryWrapper as sn, withRedisPassword as st, DeploySeedsFailed as t, describeServices as tn, withGeneratedSecrets as tt, recordRollback as u, loadEnvFiles as un, logsService as ut, DockerBuildFailed as v, FileSecretsStore as vn, forgetState as vt, workerDockerfileOf as w, deployIdentity as wn, postgresStatements as wt, dockerCommand as x, projectKey as xn, applyPostgres as xt, DockerPushFailed as y, getKeyPath as yn, applyBuckets as yt, workerUnits as z, composeBuildPaths as zt };
7549
+ export { postgresSuperuser as $, describeServices as $t, UnknownDevService as A, maskPassword as An, WORKER_PORT as At, WorkerGenerator as B, NoDomainForStage as Bn, composeFor as Bt, workerDockerfile as C, imageName as Cn, networkEnv as Ct, DevServicesNeedServerTarget as D, ownershipMarker as Dn, composeBuildPaths as Dt, DEV_SERVICES as E, markerOf as En, localRolePassword as Et, parseDevServices as F, LOGS_SERVICE as Fn, cacheBackendFor as Ft, FAKE_ENV as G, hostFor as Gt, workerEntryDir as H, resolveHost as Hn, TELEMETRY_CONTAINER as Ht, reportDevServices as I, logsAccess as In, eventsBackendFor as It, derivedContainers as J, scopeTelemetryEnv as Jt, backendsOf as K, appTelemetry as Kt, requiredStageKeys as L, logsService as Ln, providerOf as Lt, devServicesUsed as M, toEmbeddableSecrets as Mn, findBuildRoot as Mt, externalServices as N, withCustomSecret as Nn, imageBuildCredentials as Nt, ExternalServicesNotConfigured as O, projectName as On, imageLayout as Ot, manifestServiceDeclarations as P, LOCAL_LOGS_EMAIL as Pn, isImageBuild as Pt, loadLocalCredentials as Q, DEFAULT_IMAGES as Qt, suppliedOnly as R, stackLogs as Rn, storageBackendFor as Rt, ensureDockerignore as S, deployIdentity as Sn, dockerfileOf as St, validateImageRef as T, isOwnedBy as Tn, envFor as Tt, QueueGenerator as U, planFor as Ut, workerBundleName as V, isMainFrontendApp as Vn, EMULATOR_ACCESS_KEY_ID as Vt, CronGenerator as W, resourceName as Wt, workspace_exports as X, telemetryOf as Xt, reconcileWorkspace as Y, surfaceClientTraced as Yt, LOCAL_RABBITMQ_USER as Z, usesTelemetry as Zt, DeployJournal as _, getKeyPath as _n, LEGACY_LOCAL_LOGIN as _t, createEmptyState as a, createEntryWrapper as an, runsRedis as at, appDockerfile as b, readKey as bn, pgClient as bt, isDnsVerified as c, loadEnvFiles as cn, LOCAL_CA_PATH as ct, setApplicationId as d, prepareEntryCredentials as dn, applyPolicies as dt, fakeKey as en, readLocalCredentials as et, setBackupState as f, isRemoteStore as fn, applyPostgres as ft, setPostgresBackupId as g, FileSecretsStore as gn, quoteIdentifier as gt, setGeneratedSecret as h, store_exports as hn, postgresStatements as ht, assertNoStaleSecrets as i, createCredentialsPreload as in, redisService as it, assertExternalServices as j, storage_exports as jn, detectPackageManager as jt, STORAGE_KEY_PAIR as k, initStageSecrets as kn, TURBO_VERSION as kt, recordRelease as l, loadPortState as ln, forgetState as lt, setDnsVerification as m, storeFromConfig as mn, postgresDatabaseNames as mt, reportDatabaseRuns as n, portsOf as nn, REDIS_SERVICE as nt, getBackupState as o, findAvailablePort as on, stackRedis as ot, setDnsRecord as p, secretsStoreFor as pn, bucketPolicies as pt, constructGlobs as q, isTelemetryKey as qt, reportPlannedSeeds as r, primaryPortKey as rn, STACK_CACHE as rt, getGeneratedSecret as s, isPortAvailable as sn, withRedisPassword as st, DeploySeedsFailed as t, portKeys as tn, withGeneratedSecrets as tt, recordRollback as u, loadSecretsForApp as un, applyBuckets as ut, DockerBuildFailed as v, keystoreProject as vn, ensurePostgresLogin as vt, workerDockerfileOf as w, imageRef as wn, workerEnvKeys as wt, dockerCommand as x, applicationName as xn, appEnvKeys as xt, DockerPushFailed as y, projectKey as yn, bucketClient as yt, workerUnits as z, withLogsPassword as zn, CLUSTER_DATABASE as zt };
7455
7550
 
7456
- //# sourceMappingURL=seeds-DbyqaXW4.mjs.map
7551
+ //# sourceMappingURL=seeds-BK8caVMm.mjs.map