@geekmidas/cli 10.0.0-alpha.66 → 10.0.0-alpha.68

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 (151) hide show
  1. package/README.md +110 -178
  2. package/dist/{HostingerProvider-CY_VpVud.mjs → HostingerProvider-D85ayzTi.mjs} +2 -2
  3. package/dist/{HostingerProvider-CY_VpVud.mjs.map → HostingerProvider-D85ayzTi.mjs.map} +1 -1
  4. package/dist/{HostingerProvider-BznqRC2S.cjs → HostingerProvider-dxkwynSs.cjs} +2 -2
  5. package/dist/{HostingerProvider-BznqRC2S.cjs.map → HostingerProvider-dxkwynSs.cjs.map} +1 -1
  6. package/dist/{LegacyStateStore-BFcTT2QL.cjs → LegacyStateStore-BZiO9-Lw.cjs} +2 -2
  7. package/dist/{LegacyStateStore-BFcTT2QL.cjs.map → LegacyStateStore-BZiO9-Lw.cjs.map} +1 -1
  8. package/dist/{LegacyStateStore-CFheFo62.mjs → LegacyStateStore-C39UKwmf.mjs} +2 -2
  9. package/dist/{LegacyStateStore-CFheFo62.mjs.map → LegacyStateStore-C39UKwmf.mjs.map} +1 -1
  10. package/dist/{LocalStateStore-BVUexSvu.cjs → LocalStateStore-DHVsZLyF.cjs} +2 -2
  11. package/dist/{LocalStateStore-BVUexSvu.cjs.map → LocalStateStore-DHVsZLyF.cjs.map} +1 -1
  12. package/dist/{LocalStateStore-Bm3x9oOg.mjs → LocalStateStore-Dk3Wbvzk.mjs} +2 -2
  13. package/dist/{LocalStateStore-Bm3x9oOg.mjs.map → LocalStateStore-Dk3Wbvzk.mjs.map} +1 -1
  14. package/dist/{S3StateStore-CoXBhILd.cjs → S3StateStore--4tYVMAL.cjs} +2 -2
  15. package/dist/{S3StateStore-CoXBhILd.cjs.map → S3StateStore--4tYVMAL.cjs.map} +1 -1
  16. package/dist/{S3StateStore-BFHuctWM.mjs → S3StateStore-B_ZF9eHt.mjs} +2 -2
  17. package/dist/{S3StateStore-BFHuctWM.mjs.map → S3StateStore-B_ZF9eHt.mjs.map} +1 -1
  18. package/dist/{SSMStateStore-BqCA9TP2.cjs → SSMStateStore-ACZ8hRvZ.cjs} +2 -2
  19. package/dist/{SSMStateStore-BqCA9TP2.cjs.map → SSMStateStore-ACZ8hRvZ.cjs.map} +1 -1
  20. package/dist/{SSMStateStore-C1kvHScY.mjs → SSMStateStore-DjPuKqrm.mjs} +2 -2
  21. package/dist/{SSMStateStore-C1kvHScY.mjs.map → SSMStateStore-DjPuKqrm.mjs.map} +1 -1
  22. package/dist/{bundler-Dvaiud0h.mjs → bundler-C-w7aGoZ.mjs} +20 -5
  23. package/dist/bundler-C-w7aGoZ.mjs.map +1 -0
  24. package/dist/{bundler-orRqUJNZ.cjs → bundler-C1pnNrR0.cjs} +20 -5
  25. package/dist/bundler-C1pnNrR0.cjs.map +1 -0
  26. package/dist/{cli-D1JkkOwg.cjs → cli-BLyD4c0O.cjs} +14 -5
  27. package/dist/cli-BLyD4c0O.cjs.map +1 -0
  28. package/dist/{cli-CfHm0AO7.mjs → cli-Ce4pLL5O.mjs} +14 -5
  29. package/dist/cli-Ce4pLL5O.mjs.map +1 -0
  30. package/dist/{compose-CNQL8BnO.mjs → compose-DFxuYhn6.mjs} +291 -152
  31. package/dist/compose-DFxuYhn6.mjs.map +1 -0
  32. package/dist/{compose-jrLy7KGO.cjs → compose-DvsgumY2.cjs} +298 -159
  33. package/dist/compose-DvsgumY2.cjs.map +1 -0
  34. package/dist/{config-4XFxX1al.d.cts → config-BnA0kPTY.d.cts} +3 -3
  35. package/dist/{config-4XFxX1al.d.cts.map → config-BnA0kPTY.d.cts.map} +1 -1
  36. package/dist/{config-UtKzhpFb.d.mts → config-CxEp_g7j.d.mts} +3 -3
  37. package/dist/{config-UtKzhpFb.d.mts.map → config-CxEp_g7j.d.mts.map} +1 -1
  38. package/dist/config.d.cts +3 -3
  39. package/dist/config.d.mts +3 -3
  40. package/dist/{credentials-Bzbg23n8.cjs → credentials-BVs7mrtw.cjs} +239 -3
  41. package/dist/credentials-BVs7mrtw.cjs.map +1 -0
  42. package/dist/{credentials-Dq1MnWQf.mjs → credentials-pAtB70k3.mjs} +223 -5
  43. package/dist/credentials-pAtB70k3.mjs.map +1 -0
  44. package/dist/deploy/public.cjs +21 -17
  45. package/dist/deploy/public.cjs.map +1 -1
  46. package/dist/deploy/public.d.cts +15 -4
  47. package/dist/deploy/public.d.cts.map +1 -1
  48. package/dist/deploy/public.d.mts +15 -4
  49. package/dist/deploy/public.d.mts.map +1 -1
  50. package/dist/deploy/public.mjs +10 -10
  51. package/dist/{deploy-B7aJi_Ah.cjs → deploy-C08GONYW.cjs} +14 -10
  52. package/dist/deploy-C08GONYW.cjs.map +1 -0
  53. package/dist/{deploy-DWn7YfIc.mjs → deploy-D-EVOWvu.mjs} +11 -7
  54. package/dist/deploy-D-EVOWvu.mjs.map +1 -0
  55. package/dist/{dokploy-B4LcYG1E.cjs → dokploy-CZ6nfSV9.cjs} +2 -2
  56. package/dist/{dokploy-B4LcYG1E.cjs.map → dokploy-CZ6nfSV9.cjs.map} +1 -1
  57. package/dist/{dokploy-DalwLML5.mjs → dokploy-DU-XJsoT.mjs} +2 -2
  58. package/dist/{dokploy-DalwLML5.mjs.map → dokploy-DU-XJsoT.mjs.map} +1 -1
  59. package/dist/{encryption-3KlGS1sl.cjs → encryption-CCcBSLZs.cjs} +2 -2
  60. package/dist/{encryption-3KlGS1sl.cjs.map → encryption-CCcBSLZs.cjs.map} +1 -1
  61. package/dist/{encryption-CPfENwWO.mjs → encryption-DSiLau1-.mjs} +2 -2
  62. package/dist/{encryption-CPfENwWO.mjs.map → encryption-DSiLau1-.mjs.map} +1 -1
  63. package/dist/{engine-DR6KurWX.cjs → engine-BM9Dy_DT.cjs} +141 -209
  64. package/dist/engine-BM9Dy_DT.cjs.map +1 -0
  65. package/dist/{engine-CunvOG3s.mjs → engine-llWnMZH5.mjs} +137 -205
  66. package/dist/engine-llWnMZH5.mjs.map +1 -0
  67. package/dist/{github-BJ5gh9bw.cjs → github-ZoMam0OH.cjs} +2 -2
  68. package/dist/{github-BJ5gh9bw.cjs.map → github-ZoMam0OH.cjs.map} +1 -1
  69. package/dist/{github-MXA9v7Ge.mjs → github-yy6WpHIl.mjs} +2 -2
  70. package/dist/{github-MXA9v7Ge.mjs.map → github-yy6WpHIl.mjs.map} +1 -1
  71. package/dist/{index-DD1FfUHh.d.mts → index-CkX89m6S.d.mts} +2 -2
  72. package/dist/{index-CyApaHVe.d.cts.map → index-CkX89m6S.d.mts.map} +1 -1
  73. package/dist/{index-CyApaHVe.d.cts → index-EvZBkycu.d.cts} +2 -2
  74. package/dist/{index-DD1FfUHh.d.mts.map → index-EvZBkycu.d.cts.map} +1 -1
  75. package/dist/index.cjs +59 -70
  76. package/dist/index.cjs.map +1 -1
  77. package/dist/index.mjs +55 -66
  78. package/dist/index.mjs.map +1 -1
  79. package/dist/{migrate-BJBcm4sG.mjs → migrate-BWPuwiXk.mjs} +2 -2
  80. package/dist/{migrate-BJBcm4sG.mjs.map → migrate-BWPuwiXk.mjs.map} +1 -1
  81. package/dist/{migrate-CF4J4GQS.cjs → migrate-tOwxQBTI.cjs} +2 -2
  82. package/dist/{migrate-CF4J4GQS.cjs.map → migrate-tOwxQBTI.cjs.map} +1 -1
  83. package/dist/openapi.d.cts +1 -1
  84. package/dist/openapi.d.mts +1 -1
  85. package/dist/reconcile/public.d.cts +1 -1
  86. package/dist/reconcile/public.d.mts +1 -1
  87. package/dist/{resolve-CVmch0wI.cjs → resolve-BymzGtuT.cjs} +4 -4
  88. package/dist/{resolve-CVmch0wI.cjs.map → resolve-BymzGtuT.cjs.map} +1 -1
  89. package/dist/{resolve-Db0tNlrW.mjs → resolve-pIvXlZ1h.mjs} +4 -4
  90. package/dist/{resolve-Db0tNlrW.mjs.map → resolve-pIvXlZ1h.mjs.map} +1 -1
  91. package/dist/{rollback-DvAo4OKS.mjs → rollback-ZlsbQOvj.mjs} +5 -6
  92. package/dist/{rollback-DvAo4OKS.mjs.map → rollback-ZlsbQOvj.mjs.map} +1 -1
  93. package/dist/{rollback-DxJK84hy.cjs → rollback-zdfi6Reg.cjs} +6 -7
  94. package/dist/{rollback-DxJK84hy.cjs.map → rollback-zdfi6Reg.cjs.map} +1 -1
  95. package/dist/run-BJLIerMM.mjs +133 -0
  96. package/dist/run-BJLIerMM.mjs.map +1 -0
  97. package/dist/run-fVZ-rLnc.cjs +174 -0
  98. package/dist/run-fVZ-rLnc.cjs.map +1 -0
  99. package/dist/{runtime-BRqFOvPM.d.cts → runtime-B3jNgeP-.d.mts} +2 -2
  100. package/dist/{runtime-BRqFOvPM.d.cts.map → runtime-B3jNgeP-.d.mts.map} +1 -1
  101. package/dist/{runtime-CEk5n4VH.d.mts → runtime-DWThEP-r.d.cts} +2 -2
  102. package/dist/{runtime-CEk5n4VH.d.mts.map → runtime-DWThEP-r.d.cts.map} +1 -1
  103. package/dist/{sniffer-BAQhLEsI.mjs → sniffer-BdFeLSmA.mjs} +8 -60
  104. package/dist/sniffer-BdFeLSmA.mjs.map +1 -0
  105. package/dist/{sniffer-5xmrkh2a.cjs → sniffer-D66HIvi9.cjs} +7 -77
  106. package/dist/sniffer-D66HIvi9.cjs.map +1 -0
  107. package/dist/{sst-B2HbrDR3.mjs → sst-DRi3H5TB.mjs} +3 -3
  108. package/dist/{sst-B2HbrDR3.mjs.map → sst-DRi3H5TB.mjs.map} +1 -1
  109. package/dist/{sst-DcfsVH6x.cjs → sst-hBrKouBy.cjs} +5 -5
  110. package/dist/{sst-DcfsVH6x.cjs.map → sst-hBrKouBy.cjs.map} +1 -1
  111. package/dist/{state-uuYqnRVO.mjs → state-Bn5JDMFT.mjs} +1717 -1334
  112. package/dist/state-Bn5JDMFT.mjs.map +1 -0
  113. package/dist/{state-Y6ATakMX.cjs → state-CHZqCDRC.cjs} +2863 -2354
  114. package/dist/state-CHZqCDRC.cjs.map +1 -0
  115. package/dist/target/public.cjs +1 -1
  116. package/dist/target/public.d.cts +2 -2
  117. package/dist/target/public.d.mts +2 -2
  118. package/dist/target/public.mjs +1 -1
  119. package/dist/{terminal-DUhcZ1ND.mjs → terminal-C3cv-OQu.mjs} +2 -2
  120. package/dist/{terminal-DUhcZ1ND.mjs.map → terminal-C3cv-OQu.mjs.map} +1 -1
  121. package/dist/{terminal-CHy_MdjG.cjs → terminal-DjzgLu6m.cjs} +2 -2
  122. package/dist/{terminal-CHy_MdjG.cjs.map → terminal-DjzgLu6m.cjs.map} +1 -1
  123. package/dist/{types-CSw2w69a.d.mts → types-CZdCxUCK.d.cts} +64 -2
  124. package/dist/types-CZdCxUCK.d.cts.map +1 -0
  125. package/dist/{types-D5WIxRDP.d.cts → types-Da0E3Qv7.d.mts} +63 -3
  126. package/dist/types-Da0E3Qv7.d.mts.map +1 -0
  127. package/dist/workspace/index.d.cts +2 -2
  128. package/dist/workspace/index.d.mts +2 -2
  129. package/package.json +13 -13
  130. package/dist/bundler-Dvaiud0h.mjs.map +0 -1
  131. package/dist/bundler-orRqUJNZ.cjs.map +0 -1
  132. package/dist/cli-CfHm0AO7.mjs.map +0 -1
  133. package/dist/cli-D1JkkOwg.cjs.map +0 -1
  134. package/dist/compose-CNQL8BnO.mjs.map +0 -1
  135. package/dist/compose-jrLy7KGO.cjs.map +0 -1
  136. package/dist/credentials-Bzbg23n8.cjs.map +0 -1
  137. package/dist/credentials-Dq1MnWQf.mjs.map +0 -1
  138. package/dist/deploy-B7aJi_Ah.cjs.map +0 -1
  139. package/dist/deploy-DWn7YfIc.mjs.map +0 -1
  140. package/dist/engine-CunvOG3s.mjs.map +0 -1
  141. package/dist/engine-DR6KurWX.cjs.map +0 -1
  142. package/dist/local-BdH4XN0E.cjs +0 -409
  143. package/dist/local-BdH4XN0E.cjs.map +0 -1
  144. package/dist/local-CWR4Y_kW.mjs +0 -350
  145. package/dist/local-CWR4Y_kW.mjs.map +0 -1
  146. package/dist/sniffer-5xmrkh2a.cjs.map +0 -1
  147. package/dist/sniffer-BAQhLEsI.mjs.map +0 -1
  148. package/dist/state-Y6ATakMX.cjs.map +0 -1
  149. package/dist/state-uuYqnRVO.mjs.map +0 -1
  150. package/dist/types-CSw2w69a.d.mts.map +0 -1
  151. package/dist/types-D5WIxRDP.d.cts.map +0 -1
@@ -1,15 +1,15 @@
1
- import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
- import { s as run$1, u as gkmHome } from "./local-CWR4Y_kW.mjs";
1
+ import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
+ import { a as runOutput, i as run$1, s as gkmHome } from "./run-BJLIerMM.mjs";
3
3
  import { d as output, r as discover } from "./discover-BFoJWKGD.mjs";
4
4
  import { t as appKey } from "./derive-B4vDpzgP.mjs";
5
5
  import { O as TEST_STAGE, h as getPublicEnvPrefix, n as allConstructGlobs } from "./workspace-Be6uBhlF.mjs";
6
- import { ConfigNotFound, NotInAnApp, loadConfig, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
7
- import { c as runtimeOf } from "./resolve-Db0tNlrW.mjs";
6
+ import { ConfigNotFound, NotInAnApp, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
7
+ import { c as runtimeOf } from "./resolve-pIvXlZ1h.mjs";
8
8
  import { i as DEFAULT_STORAGE, n as DEFAULT_CACHE, r as DEFAULT_EVENTS$1, t as readFakes } from "./fakes-C9RiT6XG.mjs";
9
- import { basename, dirname, join, parse, resolve } from "node:path";
10
- import { constants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
11
- import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
12
- import { homedir, networkInterfaces } from "node:os";
9
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
10
+ import { constants, existsSync, readFileSync } from "node:fs";
11
+ import { appendFile, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
12
+ import { homedir, networkInterfaces, tmpdir } from "node:os";
13
13
  import { promisify } from "node:util";
14
14
  import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
15
15
  import { execFile, spawn } from "node:child_process";
@@ -271,7 +271,7 @@ function applicationName(identity, app) {
271
271
  * old file stays, so an older CLI on the same machine keeps working.
272
272
  */
273
273
  /** Key length for AES-256 encryption */
274
- const KEY_LENGTH = 32;
274
+ const KEY_LENGTH$1 = 32;
275
275
  /** A keystore project key that is not `<namespace>/<project>`. */
276
276
  var KeystoreProjectInvalid = class extends Error {
277
277
  key;
@@ -364,7 +364,7 @@ async function generateKey(stage, project, flag = "w") {
364
364
  recursive: true,
365
365
  mode: 448
366
366
  });
367
- const key = randomBytes(KEY_LENGTH).toString("hex");
367
+ const key = randomBytes(KEY_LENGTH$1).toString("hex");
368
368
  await writeFile(keyPath, key, {
369
369
  mode: 384,
370
370
  encoding: "utf-8",
@@ -433,8 +433,8 @@ async function getOrCreateKey(stage, project) {
433
433
  /** Where the files live, relative to the project root. */
434
434
  const SECRETS_DIR = ".gkm/secrets";
435
435
  /** AES-256-GCM configuration */
436
- const ALGORITHM = "aes-256-gcm";
437
- const IV_LENGTH = 12;
436
+ const ALGORITHM$1 = "aes-256-gcm";
437
+ const IV_LENGTH$1 = 12;
438
438
  /**
439
439
  * A stage's secrets as an encrypted file on this machine:
440
440
  * `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
@@ -455,7 +455,7 @@ var FileSecretsStore = class {
455
455
  constructor(root, project) {
456
456
  this.root = root;
457
457
  this.project = project ?? {
458
- key: projectKey({ name: packageName(root) ?? basename(root) }),
458
+ key: projectKey({ name: packageName$1(root) ?? basename(root) }),
459
459
  legacy: [basename(root)]
460
460
  };
461
461
  }
@@ -492,7 +492,7 @@ var MissingSecretsKey = class extends Error {
492
492
  }
493
493
  };
494
494
  /** A project's package name without its scope, as a workspace name reads it. */
495
- function packageName(root) {
495
+ function packageName$1(root) {
496
496
  try {
497
497
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
498
498
  return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
@@ -502,8 +502,8 @@ function packageName(root) {
502
502
  }
503
503
  function encrypt(secrets, keyHex) {
504
504
  const key = Buffer.from(keyHex, "hex");
505
- const iv = randomBytes(IV_LENGTH);
506
- const cipher = createCipheriv(ALGORITHM, key, iv);
505
+ const iv = randomBytes(IV_LENGTH$1);
506
+ const cipher = createCipheriv(ALGORITHM$1, key, iv);
507
507
  const ciphertext = Buffer.concat([cipher.update(JSON.stringify(secrets), "utf-8"), cipher.final()]);
508
508
  return {
509
509
  version: 1,
@@ -514,7 +514,7 @@ function encrypt(secrets, keyHex) {
514
514
  function decrypt(data, keyHex) {
515
515
  const key = Buffer.from(keyHex, "hex");
516
516
  const combined = Buffer.from(data.encrypted, "base64");
517
- const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(data.iv, "hex"));
517
+ const decipher = createDecipheriv(ALGORITHM$1, key, Buffer.from(data.iv, "hex"));
518
518
  decipher.setAuthTag(combined.subarray(-16));
519
519
  const plaintext = Buffer.concat([decipher.update(combined.subarray(0, -16)), decipher.final()]);
520
520
  return JSON.parse(plaintext.toString("utf-8"));
@@ -551,7 +551,7 @@ function isRemoteStore(workspace, stage) {
551
551
  }
552
552
  //#endregion
553
553
  //#region src/credentials/index.ts
554
- const logger$1 = console;
554
+ const logger$2 = console;
555
555
  /**
556
556
  * Load environment files
557
557
  * @internal Exported for testing
@@ -602,7 +602,7 @@ async function findAvailablePort(preferredPort, maxAttempts = 10) {
602
602
  for (let i = 0; i < maxAttempts; i++) {
603
603
  const port = preferredPort + i;
604
604
  if (await isPortAvailable(port)) return port;
605
- logger$1.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
605
+ logger$2.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
606
606
  }
607
607
  throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
608
608
  }
@@ -637,7 +637,7 @@ async function loadSecretsForApp(store, stage, appName) {
637
637
  let secrets = {};
638
638
  const stageSecrets = await store.read(stage);
639
639
  if (stageSecrets) {
640
- logger$1.log(`🔐 Loading secrets from stage: ${stage}`);
640
+ logger$2.log(`🔐 Loading secrets from stage: ${stage}`);
641
641
  secrets = toEmbeddableSecrets(stageSecrets);
642
642
  }
643
643
  if (Object.keys(secrets).length === 0) return {};
@@ -743,7 +743,7 @@ async function prepareEntryCredentials(options) {
743
743
  free: isPortAvailable,
744
744
  holder: holderOf
745
745
  });
746
- for (const { app, from, to, holder } of assigned.moved) logger$1.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
746
+ for (const { app, from, to, holder } of assigned.moved) logger$2.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
747
747
  workspace = withAppPorts(workspace, assigned.ports);
748
748
  workspaceAppPort = assigned.ports[appName] ?? workspaceAppPort;
749
749
  }
@@ -767,7 +767,7 @@ async function prepareEntryCredentials(options) {
767
767
  });
768
768
  Object.assign(credentials, reconciled.env);
769
769
  declaredKeys.push(...Object.keys(reconciled.env));
770
- if (Object.keys(reconciled.env).length > 0) logger$1.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
770
+ if (Object.keys(reconciled.env).length > 0) logger$2.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
771
771
  }
772
772
  const secretsDir = join(secretsRoot, ".gkm");
773
773
  await mkdir(secretsDir, { recursive: true });
@@ -1019,207 +1019,1049 @@ function eventsBackendFor(on) {
1019
1019
  return DEFAULT_EVENTS$1[on];
1020
1020
  }
1021
1021
  //#endregion
1022
- //#region src/reconcile/caddyfile.ts
1022
+ //#region src/docker/templates.ts
1023
1023
  /**
1024
- * The kinds the edge serves.
1025
- *
1026
- * Everything that owns a *public address*. Deployed, each of these has a real
1027
- * hostname and a certificate, so this is what makes the local shape the same
1028
- * one rather than a near-enough one — and the application is indifferent
1029
- * either way, because it reads whichever address was injected and never
1030
- * composes one.
1031
- *
1032
- * Adding a kind here is the whole of adding it to the edge.
1024
+ * The turbo every image prunes and builds with when the build root resolves
1025
+ * none of its own: a repository without turbo still needs `turbo prune` to
1026
+ * cut its slice of the workspace, and an unpinned `turbo` is whatever the
1027
+ * registry says on the day of the build.
1033
1028
  */
1034
- const EDGE_KINDS = {
1035
- "file-server": true,
1036
- "rest-api": true,
1037
- site: true
1038
- };
1029
+ const TURBO_VERSION = "2.5.4";
1039
1030
  /**
1040
- * How the edge reaches the host, from inside its container.
1041
- *
1042
- * A surface and a site are served by processes `gkm dev` starts on the host,
1043
- * not by containers — so the edge has to leave Docker's network to reach them.
1044
- * Docker Desktop resolves this name already; on Linux the compose definition
1045
- * maps it to the host gateway.
1031
+ * Caddy, which serves a static site's files. A 2.x tag, so a rebuild picks
1032
+ * up patch releases and never a major.
1046
1033
  */
1047
- const HOST_GATEWAY = "host.docker.internal";
1034
+ const STATIC_SITE_IMAGE = "caddy:2.10-alpine";
1035
+ const LOCKFILES = [
1036
+ ["pnpm-lock.yaml", "pnpm"],
1037
+ ["bun.lockb", "bun"],
1038
+ ["yarn.lock", "yarn"],
1039
+ ["package-lock.json", "npm"]
1040
+ ];
1048
1041
  /**
1049
- * The suffix every local host shares.
1050
- *
1051
- * `.localhost` rather than a made-up TLD because it resolves to loopback
1052
- * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
1053
- * subdomains of it, and Caddy issues internal certificates for it by default.
1042
+ * Detect package manager from lockfiles
1043
+ * Walks up the directory tree to find lockfile (for monorepos)
1054
1044
  */
1055
- const LOCAL_TLD = "localhost";
1045
+ function detectPackageManager(cwd = process.cwd()) {
1046
+ const lockfile = findLockfilePath(cwd);
1047
+ if (!lockfile) return "pnpm";
1048
+ const name = lockfile.slice(dirname(lockfile).length + 1);
1049
+ return LOCKFILES.find(([file]) => file === name)?.[1] ?? "pnpm";
1050
+ }
1056
1051
  /**
1057
- * The sites a plan implies, one per file server.
1052
+ * Find the lockfile path by walking up the directory tree
1053
+ * Returns the full path to the lockfile, or null if not found
1054
+ */
1055
+ function findLockfilePath(cwd = process.cwd()) {
1056
+ for (const dir of upFrom(cwd)) for (const [lockfile] of LOCKFILES) {
1057
+ const lockfilePath = join(dir, lockfile);
1058
+ if (existsSync(lockfilePath)) return lockfilePath;
1059
+ }
1060
+ return null;
1061
+ }
1062
+ /**
1063
+ * The directory every image is built from: the nearest at or above `cwd`
1064
+ * holding a lockfile or a `pnpm-workspace.yaml` — the package manager's root,
1065
+ * which is what an install resolves the workspace's dependencies against.
1058
1066
  *
1059
- * The leading label is the *server's* stage-scoped name rather than the
1060
- * bucket's, which is the difference that matters: two servers over one bucket
1061
- * are a legitimate arrangement — two cache behaviours, one origin — and naming
1062
- * the host after the bucket would make them collide.
1067
+ * In a project of its own that is the gkm workspace's root. A gkm workspace
1068
+ * nested inside a monorepo, whose dependencies are the monorepo's
1069
+ * `workspace:*` packages, is built from the monorepo's root. `cwd` itself
1070
+ * when neither is found.
1063
1071
  */
1064
- function sitesFor(plan, project, addresses = {}) {
1065
- const byId = new Map(plan.resources.map((r) => [r.id, r]));
1066
- const sites = [];
1067
- for (const resource of plan.resources) {
1068
- if (!EDGE_KINDS[resource.kind]) continue;
1069
- const host = hostFor(resource, project);
1070
- if (resource.kind === "file-server") {
1071
- const origin = resource.of ? byId.get(resource.of) : void 0;
1072
- if (!origin) continue;
1073
- sites.push({
1074
- host,
1075
- upstream: "http://minio:9000",
1076
- rewrite: `/${origin.name}{uri}`
1077
- });
1078
- continue;
1072
+ function findBuildRoot(cwd = process.cwd()) {
1073
+ for (const dir of upFrom(cwd)) {
1074
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
1075
+ for (const [lockfile] of LOCKFILES) if (existsSync(join(dir, lockfile))) return dir;
1076
+ }
1077
+ return cwd;
1078
+ }
1079
+ /** `cwd` and every directory above it, the filesystem root last. */
1080
+ function upFrom(cwd) {
1081
+ const dirs = [];
1082
+ const root = parse(cwd).root;
1083
+ let dir = cwd;
1084
+ while (dir !== root) {
1085
+ dirs.push(dir);
1086
+ dir = dirname(dir);
1087
+ }
1088
+ dirs.push(root);
1089
+ return dirs;
1090
+ }
1091
+ /** The package manager, its version, turbo's and the shape of a build root. */
1092
+ function resolveBuildTools(buildRoot) {
1093
+ const pkg = readJson(join(buildRoot, "package.json"));
1094
+ const packageManager = detectPackageManager(buildRoot);
1095
+ const field = typeof pkg?.packageManager === "string" ? pkg.packageManager : "";
1096
+ const match = /^([a-z]+)@(\d[^+\s]*)/.exec(field);
1097
+ const packageManagerVersion = match && match[1] === packageManager ? match[2] : void 0;
1098
+ return {
1099
+ packageManager,
1100
+ ...packageManagerVersion ? { packageManagerVersion } : {},
1101
+ turboVersion: resolveTurboVersion(buildRoot) ?? "2.5.4",
1102
+ monorepo: existsSync(join(buildRoot, "pnpm-workspace.yaml")) || pkg?.workspaces !== void 0
1103
+ };
1104
+ }
1105
+ /**
1106
+ * The turbo version a build root resolves: what is installed, or what its
1107
+ * lockfile pins. Undefined when it has none.
1108
+ */
1109
+ function resolveTurboVersion(buildRoot) {
1110
+ const installed = readJson(join(buildRoot, "node_modules", "turbo", "package.json"));
1111
+ if (typeof installed?.version === "string") return installed.version;
1112
+ const read = (file) => {
1113
+ try {
1114
+ return readFileSync(join(buildRoot, file), "utf-8");
1115
+ } catch {
1116
+ return;
1079
1117
  }
1080
- const address = addresses[resource.id];
1081
- if (!address) continue;
1082
- const port = portOf(address);
1083
- if (!port) continue;
1084
- sites.push({
1085
- host,
1086
- upstream: `http://${HOST_GATEWAY}:${port}`
1087
- });
1118
+ };
1119
+ const pnpm = read("pnpm-lock.yaml");
1120
+ const fromPnpm = pnpm && /^ {2}'?turbo@(\d[^:'(\s]*)'?:/m.exec(pnpm);
1121
+ if (fromPnpm) return fromPnpm[1];
1122
+ const npm = read("package-lock.json");
1123
+ if (npm) {
1124
+ const version = safeParse(npm)?.packages?.["node_modules/turbo"]?.version;
1125
+ if (version) return version;
1088
1126
  }
1089
- return sites;
1127
+ const yarn = read("yarn.lock");
1128
+ const fromYarn = yarn && /^"?turbo@[^\n]*:\n\s+version:? "?(\d[^"\s]*)"?/m.exec(yarn);
1129
+ if (fromYarn) return fromYarn[1];
1090
1130
  }
1091
- /** The port a local address answers on, defaulting by scheme. */
1092
- function portOf(address) {
1131
+ function readJson(path) {
1093
1132
  try {
1094
- const { port, protocol } = new URL(address);
1095
- return port || (protocol === "https:" ? "443" : "80");
1133
+ return safeParse(readFileSync(path, "utf-8"));
1134
+ } catch {
1135
+ return;
1136
+ }
1137
+ }
1138
+ function safeParse(text) {
1139
+ try {
1140
+ const value = JSON.parse(text);
1141
+ return value && typeof value === "object" ? value : void 0;
1096
1142
  } catch {
1097
1143
  return;
1098
1144
  }
1099
1145
  }
1100
1146
  /**
1101
- * Where one file server answers, including the stage its name carries.
1102
- *
1103
- * The project is a label rather than a decoration: it is what keeps two
1104
- * checkouts from answering on one address. Omitted when there is no project to
1105
- * name, because an empty label makes `uploads..localhost` — a host nothing
1106
- * resolves and no certificate can be issued for.
1147
+ * Check if turbo.json exists (walks up directory tree)
1107
1148
  */
1108
- function hostFor(resource, project) {
1109
- return [
1110
- resource.root && project ? stageOf(resource) : labelOf(resource),
1111
- project,
1112
- LOCAL_TLD
1113
- ].filter(Boolean).join(".");
1149
+ function hasTurboConfig(cwd = process.cwd()) {
1150
+ return upFrom(cwd).some((dir) => existsSync(join(dir, "turbo.json")));
1114
1151
  }
1115
1152
  /**
1116
- * A resource's own label: its `subdomain` where it declared one — the label a
1117
- * deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
1118
- * still answer on different hosts.
1153
+ * Package manager specific commands. `version` pins what corepack (or npm,
1154
+ * for bun) installs; without it, the latest.
1119
1155
  */
1120
- function labelOf(resource) {
1121
- if (!resource.subdomain) return resource.name;
1122
- const stage = stageOf(resource);
1123
- return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
1156
+ function getPmConfig(pm, version) {
1157
+ return {
1158
+ pnpm: {
1159
+ install: `corepack enable && corepack prepare pnpm@${version ?? "latest"} --activate`,
1160
+ lockfile: "pnpm-lock.yaml",
1161
+ installCmd: "pnpm install",
1162
+ cacheTarget: "/root/.local/share/pnpm/store",
1163
+ cacheId: "pnpm",
1164
+ run: "pnpm run",
1165
+ dlx: "pnpm dlx"
1166
+ },
1167
+ npm: {
1168
+ install: version ? `npm install -g npm@${version}` : "",
1169
+ lockfile: "package-lock.json",
1170
+ installCmd: "npm install",
1171
+ cacheTarget: "/root/.npm",
1172
+ cacheId: "npm",
1173
+ run: "npm run",
1174
+ dlx: "npx --yes"
1175
+ },
1176
+ yarn: {
1177
+ install: `corepack enable && corepack prepare yarn@${version ?? "stable"} --activate`,
1178
+ lockfile: "yarn.lock",
1179
+ installCmd: "yarn install",
1180
+ cacheTarget: "/root/.yarn/cache",
1181
+ cacheId: "yarn",
1182
+ run: "yarn run",
1183
+ dlx: "yarn dlx"
1184
+ },
1185
+ bun: {
1186
+ install: `npm install -g bun@${version ?? "latest"}`,
1187
+ lockfile: "bun.lockb",
1188
+ installCmd: "bun install",
1189
+ cacheTarget: "/root/.bun/install/cache",
1190
+ cacheId: "bun",
1191
+ run: "bun run",
1192
+ dlx: "bunx"
1193
+ }
1194
+ }[pm];
1124
1195
  }
1125
- /** The stage a resource's name carries, or nothing for the local stage's. */
1126
- function stageOf(resource) {
1127
- const base = resource.id.toLowerCase();
1128
- return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
1196
+ /** What every template's first three stages need, resolved from options. */
1197
+ function layout(options) {
1198
+ const pm = getPmConfig(options.packageManager, options.packageManagerVersion);
1199
+ const turbo = `${pm.dlx} turbo@${options.turboVersion ?? "2.5.4"}`;
1200
+ const gkmRoot = options.gkmRoot ?? ".";
1201
+ return {
1202
+ pm,
1203
+ turbo,
1204
+ installPm: pm.install ? `RUN ${pm.install}` : "",
1205
+ monorepo: options.monorepo ?? true,
1206
+ gkmRoot,
1207
+ /** The app, absolute inside the image. */
1208
+ app: `/app/${options.appPath}`.replace(/\/\.$/, "")
1209
+ };
1129
1210
  }
1130
1211
  /**
1131
- * The static root config, which only ever imports.
1212
+ * The pruner stage: the build context cut to the app's slice, in turbo's
1213
+ * `out/` layout — `out/json` for the install, `out/full` for the build.
1132
1214
  *
1133
- * Written once and identical for every stage, because the *sites* are what
1134
- * differ and they live in their own files. That split is not tidiness: one
1135
- * container serves every stage — the same way one Postgres holds `orders` and
1136
- * `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
1137
- * routes `gkm dev` is serving, and each command silently breaking the other.
1215
+ * `turbo prune` keeps the packages the app depends on and nothing else at
1216
+ * the root, so the root's own files (a `tsconfig.base.json` the packages
1217
+ * extend, the config their build reads) and what the gkm workspace keeps
1218
+ * outside any package are copied in beside them. A single package is copied
1219
+ * whole.
1138
1220
  */
1139
- function caddyfileRoot() {
1140
- return `${GENERATED}
1141
- {
1142
- # No ACME, no public DNS: the CA is local and so are the names.
1143
- local_certs
1144
- auto_https disable_redirects
1221
+ function prunerStage(options) {
1222
+ const { pm, turbo, installPm, monorepo, gkmRoot } = layout(options);
1223
+ const scopes = [options.turboPackage, ...options.prunePackages ?? []].join(" ");
1224
+ if (!monorepo) return `# Stage 1: The build context, in the layout the stages below read
1225
+ FROM ${options.baseImage} AS pruner
1226
+ WORKDIR /app
1227
+ COPY . .
1228
+ RUN mkdir -p /tmp/out/json /tmp/out/full && \\
1229
+ cp package.json /tmp/out/json/ && \\
1230
+ cp ${pm.lockfile} /tmp/out/ && \\
1231
+ cp -a . /tmp/out/full/ && \\
1232
+ mv /tmp/out ./out`;
1233
+ const gkmPaths = options.gkmPaths ?? [];
1234
+ const nested = gkmRoot !== ".";
1235
+ const dest = nested ? `/app/out/full/${gkmRoot}` : "/app/out/full";
1236
+ const copyGkm = gkmPaths.length ? `
1237
+
1238
+ # What the gkm workspace keeps outside any package — its config, the
1239
+ # directories its constructs are declared in — beside the slice.
1240
+ RUN ${nested ? `cd ${gkmRoot} && ` : ""}for path in ${gkmPaths.join(" ")}; do \\
1241
+ if [ -e "$path" ]; then \\
1242
+ mkdir -p "${dest}" && cp -rn "$path" "${dest}/"; \\
1243
+ fi; \\
1244
+ done` : "";
1245
+ return `# Stage 1: Prune the monorepo to the app's slice
1246
+ FROM ${options.baseImage} AS pruner
1247
+ WORKDIR /app
1248
+ ${installPm}
1249
+ COPY . .
1250
+ RUN ${turbo} prune ${scopes} --docker
1251
+
1252
+ # The root's own files — the tsconfig the packages extend, the config their
1253
+ # build reads — which turbo prune leaves out. What the install itself reads
1254
+ # (registry config, local tarballs) goes beside the manifests too.
1255
+ RUN find . -maxdepth 1 -type f ! -name '${pm.lockfile}' ! -name package.json \\
1256
+ -exec cp -n {} out/full/ \\; && \\
1257
+ for file in .npmrc .yarnrc.yml .pnpmfile.cjs bunfig.toml *.tgz; do \\
1258
+ if [ -f "$file" ]; then cp "$file" out/json/; fi; \\
1259
+ done${copyGkm}`;
1260
+ }
1261
+ /** The deps stage: the slice's dependencies, installed from its lockfile. */
1262
+ function depsStage(options) {
1263
+ const { pm, installPm } = layout(options);
1264
+ return `# Stage 2: Install dependencies
1265
+ FROM ${options.baseImage} AS deps
1266
+ WORKDIR /app
1267
+ ${installPm}
1268
+ COPY --from=pruner /app/out/${pm.lockfile} ./
1269
+ COPY --from=pruner /app/out/json/ ./
1270
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
1271
+ ${pm.installCmd}`;
1145
1272
  }
1273
+ /**
1274
+ * The start of the builder stage: the slice's source over its dependencies,
1275
+ * and every workspace package the app depends on built from it.
1276
+ *
1277
+ * Nothing built on the host reaches the image — `.dockerignore` keeps every
1278
+ * `dist` out — so a workspace package (a CLI that is a sibling package) is
1279
+ * built here: the root's `build` script if it has one, then turbo's build of
1280
+ * the app's dependencies (`^build`).
1281
+ */
1282
+ function builderStage(options, extra = {}) {
1283
+ const { pm, turbo, monorepo } = layout(options);
1284
+ const args = extra.args ?? [];
1285
+ const argLines = args.length ? `\n# Build-time args: public values the bundler inlines\n${args.map((a) => `ARG ${a}=""`).join("\n")}\n${args.map((a) => `ENV ${a}=$${a}`).join("\n")}\n` : "";
1286
+ const scopes = [options.turboPackage, ...options.prunePackages ?? []];
1287
+ const dependencies = monorepo ? `\nRUN ${turbo} run build ${scopes.map((s) => `--filter='${s}^...'`).join(" ")}` : "";
1288
+ return `# Stage 3: Build
1289
+ FROM deps AS builder
1290
+ WORKDIR /app
1291
+ ${argLines}
1292
+ COPY --from=pruner /app/out/full/ ./
1146
1293
 
1147
- # One file per stage, so two stages share this edge the way they already share
1148
- # one Postgres and one MinIO.
1149
- import /etc/caddy/sites/*.caddy
1150
- `;
1294
+ # The workspace packages the app depends on, built from source.
1295
+ RUN ${pm.run} --if-present build${dependencies}`;
1151
1296
  }
1152
1297
  /**
1153
- * One stage's sites, as the file imported above.
1154
- *
1155
- * `rewrite` rather than a path prefix on the address: the bucket is the
1156
- * origin's business, and the client asks for the key it stored.
1298
+ * The PATH a command in the app's directory runs with: the app's binaries,
1299
+ * then the gkm workspace's, then the root's — a nested workspace's apps
1300
+ * declare nothing, and its CLI is installed where its config is.
1301
+ */
1302
+ function binPath(options) {
1303
+ const { app, gkmRoot } = layout(options);
1304
+ const dirs = [
1305
+ `${app}/node_modules/.bin`,
1306
+ gkmRoot === "." ? "" : `/app/${gkmRoot}/node_modules/.bin`,
1307
+ "/app/node_modules/.bin"
1308
+ ].filter(Boolean);
1309
+ return `PATH="${[...new Set(dirs)].join(":")}:$PATH"`;
1310
+ }
1311
+ /**
1312
+ * Run the gkm CLI the app resolves, from the app's directory: node on its
1313
+ * `bin/gkm.mjs`, found the way Node resolves a package — up from the app.
1157
1314
  *
1158
- * `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
1159
- * and signs on the Host header, so forwarding the requested hostname would make
1160
- * it look for a bucket named after the domain.
1315
+ * Not through `node_modules/.bin`: a CLI that is a workspace package has no
1316
+ * bin linked, since its files were not there when the slice's dependencies
1317
+ * were installed.
1161
1318
  */
1162
- function toCaddyfile(sites) {
1163
- if (sites.length === 0) return `${GENERATED}
1164
- # No file server is declared in this stage, so it routes nothing.
1165
- `;
1166
- const blocks = sites.map(({ host, upstream, rewrite }) => `
1167
- https://${host} {
1168
- tls internal
1169
-
1170
- reverse_proxy ${upstream} {${rewrite ? `
1171
- rewrite ${rewrite}
1172
- # MinIO routes and signs on the Host header, so forwarding the requested
1173
- # hostname would make it look for a bucket named after the domain.
1174
- header_up Host {upstream_hostport}` : `
1175
- # The application reads whatever address was injected, so it has to see
1176
- # the one a caller used — that is what makes a redirect, a cookie domain
1177
- # and a generated link point back here rather than at the upstream.
1178
- header_up Host {host}
1179
- header_up X-Forwarded-Proto {scheme}`}
1180
- }
1181
- }`);
1182
- return `${GENERATED}${blocks.join("\n")}\n`;
1319
+ function gkmCommand(args) {
1320
+ return `GKM_BIN="$(node -e 'let d=process.cwd();const{existsSync:e}=require("fs"),{dirname:u,join:j}=require("path");for(;;){const p=j(d,"node_modules/@geekmidas/cli/bin/gkm.mjs");if(e(p)){console.log(p);break}if(u(d)===d){console.error("@geekmidas/cli is not installed where the app can resolve it");process.exit(1)}d=u(d)}')" && node "$GKM_BIN" ${args}`;
1183
1321
  }
1184
- const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
1185
- #
1186
- # One host per declared file server, in front of the object store that holds its
1187
- # objects. This is the shape a file server has deployed — a domain serving a
1188
- # bucket — which MinIO alone cannot produce: its virtual-host mode reads the
1189
- # leading label as the bucket name.
1190
- #
1191
- # Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
1192
- # root and point Node at it, so nothing has to be installed to use these URLs
1193
- # from code; a browser needs the root trusted once.
1194
- `;
1195
- //#endregion
1196
- //#region src/reconcile/emulator.ts
1197
1322
  /**
1198
- * The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
1199
- * locally — the facts the compose file, the env writer and the applier must
1200
- * agree on.
1323
+ * The encrypted credentials, from a build secret — never an ARG, which is
1324
+ * recorded in the image history — written where the build embeds them from.
1325
+ * Two lines, ciphertext then IV; absent when the app has no secrets.
1201
1326
  *
1202
- * The emulator has one account and answers with deterministic ARNs and queue
1203
- * URLs, which is what lets a connection string be composed before the topic
1204
- * or queue it names has been created.
1327
+ * A BuildKit secret is not part of the cache key, so `GKM_CIPHERTEXT_HASH`
1328
+ * (a hash of the ciphertext, not a secret) is: credentials encrypted under a
1329
+ * new key rebuild the layer that embeds them.
1205
1330
  */
1206
- /** The emulator's single account. */
1207
- const EMULATOR_ACCOUNT = "000000000000";
1208
- const EMULATOR_REGION = "us-east-1";
1331
+ function credentialsStep(appDir, then) {
1332
+ return `ARG GKM_CIPHERTEXT_HASH=""
1333
+ RUN --mount=type=secret,id=gkm_credentials,required=false \\
1334
+ echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
1335
+ if [ -s /run/secrets/gkm_credentials ]; then \\
1336
+ mkdir -p ${appDir}/.gkm && \\
1337
+ sed -n 1p /run/secrets/gkm_credentials > ${appDir}/.gkm/credentials.enc && \\
1338
+ sed -n 2p /run/secrets/gkm_credentials > ${appDir}/.gkm/credentials.iv; \\
1339
+ fi && \\
1340
+ ${then} && \\
1341
+ rm -f ${appDir}/.gkm/credentials.enc ${appDir}/.gkm/credentials.iv`;
1342
+ }
1209
1343
  /**
1210
- * The emulator's credential. The `LSIA` prefix is what LocalStack required on
1211
- * issued keys; floci does not care, and keeping it means a project that pins
1212
- * the old image still works.
1344
+ * The externals a backend's bundle leaves out (esbuild `--external`), each
1345
+ * installed for Linux at the version the builder resolved, so the runner has
1346
+ * exactly those beside `server.mjs`. Nothing when there are none: the runner
1347
+ * is the one file.
1213
1348
  */
1214
- const EMULATOR_CREDENTIALS = {
1215
- accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
1216
- secretAccessKey: "geekmidas"
1217
- };
1218
- /** The emulator's address from the host, on the port it was published on. */
1219
- function emulatorEndpoint(port) {
1220
- return `http://localhost:${port}`;
1349
+ function externalsStages(options, appDir) {
1350
+ const external = options.external ?? [];
1351
+ if (external.length === 0) return {
1352
+ stage: "",
1353
+ copy: ""
1354
+ };
1355
+ return {
1356
+ stage: `
1357
+ # Stage 4: The packages the bundle leaves external, installed for Linux at the
1358
+ # versions the build resolved — and nothing else.
1359
+ FROM builder AS externals-manifest
1360
+ WORKDIR ${appDir}
1361
+ RUN node <<'EOF'
1362
+ const { existsSync, readFileSync, writeFileSync } = require('node:fs');
1363
+ const { dirname, join } = require('node:path');
1364
+ const names = ${JSON.stringify(external)};
1365
+ const dependencies = {};
1366
+ for (const name of names) {
1367
+ let dir = process.cwd();
1368
+ for (;;) {
1369
+ const manifest = join(dir, 'node_modules', name, 'package.json');
1370
+ if (existsSync(manifest)) {
1371
+ dependencies[name] = JSON.parse(readFileSync(manifest, 'utf-8')).version;
1372
+ break;
1373
+ }
1374
+ if (dirname(dir) === dir) {
1375
+ console.error(name + ' is external to the bundle, and is not installed: add it to the app\\'s dependencies.');
1376
+ process.exit(1);
1377
+ }
1378
+ dir = dirname(dir);
1379
+ }
1380
+ }
1381
+ writeFileSync('/tmp/externals.json', JSON.stringify({ private: true, dependencies }));
1382
+ EOF
1383
+
1384
+ FROM ${options.baseImage} AS externals
1385
+ WORKDIR /externals
1386
+ COPY --from=externals-manifest /tmp/externals.json ./package.json
1387
+ RUN --mount=type=cache,id=npm,target=/root/.npm \\
1388
+ npm install --omit=dev --no-package-lock --no-audit --no-fund
1389
+ `,
1390
+ copy: "\nCOPY --from=externals --chown=hono:nodejs /externals/node_modules ./node_modules"
1391
+ };
1221
1392
  }
1222
- function emulatorTopicArn(name) {
1393
+ /**
1394
+ * Generate .dockerignore file
1395
+ *
1396
+ * Written at the build root. Nothing built on the host may reach an image —
1397
+ * every image builds its packages from source — and nothing secret may reach
1398
+ * a build context.
1399
+ */
1400
+ function generateDockerignore() {
1401
+ return `# Dependencies: installed in the image, for Linux
1402
+ **/node_modules
1403
+ .pnpm-store
1404
+
1405
+ # Build output: every image builds from source, never from the host's
1406
+ **/dist
1407
+ **/.next
1408
+ **/.turbo
1409
+ **/.gkm
1410
+
1411
+ # The key gkm build --stage encrypted secrets with: runtime env only, never
1412
+ # the build context
1413
+ **/master.key
1414
+
1415
+ # gkm compose's stacks: each app's env file holds its stage's secrets
1416
+ **/.gkm/compose
1417
+
1418
+ # IDE and editor
1419
+ .idea
1420
+ .vscode
1421
+ *.swp
1422
+ *.swo
1423
+
1424
+ # Git
1425
+ .git
1426
+
1427
+ # Logs
1428
+ *.log
1429
+ npm-debug.log*
1430
+ pnpm-debug.log*
1431
+
1432
+ # Test files
1433
+ coverage
1434
+
1435
+ # Environment files (handle secrets separately)
1436
+ **/.env
1437
+ **/.env.*
1438
+ !**/.env.example
1439
+
1440
+ # Docker files (don't copy recursively)
1441
+ Dockerfile*
1442
+ docker-compose*
1443
+ .dockerignore
1444
+ `;
1445
+ }
1446
+ /**
1447
+ * Resolve Docker configuration from GkmConfig with defaults
1448
+ */
1449
+ function resolveDockerConfig(config) {
1450
+ const docker = config.docker ?? {};
1451
+ return {
1452
+ registry: docker.registry ?? "",
1453
+ imageName: docker.imageName ?? config.name ?? "api",
1454
+ baseImage: docker.baseImage ?? "node:22-alpine",
1455
+ port: docker.port ?? 3e3
1456
+ };
1457
+ }
1458
+ /** A site's root answers its health check. */
1459
+ function siteHealthcheck(port) {
1460
+ return `# A site answers at its root; Docker, and Dokploy, read the container's
1461
+ # health from it rather than from the process merely running. 127.0.0.1, not
1462
+ # localhost: the server listens on IPv4, and localhost can resolve to ::1.
1463
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
1464
+ CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1`;
1465
+ }
1466
+ /**
1467
+ * Generate a Dockerfile for Next.js frontend apps using standalone output.
1468
+ *
1469
+ * Pruned at the build root, Next traces from there, so the standalone
1470
+ * server lands at `<appPath>/server.js` under `.next/standalone`.
1471
+ * @internal Exported for testing
1472
+ */
1473
+ function generateNextjsDockerfile(options) {
1474
+ const { port, appPath, turboPackage, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
1475
+ const { turbo } = layout(options);
1476
+ return `# syntax=docker/dockerfile:1
1477
+ # Next.js standalone Dockerfile, built from a turbo-pruned slice
1478
+
1479
+ ${prunerStage(options)}
1480
+
1481
+ ${depsStage(options)}
1482
+
1483
+ ${builderStage(options, { args: publicUrlArgs })}
1484
+
1485
+ # Ensure public directory exists (may be empty for scaffolded projects)
1486
+ RUN mkdir -p ${appPath}/public
1487
+
1488
+ ENV NEXT_TELEMETRY_DISABLED=1
1489
+
1490
+ # Build the application
1491
+ RUN ${turbo} run build --filter='${turboPackage}'
1492
+
1493
+ # The runner needs the standalone server; without it there is nothing to run.
1494
+ RUN if [ ! -d ${appPath}/.next/standalone ]; then \\
1495
+ echo "${appPath}/.next/standalone was not built: set output: 'standalone' in next.config so the image can run the app without node_modules." >&2; \\
1496
+ exit 1; \\
1497
+ fi
1498
+
1499
+ # Stage 4: Production
1500
+ FROM ${options.baseImage} AS runner
1501
+
1502
+ WORKDIR /app
1503
+
1504
+ # Install tini for proper signal handling
1505
+ RUN apk add --no-cache tini
1506
+
1507
+ # Create non-root user
1508
+ RUN addgroup --system --gid 1001 nodejs && \\
1509
+ adduser --system --uid 1001 nextjs
1510
+
1511
+ # Set environment
1512
+ ENV NODE_ENV=production
1513
+ ENV NEXT_TELEMETRY_DISABLED=1
1514
+ ENV PORT=${port}
1515
+ ENV HOSTNAME="0.0.0.0"
1516
+
1517
+ # The standalone server, with its static files and public directory beside it
1518
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
1519
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
1520
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
1521
+
1522
+ USER nextjs
1523
+
1524
+ EXPOSE ${port}
1525
+
1526
+ ${siteHealthcheck(port)}
1527
+
1528
+ ENTRYPOINT ["/sbin/tini", "--"]
1529
+ CMD ["node", "${appPath}/server.js"]
1530
+ `;
1531
+ }
1532
+ /**
1533
+ * Generate a Dockerfile for a gkm backend: pruned, installed, and bundled by
1534
+ * `gkm build --provider server --production` inside the image. The runner is
1535
+ * `server.mjs` alone — plus any package the bundle leaves external.
1536
+ * @internal Exported for testing
1537
+ */
1538
+ function generateBackendDockerfile(options) {
1539
+ const { port, healthCheckPath = "/health" } = options;
1540
+ const { app } = layout(options);
1541
+ const externals = externalsStages(options, app);
1542
+ return `# syntax=docker/dockerfile:1
1543
+ # Backend Dockerfile: a turbo-pruned slice, bundled by gkm build in the image
1544
+
1545
+ ${prunerStage(options)}
1546
+
1547
+ ${depsStage(options)}
1548
+
1549
+ ${builderStage(options)}
1550
+
1551
+ # Bundle the production server, with the encrypted credentials embedded
1552
+ ${credentialsStep(app, `cd ${app} && ${gkmCommand("build --provider server --production")}`)}
1553
+ ${externals.stage}
1554
+ # Production
1555
+ FROM ${options.baseImage} AS runner
1556
+
1557
+ WORKDIR /app
1558
+
1559
+ RUN apk add --no-cache tini
1560
+
1561
+ RUN addgroup --system --gid 1001 nodejs && \\
1562
+ adduser --system --uid 1001 hono
1563
+
1564
+ # The bundled server
1565
+ COPY --from=builder --chown=hono:nodejs ${app}/.gkm/server/dist/server.mjs ./${externals.copy}
1566
+
1567
+ ENV NODE_ENV=production
1568
+ ENV PORT=${port}
1569
+
1570
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
1571
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
1572
+
1573
+ USER hono
1574
+
1575
+ EXPOSE ${port}
1576
+
1577
+ ENTRYPOINT ["/sbin/tini", "--"]
1578
+ CMD ["node", "server.mjs"]
1579
+ `;
1580
+ }
1581
+ /**
1582
+ * Generate a Dockerfile for apps with a custom entry point.
1583
+ * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
1584
+ * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
1585
+ * @internal Exported for testing
1586
+ */
1587
+ function generateEntryDockerfile(options) {
1588
+ const { port, entry, healthCheckPath = "/health" } = options;
1589
+ const { app } = layout(options);
1590
+ const esbuild = (defines) => `esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \\
1591
+ --outfile=dist/index.mjs --packages=bundle \\
1592
+ --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'${defines}`;
1593
+ return `# syntax=docker/dockerfile:1
1594
+ # Entry-based Dockerfile: a turbo-pruned slice, bundled by esbuild in the image
1595
+
1596
+ ${prunerStage(options)}
1597
+
1598
+ ${depsStage(options)}
1599
+
1600
+ ${builderStage(options)}
1601
+
1602
+ # Bundle the entry point with every dependency (dist/index.mjs), embedding the
1603
+ # encrypted credentials when there are any.
1604
+ ARG GKM_CIPHERTEXT_HASH=""
1605
+ RUN --mount=type=secret,id=gkm_credentials,required=false \\
1606
+ echo "credentials: \${GKM_CIPHERTEXT_HASH:-none}" && \\
1607
+ cd ${app} && export ${binPath(options)} && \\
1608
+ if [ -s /run/secrets/gkm_credentials ]; then \\
1609
+ CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \\
1610
+ IV=$(sed -n 2p /run/secrets/gkm_credentials) && \\
1611
+ ${esbuild(` \\
1612
+ --define:__GKM_ENCRYPTED_CREDENTIALS__="\\"$CREDS\\"" \\
1613
+ --define:__GKM_CREDENTIALS_IV__="\\"$IV\\""`)}; \\
1614
+ else \\
1615
+ ${esbuild("")}; \\
1616
+ fi
1617
+
1618
+ # Production
1619
+ FROM ${options.baseImage} AS runner
1620
+
1621
+ WORKDIR /app
1622
+
1623
+ RUN apk add --no-cache tini
1624
+
1625
+ RUN addgroup --system --gid 1001 nodejs && \\
1626
+ adduser --system --uid 1001 app
1627
+
1628
+ # Copy bundled output only (no node_modules needed - fully bundled)
1629
+ COPY --from=builder --chown=app:nodejs ${app}/dist/index.mjs ./
1630
+
1631
+ ENV NODE_ENV=production
1632
+ ENV PORT=${port}
1633
+
1634
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
1635
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
1636
+
1637
+ USER app
1638
+
1639
+ EXPOSE ${port}
1640
+
1641
+ ENTRYPOINT ["/sbin/tini", "--"]
1642
+ CMD ["node", "index.mjs"]
1643
+ `;
1644
+ }
1645
+ /**
1646
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
1647
+ * Remix). Builds with turbo, then runs the framework's production start
1648
+ * script.
1649
+ *
1650
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
1651
+ * can inline them at build time. Per-framework prefix is the caller's job.
1652
+ * @internal Exported for testing
1653
+ */
1654
+ function generateNodeWebDockerfile(options) {
1655
+ const { port, appPath, turboPackage } = options;
1656
+ const { turbo } = layout(options);
1657
+ return `# syntax=docker/dockerfile:1
1658
+ # Node SSR web Dockerfile (TanStack Start / Remix), from a turbo-pruned slice
1659
+
1660
+ ${prunerStage(options)}
1661
+
1662
+ ${depsStage(options)}
1663
+
1664
+ ${builderStage(options, { args: options.publicUrlArgs ?? [] })}
1665
+ RUN ${turbo} run build --filter='${turboPackage}'
1666
+
1667
+ FROM ${options.baseImage} AS runner
1668
+ WORKDIR /app
1669
+ RUN apk add --no-cache tini
1670
+ RUN addgroup --system --gid 1001 nodejs && \\
1671
+ adduser --system --uid 1001 app
1672
+
1673
+ # Copy the whole built workspace — frameworks differ on output paths
1674
+ COPY --from=builder --chown=app:nodejs /app/ ./
1675
+
1676
+ ENV NODE_ENV=production
1677
+ ENV PORT=${port}
1678
+ ENV HOSTNAME="0.0.0.0"
1679
+
1680
+ USER app
1681
+ EXPOSE ${port}
1682
+
1683
+ ${siteHealthcheck(port)}
1684
+
1685
+ ENTRYPOINT ["/sbin/tini", "--"]
1686
+ # npm ships with node, and runs the start script whichever package manager
1687
+ # installed it: the runner needs no other.
1688
+ CMD ["sh", "-c", "cd ${appPath} && npm start"]
1689
+ `;
1690
+ }
1691
+ /**
1692
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
1693
+ * turbo, then serves it with Caddy: Vite's hashed `/assets/*` cached for a
1694
+ * year, everything else — `index.html`, and every deep link that falls back
1695
+ * to it — revalidated on each request.
1696
+ * @internal Exported for testing
1697
+ */
1698
+ function generateViteStaticDockerfile(options) {
1699
+ const { port, appPath, turboPackage } = options;
1700
+ const { turbo } = layout(options);
1701
+ return `# syntax=docker/dockerfile:1
1702
+ # Vite SPA Dockerfile — a turbo-pruned slice built to static files, served by Caddy
1703
+
1704
+ ${prunerStage(options)}
1705
+
1706
+ ${depsStage(options)}
1707
+
1708
+ ${builderStage(options, { args: options.publicUrlArgs ?? [] })}
1709
+ RUN ${turbo} run build --filter='${turboPackage}'
1710
+
1711
+ FROM ${STATIC_SITE_IMAGE} AS runner
1712
+
1713
+ COPY <<'EOF' /etc/caddy/Caddyfile
1714
+ {
1715
+ admin off
1716
+ auto_https off
1717
+ persist_config off
1718
+ }
1719
+
1720
+ :${port} {
1721
+ root * /srv
1722
+ encode zstd gzip
1723
+
1724
+ # Vite names every built asset by its content hash: a changed file is a
1725
+ # new URL, so the old one can be cached for good.
1726
+ handle /assets/* {
1727
+ header Cache-Control "public, max-age=31536000, immutable"
1728
+ file_server
1729
+ }
1730
+
1731
+ # index.html, and every client-side route that falls back to it, is
1732
+ # asked for again each time so a deploy is seen at once.
1733
+ handle {
1734
+ header Cache-Control "no-cache"
1735
+ try_files {path} /index.html
1736
+ file_server
1737
+ }
1738
+ }
1739
+ EOF
1740
+
1741
+ COPY --from=builder /app/${appPath}/dist /srv
1742
+
1743
+ RUN addgroup -S -g 1001 site && adduser -S -u 1001 -G site site && \\
1744
+ chown -R site:site /data /config
1745
+
1746
+ USER site
1747
+
1748
+ EXPOSE ${port}
1749
+
1750
+ ${siteHealthcheck(port)}
1751
+
1752
+ CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
1753
+ `;
1754
+ }
1755
+ //#endregion
1756
+ //#region src/docker/layout.ts
1757
+ /**
1758
+ * Where a workspace's images are built from, and what each is built with.
1759
+ *
1760
+ * Every builder of images — `gkm docker`, `gkm compose`, a Dokploy deploy —
1761
+ * builds from the same place with the same templates: the build root (the
1762
+ * package manager's root, at or above the gkm workspace), cut by `turbo
1763
+ * prune` to one app's slice. Nothing is built on the host.
1764
+ */
1765
+ /** A workspace of packages with no turbo.json, which every image prunes with. */
1766
+ var MonorepoNeedsTurbo = class extends Error {
1767
+ buildRoot;
1768
+ constructor(buildRoot) {
1769
+ 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:
1770
+ 1. Install turbo: pnpm add -Dw turbo
1771
+ 2. Create turbo.json in ${buildRoot}\n 3. Run this command again
1772
+
1773
+ See: https://turbo.build/repo/docs/guides/tools/docker`);
1774
+ this.buildRoot = buildRoot;
1775
+ this.name = "MonorepoNeedsTurbo";
1776
+ }
1777
+ };
1778
+ /**
1779
+ * Where a workspace's images are built from.
1780
+ *
1781
+ * Refuses a workspace of packages with no turbo.json — every image prunes
1782
+ * with turbo — so only a caller about to generate or build images asks.
1783
+ */
1784
+ function imageLayout(workspace) {
1785
+ const buildRoot = findBuildRoot(workspace.root);
1786
+ const tools = resolveBuildTools(buildRoot);
1787
+ if (tools.monorepo && !hasTurboConfig(buildRoot)) throw new MonorepoNeedsTurbo(buildRoot);
1788
+ const gkmRoot = toPosix(relative(buildRoot, workspace.root)) || ".";
1789
+ const workspacePackage = gkmRoot !== "." ? packageName(workspace.root) : void 0;
1790
+ return {
1791
+ buildRoot,
1792
+ gkmRoot,
1793
+ tools,
1794
+ ...workspacePackage ? { workspacePackage } : {},
1795
+ gkmPaths: ["gkm.config.*", ...constructRoots(workspace)]
1796
+ };
1797
+ }
1798
+ /**
1799
+ * How a compose file builds an app: the build root as the context, relative
1800
+ * to the directory the compose file is in, and the Dockerfile — written under
1801
+ * the gkm root — relative to that context, which is what compose resolves
1802
+ * `dockerfile` against.
1803
+ */
1804
+ function composeBuildPaths(options) {
1805
+ const { composeDir, buildRoot, workspaceRoot, dockerfile } = options;
1806
+ return {
1807
+ context: toPosix(relative(composeDir, buildRoot)) || ".",
1808
+ dockerfile: toPosix(join(relative(buildRoot, workspaceRoot), dockerfile))
1809
+ };
1810
+ }
1811
+ /** A path under the gkm root, as the build root sees it. */
1812
+ function fromBuildRoot(layout, path) {
1813
+ if (layout.gkmRoot === ".") return toPosix(path);
1814
+ const joined = toPosix(join(layout.gkmRoot, path));
1815
+ return joined === "" ? "." : joined;
1816
+ }
1817
+ /** Everything a template needs about one app, relative to the build root. */
1818
+ function appImageOptions(layout, appName, app, workspaceRoot) {
1819
+ const turboPackage = packageName(isAbsolute(app.path) ? app.path : join(workspaceRoot, app.path)) ?? appName;
1820
+ return {
1821
+ imageName: appName,
1822
+ baseImage: "node:22-alpine",
1823
+ port: app.port,
1824
+ appPath: fromBuildRoot(layout, app.path),
1825
+ turboPackage,
1826
+ packageManager: layout.tools.packageManager,
1827
+ ...layout.tools.packageManagerVersion ? { packageManagerVersion: layout.tools.packageManagerVersion } : {},
1828
+ turboVersion: layout.tools.turboVersion,
1829
+ monorepo: layout.tools.monorepo,
1830
+ gkmRoot: layout.gkmRoot,
1831
+ ...app.type === "backend" ? {
1832
+ gkmPaths: layout.gkmPaths,
1833
+ ...layout.workspacePackage && layout.workspacePackage !== turboPackage ? { prunePackages: [layout.workspacePackage] } : {}
1834
+ } : {}
1835
+ };
1836
+ }
1837
+ /**
1838
+ * The directories the workspace's construct globs start in, relative to its
1839
+ * root: `./constructs/**\/*.ts` reads from `constructs`.
1840
+ */
1841
+ function constructRoots(workspace) {
1842
+ const roots = /* @__PURE__ */ new Set();
1843
+ for (const glob of allConstructGlobs(workspace)) {
1844
+ const rel = toPosix(relative(workspace.root, glob));
1845
+ if (!rel || rel.startsWith("..")) continue;
1846
+ const first = rel.split("/")[0];
1847
+ if (/[*?{}[\]!]/.test(first)) continue;
1848
+ roots.add(first);
1849
+ }
1850
+ return [...roots].sort();
1851
+ }
1852
+ function packageName(dir) {
1853
+ try {
1854
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
1855
+ return typeof pkg.name === "string" ? pkg.name : void 0;
1856
+ } catch {
1857
+ return;
1858
+ }
1859
+ }
1860
+ function toPosix(path) {
1861
+ return path.split(sep).join("/");
1862
+ }
1863
+ //#endregion
1864
+ //#region src/reconcile/caddyfile.ts
1865
+ /**
1866
+ * The kinds the edge serves.
1867
+ *
1868
+ * Everything that owns a *public address*. Deployed, each of these has a real
1869
+ * hostname and a certificate, so this is what makes the local shape the same
1870
+ * one rather than a near-enough one — and the application is indifferent
1871
+ * either way, because it reads whichever address was injected and never
1872
+ * composes one.
1873
+ *
1874
+ * Adding a kind here is the whole of adding it to the edge.
1875
+ */
1876
+ const EDGE_KINDS = {
1877
+ "file-server": true,
1878
+ "rest-api": true,
1879
+ site: true
1880
+ };
1881
+ /**
1882
+ * How the edge reaches the host, from inside its container.
1883
+ *
1884
+ * A surface and a site are served by processes `gkm dev` starts on the host,
1885
+ * not by containers — so the edge has to leave Docker's network to reach them.
1886
+ * Docker Desktop resolves this name already; on Linux the compose definition
1887
+ * maps it to the host gateway.
1888
+ */
1889
+ const HOST_GATEWAY = "host.docker.internal";
1890
+ /**
1891
+ * The suffix every local host shares.
1892
+ *
1893
+ * `.localhost` rather than a made-up TLD because it resolves to loopback
1894
+ * without touching `/etc/hosts` — macOS and systemd-resolved both answer for
1895
+ * subdomains of it, and Caddy issues internal certificates for it by default.
1896
+ */
1897
+ const LOCAL_TLD = "localhost";
1898
+ /**
1899
+ * The sites a plan implies, one per file server.
1900
+ *
1901
+ * The leading label is the *server's* stage-scoped name rather than the
1902
+ * bucket's, which is the difference that matters: two servers over one bucket
1903
+ * are a legitimate arrangement — two cache behaviours, one origin — and naming
1904
+ * the host after the bucket would make them collide.
1905
+ */
1906
+ function sitesFor(plan, project, addresses = {}) {
1907
+ const byId = new Map(plan.resources.map((r) => [r.id, r]));
1908
+ const sites = [];
1909
+ for (const resource of plan.resources) {
1910
+ if (!EDGE_KINDS[resource.kind]) continue;
1911
+ const host = hostFor(resource, project);
1912
+ if (resource.kind === "file-server") {
1913
+ const origin = resource.of ? byId.get(resource.of) : void 0;
1914
+ if (!origin) continue;
1915
+ sites.push({
1916
+ host,
1917
+ upstream: "http://minio:9000",
1918
+ rewrite: `/${origin.name}{uri}`
1919
+ });
1920
+ continue;
1921
+ }
1922
+ const address = addresses[resource.id];
1923
+ if (!address) continue;
1924
+ const port = portOf(address);
1925
+ if (!port) continue;
1926
+ sites.push({
1927
+ host,
1928
+ upstream: `http://${HOST_GATEWAY}:${port}`
1929
+ });
1930
+ }
1931
+ return sites;
1932
+ }
1933
+ /** The port a local address answers on, defaulting by scheme. */
1934
+ function portOf(address) {
1935
+ try {
1936
+ const { port, protocol } = new URL(address);
1937
+ return port || (protocol === "https:" ? "443" : "80");
1938
+ } catch {
1939
+ return;
1940
+ }
1941
+ }
1942
+ /**
1943
+ * Where one file server answers, including the stage its name carries.
1944
+ *
1945
+ * The project is a label rather than a decoration: it is what keeps two
1946
+ * checkouts from answering on one address. Omitted when there is no project to
1947
+ * name, because an empty label makes `uploads..localhost` — a host nothing
1948
+ * resolves and no certificate can be issued for.
1949
+ */
1950
+ function hostFor(resource, project) {
1951
+ return [
1952
+ resource.root && project ? stageOf(resource) : labelOf(resource),
1953
+ project,
1954
+ LOCAL_TLD
1955
+ ].filter(Boolean).join(".");
1956
+ }
1957
+ /**
1958
+ * A resource's own label: its `subdomain` where it declared one — the label a
1959
+ * deploy uses too — with the stage it carries kept, so `gkm test` and `gkm dev`
1960
+ * still answer on different hosts.
1961
+ */
1962
+ function labelOf(resource) {
1963
+ if (!resource.subdomain) return resource.name;
1964
+ const stage = stageOf(resource);
1965
+ return stage ? `${resource.subdomain}-${stage}` : resource.subdomain;
1966
+ }
1967
+ /** The stage a resource's name carries, or nothing for the local stage's. */
1968
+ function stageOf(resource) {
1969
+ const base = resource.id.toLowerCase();
1970
+ return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
1971
+ }
1972
+ /**
1973
+ * The static root config, which only ever imports.
1974
+ *
1975
+ * Written once and identical for every stage, because the *sites* are what
1976
+ * differ and they live in their own files. That split is not tidiness: one
1977
+ * container serves every stage — the same way one Postgres holds `orders` and
1978
+ * `orders_test` — so a single Caddyfile would mean `gkm test` deleting the
1979
+ * routes `gkm dev` is serving, and each command silently breaking the other.
1980
+ */
1981
+ function caddyfileRoot() {
1982
+ return `${GENERATED}
1983
+ {
1984
+ # No ACME, no public DNS: the CA is local and so are the names.
1985
+ local_certs
1986
+ auto_https disable_redirects
1987
+ }
1988
+
1989
+ # One file per stage, so two stages share this edge the way they already share
1990
+ # one Postgres and one MinIO.
1991
+ import /etc/caddy/sites/*.caddy
1992
+ `;
1993
+ }
1994
+ /**
1995
+ * One stage's sites, as the file imported above.
1996
+ *
1997
+ * `rewrite` rather than a path prefix on the address: the bucket is the
1998
+ * origin's business, and the client asks for the key it stored.
1999
+ *
2000
+ * `header_up Host {upstream_hostport}` matters more than it looks. MinIO routes
2001
+ * and signs on the Host header, so forwarding the requested hostname would make
2002
+ * it look for a bucket named after the domain.
2003
+ */
2004
+ function toCaddyfile(sites) {
2005
+ if (sites.length === 0) return `${GENERATED}
2006
+ # No file server is declared in this stage, so it routes nothing.
2007
+ `;
2008
+ const blocks = sites.map(({ host, upstream, rewrite }) => `
2009
+ https://${host} {
2010
+ tls internal
2011
+
2012
+ reverse_proxy ${upstream} {${rewrite ? `
2013
+ rewrite ${rewrite}
2014
+ # MinIO routes and signs on the Host header, so forwarding the requested
2015
+ # hostname would make it look for a bucket named after the domain.
2016
+ header_up Host {upstream_hostport}` : `
2017
+ # The application reads whatever address was injected, so it has to see
2018
+ # the one a caller used — that is what makes a redirect, a cookie domain
2019
+ # and a generated link point back here rather than at the upstream.
2020
+ header_up Host {host}
2021
+ header_up X-Forwarded-Proto {scheme}`}
2022
+ }
2023
+ }`);
2024
+ return `${GENERATED}${blocks.join("\n")}\n`;
2025
+ }
2026
+ const GENERATED = `# Generated by gkm from the construct manifest — do not edit.
2027
+ #
2028
+ # One host per declared file server, in front of the object store that holds its
2029
+ # objects. This is the shape a file server has deployed — a domain serving a
2030
+ # bucket — which MinIO alone cannot produce: its virtual-host mode reads the
2031
+ # leading label as the bucket name.
2032
+ #
2033
+ # Certificates come from Caddy's own CA. "gkm dev" and "gkm test" export its
2034
+ # root and point Node at it, so nothing has to be installed to use these URLs
2035
+ # from code; a browser needs the root trusted once.
2036
+ `;
2037
+ //#endregion
2038
+ //#region src/reconcile/emulator.ts
2039
+ /**
2040
+ * The AWS emulator (floci, LocalStack-compatible) the `sns` backend runs on
2041
+ * locally — the facts the compose file, the env writer and the applier must
2042
+ * agree on.
2043
+ *
2044
+ * The emulator has one account and answers with deterministic ARNs and queue
2045
+ * URLs, which is what lets a connection string be composed before the topic
2046
+ * or queue it names has been created.
2047
+ */
2048
+ /** The emulator's single account. */
2049
+ const EMULATOR_ACCOUNT = "000000000000";
2050
+ const EMULATOR_REGION = "us-east-1";
2051
+ /**
2052
+ * The emulator's credential. The `LSIA` prefix is what LocalStack required on
2053
+ * issued keys; floci does not care, and keeping it means a project that pins
2054
+ * the old image still works.
2055
+ */
2056
+ const EMULATOR_CREDENTIALS = {
2057
+ accessKeyId: "LSIAQAAAAAAVNCBMPNSG",
2058
+ secretAccessKey: "geekmidas"
2059
+ };
2060
+ /** The emulator's address from the host, on the port it was published on. */
2061
+ function emulatorEndpoint(port) {
2062
+ return `http://localhost:${port}`;
2063
+ }
2064
+ function emulatorTopicArn(name) {
1223
2065
  return `arn:aws:sns:${EMULATOR_REGION}:${EMULATOR_ACCOUNT}:${name}`;
1224
2066
  }
1225
2067
  function emulatorQueueUrl(name, port) {
@@ -2168,19 +3010,24 @@ function appServices(workspace, manifest, containers, runnables = {}, fakes = {}
2168
3010
  addresses: surfaceAddresses(workspace, manifest)
2169
3011
  });
2170
3012
  const services = {};
3013
+ const buildRoot = findBuildRoot(workspace.root);
2171
3014
  for (const [name, app] of Object.entries(workspace.apps)) {
2172
3015
  if (app.type === "mobile" || !app.port) continue;
2173
3016
  const key = appKey(name);
2174
3017
  const health = app.type === "web" ? "/" : "/health";
2175
- const dockerfile = dockerfileOf(name, app.path);
3018
+ const build = composeBuildPaths({
3019
+ composeDir: workspace.root,
3020
+ buildRoot,
3021
+ workspaceRoot: workspace.root,
3022
+ dockerfile: dockerfileOf(name, app.path)
3023
+ });
2176
3024
  const ports = [`${app.port}:${app.port}`];
2177
3025
  if (app.type === "web") {
2178
3026
  const args = sitePublicArgs(manifest, name, browser);
2179
3027
  services[key] = {
2180
3028
  image: `${key}:\${TAG:-latest}`,
2181
3029
  build: {
2182
- context: ".",
2183
- dockerfile,
3030
+ ...build,
2184
3031
  ...Object.keys(args).length ? { args } : {}
2185
3032
  },
2186
3033
  profiles: [APPS_PROFILE],
@@ -2193,10 +3040,7 @@ function appServices(workspace, manifest, containers, runnables = {}, fakes = {}
2193
3040
  const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
2194
3041
  services[key] = {
2195
3042
  image: `${key}:\${TAG:-latest}`,
2196
- build: {
2197
- context: ".",
2198
- dockerfile
2199
- },
3043
+ build,
2200
3044
  profiles: [APPS_PROFILE],
2201
3045
  ports,
2202
3046
  environment: {
@@ -2263,15 +3107,18 @@ function pgClient(port, password = LOCAL_USER$1) {
2263
3107
  }
2264
3108
  } };
2265
3109
  }
2266
- /** A bucket client pointed at the local MinIO. */
2267
- function bucketClient(port) {
3110
+ /** A bucket client pointed at a MinIO on this machine. */
3111
+ function bucketClient(port, credentials = {
3112
+ user: LOCAL_USER$1,
3113
+ password: LOCAL_USER$1
3114
+ }) {
2268
3115
  const s3 = new S3Client({
2269
3116
  region: "us-east-1",
2270
3117
  endpoint: `http://localhost:${port}`,
2271
3118
  forcePathStyle: true,
2272
3119
  credentials: {
2273
- accessKeyId: LOCAL_USER$1,
2274
- secretAccessKey: LOCAL_USER$1
3120
+ accessKeyId: credentials.user,
3121
+ secretAccessKey: credentials.password
2275
3122
  }
2276
3123
  });
2277
3124
  return {
@@ -3630,1007 +4477,436 @@ async function reconcileWorkspace(workspace, options) {
3630
4477
  ...options.progress ? { progress: options.progress } : {}
3631
4478
  });
3632
4479
  await savePortState(workspace.root, { ...result.ports });
3633
- return {
3634
- ...result,
3635
- manifest
3636
- };
3637
- }
3638
- /**
3639
- * Where each mobile app's Metro runs: its app's port, which `gkm exec` hands
3640
- * Expo as `RCT_METRO_PORT`. Kept apart from the surfaces' addresses — a phone
3641
- * reaches Metro by `exp://`, not through the edge.
3642
- */
3643
- function metroPorts(workspace, manifest) {
3644
- const ports = {};
3645
- for (const [id, declaration] of Object.entries(manifest)) {
3646
- if (declaration.kind !== "mobile-app") continue;
3647
- const port = workspace.apps[appKey(id)]?.port;
3648
- if (port) ports[id] = port;
3649
- }
3650
- return ports;
3651
- }
3652
- /**
3653
- * Where each declared surface and site answers locally.
3654
- *
3655
- * The addresses come from the ports the workspace already assigns; which
3656
- * construct sits at which one comes from the manifest. That split is the point:
3657
- * this used to answer "who may call whom" by listing every app the workspace
3658
- * runs, which is a different question that happened to give the same answer in
3659
- * a single-repo workspace and no answer at all deployed. Now it answers only
3660
- * "where does this construct answer", and the graph answers the rest.
3661
- *
3662
- * A site is matched to its app by `path`, because that is the one thing a site
3663
- * declaration and a workspace app both name. Surfaces are matched to the
3664
- * backend app: every `rest-api` in a process answers on that process's port, so
3665
- * an app serving both its own API and an auth server publishes one address
3666
- * twice — which is exactly what it does at runtime.
3667
- */
3668
- function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
3669
- const addresses = {};
3670
- for (const [id, declaration] of Object.entries(manifest)) {
3671
- if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
3672
- const host = appKey(id);
3673
- const port = workspace.apps[host]?.port;
3674
- if (port) addresses[id] = at(host, port);
3675
- }
3676
- return addresses;
3677
- }
3678
- /** Where a local process answers, given the port the workspace gave it. */
3679
- function localAddress(port) {
3680
- return `http://localhost:${port}`;
3681
- }
3682
- //#endregion
3683
- //#region src/target/dokploy/domain.ts
3684
- /** A stage was deployed that `deploy.domains` gives no domain. */
3685
- var NoDomainForStage = class extends Error {
3686
- stage;
3687
- constructor(stage) {
3688
- super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
3689
- this.stage = stage;
3690
- this.name = "NoDomainForStage";
3691
- }
3692
- };
3693
- /**
3694
- * The hostname an app answers on for a stage.
3695
- *
3696
- * 1. An explicit `app.domain` — a string, or one per stage — wins.
3697
- * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
3698
- * answers on the domain itself, and every other app on
3699
- * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
3700
- *
3701
- * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
3702
- */
3703
- function resolveHost(appName, app, stage, domains, isMainFrontend) {
3704
- if (app.domain) {
3705
- if (typeof app.domain === "string") return app.domain;
3706
- if (app.domain[stage]) return app.domain[stage];
3707
- }
3708
- const baseDomain = domains?.[stage];
3709
- if (!baseDomain) throw new NoDomainForStage(stage);
3710
- if (isMainFrontend) return baseDomain;
3711
- return `${app.subdomain ?? appName}.${baseDomain}`;
3712
- }
3713
- /**
3714
- * Whether this app is the site the base domain points at — see `rootSite`,
3715
- * which the local edge asks too.
3716
- *
3717
- * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
3718
- */
3719
- function isMainFrontendApp(appName, app, allApps) {
3720
- if (app.type !== "web") return false;
3721
- return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
3722
- name,
3723
- root: a.root
3724
- }))) === appName;
3725
- }
3726
- /**
3727
- * Get public URL arg names from app dependencies.
3728
- *
3729
- * @param app - The web/mobile app configuration
3730
- * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
3731
- * without a public prefix (e.g. Remix).
3732
- */
3733
- function getPublicUrlArgNames(app) {
3734
- const prefix = getPublicEnvPrefix(app.framework);
3735
- if (!prefix) return [];
3736
- return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
3737
- }
3738
- //#endregion
3739
- //#region src/docker/imageRef.ts
3740
- /**
3741
- * An image reference, checked before it reaches `docker`.
3742
- *
3743
- * The parts come from the workspace — a registry in `gkm.config.ts`, an image
3744
- * name from the project, a tag from `--tag` — and an argument array already
3745
- * keeps them out of a shell. What it cannot do is stop `docker` reading a value
3746
- * that starts with `-` as one of its own flags, so a ref is held to Docker's
3747
- * own grammar (distribution/reference) and anything else is refused by name
3748
- * before a build starts.
3749
- */
3750
- const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
3751
- const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
3752
- const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
3753
- const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
3754
- /** Docker refuses a repository name longer than this. */
3755
- const MAX_NAME_LENGTH = 255;
3756
- /** A value that is not an image reference was about to be built or pushed. */
3757
- var ImageRefInvalid = class extends Error {
3758
- ref;
3759
- constructor(ref) {
3760
- super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
3761
- this.ref = ref;
3762
- this.name = "ImageRefInvalid";
3763
- }
3764
- };
3765
- /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
3766
- function validateImageRef(ref) {
3767
- const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
3768
- if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
3769
- return ref;
3770
- }
3771
- //#endregion
3772
- //#region src/docker/templates.ts
3773
- const LOCKFILES = [
3774
- ["pnpm-lock.yaml", "pnpm"],
3775
- ["bun.lockb", "bun"],
3776
- ["yarn.lock", "yarn"],
3777
- ["package-lock.json", "npm"]
3778
- ];
3779
- /**
3780
- * Detect package manager from lockfiles
3781
- * Walks up the directory tree to find lockfile (for monorepos)
3782
- */
3783
- function detectPackageManager(cwd = process.cwd()) {
3784
- let dir = cwd;
3785
- const root = parse(dir).root;
3786
- while (dir !== root) {
3787
- for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
3788
- dir = dirname(dir);
3789
- }
3790
- for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
3791
- return "pnpm";
4480
+ return {
4481
+ ...result,
4482
+ manifest
4483
+ };
3792
4484
  }
3793
4485
  /**
3794
- * Find the lockfile path by walking up the directory tree
3795
- * Returns the full path to the lockfile, or null if not found
4486
+ * Where each mobile app's Metro runs: its app's port, which `gkm exec` hands
4487
+ * Expo as `RCT_METRO_PORT`. Kept apart from the surfaces' addresses — a phone
4488
+ * reaches Metro by `exp://`, not through the edge.
3796
4489
  */
3797
- function findLockfilePath(cwd = process.cwd()) {
3798
- let dir = cwd;
3799
- const root = parse(dir).root;
3800
- while (dir !== root) {
3801
- for (const [lockfile] of LOCKFILES) {
3802
- const lockfilePath = join(dir, lockfile);
3803
- if (existsSync(lockfilePath)) return lockfilePath;
3804
- }
3805
- dir = dirname(dir);
3806
- }
3807
- for (const [lockfile] of LOCKFILES) {
3808
- const lockfilePath = join(root, lockfile);
3809
- if (existsSync(lockfilePath)) return lockfilePath;
4490
+ function metroPorts(workspace, manifest) {
4491
+ const ports = {};
4492
+ for (const [id, declaration] of Object.entries(manifest)) {
4493
+ if (declaration.kind !== "mobile-app") continue;
4494
+ const port = workspace.apps[appKey(id)]?.port;
4495
+ if (port) ports[id] = port;
3810
4496
  }
3811
- return null;
3812
- }
3813
- /**
3814
- * Check if we're in a monorepo (lockfile is in a parent directory)
3815
- */
3816
- function isMonorepo(cwd = process.cwd()) {
3817
- const lockfilePath = findLockfilePath(cwd);
3818
- if (!lockfilePath) return false;
3819
- return dirname(lockfilePath) !== cwd;
4497
+ return ports;
3820
4498
  }
3821
4499
  /**
3822
- * Check if turbo.json exists (walks up directory tree)
4500
+ * Where each declared surface and site answers locally.
4501
+ *
4502
+ * The addresses come from the ports the workspace already assigns; which
4503
+ * construct sits at which one comes from the manifest. That split is the point:
4504
+ * this used to answer "who may call whom" by listing every app the workspace
4505
+ * runs, which is a different question that happened to give the same answer in
4506
+ * a single-repo workspace and no answer at all deployed. Now it answers only
4507
+ * "where does this construct answer", and the graph answers the rest.
4508
+ *
4509
+ * A site is matched to its app by `path`, because that is the one thing a site
4510
+ * declaration and a workspace app both name. Surfaces are matched to the
4511
+ * backend app: every `rest-api` in a process answers on that process's port, so
4512
+ * an app serving both its own API and an auth server publishes one address
4513
+ * twice — which is exactly what it does at runtime.
3823
4514
  */
3824
- function hasTurboConfig(cwd = process.cwd()) {
3825
- let dir = cwd;
3826
- const root = parse(dir).root;
3827
- while (dir !== root) {
3828
- if (existsSync(join(dir, "turbo.json"))) return true;
3829
- dir = dirname(dir);
4515
+ function surfaceAddresses(workspace, manifest, at = (_app, port) => localAddress(port)) {
4516
+ const addresses = {};
4517
+ for (const [id, declaration] of Object.entries(manifest)) {
4518
+ if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
4519
+ const host = appKey(id);
4520
+ const port = workspace.apps[host]?.port;
4521
+ if (port) addresses[id] = at(host, port);
3830
4522
  }
3831
- return existsSync(join(root, "turbo.json"));
4523
+ return addresses;
3832
4524
  }
3833
- /**
3834
- * Get install command for turbo builds (without frozen lockfile)
3835
- * Turbo prune creates a subset that may not perfectly match the lockfile
3836
- */
3837
- function getTurboInstallCmd(pm) {
3838
- return {
3839
- pnpm: "pnpm install",
3840
- npm: "npm install",
3841
- yarn: "yarn install",
3842
- bun: "bun install"
3843
- }[pm];
4525
+ /** Where a local process answers, given the port the workspace gave it. */
4526
+ function localAddress(port) {
4527
+ return `http://localhost:${port}`;
3844
4528
  }
4529
+ //#endregion
4530
+ //#region src/secrets/encryption.ts
4531
+ var encryption_exports = /* @__PURE__ */ __exportAll({
4532
+ encryptSecrets: () => encryptSecrets,
4533
+ generateDefineOptions: () => generateDefineOptions,
4534
+ keyFingerprint: () => keyFingerprint
4535
+ });
4536
+ /** AES-256-GCM configuration */
4537
+ const ALGORITHM = "aes-256-gcm";
4538
+ const KEY_LENGTH = 32;
4539
+ const IV_LENGTH = 12;
3845
4540
  /**
3846
- * Get package manager specific commands and paths
4541
+ * Encrypt secrets for embedding in a bundle.
4542
+ * Uses AES-256-GCM with a randomly generated ephemeral key.
4543
+ *
4544
+ * @param secrets - Key-value pairs to encrypt
4545
+ * @returns Encrypted payload with ephemeral master key
3847
4546
  */
3848
- function getPmConfig(pm) {
4547
+ function encryptSecrets(secrets) {
4548
+ const masterKey = randomBytes(KEY_LENGTH);
4549
+ const iv = randomBytes(IV_LENGTH);
4550
+ const plaintext = JSON.stringify(secrets);
4551
+ const cipher = createCipheriv(ALGORITHM, masterKey, iv);
4552
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
4553
+ const authTag = cipher.getAuthTag();
3849
4554
  return {
3850
- pnpm: {
3851
- install: "corepack enable && corepack prepare pnpm@latest --activate",
3852
- lockfile: "pnpm-lock.yaml",
3853
- fetch: "pnpm fetch",
3854
- installCmd: "pnpm install --frozen-lockfile --offline",
3855
- cacheTarget: "/root/.local/share/pnpm/store",
3856
- cacheId: "pnpm",
3857
- run: "pnpm",
3858
- exec: "pnpm exec",
3859
- dlx: "pnpm dlx",
3860
- addGlobal: "pnpm add -g"
3861
- },
3862
- npm: {
3863
- install: "",
3864
- lockfile: "package-lock.json",
3865
- fetch: "",
3866
- installCmd: "npm ci",
3867
- cacheTarget: "/root/.npm",
3868
- cacheId: "npm",
3869
- run: "npm run",
3870
- exec: "npx",
3871
- dlx: "npx",
3872
- addGlobal: "npm install -g"
3873
- },
3874
- yarn: {
3875
- install: "corepack enable && corepack prepare yarn@stable --activate",
3876
- lockfile: "yarn.lock",
3877
- fetch: "",
3878
- installCmd: "yarn install --frozen-lockfile",
3879
- cacheTarget: "/root/.yarn/cache",
3880
- cacheId: "yarn",
3881
- run: "yarn",
3882
- exec: "yarn exec",
3883
- dlx: "yarn dlx",
3884
- addGlobal: "yarn global add"
3885
- },
3886
- bun: {
3887
- install: "npm install -g bun",
3888
- lockfile: "bun.lockb",
3889
- fetch: "",
3890
- installCmd: "bun install --frozen-lockfile",
3891
- cacheTarget: "/root/.bun/install/cache",
3892
- cacheId: "bun",
3893
- run: "bun run",
3894
- exec: "bunx",
3895
- dlx: "bunx",
3896
- addGlobal: "bun add -g"
3897
- }
3898
- }[pm];
3899
- }
3900
- /**
3901
- * Generate a multi-stage Dockerfile for building from source
3902
- * Optimized for build speed with:
3903
- * - BuildKit cache mounts for package manager store
3904
- * - pnpm fetch for better layer caching (when using pnpm)
3905
- * - Optional turbo prune for monorepos
3906
- */
3907
- function generateMultiStageDockerfile(options) {
3908
- const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
3909
- if (turbo) return generateTurboDockerfile({
3910
- ...options,
3911
- turboPackage: turboPackage ?? "api"
3912
- });
3913
- const pm = getPmConfig(packageManager);
3914
- return `# syntax=docker/dockerfile:1
3915
- # Stage 1: Dependencies
3916
- FROM ${baseImage} AS deps
3917
-
3918
- WORKDIR /app
3919
- ${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
3920
- ${packageManager === "pnpm" ? `# Copy lockfile first for better caching
3921
- COPY ${pm.lockfile} ./
3922
-
3923
- # Fetch dependencies (downloads to virtual store, cached separately)
3924
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
3925
- ${pm.fetch}
3926
-
3927
- # Copy package.json after fetch
3928
- COPY package.json ./
3929
-
3930
- # Install from cache (fast - no network needed)
3931
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
3932
- ${pm.installCmd}` : `# Copy package files
3933
- COPY package.json ${pm.lockfile} ./
3934
-
3935
- # Install dependencies with cache
3936
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
3937
- ${pm.installCmd}`}
3938
-
3939
- # Stage 2: Build
3940
- FROM deps AS builder
3941
-
3942
- WORKDIR /app
3943
-
3944
- # Copy source (deps already installed)
3945
- COPY . .
3946
-
3947
- # Build any workspace packages the app depends on.
3948
- #
3949
- # A no-op for a project whose dependencies come from a registry, and required
3950
- # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
3951
- # workspace package's \`dist\` never reaches the image and its bin has nothing to
3952
- # import. \`--if-present\` is what makes the same Dockerfile correct for both.
3953
- RUN ${pm.run} --if-present build
3954
-
3955
- # Build production server using gkm
3956
- RUN ${pm.exec} gkm build --provider server --production
3957
-
3958
- # Stage 3: Production
3959
- FROM ${baseImage} AS runner
3960
-
3961
- WORKDIR /app
3962
-
3963
- # Install tini for proper signal handling as PID 1
3964
- RUN apk add --no-cache tini
3965
-
3966
- # Create non-root user
3967
- RUN addgroup --system --gid 1001 nodejs && \\
3968
- adduser --system --uid 1001 hono
3969
-
3970
- # Copy bundled server
3971
- COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
3972
-
3973
- # Environment
3974
- ENV NODE_ENV=production
3975
- ENV PORT=${port}
3976
-
3977
- # Health check
3978
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
3979
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
3980
-
3981
- # Switch to non-root user
3982
- USER hono
3983
-
3984
- EXPOSE ${port}
3985
-
3986
- # Use tini as entrypoint to handle PID 1 responsibilities
3987
- ENTRYPOINT ["/sbin/tini", "--"]
3988
- CMD ["node", "server.mjs"]
3989
- `;
3990
- }
3991
- /**
3992
- * Generate a Dockerfile optimized for Turbo monorepos
3993
- * Uses turbo prune to create minimal Docker context
3994
- */
3995
- function generateTurboDockerfile(options) {
3996
- const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
3997
- const pm = getPmConfig(packageManager);
3998
- const installPm = pm.install ? `RUN ${pm.install}` : "";
3999
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4000
- return `# syntax=docker/dockerfile:1
4001
- # Stage 1: Prune monorepo
4002
- FROM ${baseImage} AS pruner
4003
-
4004
- WORKDIR /app
4005
-
4006
- ${installPm}
4007
-
4008
- COPY . .
4009
-
4010
- # Prune to only include necessary packages
4011
- RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4012
-
4013
- # Stage 2: Install dependencies
4014
- FROM ${baseImage} AS deps
4015
-
4016
- WORKDIR /app
4017
-
4018
- ${installPm}
4019
-
4020
- # Copy pruned lockfile and package.jsons
4021
- COPY --from=pruner /app/out/${pm.lockfile} ./
4022
- COPY --from=pruner /app/out/json/ ./
4023
-
4024
- # Install dependencies (no frozen-lockfile since turbo prune creates a subset)
4025
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4026
- ${turboInstallCmd}
4027
-
4028
- # Stage 3: Build
4029
- FROM deps AS builder
4030
-
4031
- WORKDIR /app
4032
-
4033
- # Copy pruned source
4034
- COPY --from=pruner /app/out/full/ ./
4035
-
4036
- # Build any workspace packages the app depends on.
4037
- #
4038
- # A no-op for a project whose dependencies come from a registry, and required
4039
- # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
4040
- # workspace package's \`dist\` never reaches the image and its bin has nothing to
4041
- # import. \`--if-present\` is what makes the same Dockerfile correct for both.
4042
- RUN ${pm.run} --if-present build
4043
-
4044
- # Build production server using gkm
4045
- RUN ${pm.exec} gkm build --provider server --production
4046
-
4047
- # Stage 4: Production
4048
- FROM ${baseImage} AS runner
4049
-
4050
- WORKDIR /app
4051
-
4052
- RUN apk add --no-cache tini
4053
-
4054
- RUN addgroup --system --gid 1001 nodejs && \\
4055
- adduser --system --uid 1001 hono
4056
-
4057
- COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
4058
-
4059
- ENV NODE_ENV=production
4060
- ENV PORT=${port}
4061
-
4062
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4063
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4064
-
4065
- USER hono
4066
-
4067
- EXPOSE ${port}
4068
-
4069
- ENTRYPOINT ["/sbin/tini", "--"]
4070
- CMD ["node", "server.mjs"]
4071
- `;
4072
- }
4073
- /**
4074
- * Generate a slim Dockerfile for pre-built bundles
4075
- */
4076
- function generateSlimDockerfile(options) {
4077
- const { baseImage, port, healthCheckPath, bundle = ".gkm/server/dist/server.mjs" } = options;
4078
- return `# Slim Dockerfile for pre-built production bundle
4079
- FROM ${baseImage}
4080
-
4081
- WORKDIR /app
4082
-
4083
- # Install tini for proper signal handling as PID 1
4084
- # Handles SIGTERM propagation and zombie process reaping
4085
- RUN apk add --no-cache tini
4086
-
4087
- # Create non-root user
4088
- RUN addgroup --system --gid 1001 nodejs && \\
4089
- adduser --system --uid 1001 hono
4090
-
4091
- # Copy pre-built bundle
4092
- COPY ${bundle} ./
4093
-
4094
- # Environment
4095
- ENV NODE_ENV=production
4096
- ENV PORT=${port}
4097
-
4098
- # Health check
4099
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4100
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4101
-
4102
- # Switch to non-root user
4103
- USER hono
4104
-
4105
- EXPOSE ${port}
4106
-
4107
- # Use tini as entrypoint to handle PID 1 responsibilities
4108
- ENTRYPOINT ["/sbin/tini", "--"]
4109
- CMD ["node", "server.mjs"]
4110
- `;
4555
+ encrypted: Buffer.concat([ciphertext, authTag]).toString("base64"),
4556
+ iv: iv.toString("hex"),
4557
+ masterKey: masterKey.toString("hex")
4558
+ };
4111
4559
  }
4112
4560
  /**
4113
- * Generate .dockerignore file
4561
+ * Generate the define options for tsdown/esbuild.
4562
+ * These will be injected at build time.
4114
4563
  */
4115
- function generateDockerignore() {
4116
- return `# Dependencies
4117
- node_modules
4118
- .pnpm-store
4119
-
4120
- # Build output (except what we need)
4121
- .gkm/aws*
4122
- .gkm/server/*.ts
4123
- !.gkm/server/dist
4124
-
4125
- # The key gkm build --stage encrypted secrets with: runtime env only, never
4126
- # the build context
4127
- .gkm/server/master.key
4128
-
4129
- # gkm compose's stacks: each app's env file holds its stage's secrets
4130
- .gkm/compose
4131
-
4132
- # IDE and editor
4133
- .idea
4134
- .vscode
4135
- *.swp
4136
- *.swo
4137
-
4138
- # Git
4139
- .git
4140
- .gitignore
4141
-
4142
- # Logs
4143
- *.log
4144
- npm-debug.log*
4145
- pnpm-debug.log*
4146
-
4147
- # Test files
4148
- **/*.test.ts
4149
- **/*.spec.ts
4150
- **/__tests__
4151
- coverage
4152
-
4153
- # Documentation
4154
- docs
4155
- *.md
4156
- !README.md
4157
-
4158
- # Environment files (handle secrets separately)
4159
- .env
4160
- .env.*
4161
- !.env.example
4162
-
4163
- # Docker files (don't copy recursively)
4164
- Dockerfile*
4165
- docker-compose*
4166
- .dockerignore
4167
- `;
4564
+ function generateDefineOptions(payload) {
4565
+ return {
4566
+ __GKM_ENCRYPTED_CREDENTIALS__: JSON.stringify(payload.encrypted),
4567
+ __GKM_CREDENTIALS_IV__: JSON.stringify(payload.iv)
4568
+ };
4168
4569
  }
4169
4570
  /**
4170
- * Generate docker-entrypoint.sh for custom startup logic
4571
+ * A short, one-way name for a master key: the first 8 hex characters of its
4572
+ * SHA-256.
4573
+ *
4574
+ * Printed where the key itself used to be, so a person can tell which key a
4575
+ * build used — and match it against the one they hold — without the output
4576
+ * carrying a value that decrypts every secret of the stage.
4171
4577
  */
4172
- function generateDockerEntrypoint() {
4173
- return `#!/bin/sh
4174
- set -e
4175
-
4176
- # Run any custom startup scripts here
4177
- # Example: wait for database
4178
- # until nc -z $DB_HOST $DB_PORT; do
4179
- # echo "Waiting for database..."
4180
- # sleep 1
4181
- # done
4182
-
4183
- # Execute the main command
4184
- exec "$@"
4185
- `;
4578
+ function keyFingerprint(masterKey) {
4579
+ return createHash("sha256").update(masterKey).digest("hex").slice(0, 8);
4186
4580
  }
4581
+ //#endregion
4582
+ //#region src/deploy/devServices.ts
4583
+ /**
4584
+ * Mail and object storage on a deployed stage: real ones, or — only when
4585
+ * someone asked — the dev services the local stage runs.
4586
+ *
4587
+ * Locally a declared `Email` is Mailpit and a declared bucket is MinIO, and
4588
+ * nobody has to configure either. Deployed, the same two containers would be
4589
+ * a stage that silently delivers no mail and keeps its files on one
4590
+ * container's disk. So a deployed stage on a server target takes both from
4591
+ * its secrets — the mail server's URL and sending address, and each bucket's
4592
+ * URL — and a stage missing any of them fails before anything is built,
4593
+ * provisioned or written, naming every key at once.
4594
+ *
4595
+ * A bucket's credentials are not checked: they are optional, and come from
4596
+ * either of two places. The bucket's URL may carry a key of its own
4597
+ * (`s3://KEY:SECRET@uploads`), which wins; otherwise the S3 client's default
4598
+ * chain applies — the stage's shared `AWS_ACCESS_KEY_ID`/
4599
+ * `AWS_SECRET_ACCESS_KEY` when it set them, or a role.
4600
+ *
4601
+ * `--allow-dev-services minio,mailpit` is the way out for a stage that is
4602
+ * not production — a preview, a demo: the target runs the dev service and
4603
+ * derives the keys from it. Keys the stage did set still win. Every run that
4604
+ * uses one says so, loudly.
4605
+ *
4606
+ * Pure: what each target runs is decided here from declarations and the
4607
+ * stage's values, and the targets do the running.
4608
+ */
4609
+ /** The dev services a deployed stage may be allowed to run. */
4610
+ const DEV_SERVICES = ["minio", "mailpit"];
4611
+ /**
4612
+ * The S3 client's shared key pair. Optional: a bucket whose URL carries its own
4613
+ * key (`s3://KEY:SECRET@bucket`) signs with that, and one without signs with
4614
+ * this pair when the stage set it, or with whatever else the SDK's default
4615
+ * chain finds — a role.
4616
+ */
4617
+ const STORAGE_KEY_PAIR = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
4618
+ /** A `--allow-dev-services` value that is not a dev service. */
4619
+ var UnknownDevService = class extends Error {
4620
+ value;
4621
+ constructor(value) {
4622
+ super(`'${value}' is not a dev service. --allow-dev-services takes a comma-separated list of ${DEV_SERVICES.join(", ")}: --allow-dev-services ${DEV_SERVICES.join(",")}`);
4623
+ this.value = value;
4624
+ this.name = "UnknownDevService";
4625
+ }
4626
+ };
4627
+ /** Dev services asked of a target that runs no containers. */
4628
+ var DevServicesNeedServerTarget = class extends Error {
4629
+ target;
4630
+ services;
4631
+ constructor(target, services) {
4632
+ super(`--allow-dev-services applies to server targets only, and '${target}' deploys to AWS, where a bucket is S3 and mail is the stage's own. Drop --allow-dev-services ${services.join(",")}.`);
4633
+ this.target = target;
4634
+ this.services = services;
4635
+ this.name = "DevServicesNeedServerTarget";
4636
+ }
4637
+ };
4187
4638
  /**
4188
- * Resolve Docker configuration from GkmConfig with defaults
4639
+ * A deployed stage with mail or object storage that its secrets do not
4640
+ * configure — every missing key, across every app, at once.
4189
4641
  */
4190
- function resolveDockerConfig(config) {
4191
- const docker = config.docker ?? {};
4192
- return {
4193
- registry: docker.registry ?? "",
4194
- imageName: docker.imageName ?? config.name ?? "api",
4195
- baseImage: docker.baseImage ?? "node:22-alpine",
4196
- port: docker.port ?? 3e3
4197
- };
4642
+ var ExternalServicesNotConfigured = class extends Error {
4643
+ stage;
4644
+ missing;
4645
+ constructor(stage, missing) {
4646
+ const services = [...new Set(missing.map((m) => m.service))].sort();
4647
+ const lines = missing.map((m) => {
4648
+ const by = m.apps?.length ? `, read by ${m.apps.join(", ")}` : "";
4649
+ return ` gkm secrets:set ${m.key} '${m.example}' --stage ${stage}\n ${m.what}${by}` + (m.note ? `\n ${m.note}` : "");
4650
+ });
4651
+ super(`The stage '${stage}' is deployed, and a deployed stage's mail and object storage are real services: gkm runs no Mailpit or MinIO for it. Set ${missing.length === 1 ? "this key" : `these ${missing.length} keys`} in the stage's secrets:\n\n${lines.join("\n")}\n\nFor a stage that is not production — a preview, a demo — the dev services can run instead, with --allow-dev-services ${services.join(",")}. Mailpit delivers no mail, and MinIO keeps every object on one container's disk.`);
4652
+ this.stage = stage;
4653
+ this.missing = missing;
4654
+ this.name = "ExternalServicesNotConfigured";
4655
+ }
4656
+ };
4657
+ /**
4658
+ * The dev services a value names: `'minio,mailpit'`, or a list of them.
4659
+ *
4660
+ * @throws {UnknownDevService} for anything that is not one
4661
+ */
4662
+ function parseDevServices(value) {
4663
+ if (value === void 0) return [];
4664
+ const items = (typeof value === "string" ? [value] : value).flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
4665
+ const services = /* @__PURE__ */ new Set();
4666
+ for (const item of items) {
4667
+ if (!DEV_SERVICES.includes(item)) throw new UnknownDevService(item);
4668
+ services.add(item);
4669
+ }
4670
+ return [...services].sort();
4198
4671
  }
4199
4672
  /**
4200
- * Generate a Dockerfile for Next.js frontend apps using standalone output.
4201
- * Uses turbo prune for monorepo optimization.
4202
- * @internal Exported for testing
4673
+ * Every mail and storage construct a manifest declares, with the apps that
4674
+ * depend on each — what a target that provisions the whole manifest checks.
4203
4675
  */
4204
- function generateNextjsDockerfile(options) {
4205
- const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
4206
- const pm = getPmConfig(packageManager);
4207
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4208
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4209
- const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4210
- const publicUrlArgDeclarations = publicUrlArgs.map((arg) => `ARG ${arg}=""`).join("\n");
4211
- const publicUrlEnvDeclarations = publicUrlArgs.map((arg) => `ENV ${arg}=$${arg}`).join("\n");
4212
- return `# syntax=docker/dockerfile:1
4213
- # Next.js standalone Dockerfile with turbo prune optimization
4214
-
4215
- # Stage 1: Prune monorepo
4216
- FROM ${baseImage} AS pruner
4217
-
4218
- WORKDIR /app
4219
-
4220
- ${installPm}
4221
-
4222
- COPY . .
4223
-
4224
- # Prune to only include necessary packages
4225
- RUN ${turboCmd} prune ${turboPackage} --docker
4226
-
4227
- # Stage 2: Install dependencies
4228
- FROM ${baseImage} AS deps
4229
-
4230
- WORKDIR /app
4231
-
4232
- ${installPm}
4233
-
4234
- # Copy pruned lockfile and package.jsons
4235
- COPY --from=pruner /app/out/${pm.lockfile} ./
4236
- COPY --from=pruner /app/out/json/ ./
4237
-
4238
- # Install dependencies
4239
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4240
- ${turboInstallCmd}
4241
-
4242
- # Stage 3: Build
4243
- FROM deps AS builder
4244
-
4245
- WORKDIR /app
4246
-
4247
- # Build-time args for public API URLs (populated by gkm deploy)
4248
- # These get baked into the Next.js build as public environment variables
4249
- ${publicUrlArgDeclarations}
4250
-
4251
- # Convert ARGs to ENVs for Next.js build
4252
- ${publicUrlEnvDeclarations}
4253
-
4254
- # Copy pruned source
4255
- COPY --from=pruner /app/out/full/ ./
4256
-
4257
- # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4258
- # Using wildcard to make it optional for single-app projects
4259
- COPY --from=pruner /app/tsconfig.* ./
4260
-
4261
- # Ensure public directory exists (may be empty for scaffolded projects)
4262
- RUN mkdir -p ${appPath}/public
4263
-
4264
- # Set Next.js to produce standalone output
4265
- ENV NEXT_TELEMETRY_DISABLED=1
4266
-
4267
- # Build the application
4268
- RUN ${turboCmd} run build --filter=${turboPackage}
4269
-
4270
- # Stage 4: Production
4271
- FROM ${baseImage} AS runner
4272
-
4273
- WORKDIR /app
4274
-
4275
- # Install tini for proper signal handling
4276
- RUN apk add --no-cache tini
4277
-
4278
- # Create non-root user
4279
- RUN addgroup --system --gid 1001 nodejs && \\
4280
- adduser --system --uid 1001 nextjs
4281
-
4282
- # Set environment
4283
- ENV NODE_ENV=production
4284
- ENV NEXT_TELEMETRY_DISABLED=1
4285
- ENV PORT=${port}
4286
- ENV HOSTNAME="0.0.0.0"
4287
-
4288
- # Copy static files and standalone output
4289
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
4290
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
4291
- COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
4292
-
4293
- USER nextjs
4294
-
4295
- EXPOSE ${port}
4296
-
4297
- # A site answers at its root; Docker, and Dokploy, read the container's
4298
- # health from it rather than from the process merely running. 127.0.0.1, not
4299
- # localhost: the server listens on IPv4, and localhost can resolve to ::1.
4300
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4301
- CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
4302
-
4303
- ENTRYPOINT ["/sbin/tini", "--"]
4304
- CMD ["node", "${appPath}/server.js"]
4305
- `;
4676
+ function manifestServiceDeclarations(manifest) {
4677
+ return Object.entries(manifest).flatMap(([id, declaration]) => {
4678
+ const { kind } = declaration;
4679
+ if (kind !== "email" && kind !== "objects" && kind !== "file-server") return [];
4680
+ const apps = dependentsOf(manifest, id).filter((caller) => {
4681
+ const callerKind = manifest[caller]?.kind;
4682
+ return callerKind === "rest-api" || callerKind === "site";
4683
+ }).map(appKey);
4684
+ return [{
4685
+ id,
4686
+ kind,
4687
+ ...declaration.kind === "file-server" ? { of: declaration.of } : {},
4688
+ ...apps.length ? { apps } : {}
4689
+ }];
4690
+ });
4306
4691
  }
4307
4692
  /**
4308
- * Generate a Dockerfile for backend apps in a workspace.
4309
- * Uses turbo prune for monorepo optimization.
4310
- * @internal Exported for testing
4693
+ * Where a deployed stage's mail and storage come from, and what it lacks.
4694
+ *
4695
+ * - Mail is external once its URL is set, and then needs its sending
4696
+ * address too. Unset, it is Mailpit where allowed — the address derived
4697
+ * unless set — and missing otherwise.
4698
+ * - A bucket is external once its URL is set; its credentials, in the URL or
4699
+ * the shared pair, are optional. Unset, it is MinIO where allowed, and
4700
+ * missing otherwise.
4701
+ * - A file server's URL is derived where its bucket is MinIO, unless set;
4702
+ * over an external bucket the stage must set it.
4311
4703
  */
4312
- function generateBackendDockerfile(options) {
4313
- const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
4314
- const pm = getPmConfig(packageManager);
4315
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4316
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4317
- return `# syntax=docker/dockerfile:1
4318
- # Backend Dockerfile with turbo prune optimization
4319
-
4320
- # Stage 1: Prune monorepo
4321
- FROM ${baseImage} AS pruner
4322
-
4323
- WORKDIR /app
4324
-
4325
- ${installPm}
4326
-
4327
- COPY . .
4328
-
4329
- # Prune to only include necessary packages
4330
- RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4331
-
4332
- # Stage 2: Install dependencies
4333
- FROM ${baseImage} AS deps
4334
-
4335
- WORKDIR /app
4336
-
4337
- ${installPm}
4338
-
4339
- # Copy pruned lockfile and package.jsons
4340
- COPY --from=pruner /app/out/${pm.lockfile} ./
4341
- COPY --from=pruner /app/out/json/ ./
4342
-
4343
- # Install dependencies
4344
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4345
- ${turboInstallCmd}
4346
-
4347
- # Stage 3: Build
4348
- FROM deps AS builder
4349
-
4350
- WORKDIR /app
4351
-
4352
- # Copy pruned source
4353
- COPY --from=pruner /app/out/full/ ./
4354
-
4355
- # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4356
- # Using wildcard to make it optional for single-app projects
4357
- COPY --from=pruner /app/gkm.config.* ./
4358
- COPY --from=pruner /app/tsconfig.* ./
4359
-
4360
- # Write encrypted credentials for gkm build to embed.
4361
- # A build secret (\`docker build --secret id=gkm_credentials,src=…\`), not an
4362
- # ARG: an ARG is recorded in the image history. Two lines, ciphertext then IV;
4363
- # absent when the app has no secrets.
4364
- RUN --mount=type=secret,id=gkm_credentials,required=false \
4365
- if [ -s /run/secrets/gkm_credentials ]; then \
4366
- mkdir -p ${appPath}/.gkm && \
4367
- sed -n 1p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.enc && \
4368
- sed -n 2p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.iv; \
4369
- fi
4370
-
4371
- # Build any workspace packages the app depends on.
4372
- #
4373
- # A no-op for a project whose dependencies come from a registry, and required
4374
- # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
4375
- # workspace package's \`dist\` never reaches the image and its bin has nothing to
4376
- # import. \`--if-present\` is what makes the same Dockerfile correct for both.
4377
- RUN ${pm.run} --if-present build
4378
-
4379
- # Build production server using gkm
4380
- RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
4381
-
4382
- # Stage 4: Production
4383
- FROM ${baseImage} AS runner
4384
-
4385
- WORKDIR /app
4386
-
4387
- RUN apk add --no-cache tini
4388
-
4389
- RUN addgroup --system --gid 1001 nodejs && \\
4390
- adduser --system --uid 1001 hono
4391
-
4392
- # Copy bundled server
4393
- COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
4394
-
4395
- ENV NODE_ENV=production
4396
- ENV PORT=${port}
4397
-
4398
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4399
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4400
-
4401
- USER hono
4402
-
4403
- EXPOSE ${port}
4404
-
4405
- ENTRYPOINT ["/sbin/tini", "--"]
4406
- CMD ["node", "server.mjs"]
4407
- `;
4704
+ function externalServices(input) {
4705
+ const { supplied, allow } = input;
4706
+ const has = (key) => supplied[key] !== void 0;
4707
+ const domain = input.domain ?? "example.com";
4708
+ const missing = [];
4709
+ const minio = [];
4710
+ const mailpit = [];
4711
+ const add = (entry) => {
4712
+ if (!missing.some((m) => m.key === entry.key)) missing.push(entry);
4713
+ };
4714
+ const declarations = [...input.declarations].sort((a, b) => a.id.localeCompare(b.id));
4715
+ const apps = (d) => d.apps?.length ? { apps: [...d.apps].sort() } : {};
4716
+ for (const d of declarations.filter((d) => d.kind === "email")) {
4717
+ const url = provideKey(d.id, "url");
4718
+ const from = provideKey(d.id, "from");
4719
+ if (!has(url) && allow.includes("mailpit")) {
4720
+ mailpit.push(d.id);
4721
+ continue;
4722
+ }
4723
+ if (!has(url)) add({
4724
+ key: url,
4725
+ id: d.id,
4726
+ what: `where '${d.id}' sends mail — any SMTP server`,
4727
+ example: `smtp://user:password@smtp.${domain}:587`,
4728
+ service: "mailpit",
4729
+ ...apps(d)
4730
+ });
4731
+ if (!has(from)) add({
4732
+ key: from,
4733
+ id: d.id,
4734
+ what: `the address '${d.id}' sends from, on a domain the mail server has verified`,
4735
+ example: `noreply@${domain}`,
4736
+ service: "mailpit",
4737
+ ...apps(d)
4738
+ });
4739
+ }
4740
+ for (const d of declarations.filter((d) => d.kind === "objects")) {
4741
+ const url = provideKey(d.id, "url");
4742
+ if (has(url)) continue;
4743
+ if (allow.includes("minio")) {
4744
+ minio.push(d.id);
4745
+ continue;
4746
+ }
4747
+ add({
4748
+ key: url,
4749
+ id: d.id,
4750
+ what: `the bucket '${d.id}' — S3, R2, or any S3-compatible store (add &endpoint=… for one that is not S3)`,
4751
+ note: `credentials are optional: a key for this bucket alone in the URL (s3://KEY:SECRET@${appKey(d.id)}?…) wins; without one, the shared AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or a role signs`,
4752
+ example: `s3://${appKey(d.id)}?region=eu-west-1`,
4753
+ service: "minio",
4754
+ ...apps(d)
4755
+ });
4756
+ }
4757
+ for (const d of declarations.filter((d) => d.kind === "file-server")) {
4758
+ const url = provideKey(d.id, "url");
4759
+ if (has(url)) continue;
4760
+ if (d.of && minio.includes(d.of)) continue;
4761
+ add({
4762
+ key: url,
4763
+ id: d.id,
4764
+ what: `the public address '${d.id}' serves its bucket on — a CDN or the bucket's own domain`,
4765
+ example: `https://${appKey(d.id)}.${domain}`,
4766
+ service: "minio",
4767
+ ...apps(d)
4768
+ });
4769
+ }
4770
+ return {
4771
+ missing,
4772
+ minio,
4773
+ mailpit
4774
+ };
4408
4775
  }
4409
4776
  /**
4410
- * Generate a Dockerfile for apps with a custom entry point.
4411
- * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
4412
- * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
4413
- * @internal Exported for testing
4777
+ * `externalServices`, refusing a stage that lacks anything.
4778
+ *
4779
+ * @throws {ExternalServicesNotConfigured} naming every missing key
4414
4780
  */
4415
- function generateEntryDockerfile(options) {
4416
- const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
4417
- const pm = getPmConfig(packageManager);
4418
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4419
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4420
- return `# syntax=docker/dockerfile:1
4421
- # Entry-based Dockerfile with turbo prune + tsdown bundling
4422
-
4423
- # Stage 1: Prune monorepo
4424
- FROM ${baseImage} AS pruner
4425
-
4426
- WORKDIR /app
4427
-
4428
- ${installPm}
4429
-
4430
- COPY . .
4431
-
4432
- # Prune to only include necessary packages
4433
- RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
4434
-
4435
- # Stage 2: Install dependencies
4436
- FROM ${baseImage} AS deps
4437
-
4438
- WORKDIR /app
4439
-
4440
- ${installPm}
4441
-
4442
- # Copy pruned lockfile and package.jsons
4443
- COPY --from=pruner /app/out/${pm.lockfile} ./
4444
- COPY --from=pruner /app/out/json/ ./
4445
-
4446
- # Install dependencies
4447
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4448
- ${turboInstallCmd}
4449
-
4450
- # Stage 3: Build with tsdown
4451
- FROM deps AS builder
4452
-
4453
- WORKDIR /app
4454
-
4455
- # Copy pruned source
4456
- COPY --from=pruner /app/out/full/ ./
4457
-
4458
- # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
4459
- # Using wildcard to make it optional for single-app projects
4460
- COPY --from=pruner /app/tsconfig.* ./
4461
-
4462
- # Bundle entry point with esbuild (outputs to dist/index.mjs)
4463
- # Creates a fully standalone bundle with all dependencies included
4464
- # Use define to embed credentials if present.
4465
- # They arrive as a build secret (\`docker build --secret id=gkm_credentials,src=…\`),
4466
- # not an ARG: an ARG is recorded in the image history. Two lines, ciphertext
4467
- # then IV; absent when the app has no secrets.
4468
- RUN --mount=type=secret,id=gkm_credentials,required=false \
4469
- cd ${appPath} && \
4470
- if [ -s /run/secrets/gkm_credentials ]; then \
4471
- CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \
4472
- IV=$(sed -n 2p /run/secrets/gkm_credentials) && \
4473
- npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
4474
- --outfile=dist/index.mjs --packages=bundle \
4475
- --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
4476
- --define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
4477
- --define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
4478
- else \
4479
- npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
4480
- --outfile=dist/index.mjs --packages=bundle \
4481
- --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
4482
- fi
4483
-
4484
- # Stage 4: Production
4485
- FROM ${baseImage} AS runner
4486
-
4487
- WORKDIR /app
4488
-
4489
- RUN apk add --no-cache tini
4490
-
4491
- RUN addgroup --system --gid 1001 nodejs && \\
4492
- adduser --system --uid 1001 app
4493
-
4494
- # Copy bundled output only (no node_modules needed - fully bundled)
4495
- COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
4496
-
4497
- ENV NODE_ENV=production
4498
- ENV PORT=${port}
4499
-
4500
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4501
- CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
4502
-
4503
- USER app
4504
-
4505
- EXPOSE ${port}
4506
-
4507
- ENTRYPOINT ["/sbin/tini", "--"]
4508
- CMD ["node", "index.mjs"]
4509
- `;
4781
+ function assertExternalServices(input) {
4782
+ const services = externalServices(input);
4783
+ if (services.missing.length > 0) throw new ExternalServicesNotConfigured(input.stage, services.missing);
4784
+ return services;
4785
+ }
4786
+ /** The dev services a run uses, one entry per service it runs. */
4787
+ function devServicesUsed(services) {
4788
+ const used = [];
4789
+ if (services.mailpit.length > 0) used.push({
4790
+ service: "mailpit",
4791
+ ids: services.mailpit
4792
+ });
4793
+ if (services.minio.length > 0) used.push({
4794
+ service: "minio",
4795
+ ids: services.minio
4796
+ });
4797
+ return used;
4798
+ }
4799
+ /** The line a run prints for each dev service a deployed stage uses. */
4800
+ function devServiceWarning(stage, use) {
4801
+ return `⚠️ DEV SERVICE ON A DEPLOYED STAGE (${stage}): ${use.service === "mailpit" ? `Mailpit is catching mail for ${use.ids.join(", ")}. It delivers NO mail: nobody receives a sign-in link, a receipt or a reset` : `MinIO is storing ${use.ids.join(", ")}. Every object is on one container's disk, with no replication and no backup`}. It is not production-grade — set the stage's own keys in its secrets for that.`;
4802
+ }
4803
+ /**
4804
+ * Report each dev service a run uses: a loud line, and a `dev-service.used`
4805
+ * event a host can act on.
4806
+ */
4807
+ function reportDevServices(ctx, uses) {
4808
+ for (const use of uses) {
4809
+ ctx.logger.warn(`\n${devServiceWarning(ctx.stage, use)}`);
4810
+ ctx.emit({
4811
+ type: "dev-service.used",
4812
+ service: use.service,
4813
+ stage: ctx.stage,
4814
+ constructs: use.ids
4815
+ });
4816
+ }
4817
+ }
4818
+ //#endregion
4819
+ //#region src/target/dokploy/domain.ts
4820
+ /** A stage was deployed that `deploy.domains` gives no domain. */
4821
+ var NoDomainForStage = class extends Error {
4822
+ stage;
4823
+ constructor(stage) {
4824
+ super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
4825
+ this.stage = stage;
4826
+ this.name = "NoDomainForStage";
4827
+ }
4828
+ };
4829
+ /**
4830
+ * The hostname an app answers on for a stage.
4831
+ *
4832
+ * 1. An explicit `app.domain` — a string, or one per stage — wins.
4833
+ * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
4834
+ * answers on the domain itself, and every other app on
4835
+ * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
4836
+ *
4837
+ * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
4838
+ */
4839
+ function resolveHost(appName, app, stage, domains, isMainFrontend) {
4840
+ if (app.domain) {
4841
+ if (typeof app.domain === "string") return app.domain;
4842
+ if (app.domain[stage]) return app.domain[stage];
4843
+ }
4844
+ const baseDomain = domains?.[stage];
4845
+ if (!baseDomain) throw new NoDomainForStage(stage);
4846
+ if (isMainFrontend) return baseDomain;
4847
+ return `${app.subdomain ?? appName}.${baseDomain}`;
4510
4848
  }
4511
4849
  /**
4512
- * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
4513
- * Remix). Builds with turbo, then runs the framework's production start
4514
- * script via `npm start`.
4850
+ * Whether this app is the site the base domain points at — see `rootSite`,
4851
+ * which the local edge asks too.
4515
4852
  *
4516
- * Public URL build args are declared so framework bundlers (Vite, Vinxi)
4517
- * can inline them at build time. Per-framework prefix is the caller's job.
4518
- * @internal Exported for testing
4853
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
4519
4854
  */
4520
- function generateNodeWebDockerfile(options) {
4521
- const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4522
- const pm = getPmConfig(packageManager);
4523
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4524
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4525
- const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4526
- const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
4527
- const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4528
- const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4529
- return `# syntax=docker/dockerfile:1
4530
- # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
4531
-
4532
- FROM ${baseImage} AS pruner
4533
- WORKDIR /app
4534
- ${installPm}
4535
- COPY . .
4536
- RUN ${turboCmd} prune ${turboPackage} --docker
4537
-
4538
- FROM ${baseImage} AS deps
4539
- WORKDIR /app
4540
- ${installPm}
4541
- COPY --from=pruner /app/out/${pm.lockfile} ./
4542
- COPY --from=pruner /app/out/json/ ./
4543
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4544
- ${turboInstallCmd}
4545
-
4546
- FROM deps AS builder
4547
- WORKDIR /app
4548
- ${argDecls}
4549
- ${envDecls}
4550
- COPY --from=pruner /app/out/full/ ./
4551
- COPY --from=pruner /app/tsconfig.* ./
4552
- RUN ${turboCmd} run build --filter=${turboPackage}
4553
-
4554
- FROM ${baseImage} AS runner
4555
- WORKDIR /app
4556
- RUN apk add --no-cache tini
4557
- RUN addgroup --system --gid 1001 nodejs && \\
4558
- adduser --system --uid 1001 app
4559
-
4560
- # Copy the whole built workspace — frameworks differ on output paths
4561
- COPY --from=builder --chown=app:nodejs /app/ ./
4562
-
4563
- ENV NODE_ENV=production
4564
- ENV PORT=${port}
4565
- ENV HOSTNAME="0.0.0.0"
4566
-
4567
- USER app
4568
- EXPOSE ${port}
4569
-
4570
- # A site answers at its root; Docker, and Dokploy, read the container's
4571
- # health from it rather than from the process merely running. 127.0.0.1, not
4572
- # localhost: the server listens on IPv4, and localhost can resolve to ::1.
4573
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4574
- CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
4575
-
4576
- ENTRYPOINT ["/sbin/tini", "--"]
4577
- CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
4578
- `;
4855
+ function isMainFrontendApp(appName, app, allApps) {
4856
+ if (app.type !== "web") return false;
4857
+ return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
4858
+ name,
4859
+ root: a.root
4860
+ }))) === appName;
4579
4861
  }
4580
4862
  /**
4581
- * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
4582
- * turbo, then serves it via nginx.
4583
- * @internal Exported for testing
4863
+ * Get public URL arg names from app dependencies.
4864
+ *
4865
+ * @param app - The web/mobile app configuration
4866
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
4867
+ * without a public prefix (e.g. Remix).
4584
4868
  */
4585
- function generateViteStaticDockerfile(options) {
4586
- const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
4587
- const pm = getPmConfig(packageManager);
4588
- const installPm = pm.install ? `RUN ${pm.install}` : "";
4589
- const turboInstallCmd = getTurboInstallCmd(packageManager);
4590
- const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
4591
- const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
4592
- const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
4593
- return `# syntax=docker/dockerfile:1
4594
- # Vite SPA Dockerfile — builds static assets, serves via nginx
4595
-
4596
- FROM ${baseImage} AS pruner
4597
- WORKDIR /app
4598
- ${installPm}
4599
- COPY . .
4600
- RUN ${turboCmd} prune ${turboPackage} --docker
4601
-
4602
- FROM ${baseImage} AS deps
4603
- WORKDIR /app
4604
- ${installPm}
4605
- COPY --from=pruner /app/out/${pm.lockfile} ./
4606
- COPY --from=pruner /app/out/json/ ./
4607
- RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4608
- ${turboInstallCmd}
4609
-
4610
- FROM deps AS builder
4611
- WORKDIR /app
4612
- ${argDecls}
4613
- ${envDecls}
4614
- COPY --from=pruner /app/out/full/ ./
4615
- COPY --from=pruner /app/tsconfig.* ./
4616
- RUN ${turboCmd} run build --filter=${turboPackage}
4617
-
4618
- FROM nginx:alpine AS runner
4619
- COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
4620
- RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
4621
- > /etc/nginx/conf.d/default.conf
4622
- EXPOSE ${port}
4623
- # A site answers at its root; Docker, and Dokploy, read the container's
4624
- # health from it rather than from the process merely running. 127.0.0.1, not
4625
- # localhost: the server listens on IPv4, and localhost can resolve to ::1.
4626
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
4627
- CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
4628
- CMD ["nginx", "-g", "daemon off;"]
4629
- `;
4869
+ function getPublicUrlArgNames(app) {
4870
+ const prefix = getPublicEnvPrefix(app.framework);
4871
+ if (!prefix) return [];
4872
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
4873
+ }
4874
+ //#endregion
4875
+ //#region src/docker/imageRef.ts
4876
+ /**
4877
+ * An image reference, checked before it reaches `docker`.
4878
+ *
4879
+ * The parts come from the workspace — a registry in `gkm.config.ts`, an image
4880
+ * name from the project, a tag from `--tag` — and an argument array already
4881
+ * keeps them out of a shell. What it cannot do is stop `docker` reading a value
4882
+ * that starts with `-` as one of its own flags, so a ref is held to Docker's
4883
+ * own grammar (distribution/reference) and anything else is refused by name
4884
+ * before a build starts.
4885
+ */
4886
+ const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
4887
+ const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
4888
+ const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
4889
+ const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
4890
+ /** Docker refuses a repository name longer than this. */
4891
+ const MAX_NAME_LENGTH = 255;
4892
+ /** A value that is not an image reference was about to be built or pushed. */
4893
+ var ImageRefInvalid = class extends Error {
4894
+ ref;
4895
+ constructor(ref) {
4896
+ super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
4897
+ this.ref = ref;
4898
+ this.name = "ImageRefInvalid";
4899
+ }
4900
+ };
4901
+ /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
4902
+ function validateImageRef(ref) {
4903
+ const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
4904
+ if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
4905
+ return ref;
4630
4906
  }
4631
4907
  //#endregion
4632
4908
  //#region src/docker/index.ts
4633
- const logger = output;
4909
+ const logger$1 = output;
4634
4910
  /** `docker build` failed; what docker printed above says why. */
4635
4911
  var DockerBuildFailed = class extends Error {
4636
4912
  imageRef;
@@ -4659,130 +4935,45 @@ var PushNeedsRegistry = class extends Error {
4659
4935
  }
4660
4936
  };
4661
4937
  /**
4662
- * Docker command implementation
4663
- * Generates Dockerfile, docker-compose.yml, and related files
4664
- *
4665
- * Default: Multi-stage Dockerfile that builds from source inside Docker
4666
- * --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
4938
+ * Docker command implementation: one Dockerfile per deployable app, each
4939
+ * building its image inside Docker from a turbo-pruned slice of the build
4940
+ * root — nothing is built on the host — plus the build root's
4941
+ * `.dockerignore` and `docker-compose.constructs.yml`.
4667
4942
  */
4668
4943
  async function dockerCommand(options) {
4669
4944
  const cwd = options.cwd ?? process.cwd();
4670
- const loadedConfig = await loadWorkspaceConfig(cwd);
4671
- if (loadedConfig.type === "workspace") {
4672
- logger.log("📦 Detected workspace configuration");
4673
- return workspaceDockerCommand(loadedConfig.workspace, loadedConfig.manifest);
4674
- }
4675
- const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
4676
- const healthCheckPath = "/health";
4677
- const useSlim = options.slim === true;
4678
- if (useSlim) {
4679
- const distDir = join(cwd, ".gkm", "server", "dist");
4680
- if (!existsSync(join(distDir, "server.mjs"))) throw new Error("Slim Dockerfile requires a pre-built bundle. Run `gkm build --provider server --production` first, or omit --slim to use multi-stage build.");
4681
- }
4682
- const dockerDir = join(cwd, ".gkm", "docker");
4683
- await mkdir(dockerDir, { recursive: true });
4684
- const packageManager = detectPackageManager(cwd);
4685
- const inMonorepo = isMonorepo(cwd);
4686
- const hasTurbo = hasTurboConfig(cwd);
4687
- let useTurbo = options.turbo ?? false;
4688
- if (inMonorepo && !useSlim) {
4689
- if (hasTurbo) {
4690
- useTurbo = true;
4691
- logger.log(" Detected monorepo with turbo.json - using turbo prune");
4692
- } else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
4693
- }
4694
- let turboPackage = options.turboPackage ?? dockerConfig.imageName;
4695
- if (useTurbo && !options.turboPackage) try {
4696
- const pkg = __require(`${cwd}/package.json`);
4697
- if (pkg.name) {
4698
- turboPackage = pkg.name;
4699
- logger.log(` Turbo package: ${turboPackage}`);
4700
- }
4701
- } catch {}
4702
- const templateOptions = {
4703
- imageName: dockerConfig.imageName,
4704
- baseImage: dockerConfig.baseImage,
4705
- port: dockerConfig.port,
4706
- healthCheckPath,
4707
- prebuilt: useSlim,
4708
- turbo: useTurbo,
4709
- turboPackage,
4710
- packageManager
4711
- };
4712
- const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
4713
- const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
4714
- const dockerfilePath = join(dockerDir, "Dockerfile");
4715
- await writeFile(dockerfilePath, dockerfile);
4716
- logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
4717
- const composePath = await writeConstructsCompose(loadedConfig.workspace);
4718
- const dockerignore = generateDockerignore();
4719
- const dockerignorePath = join(cwd, ".dockerignore");
4720
- await writeFile(dockerignorePath, dockerignore);
4721
- logger.log("Generated: .dockerignore (project root)");
4722
- const entrypoint = generateDockerEntrypoint();
4723
- const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
4724
- await writeFile(entrypointPath, entrypoint);
4725
- logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
4726
- const result = {
4727
- dockerfile: dockerfilePath,
4728
- dockerCompose: composePath,
4729
- dockerignore: dockerignorePath,
4730
- entrypoint: entrypointPath
4731
- };
4732
- if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
4733
- if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
4945
+ const loaded = await loadWorkspaceConfig(cwd);
4946
+ const names = loaded.type === "single" ? Object.fromEntries(Object.keys(loaded.workspace.apps).map((name) => [name, resolveDockerConfig(loaded.raw).imageName])) : {};
4947
+ const result = await workspaceDockerCommand(loaded.workspace, loaded.manifest, names);
4948
+ if (options.build) for (const app of result.apps) await buildDockerImage(app, result.buildRoot, options);
4949
+ if (options.push) for (const app of result.apps) await pushDockerImage(app.imageName, options, result.buildRoot);
4734
4950
  return result;
4735
4951
  }
4736
4952
  /**
4737
- * Ensure lockfile exists in the build context
4738
- * For monorepos, copies from workspace root if needed
4739
- * Returns cleanup function if file was copied
4740
- */
4741
- function ensureLockfile(cwd) {
4742
- const lockfilePath = findLockfilePath(cwd);
4743
- if (!lockfilePath) {
4744
- logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
4745
- return null;
4746
- }
4747
- const lockfileName = basename(lockfilePath);
4748
- const localLockfile = join(cwd, lockfileName);
4749
- if (lockfilePath === localLockfile) return null;
4750
- logger.log(` Copying ${lockfileName} from monorepo root...`);
4751
- copyFileSync(lockfilePath, localLockfile);
4752
- return () => {
4753
- try {
4754
- unlinkSync(localLockfile);
4755
- } catch {}
4756
- };
4757
- }
4758
- /**
4759
- * Build Docker image
4953
+ * Build one app's image, from the build root.
4760
4954
  * Uses BuildKit for cache mount support
4761
4955
  */
4762
- async function buildDockerImage(imageName, options, cwd) {
4956
+ async function buildDockerImage(app, buildRoot, options) {
4763
4957
  const tag = options.tag ?? "latest";
4764
4958
  const registry = options.registry;
4765
- const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
4766
- logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
4767
- const cleanup = ensureLockfile(cwd);
4959
+ const fullImageName = validateImageRef(registry ? `${registry}/${app.imageName}:${tag}` : `${app.imageName}:${tag}`);
4960
+ logger$1.log(`\n🐳 Building Docker image: ${fullImageName}`);
4768
4961
  try {
4769
4962
  await run$1("docker", [
4770
4963
  "build",
4771
- "--file=.gkm/docker/Dockerfile",
4964
+ `--file=${relative(buildRoot, app.dockerfile)}`,
4772
4965
  `--tag=${fullImageName}`,
4773
4966
  "."
4774
4967
  ], {
4775
- cwd,
4968
+ cwd: buildRoot,
4776
4969
  env: {
4777
4970
  ...process.env,
4778
4971
  DOCKER_BUILDKIT: "1"
4779
4972
  }
4780
4973
  });
4781
- logger.log(`✅ Docker image built: ${fullImageName}`);
4974
+ logger$1.log(`✅ Docker image built: ${fullImageName}`);
4782
4975
  } catch (error) {
4783
4976
  throw new DockerBuildFailed(fullImageName, error);
4784
- } finally {
4785
- cleanup?.();
4786
4977
  }
4787
4978
  }
4788
4979
  /**
@@ -4793,103 +4984,88 @@ async function pushDockerImage(imageName, options, cwd) {
4793
4984
  const registry = options.registry;
4794
4985
  if (!registry) throw new PushNeedsRegistry(imageName);
4795
4986
  const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
4796
- logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
4987
+ logger$1.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
4797
4988
  try {
4798
4989
  await run$1("docker", ["push", fullImageName], { cwd });
4799
- logger.log(`✅ Docker image pushed: ${fullImageName}`);
4990
+ logger$1.log(`✅ Docker image pushed: ${fullImageName}`);
4800
4991
  } catch (error) {
4801
4992
  throw new DockerPushFailed(fullImageName, error);
4802
4993
  }
4803
4994
  }
4804
4995
  /**
4805
- * Get the package name from package.json in an app directory.
4806
- */
4807
- function getAppPackageName(appPath) {
4808
- try {
4809
- const pkgPath = join(appPath, "package.json");
4810
- if (!existsSync(pkgPath)) return;
4811
- const content = readFileSync(pkgPath, "utf-8");
4812
- return JSON.parse(content).name;
4813
- } catch {
4814
- return;
4815
- }
4816
- }
4817
- /**
4818
4996
  * Generate Dockerfiles for all apps in a workspace.
4819
4997
  * @internal Exported for testing
4820
4998
  */
4821
- async function workspaceDockerCommand(workspace, manifest) {
4999
+ async function workspaceDockerCommand(workspace, manifest, imageNames = {}) {
4822
5000
  const results = [];
4823
- const apps = Object.entries(workspace.apps);
4824
- logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
4825
- const dockerDir = join(workspace.root, ".gkm", "docker");
4826
- await mkdir(dockerDir, { recursive: true });
4827
- const packageManager = detectPackageManager(workspace.root);
4828
- logger.log(` Package manager: ${packageManager}`);
4829
- for (const [appName, app] of apps) {
5001
+ const layout = imageLayout(workspace);
5002
+ logger$1.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
5003
+ logger$1.log(` Build root: ${layout.buildRoot}`);
5004
+ logger$1.log(` Package manager: ${layout.tools.packageManager}${layout.tools.packageManagerVersion ? `@${layout.tools.packageManagerVersion}` : ""}, turbo@${layout.tools.turboVersion}`);
5005
+ for (const [appName, app] of Object.entries(workspace.apps)) {
4830
5006
  if (app.type === "mobile") {
4831
- logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
5007
+ logger$1.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
4832
5008
  continue;
4833
5009
  }
4834
- const appPath = app.path;
4835
- const turboPackage = getAppPackageName(join(workspace.root, appPath)) ?? appName;
4836
- const imageName = appName;
4837
- const buildType = !!app.entry ? "entry" : app.type;
4838
- logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
4839
- let dockerfile;
4840
- if (app.type === "web") dockerfile = siteDockerfile(appName, app, {
4841
- turboPackage,
4842
- packageManager,
5010
+ const buildType = app.entry ? "entry" : app.type;
5011
+ logger$1.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
5012
+ const dockerfile = appDockerfile(appName, app, {
5013
+ layout,
5014
+ workspaceRoot: workspace.root,
4843
5015
  ...manifest ? { manifest } : {}
4844
5016
  });
4845
- else if (app.entry) dockerfile = generateEntryDockerfile({
4846
- imageName,
4847
- baseImage: "node:22-alpine",
4848
- port: app.port,
4849
- appPath,
4850
- entry: app.entry,
4851
- turboPackage,
4852
- packageManager,
4853
- healthCheckPath: "/health"
4854
- });
4855
- else dockerfile = generateBackendDockerfile({
4856
- imageName,
4857
- baseImage: "node:22-alpine",
4858
- port: app.port,
4859
- appPath,
4860
- turboPackage,
4861
- packageManager,
4862
- healthCheckPath: "/health"
4863
- });
4864
- const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
5017
+ const path = dockerfileOf(appName, app.path);
5018
+ const dockerfilePath = join(workspace.root, path);
5019
+ await mkdir(dirname(dockerfilePath), { recursive: true });
4865
5020
  await writeFile(dockerfilePath, dockerfile);
4866
- logger.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
5021
+ logger$1.log(` Generated: ${path}`);
4867
5022
  results.push({
4868
5023
  appName,
4869
5024
  type: app.type,
4870
5025
  dockerfile: dockerfilePath,
4871
- imageName
5026
+ imageName: imageNames[appName] ?? appName
4872
5027
  });
4873
5028
  }
4874
- const dockerignore = generateDockerignore();
4875
- const dockerignorePath = join(workspace.root, ".dockerignore");
4876
- await writeFile(dockerignorePath, dockerignore);
4877
- logger.log(`\n Generated: .dockerignore (workspace root)`);
5029
+ const dockerignorePath = await ensureDockerignore(layout.buildRoot);
5030
+ logger$1.log(`\n Ensured: ${dockerignorePath}`);
4878
5031
  const composePath = await writeConstructsCompose(workspace);
4879
- logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
4880
- logger.log("\n📋 Build commands:");
5032
+ logger$1.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
5033
+ logger$1.log(`\n📋 Build commands (from ${layout.buildRoot}):`);
4881
5034
  for (const result of results) {
4882
5035
  const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
4883
- logger.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
5036
+ logger$1.log(` ${icon} docker build -f ${relative(layout.buildRoot, result.dockerfile)} -t ${result.imageName} .`);
4884
5037
  }
4885
5038
  printRunInstructions(workspace);
4886
5039
  return {
4887
5040
  apps: results,
4888
5041
  dockerCompose: composePath,
4889
- dockerignore: dockerignorePath
5042
+ dockerignore: dockerignorePath,
5043
+ buildRoot: layout.buildRoot
4890
5044
  };
4891
5045
  }
4892
5046
  /**
5047
+ * One app's Dockerfile, by what it is: a gkm backend bundled by `gkm build`,
5048
+ * a backend with its own entry bundled by esbuild, or a site by its
5049
+ * framework. The one choice every builder of images makes.
5050
+ */
5051
+ function appDockerfile(appName, app, options) {
5052
+ const image = appImageOptions(options.layout, appName, app, options.workspaceRoot);
5053
+ if (app.type === "web") return siteDockerfile(appName, app, {
5054
+ image,
5055
+ ...options.manifest ? { manifest: options.manifest } : {},
5056
+ ...options.publicUrlArgs ? { publicUrlArgs: options.publicUrlArgs } : {}
5057
+ });
5058
+ if (app.entry) return generateEntryDockerfile({
5059
+ ...image,
5060
+ entry: app.entry,
5061
+ healthCheckPath: "/health"
5062
+ });
5063
+ return generateBackendDockerfile({
5064
+ ...image,
5065
+ healthCheckPath: "/health"
5066
+ });
5067
+ }
5068
+ /**
4893
5069
  * A site's Dockerfile, with an `ARG` for every key its bundler inlines.
4894
5070
  *
4895
5071
  * The keys come from the site's declaration where there is one — every
@@ -4899,14 +5075,9 @@ async function workspaceDockerCommand(workspace, manifest) {
4899
5075
  */
4900
5076
  function siteDockerfile(appName, app, options) {
4901
5077
  const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
4902
- const publicUrlArgs = declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app);
5078
+ const publicUrlArgs = options.publicUrlArgs ?? (declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app));
4903
5079
  const webOpts = {
4904
- imageName: appName,
4905
- baseImage: "node:22-alpine",
4906
- port: app.port,
4907
- appPath: app.path,
4908
- turboPackage: options.turboPackage,
4909
- packageManager: options.packageManager,
5080
+ ...options.image,
4910
5081
  publicUrlArgs
4911
5082
  };
4912
5083
  switch (app.framework) {
@@ -4917,10 +5088,37 @@ function siteDockerfile(appName, app, options) {
4917
5088
  }
4918
5089
  }
4919
5090
  /**
4920
- * The package name turbo prunes an app by: its package.json's, or its name.
5091
+ * What a build root's `.dockerignore` must leave out of every image's
5092
+ * context: dependencies installed for another platform, anything built on the
5093
+ * host (every image builds from source), git, and gkm's own output —
5094
+ * `.gkm/compose`'s env files hold a stage's secrets.
4921
5095
  */
4922
- function appPackageName(root, appName, path) {
4923
- return getAppPackageName(join(root, path)) ?? appName;
5096
+ const REQUIRED_IGNORES = [
5097
+ "**/node_modules",
5098
+ ".git",
5099
+ "**/dist",
5100
+ "**/.next",
5101
+ "**/.turbo",
5102
+ "**/.gkm",
5103
+ "**/.gkm/compose"
5104
+ ];
5105
+ /**
5106
+ * Make sure the build root's `.dockerignore` leaves out what no image may
5107
+ * hold: written whole where there is none, and each missing line appended to
5108
+ * one the project keeps.
5109
+ */
5110
+ async function ensureDockerignore(buildRoot) {
5111
+ const path = join(buildRoot, ".dockerignore");
5112
+ if (!existsSync(path)) {
5113
+ await writeFile(path, generateDockerignore());
5114
+ return path;
5115
+ }
5116
+ const current = await readFile(path, "utf-8");
5117
+ const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()));
5118
+ const missing = REQUIRED_IGNORES.filter((line) => !lines.has(line));
5119
+ if (missing.length === 0) return path;
5120
+ await appendFile(path, `${current.endsWith("\n") || current === "" ? "" : "\n"}\n# gkm: images build from source, and a stack's env files hold its secrets\n${missing.join("\n")}\n`);
5121
+ return path;
4924
5122
  }
4925
5123
  /**
4926
5124
  * Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
@@ -4934,14 +5132,199 @@ async function writeConstructsCompose(workspace) {
4934
5132
  stage: workspace.stages.local,
4935
5133
  start: false
4936
5134
  });
4937
- logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
5135
+ logger$1.log(` Generated: ${COMPOSE_PATH} (project root)`);
4938
5136
  return join(workspace.root, COMPOSE_PATH);
4939
5137
  }
4940
5138
  function printRunInstructions(workspace) {
4941
5139
  const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
4942
- logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
4943
- logger.log(" gkm setup # starts the containers and creates what they hold");
4944
- logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
5140
+ logger$1.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
5141
+ logger$1.log(" gkm setup # starts the containers and creates what they hold");
5142
+ logger$1.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
5143
+ }
5144
+ //#endregion
5145
+ //#region src/deploy/docker.ts
5146
+ const logger = output;
5147
+ /**
5148
+ * The id the Dockerfile templates mount the encrypted credentials under:
5149
+ * `RUN --mount=type=secret,id=gkm_credentials`.
5150
+ */
5151
+ const CREDENTIALS_SECRET_ID = "gkm_credentials";
5152
+ /**
5153
+ * Get the full image reference
5154
+ */
5155
+ function getImageRef(registry, imageName, tag) {
5156
+ if (registry) return `${registry}/${imageName}:${tag}`;
5157
+ return `${imageName}:${tag}`;
5158
+ }
5159
+ /**
5160
+ * The `docker build` argv for an image.
5161
+ *
5162
+ * One element per value, so nothing in a ref or a build arg is read by a
5163
+ * shell, and `--opt=value` rather than `--opt value`, so a value that starts
5164
+ * with `-` cannot be taken for one of docker's own flags.
5165
+ *
5166
+ * @internal Exported for testing
5167
+ */
5168
+ function dockerBuildArgs(options) {
5169
+ const { dockerfilePath, imageRef, buildArgs = [], credentialsFile } = options;
5170
+ return [
5171
+ "build",
5172
+ "--platform=linux/amd64",
5173
+ `--file=${dockerfilePath}`,
5174
+ `--tag=${imageRef}`,
5175
+ ...buildArgs.map((arg) => `--build-arg=${arg}`),
5176
+ ...credentialsFile ? [`--secret=id=${CREDENTIALS_SECRET_ID},src=${credentialsFile}`] : [],
5177
+ "."
5178
+ ];
5179
+ }
5180
+ /**
5181
+ * The build arg that names which credentials an image embeds: a hash of the
5182
+ * ciphertext, which is not a secret. A BuildKit secret is no part of the
5183
+ * build cache's key, so without it a rebuild with credentials encrypted under
5184
+ * a new key would reuse the layer that embedded the old ones.
5185
+ */
5186
+ function credentialsBuildArg(credentials) {
5187
+ return `GKM_CIPHERTEXT_HASH=${createHash("sha256").update(`${credentials.encrypted}\n${credentials.iv}`).digest("hex").slice(0, 16)}`;
5188
+ }
5189
+ /** The secret file's content: ciphertext, then IV, one per line. */
5190
+ function credentialsFileContent(credentials) {
5191
+ return `${credentials.encrypted}\n${credentials.iv}\n`;
5192
+ }
5193
+ /**
5194
+ * Write the encrypted credentials where `docker build --secret` reads them.
5195
+ *
5196
+ * Two lines — ciphertext, then IV — so a template can split them with `sed`
5197
+ * in any base image. Owner-only, in a directory of its own, and the returned
5198
+ * cleanup removes both whatever the build did.
5199
+ *
5200
+ * @internal Exported for testing
5201
+ */
5202
+ async function writeCredentialsFile(credentials) {
5203
+ const dir = await mkdtemp(join(tmpdir(), "gkm-credentials-"));
5204
+ const path = join(dir, CREDENTIALS_SECRET_ID);
5205
+ await writeFile(path, credentialsFileContent(credentials), { mode: 384 });
5206
+ await chmod(path, 384);
5207
+ return {
5208
+ path,
5209
+ cleanup: () => rm(dir, {
5210
+ recursive: true,
5211
+ force: true
5212
+ })
5213
+ };
5214
+ }
5215
+ /**
5216
+ * Build Docker image
5217
+ * @param imageRef - Full image reference (registry/name:tag)
5218
+ * @param buildArgs - Build arguments to pass to docker build
5219
+ * @param credentials - Encrypted credentials, passed as a build secret
5220
+ */
5221
+ async function buildImage(imageRef, buildArgs, credentials, { cwd, dockerfile, signal, stdio }) {
5222
+ logger.log(`\n🔨 Building Docker image: ${imageRef}`);
5223
+ logger.log(" Generating Dockerfile...");
5224
+ const { buildRoot } = await dockerCommand({ cwd });
5225
+ const dockerfilePath = relative(buildRoot, dockerfile);
5226
+ const secret = credentials ? await writeCredentialsFile(credentials) : void 0;
5227
+ try {
5228
+ await run$1("docker", dockerBuildArgs({
5229
+ dockerfilePath,
5230
+ imageRef,
5231
+ buildArgs: [...buildArgs ?? [], ...credentials ? [credentialsBuildArg(credentials)] : []],
5232
+ credentialsFile: secret?.path
5233
+ }), {
5234
+ cwd: buildRoot,
5235
+ env: {
5236
+ ...process.env,
5237
+ DOCKER_BUILDKIT: "1"
5238
+ },
5239
+ ...signal ? { signal } : {},
5240
+ ...stdio ? { stdio } : {}
5241
+ });
5242
+ logger.log(`✅ Image built: ${imageRef}`);
5243
+ } catch (error) {
5244
+ throw new DockerBuildFailed(imageRef, error);
5245
+ } finally {
5246
+ await secret?.cleanup();
5247
+ }
5248
+ }
5249
+ /**
5250
+ * Push Docker image to registry
5251
+ */
5252
+ async function pushImage(imageRef, { cwd, signal, stdio }) {
5253
+ logger.log(`\n☁️ Pushing image: ${imageRef}`);
5254
+ try {
5255
+ await run$1("docker", ["push", imageRef], {
5256
+ cwd,
5257
+ ...signal ? { signal } : {},
5258
+ ...stdio ? { stdio } : {}
5259
+ });
5260
+ logger.log(`✅ Image pushed: ${imageRef}`);
5261
+ } catch (error) {
5262
+ throw new DockerPushFailed(imageRef, error);
5263
+ }
5264
+ }
5265
+ /**
5266
+ * The digest the registry gave a pushed image, `sha256:…`.
5267
+ *
5268
+ * A tag can be pushed over — by the next deploy, or by anything else that
5269
+ * shares the repository — and the digest is what says which image a stage
5270
+ * actually ran. `RepoDigests` has one entry per repository the image was
5271
+ * pushed to, so the one for this ref's repository is picked by name.
5272
+ *
5273
+ * @internal Exported for testing
5274
+ */
5275
+ async function pushedDigest(imageRef, { cwd, signal } = { cwd: process.cwd() }) {
5276
+ const repository = imageRef.split("@")[0].replace(/:[\w][\w.-]*$/, "");
5277
+ const output = await runOutput("docker", [
5278
+ "image",
5279
+ "inspect",
5280
+ "--format={{json .RepoDigests}}",
5281
+ imageRef
5282
+ ], {
5283
+ cwd,
5284
+ ...signal ? { signal } : {}
5285
+ });
5286
+ let digests;
5287
+ try {
5288
+ digests = JSON.parse(output.trim() || "[]");
5289
+ } catch {
5290
+ return;
5291
+ }
5292
+ if (!Array.isArray(digests)) return void 0;
5293
+ return digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`))?.slice(repository.length + 1);
5294
+ }
5295
+ /**
5296
+ * Deploy using Docker (build and optionally push image)
5297
+ */
5298
+ async function deployDocker(options) {
5299
+ const { stage, tag, skipPush, masterKey, config, buildArgs, credentials } = options;
5300
+ const imageName = config.imageName;
5301
+ const imageRef = validateImageRef(getImageRef(config.registry, imageName, tag));
5302
+ const cwd = options.cwd ?? process.cwd();
5303
+ const docker = {
5304
+ cwd,
5305
+ dockerfile: join(cwd, dockerfileOf(config.appName ?? imageName, options.appPath ?? ".")),
5306
+ ...options.signal ? { signal: options.signal } : {},
5307
+ ...options.stdio ? { stdio: options.stdio } : {}
5308
+ };
5309
+ await buildImage(imageRef, buildArgs, credentials, docker);
5310
+ let digest;
5311
+ if (!skipPush) {
5312
+ if (!config.registry) logger.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
5313
+ else {
5314
+ await pushImage(imageRef, docker);
5315
+ digest = await pushedDigest(imageRef, docker);
5316
+ }
5317
+ }
5318
+ logger.log("\n✅ Docker deployment ready!");
5319
+ logger.log(`\n📋 Deployment details:`);
5320
+ logger.log(` Image: ${imageRef}`);
5321
+ logger.log(` Stage: ${stage}`);
5322
+ if (masterKey) logger.log(`\n🔐 Run the container with GKM_MASTER_KEY set to the key with fingerprint ${keyFingerprint(masterKey)}.`);
5323
+ return {
5324
+ imageRef,
5325
+ ...digest ? { digest } : {},
5326
+ masterKey
5327
+ };
4945
5328
  }
4946
5329
  //#endregion
4947
5330
  //#region src/deploy/generated.ts
@@ -5245,6 +5628,6 @@ function setPostgresBackupId(state, backupId) {
5245
5628
  if (state.backups) state.backups.postgresBackupId = backupId;
5246
5629
  }
5247
5630
  //#endregion
5248
- export { describeServices as $, reconcileWorkspace as A, withCustomSecret as At, appEnvKeys as B, validateImageRef as C, markerOf as Ct, backendsOf as D, maskPassword as Dt, FAKE_ENV as E, initStageSecrets as Et, postgresDatabaseNames as F, planFor as G, networkEnv as H, postgresStatements as I, cacheBackendFor as J, resourceName as K, quoteIdentifier as L, LOCAL_CA_PATH as M, forgetState as N, constructGlobs as O, storage_exports as Ot, applyPostgres as P, DEFAULT_IMAGES as Q, composeFor as R, generateSlimDockerfile as S, isOwnedBy as St, resolveHost as T, projectName as Tt, envFor as U, dockerfileOf as V, localRolePassword as W, providerOf as X, eventsBackendFor as Y, storageBackendFor as Z, appPackageName as _, readKey as _t, recordRelease as a, findAvailablePort as at, detectPackageManager as b, imageName as bt, setBackupState as c, loadSecretsForApp as ct, setGeneratedSecret as d, secretsStoreFor as dt, fakeKey as et, setPostgresBackupId as f, store_exports as ft, DockerPushFailed as g, projectKey as gt, DockerBuildFailed as h, keystoreProject as ht, isDnsVerified as i, createEntryWrapper as it, workspace_exports as j, derivedContainers as k, toEmbeddableSecrets as kt, setDnsRecord as l, prepareEntryCredentials as lt, withGeneratedSecrets as m, getKeyPath as mt, getBackupState as n, primaryPortKey as nt, recordRollback as o, isPortAvailable as ot, DeployJournal as p, FileSecretsStore as pt, hostFor as q, getGeneratedSecret as r, createCredentialsPreload as rt, setApplicationId as s, loadEnvFiles as st, createEmptyState as t, portKeys as tt, setDnsVerification as u, isRemoteStore as ut, dockerCommand as v, applicationName as vt, isMainFrontendApp as w, ownershipMarker as wt, generateDockerignore as x, imageRef as xt, siteDockerfile as y, deployIdentity as yt, pgClient as z };
5631
+ export { quoteIdentifier as $, assertExternalServices as A, prepareEntryCredentials as At, backendsOf as B, deployIdentity as Bt, isMainFrontendApp as C, primaryPortKey as Ct, ExternalServicesNotConfigured as D, isPortAvailable as Dt, DevServicesNeedServerTarget as E, findAvailablePort as Et, reportDevServices as F, getKeyPath as Ft, LOCAL_CA_PATH as G, ownershipMarker as Gt, derivedContainers as H, imageRef as Ht, encryptSecrets as I, keystoreProject as It, applyPolicies as J, maskPassword as Jt, forgetState as K, projectName as Kt, encryption_exports as L, projectKey as Lt, externalServices as M, secretsStoreFor as Mt, manifestServiceDeclarations as N, store_exports as Nt, STORAGE_KEY_PAIR as O, loadEnvFiles as Ot, parseDevServices as P, FileSecretsStore as Pt, postgresStatements as Q, keyFingerprint as R, readKey as Rt, NoDomainForStage as S, portKeys as St, DEV_SERVICES as T, createEntryWrapper as Tt, reconcileWorkspace as U, isOwnedBy as Ut, constructGlobs as V, imageName as Vt, workspace_exports as W, markerOf as Wt, bucketPolicies as X, toEmbeddableSecrets as Xt, applyPostgres as Y, storage_exports as Yt, postgresDatabaseNames as Z, withCustomSecret as Zt, deployDocker as _, providerOf as _t, recordRelease as a, envFor as at, ensureDockerignore as b, describeServices as bt, setBackupState as c, resourceName as ct, setGeneratedSecret as d, imageLayout as dt, composeFor as et, setPostgresBackupId as f, TURBO_VERSION as ft, credentialsFileContent as g, eventsBackendFor as gt, credentialsBuildArg as h, cacheBackendFor as ht, isDnsVerified as i, networkEnv as it, devServicesUsed as j, isRemoteStore as jt, UnknownDevService as k, loadSecretsForApp as kt, setDnsRecord as l, hostFor as lt, withGeneratedSecrets as m, findBuildRoot as mt, getBackupState as n, pgClient as nt, recordRollback as o, localRolePassword as ot, DeployJournal as p, detectPackageManager as pt, applyBuckets as q, initStageSecrets as qt, getGeneratedSecret as r, appEnvKeys as rt, setApplicationId as s, planFor as st, createEmptyState as t, bucketClient as tt, setDnsVerification as u, composeBuildPaths as ut, appDockerfile as v, storageBackendFor as vt, resolveHost as w, createCredentialsPreload as wt, validateImageRef as x, fakeKey as xt, dockerCommand as y, DEFAULT_IMAGES as yt, FAKE_ENV as z, applicationName as zt };
5249
5632
 
5250
- //# sourceMappingURL=state-uuYqnRVO.mjs.map
5633
+ //# sourceMappingURL=state-Bn5JDMFT.mjs.map