@geekmidas/cli 10.0.0-alpha.59 → 10.0.0-alpha.60

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 (150) hide show
  1. package/README.md +18 -1
  2. package/dist/{HostingerProvider-BZFGYYVA.cjs → HostingerProvider-BTZd2u0f.cjs} +3 -3
  3. package/dist/{HostingerProvider-BZFGYYVA.cjs.map → HostingerProvider-BTZd2u0f.cjs.map} +1 -1
  4. package/dist/{HostingerProvider-YQY3rMpV.mjs → HostingerProvider-DgzKa_Tj.mjs} +2 -2
  5. package/dist/{HostingerProvider-YQY3rMpV.mjs.map → HostingerProvider-DgzKa_Tj.mjs.map} +1 -1
  6. package/dist/{LegacyStateStore-CB4ch3Dl.mjs → LegacyStateStore-CToa2hga.mjs} +2 -2
  7. package/dist/{LegacyStateStore-CB4ch3Dl.mjs.map → LegacyStateStore-CToa2hga.mjs.map} +1 -1
  8. package/dist/{LegacyStateStore-CQJqvmXw.cjs → LegacyStateStore-CidlPT0N.cjs} +7 -7
  9. package/dist/{LegacyStateStore-CQJqvmXw.cjs.map → LegacyStateStore-CidlPT0N.cjs.map} +1 -1
  10. package/dist/LocalStateStore-BFLiOocU.mjs +165 -0
  11. package/dist/LocalStateStore-BFLiOocU.mjs.map +1 -0
  12. package/dist/LocalStateStore-Dpuqka1N.cjs +176 -0
  13. package/dist/LocalStateStore-Dpuqka1N.cjs.map +1 -0
  14. package/dist/{OpenApiTsGenerator-BCWPRZwD.cjs → OpenApiTsGenerator-BTuUumsV.cjs} +3 -3
  15. package/dist/{OpenApiTsGenerator-BCWPRZwD.cjs.map → OpenApiTsGenerator-BTuUumsV.cjs.map} +1 -1
  16. package/dist/{OpenApiTsGenerator-BRWD1Wit.mjs → OpenApiTsGenerator-CvVr1Ilz.mjs} +3 -3
  17. package/dist/{OpenApiTsGenerator-BRWD1Wit.mjs.map → OpenApiTsGenerator-CvVr1Ilz.mjs.map} +1 -1
  18. package/dist/{S3StateStore-CXTenDTW.mjs → S3StateStore-aaGkjcxO.mjs} +2 -2
  19. package/dist/{S3StateStore-CXTenDTW.mjs.map → S3StateStore-aaGkjcxO.mjs.map} +1 -1
  20. package/dist/{S3StateStore-DLS4rEc0.cjs → S3StateStore-cnm813Xn.cjs} +5 -5
  21. package/dist/{S3StateStore-DLS4rEc0.cjs.map → S3StateStore-cnm813Xn.cjs.map} +1 -1
  22. package/dist/{SSMStateStore-hnP5deFJ.mjs → SSMStateStore-BllaJKGo.mjs} +2 -2
  23. package/dist/{SSMStateStore-hnP5deFJ.mjs.map → SSMStateStore-BllaJKGo.mjs.map} +1 -1
  24. package/dist/{SSMStateStore-EqsTBIOs.cjs → SSMStateStore-DwaGprVX.cjs} +9 -9
  25. package/dist/{SSMStateStore-EqsTBIOs.cjs.map → SSMStateStore-DwaGprVX.cjs.map} +1 -1
  26. package/dist/appPorts-C9z9Pzn9.mjs +223 -0
  27. package/dist/appPorts-C9z9Pzn9.mjs.map +1 -0
  28. package/dist/appPorts-Cu79hJF6.cjs +264 -0
  29. package/dist/appPorts-Cu79hJF6.cjs.map +1 -0
  30. package/dist/{bundler-By6_sXiL.cjs → bundler-CzXZPSBJ.cjs} +4 -4
  31. package/dist/{bundler-By6_sXiL.cjs.map → bundler-CzXZPSBJ.cjs.map} +1 -1
  32. package/dist/{bundler-ouDdNvk9.mjs → bundler-DJVR4QlW.mjs} +4 -4
  33. package/dist/{bundler-ouDdNvk9.mjs.map → bundler-DJVR4QlW.mjs.map} +1 -1
  34. package/dist/cli-D_qTl20Z.cjs +69 -0
  35. package/dist/cli-D_qTl20Z.cjs.map +1 -0
  36. package/dist/cli-DzR1hpum.mjs +69 -0
  37. package/dist/cli-DzR1hpum.mjs.map +1 -0
  38. package/dist/{config-EuzFEkcH.mjs → config-C5-EEdmr.mjs} +4 -4
  39. package/dist/config-C5-EEdmr.mjs.map +1 -0
  40. package/dist/{config-WMOB9bLL.cjs → config-DXnkCEq0.cjs} +4 -4
  41. package/dist/config-DXnkCEq0.cjs.map +1 -0
  42. package/dist/config.cjs +1 -1
  43. package/dist/config.d.cts +2 -2
  44. package/dist/config.d.cts.map +1 -1
  45. package/dist/config.d.mts +2 -2
  46. package/dist/config.d.mts.map +1 -1
  47. package/dist/config.mjs +1 -1
  48. package/dist/deploy/public.cjs +22 -0
  49. package/dist/deploy/public.d.cts +459 -0
  50. package/dist/deploy/public.d.cts.map +1 -0
  51. package/dist/deploy/public.d.mts +459 -0
  52. package/dist/deploy/public.d.mts.map +1 -0
  53. package/dist/deploy/public.mjs +5 -0
  54. package/dist/deploy/sniffer-hooks.cjs +1 -1
  55. package/dist/deploy/sniffer-hooks.mjs +1 -1
  56. package/dist/deploy/sniffer-loader.cjs +1 -1
  57. package/dist/deploy/sniffer-loader.mjs +1 -1
  58. package/dist/deploy-0fBVeWO-.cjs +3518 -0
  59. package/dist/deploy-0fBVeWO-.cjs.map +1 -0
  60. package/dist/deploy-D6Q09OkA.mjs +3429 -0
  61. package/dist/deploy-D6Q09OkA.mjs.map +1 -0
  62. package/dist/{discover-DXNviAh-.cjs → discover-Bc-4eA_p.cjs} +55 -3
  63. package/dist/discover-Bc-4eA_p.cjs.map +1 -0
  64. package/dist/{discover-Cf2jAcgF.mjs → discover-FrYvzkeC.mjs} +44 -4
  65. package/dist/discover-FrYvzkeC.mjs.map +1 -0
  66. package/dist/{encryption-BP8zfxEJ.cjs → encryption-CML4UaPi.cjs} +5 -5
  67. package/dist/{encryption-BP8zfxEJ.cjs.map → encryption-CML4UaPi.cjs.map} +1 -1
  68. package/dist/{encryption-BW7Gh51G.mjs → encryption-aDA2AzVx.mjs} +4 -4
  69. package/dist/{encryption-BW7Gh51G.mjs.map → encryption-aDA2AzVx.mjs.map} +1 -1
  70. package/dist/fakes-C9RiT6XG.mjs +125 -0
  71. package/dist/fakes-C9RiT6XG.mjs.map +1 -0
  72. package/dist/fakes-zW-jNAbQ.cjs +148 -0
  73. package/dist/fakes-zW-jNAbQ.cjs.map +1 -0
  74. package/dist/{github-DHpDMBT7.cjs → github-Cq57poPj.cjs} +7 -7
  75. package/dist/github-Cq57poPj.cjs.map +1 -0
  76. package/dist/{github-T_6sK3Rz.mjs → github-DmCj2u95.mjs} +7 -7
  77. package/dist/github-DmCj2u95.mjs.map +1 -0
  78. package/dist/home-DOzBaWaL.cjs +33 -0
  79. package/dist/home-DOzBaWaL.cjs.map +1 -0
  80. package/dist/home-GI7pJl1M.mjs +22 -0
  81. package/dist/home-GI7pJl1M.mjs.map +1 -0
  82. package/dist/{index-DV6j4Ydq.d.cts → index-Da1-oa9W.d.mts} +2 -2
  83. package/dist/{index-B5TJ-mB6.d.mts.map → index-Da1-oa9W.d.mts.map} +1 -1
  84. package/dist/{index-B5TJ-mB6.d.mts → index-UiWCaGzU.d.cts} +2 -2
  85. package/dist/{index-DV6j4Ydq.d.cts.map → index-UiWCaGzU.d.cts.map} +1 -1
  86. package/dist/index.cjs +341 -6412
  87. package/dist/index.cjs.map +1 -1
  88. package/dist/index.mjs +289 -6335
  89. package/dist/index.mjs.map +1 -1
  90. package/dist/{migrate-CThJfVHR.cjs → migrate-BTqIflwc.cjs} +11 -11
  91. package/dist/{migrate-CThJfVHR.cjs.map → migrate-BTqIflwc.cjs.map} +1 -1
  92. package/dist/{migrate-BrFREmgQ.mjs → migrate-B_6KofUo.mjs} +6 -6
  93. package/dist/{migrate-BrFREmgQ.mjs.map → migrate-B_6KofUo.mjs.map} +1 -1
  94. package/dist/openapi.cjs +3 -3
  95. package/dist/openapi.d.cts +1 -1
  96. package/dist/openapi.d.mts +1 -1
  97. package/dist/openapi.mjs +3 -3
  98. package/dist/{ready-mR96orkJ.cjs → ready-CCLIAkwR.cjs} +7 -148
  99. package/dist/ready-CCLIAkwR.cjs.map +1 -0
  100. package/dist/{ready-DbDHneLR.mjs → ready-CIHpnUc5.mjs} +7 -124
  101. package/dist/ready-CIHpnUc5.mjs.map +1 -0
  102. package/dist/reconcile/public.cjs +1 -1
  103. package/dist/reconcile/public.d.cts +1 -1
  104. package/dist/reconcile/public.d.cts.map +1 -1
  105. package/dist/reconcile/public.d.mts +1 -1
  106. package/dist/reconcile/public.d.mts.map +1 -1
  107. package/dist/reconcile/public.mjs +1 -1
  108. package/dist/{workspace-BRZ14o-o.mjs → sniffer-BM8fpCas.mjs} +3317 -27
  109. package/dist/sniffer-BM8fpCas.mjs.map +1 -0
  110. package/dist/{workspace-Dbn07ILl.cjs → sniffer-DIv12ncU.cjs} +3616 -91
  111. package/dist/sniffer-DIv12ncU.cjs.map +1 -0
  112. package/dist/store-BS8A7NcA.mjs +415 -0
  113. package/dist/store-BS8A7NcA.mjs.map +1 -0
  114. package/dist/store-KvNQwYaB.cjs +510 -0
  115. package/dist/store-KvNQwYaB.cjs.map +1 -0
  116. package/dist/{types-CipIIm8f.d.mts → types-Bp1mVxG0.d.mts} +17 -4
  117. package/dist/types-Bp1mVxG0.d.mts.map +1 -0
  118. package/dist/{types-D9b-IYdW.d.cts → types-efNLu9xu.d.cts} +17 -4
  119. package/dist/types-efNLu9xu.d.cts.map +1 -0
  120. package/dist/vitest.cjs +2 -2
  121. package/dist/vitest.mjs +2 -2
  122. package/dist/workspace/index.d.cts +2 -2
  123. package/dist/workspace/index.d.mts +2 -2
  124. package/dist/workspace-B64Xk1Mb.cjs.map +1 -1
  125. package/dist/workspace-Bp1xVaOz.mjs.map +1 -1
  126. package/package.json +23 -13
  127. package/dist/StateStore-B4u0wx2t.cjs +0 -412
  128. package/dist/StateStore-B4u0wx2t.cjs.map +0 -1
  129. package/dist/StateStore-CYUfGYcx.mjs +0 -359
  130. package/dist/StateStore-CYUfGYcx.mjs.map +0 -1
  131. package/dist/backends-CjuCiE8h.mjs +0 -59
  132. package/dist/backends-CjuCiE8h.mjs.map +0 -1
  133. package/dist/backends-v3xDuhO0.cjs +0 -82
  134. package/dist/backends-v3xDuhO0.cjs.map +0 -1
  135. package/dist/config-EuzFEkcH.mjs.map +0 -1
  136. package/dist/config-WMOB9bLL.cjs.map +0 -1
  137. package/dist/discover-Cf2jAcgF.mjs.map +0 -1
  138. package/dist/discover-DXNviAh-.cjs.map +0 -1
  139. package/dist/github-DHpDMBT7.cjs.map +0 -1
  140. package/dist/github-T_6sK3Rz.mjs.map +0 -1
  141. package/dist/ready-DbDHneLR.mjs.map +0 -1
  142. package/dist/ready-mR96orkJ.cjs.map +0 -1
  143. package/dist/store-DQgxT_WR.cjs +0 -211
  144. package/dist/store-DQgxT_WR.cjs.map +0 -1
  145. package/dist/store-DeGATCS-.mjs +0 -182
  146. package/dist/store-DeGATCS-.mjs.map +0 -1
  147. package/dist/types-CipIIm8f.d.mts.map +0 -1
  148. package/dist/types-D9b-IYdW.d.cts.map +0 -1
  149. package/dist/workspace-BRZ14o-o.mjs.map +0 -1
  150. package/dist/workspace-Dbn07ILl.cjs.map +0 -1
@@ -1,20 +1,25 @@
1
- import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
- import { t as discover } from "./discover-Cf2jAcgF.mjs";
3
- import { c as loadWorkspaceAppInfo, f as appKey, l as loadWorkspaceConfig, r as NotInAnApp, t as ConfigNotFound } from "./config-EuzFEkcH.mjs";
4
- import { E as TEST_STAGE, t as allConstructGlobs } from "./workspace-Bp1xVaOz.mjs";
1
+ import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
+ import { n as gkmHome } from "./home-GI7pJl1M.mjs";
3
+ import { a as output, t as discover } from "./discover-FrYvzkeC.mjs";
4
+ import { c as loadWorkspaceAppInfo, f as appKey, l as loadWorkspaceConfig, r as NotInAnApp, s as loadConfig, t as ConfigNotFound } from "./config-C5-EEdmr.mjs";
5
+ import { E as TEST_STAGE, p as getPublicEnvPrefix, t as allConstructGlobs } from "./workspace-Bp1xVaOz.mjs";
5
6
  import { i as toEmbeddableSecrets } from "./storage-BzaUxmqe.mjs";
6
- import { i as FileSecretsStore, n as secretsStoreFor } from "./store-DeGATCS-.mjs";
7
- import { l as readFakes, u as DEFAULT_CACHE } from "./ready-DbDHneLR.mjs";
8
- import { n as eventsBackendFor, r as providerOf, t as cacheBackendFor } from "./backends-CjuCiE8h.mjs";
9
- import { existsSync } from "node:fs";
7
+ import { i as FileSecretsStore, n as secretsStoreFor } from "./store-BS8A7NcA.mjs";
8
+ import { i as DEFAULT_STORAGE, n as DEFAULT_CACHE, r as DEFAULT_EVENTS$1, t as readFakes } from "./fakes-C9RiT6XG.mjs";
9
+ import { createRequire } from "node:module";
10
+ import { basename, dirname, join, parse, resolve } from "node:path";
11
+ import { stdin, stdout } from "node:process";
12
+ import * as readline from "node:readline/promises";
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
10
14
  import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
11
- import { networkInterfaces } from "node:os";
12
- import { dirname, join, resolve } from "node:path";
15
+ import { hostname, networkInterfaces, userInfo } from "node:os";
16
+ import { promisify } from "node:util";
13
17
  import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase } from "@geekmidas/manifest";
14
18
  import { execFile, spawn } from "node:child_process";
15
19
  import { createServer } from "node:net";
16
20
  import { config } from "dotenv";
17
- import { createHash } from "node:crypto";
21
+ import { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
23
  import { formatKeyring } from "@geekmidas/constructs/encryption";
19
24
  import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
20
25
  import * as snsUrl from "@geekmidas/events/sns/url";
@@ -24,10 +29,316 @@ import { CreateTopicCommand, GetTopicAttributesCommand, SNSClient } from "@aws-s
24
29
  import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/client-sqs";
25
30
  import { Client } from "pg";
26
31
  import { stringify } from "yaml";
27
- import { promisify } from "node:util";
28
32
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
33
+ //#region src/prompt.ts
34
+ /**
35
+ * Asking the person at the terminal.
36
+ *
37
+ * CLI layer only: a command's action may prompt, nothing it calls may. A
38
+ * library call (`deploy()`) that needs something it was not given raises a
39
+ * named error instead, and the command decides whether to ask.
40
+ */
41
+ /** A prompt was needed with no terminal to ask at. */
42
+ var PromptNeedsTerminal = class extends Error {
43
+ question;
44
+ instead;
45
+ constructor(question, instead) {
46
+ super(`Interactive input required. ${instead}`);
47
+ this.question = question;
48
+ this.instead = instead;
49
+ this.name = "PromptNeedsTerminal";
50
+ }
51
+ };
52
+ /** Whether there is a person at a terminal to ask. */
53
+ function canPrompt() {
54
+ return process.stdin.isTTY === true;
55
+ }
56
+ /**
57
+ * Ask `message` and resolve with the line typed — without echoing it when
58
+ * `hidden`. Ctrl+C at a hidden prompt exits, as it would anywhere else in a
59
+ * terminal program: raw mode has taken the signal away from the shell.
60
+ */
61
+ async function prompt(message, options) {
62
+ if (!canPrompt()) throw new PromptNeedsTerminal(message, options.instead);
63
+ if (options.hidden) {
64
+ process.stdout.write(message);
65
+ return new Promise((resolve, reject) => {
66
+ let value = "";
67
+ const cleanup = () => {
68
+ process.stdin.setRawMode(false);
69
+ process.stdin.pause();
70
+ process.stdin.removeListener("data", onData);
71
+ process.stdin.removeListener("error", onError);
72
+ };
73
+ const onError = (err) => {
74
+ cleanup();
75
+ reject(err);
76
+ };
77
+ const onData = (char) => {
78
+ const c = char.toString();
79
+ if (c === "\n" || c === "\r") {
80
+ cleanup();
81
+ process.stdout.write("\n");
82
+ resolve(value);
83
+ } else if (c === "") {
84
+ cleanup();
85
+ process.stdout.write("\n");
86
+ process.exit(1);
87
+ } else if (c === "" || c === "\b") {
88
+ if (value.length > 0) value = value.slice(0, -1);
89
+ } else value += c;
90
+ };
91
+ process.stdin.setRawMode(true);
92
+ process.stdin.resume();
93
+ process.stdin.on("data", onData);
94
+ process.stdin.on("error", onError);
95
+ });
96
+ }
97
+ const rl = readline.createInterface({
98
+ input: stdin,
99
+ output: stdout
100
+ });
101
+ try {
102
+ return await rl.question(message);
103
+ } finally {
104
+ rl.close();
105
+ }
106
+ }
107
+ //#endregion
108
+ //#region src/auth/credentials.ts
109
+ /**
110
+ * Get the path to the credentials directory
111
+ */
112
+ function getCredentialsDir(options) {
113
+ if (options?.home) return options.home;
114
+ return options?.root ? join(options.root, ".gkm") : gkmHome();
115
+ }
116
+ /**
117
+ * Get the path to the credentials file
118
+ */
119
+ function getCredentialsPath(options) {
120
+ return join(getCredentialsDir(options), "credentials.json");
121
+ }
122
+ /**
123
+ * Ensure the credentials directory exists
124
+ */
125
+ function ensureCredentialsDir(options) {
126
+ const dir = getCredentialsDir(options);
127
+ if (!existsSync(dir)) mkdirSync(dir, {
128
+ recursive: true,
129
+ mode: 448
130
+ });
131
+ }
132
+ /**
133
+ * Read stored credentials from disk
134
+ */
135
+ async function readCredentials(options) {
136
+ const path = getCredentialsPath(options);
137
+ if (!existsSync(path)) return {};
138
+ try {
139
+ const content = await readFile(path, "utf-8");
140
+ return JSON.parse(content);
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+ /**
146
+ * Write credentials to disk
147
+ */
148
+ async function writeCredentials(credentials, options) {
149
+ ensureCredentialsDir(options);
150
+ const path = getCredentialsPath(options);
151
+ await writeFile(path, JSON.stringify(credentials, null, 2), { mode: 384 });
152
+ }
153
+ /**
154
+ * Store Dokploy credentials
155
+ */
156
+ async function storeDokployCredentials(token, endpoint, options) {
157
+ const credentials = await readCredentials(options);
158
+ credentials.dokploy = {
159
+ token,
160
+ endpoint,
161
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
162
+ };
163
+ await writeCredentials(credentials, options);
164
+ }
165
+ /**
166
+ * Get stored Dokploy credentials
167
+ */
168
+ async function getDokployCredentials(options) {
169
+ const credentials = await readCredentials(options);
170
+ const token = process.env.DOKPLOY_API_TOKEN ?? credentials.dokploy?.token;
171
+ const endpoint = process.env.DOKPLOY_ENDPOINT ?? credentials.dokploy?.endpoint;
172
+ if (!token || !endpoint) return null;
173
+ return {
174
+ token,
175
+ endpoint: endpoint.replace(/\/$/, ""),
176
+ ...credentials.dokploy?.registryId ? { registryId: credentials.dokploy.registryId } : {}
177
+ };
178
+ }
179
+ /**
180
+ * Remove Dokploy credentials
181
+ */
182
+ async function removeDokployCredentials(options) {
183
+ const credentials = await readCredentials(options);
184
+ if (!credentials.dokploy) return false;
185
+ delete credentials.dokploy;
186
+ await writeCredentials(credentials, options);
187
+ return true;
188
+ }
189
+ /**
190
+ * Get Dokploy API token, checking stored credentials first, then environment
191
+ */
192
+ async function getDokployToken(options) {
193
+ const envToken = process.env.DOKPLOY_API_TOKEN;
194
+ if (envToken) return envToken;
195
+ const stored = await getDokployCredentials(options);
196
+ if (stored) return stored.token;
197
+ return null;
198
+ }
199
+ /**
200
+ * Get Dokploy registry ID from stored credentials
201
+ */
202
+ async function getDokployRegistryId(options) {
203
+ return (await getDokployCredentials(options))?.registryId ?? void 0;
204
+ }
205
+ /**
206
+ * Store Hostinger API token
207
+ *
208
+ * @param token - API token from hpanel.hostinger.com/profile/api
209
+ */
210
+ async function storeHostingerToken(token, options) {
211
+ const credentials = await readCredentials(options);
212
+ credentials.hostinger = {
213
+ token,
214
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
215
+ };
216
+ await writeCredentials(credentials, options);
217
+ }
218
+ /**
219
+ * Get stored Hostinger API token
220
+ *
221
+ * Checks environment variable first (HOSTINGER_API_TOKEN),
222
+ * then falls back to stored credentials.
223
+ */
224
+ async function getHostingerToken(options) {
225
+ const envToken = process.env.HOSTINGER_API_TOKEN;
226
+ if (envToken) return envToken;
227
+ return (await readCredentials(options)).hostinger?.token ?? null;
228
+ }
229
+ //#endregion
230
+ //#region src/auth/index.ts
231
+ const logger$2 = console;
232
+ /**
233
+ * Validate Dokploy token by making a test API call
234
+ */
235
+ async function validateDokployToken(endpoint, token) {
236
+ const { DokployApi } = await Promise.resolve().then(() => dokploy_api_exports);
237
+ return new DokployApi({
238
+ baseUrl: endpoint,
239
+ token
240
+ }).validateToken();
241
+ }
242
+ /** What answers a login prompt without a terminal. */
243
+ const LOGIN_INSTEAD = "Please provide --token option.";
244
+ /**
245
+ * Login to a service
246
+ */
247
+ async function loginCommand(options) {
248
+ const { provider, token: providedToken, endpoint: providedEndpoint } = options;
249
+ if (provider === "dokploy") {
250
+ logger$2.log("\n🔐 Logging in to Dokploy...\n");
251
+ let endpoint = providedEndpoint;
252
+ if (!endpoint) endpoint = await prompt("Dokploy URL (e.g., https://dokploy.example.com): ", { instead: LOGIN_INSTEAD });
253
+ endpoint = endpoint.replace(/\/$/, "");
254
+ try {
255
+ new URL(endpoint);
256
+ } catch {
257
+ logger$2.error("Invalid URL format");
258
+ process.exit(1);
259
+ }
260
+ let token = providedToken;
261
+ if (!token) {
262
+ logger$2.log(`\nGenerate a token at: ${endpoint}/settings/profile\n`);
263
+ token = await prompt("API Token: ", {
264
+ hidden: true,
265
+ instead: LOGIN_INSTEAD
266
+ });
267
+ }
268
+ if (!token) {
269
+ logger$2.error("Token is required");
270
+ process.exit(1);
271
+ }
272
+ logger$2.log("\nValidating credentials...");
273
+ if (!await validateDokployToken(endpoint, token)) {
274
+ logger$2.error("\n✗ Invalid credentials. Please check your token and try again.");
275
+ process.exit(1);
276
+ }
277
+ await storeDokployCredentials(token, endpoint);
278
+ logger$2.log("\n✓ Successfully logged in to Dokploy!");
279
+ logger$2.log(` Endpoint: ${endpoint}`);
280
+ logger$2.log(` Credentials stored in: ${getCredentialsPath()}`);
281
+ logger$2.log("\nYou can now use deploy commands without setting DOKPLOY_API_TOKEN.");
282
+ }
283
+ if (provider === "hostinger") {
284
+ logger$2.log("\n🔐 Logging in to Hostinger...\n");
285
+ let token = providedToken;
286
+ if (!token) {
287
+ logger$2.log("\nGenerate a token at: https://hpanel.hostinger.com/profile/api\n");
288
+ token = await prompt("API Token: ", {
289
+ hidden: true,
290
+ instead: LOGIN_INSTEAD
291
+ });
292
+ }
293
+ if (!token) {
294
+ logger$2.error("Token is required");
295
+ process.exit(1);
296
+ }
297
+ await storeHostingerToken(token);
298
+ logger$2.log("\n✓ Hostinger token stored.");
299
+ logger$2.log(` Credentials stored in: ${getCredentialsPath()}`);
300
+ logger$2.log(" Not validated here: every DNS endpoint is scoped to a domain, so the\n first deploy that touches DNS is where a bad token reports itself.");
301
+ logger$2.log("\nYou can now use it as a DNS provider without setting HOSTINGER_API_TOKEN.");
302
+ }
303
+ }
304
+ /**
305
+ * Logout from a service
306
+ */
307
+ async function logoutCommand(options) {
308
+ const { provider = "dokploy" } = options;
309
+ if (provider === "all") {
310
+ if (await removeDokployCredentials()) logger$2.log("\n✓ Logged out from all services");
311
+ else logger$2.log("\nNo stored credentials found");
312
+ return;
313
+ }
314
+ if (provider === "dokploy") {
315
+ if (await removeDokployCredentials()) logger$2.log("\n✓ Logged out from Dokploy");
316
+ else logger$2.log("\nNo Dokploy credentials found");
317
+ }
318
+ }
319
+ /**
320
+ * Show current login status
321
+ */
322
+ async function whoamiCommand() {
323
+ logger$2.log("\n📋 Current credentials:\n");
324
+ const dokploy = await getDokployCredentials();
325
+ if (dokploy) {
326
+ logger$2.log(" Dokploy:");
327
+ logger$2.log(` Endpoint: ${dokploy.endpoint}`);
328
+ logger$2.log(` Token: ${maskToken(dokploy.token)}`);
329
+ } else logger$2.log(" Dokploy: Not logged in");
330
+ logger$2.log(`\n Credentials file: ${getCredentialsPath()}`);
331
+ }
332
+ /**
333
+ * Mask a token for display
334
+ */
335
+ function maskToken(token) {
336
+ if (token.length <= 8) return "****";
337
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
338
+ }
339
+ //#endregion
29
340
  //#region src/credentials/index.ts
30
- const logger = console;
341
+ const logger$1 = console;
31
342
  /**
32
343
  * Load environment files
33
344
  * @internal Exported for testing
@@ -78,7 +389,7 @@ async function findAvailablePort(preferredPort, maxAttempts = 10) {
78
389
  for (let i = 0; i < maxAttempts; i++) {
79
390
  const port = preferredPort + i;
80
391
  if (await isPortAvailable(port)) return port;
81
- logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
392
+ logger$1.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
82
393
  }
83
394
  throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
84
395
  }
@@ -113,7 +424,7 @@ async function loadSecretsForApp(store, stage, appName) {
113
424
  let secrets = {};
114
425
  const stageSecrets = await store.read(stage);
115
426
  if (stageSecrets) {
116
- logger.log(`🔐 Loading secrets from stage: ${stage}`);
427
+ logger$1.log(`🔐 Loading secrets from stage: ${stage}`);
117
428
  secrets = toEmbeddableSecrets(stageSecrets);
118
429
  }
119
430
  if (Object.keys(secrets).length === 0) return {};
@@ -214,17 +525,17 @@ async function prepareEntryCredentials(options) {
214
525
  secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
215
526
  }
216
527
  if (appName && workspace && options.explicitPort === void 0) {
217
- const { assignAppPorts, describeHolder, holderOf, withAppPorts } = await import("./index.mjs").then((n) => n.o);
528
+ const { assignAppPorts, describeHolder, holderOf, withAppPorts } = await import("./appPorts-C9z9Pzn9.mjs").then((n) => n.n);
218
529
  const assigned = await assignAppPorts(workspace, [appName], {
219
530
  free: isPortAvailable,
220
531
  holder: holderOf
221
532
  });
222
- for (const { app, from, to, holder } of assigned.moved) logger.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
533
+ 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}`);
223
534
  workspace = withAppPorts(workspace, assigned.ports);
224
535
  workspaceAppPort = assigned.ports[appName] ?? workspaceAppPort;
225
536
  }
226
537
  if (appName) {
227
- const { APP_TAG_ENV, appTag } = await import("./index.mjs").then((n) => n.o);
538
+ const { APP_TAG_ENV, appTag } = await import("./appPorts-C9z9Pzn9.mjs").then((n) => n.n);
228
539
  process.env[APP_TAG_ENV] = appTag(secretsRoot, appName);
229
540
  }
230
541
  const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
@@ -243,7 +554,7 @@ async function prepareEntryCredentials(options) {
243
554
  });
244
555
  Object.assign(credentials, reconciled.env);
245
556
  declaredKeys.push(...Object.keys(reconciled.env));
246
- if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
557
+ if (Object.keys(reconciled.env).length > 0) logger$1.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
247
558
  }
248
559
  const secretsDir = join(secretsRoot, ".gkm");
249
560
  await mkdir(secretsDir, { recursive: true });
@@ -262,6 +573,56 @@ async function prepareEntryCredentials(options) {
262
573
  };
263
574
  }
264
575
  //#endregion
576
+ //#region src/workspace/client-generator.ts
577
+ /**
578
+ * Normalize routes to an array of patterns.
579
+ * Handles a glob or a list of them.
580
+ * @internal Exported for use in dev command
581
+ */
582
+ function normalizeRoutes(routes) {
583
+ if (!routes) return [];
584
+ return Array.isArray(routes) ? routes : [routes];
585
+ }
586
+ //#endregion
587
+ //#region src/owningTsconfigJsx.ts
588
+ const REGISTER = "owning-tsconfig-jsx-register.mjs";
589
+ /** The CLI's `bin/` was not found above the module looking for it. */
590
+ var OwningTsconfigJsxHookMissing = class extends Error {
591
+ from;
592
+ constructor(from) {
593
+ super(`Could not find bin/${REGISTER} in any directory above '${from}'. It ships with @geekmidas/cli beside bin/gkm.mjs; reinstall the package if it is missing.`);
594
+ this.from = from;
595
+ this.name = "OwningTsconfigJsxHookMissing";
596
+ }
597
+ };
598
+ /**
599
+ * The CLI's `bin/owning-tsconfig-jsx-register.mjs`, as a file URL. Searched
600
+ * upward because this module runs from `src/` in the repo and from a chunk in
601
+ * `dist/` once built, and `bin/` sits beside both.
602
+ */
603
+ function registerUrl() {
604
+ const from = dirname(fileURLToPath(import.meta.url));
605
+ for (let dir = from;; dir = dirname(dir)) {
606
+ const candidate = join(dir, "bin", REGISTER);
607
+ if (existsSync(candidate)) return pathToFileURL(candidate).href;
608
+ if (dirname(dir) === dir) throw new OwningTsconfigJsxHookMissing(from);
609
+ }
610
+ }
611
+ /**
612
+ * `NODE_OPTIONS` for a process that runs app code through tsx, with the hook
613
+ * that compiles each `.tsx` with its own tsconfig's JSX settings (see
614
+ * `bin/owning-tsconfig-jsx.mjs`) put in front of every other `--import`, so it
615
+ * is registered before tsx and reads the source on tsx's behalf.
616
+ *
617
+ * `bin/gkm.mjs` already starts the CLI this way, and its children inherit it;
618
+ * this is for those that set `NODE_OPTIONS` themselves, or whose CLI was
619
+ * started with tsx already imported.
620
+ */
621
+ function withOwningTsconfigJsx(nodeOptions = "") {
622
+ if (nodeOptions.includes(REGISTER)) return nodeOptions;
623
+ return [`--import=${registerUrl()}`, nodeOptions].filter(Boolean).join(" ");
624
+ }
625
+ //#endregion
265
626
  //#region src/reconcile/containers.ts
266
627
  /**
267
628
  * What gkm knows about each container it can derive.
@@ -438,6 +799,61 @@ function volumeOf(container) {
438
799
  }[container];
439
800
  }
440
801
  //#endregion
802
+ //#region src/workspace/backends.ts
803
+ /**
804
+ * Which backend each declared kind resolves to — decided by where the project
805
+ * deploys, and by nothing else.
806
+ *
807
+ * These used to be read out of a `services:` block, where one key answered two
808
+ * questions: *does this exist* and *what backs it*. The first is the manifest's
809
+ * — a declared `Cache` is why there is a cache — and the second is the deploy
810
+ * target's, because the right cache for a Lambda is not the right cache for a
811
+ * box already running Postgres. With both answered elsewhere there was nothing
812
+ * left for the block to say, so it is gone and these read the target.
813
+ *
814
+ * Mail has no answer here at all: every provider speaks SMTP, so the provider
815
+ * is whichever one the stage's mail URL points at.
816
+ */
817
+ /**
818
+ * Which family of defaults a workspace's deploy target belongs to.
819
+ *
820
+ * One question, asked once: **does this target run containers the project
821
+ * controls?** Dokploy and a bare server do, so a bucket or a cache can live
822
+ * beside the app; AWS, Vercel and Cloudflare do not, so the default has to be
823
+ * something managed. That is the whole of the distinction, and it is why
824
+ * `MainProvider` is the right axis rather than the deploy target's own name —
825
+ * three targets share one answer.
826
+ *
827
+ * An AWS deploy names `deploy.default: 'sst'`, because it goes through SST.
828
+ */
829
+ function providerOf(workspace) {
830
+ const target = workspace.deploy?.default;
831
+ return target === "dokploy" || target === "server" ? "server" : "aws";
832
+ }
833
+ /**
834
+ * The cache backend a target implies.
835
+ *
836
+ * The same answer locally and deployed: a local `gkm dev` for an AWS app still
837
+ * uses the Upstash protocol, because a backend that differed between the two
838
+ * would be worse than a slower one.
839
+ */
840
+ function cacheBackendFor(on) {
841
+ return DEFAULT_CACHE[on];
842
+ }
843
+ /** Where a declared bucket lives on a target. */
844
+ function storageBackendFor(on) {
845
+ return DEFAULT_STORAGE[on];
846
+ }
847
+ /**
848
+ * Which broker carries declared queues and topics on a target.
849
+ *
850
+ * Only consulted when something declares one; a project with no `Topic` or
851
+ * `Queue` has no events at all, whatever the target.
852
+ */
853
+ function eventsBackendFor(on) {
854
+ return DEFAULT_EVENTS$1[on];
855
+ }
856
+ //#endregion
441
857
  //#region src/reconcile/caddyfile.ts
442
858
  /**
443
859
  * The kinds the edge serves.
@@ -1978,7 +2394,7 @@ var UnassignedPort = class extends Error {
1978
2394
  * Every call is scoped to the generated compose file. Nothing here can touch a
1979
2395
  * container this project did not generate.
1980
2396
  */
1981
- const run = promisify(execFile);
2397
+ const run$1 = promisify(execFile);
1982
2398
  /**
1983
2399
  * The generated file, then the project's own if it has one.
1984
2400
  *
@@ -2021,7 +2437,7 @@ function shown(args, services) {
2021
2437
  const dockerCli = {
2022
2438
  async publishedPort(composePath, service, inside) {
2023
2439
  try {
2024
- const { stdout } = await run("docker", [
2440
+ const { stdout } = await run$1("docker", [
2025
2441
  "compose",
2026
2442
  ...composeFiles(composePath),
2027
2443
  "port",
@@ -2046,11 +2462,11 @@ const dockerCli = {
2046
2462
  ...services
2047
2463
  ];
2048
2464
  if (options.show) return shown(args, services);
2049
- await run("docker", args);
2465
+ await run$1("docker", args);
2050
2466
  },
2051
2467
  async copyOut(composePath, service, from, to) {
2052
2468
  await mkdir(dirname(to), { recursive: true });
2053
- await run("docker", [
2469
+ await run$1("docker", [
2054
2470
  "compose",
2055
2471
  ...composeFiles(composePath),
2056
2472
  "cp",
@@ -2059,7 +2475,7 @@ const dockerCli = {
2059
2475
  ]);
2060
2476
  },
2061
2477
  async reload(composePath, service) {
2062
- await run("docker", [
2478
+ await run$1("docker", [
2063
2479
  "compose",
2064
2480
  ...composeFiles(composePath),
2065
2481
  "exec",
@@ -2074,7 +2490,7 @@ const dockerCli = {
2074
2490
  async healthy(composePath, services) {
2075
2491
  if (services.length === 0) return true;
2076
2492
  try {
2077
- const { stdout } = await run("docker", [
2493
+ const { stdout } = await run$1("docker", [
2078
2494
  "compose",
2079
2495
  ...composeFiles(composePath),
2080
2496
  "ps",
@@ -3026,6 +3442,2880 @@ function localAddress(port) {
3026
3442
  return `http://localhost:${port}`;
3027
3443
  }
3028
3444
  //#endregion
3029
- export { loadEnvFiles as C, isPortAvailable as S, prepareEntryCredentials as T, fakeKey as _, reconcileWorkspace as a, createEntryWrapper as b, LOCAL_CA_PATH as c, postgresDatabaseNames as d, quoteIdentifier as f, describeServices as g, rootSite as h, derivedContainers as i, forgetState as l, resourceName as m, backendsOf as n, workspace_exports as o, pgClient as p, constructGlobs as r, COMPOSE_PATH as s, FAKE_ENV as t, applyPostgres as u, primaryPortKey as v, loadSecretsForApp as w, findAvailablePort as x, createCredentialsPreload as y };
3445
+ //#region src/run.ts
3446
+ /**
3447
+ * Start a program with an argument array, never a shell string.
3448
+ *
3449
+ * `gkm build`, `gkm docker` and `gkm deploy` pass values that come from the
3450
+ * workspace — image refs, tags, build args, package names — to `docker` and
3451
+ * `turbo`. Joined into a string and handed to a shell, a package named
3452
+ * `x;curl …|sh` or a tag holding `$(…)` became part of the command line, which
3453
+ * matters most where the CLI builds a repository it didn't write: a CI job for a
3454
+ * fork, a shared build runner. Here every value reaches the program as one
3455
+ * `argv` element, whatever it contains.
3456
+ */
3457
+ /** Long enough for a cold image build; short enough that a hung one ends. */
3458
+ const DEFAULT_TIMEOUT_MS = 18e5;
3459
+ /** How long a timed-out child has to exit on SIGTERM before it is killed. */
3460
+ const KILL_GRACE_MS = 5e3;
3461
+ /** The command line as a person would read it — for messages, never to run. */
3462
+ function display(command, args) {
3463
+ return [command, ...args].join(" ");
3464
+ }
3465
+ /** The program ran and exited unsuccessfully; its own output said why. */
3466
+ var CommandFailed = class extends Error {
3467
+ command;
3468
+ args;
3469
+ exitCode;
3470
+ signal;
3471
+ constructor(command, args, exitCode, signal) {
3472
+ super(`\`${display(command, args)}\` ${exitCode === null ? `was stopped by ${signal ?? "a signal"}` : `exited with code ${exitCode}`}. Its output above says why; run it by hand to see it again.`);
3473
+ this.command = command;
3474
+ this.args = args;
3475
+ this.exitCode = exitCode;
3476
+ this.signal = signal;
3477
+ this.name = "CommandFailed";
3478
+ }
3479
+ };
3480
+ /** The program ran past its timeout and was killed. */
3481
+ var CommandTimedOut = class extends Error {
3482
+ command;
3483
+ args;
3484
+ timeoutMs;
3485
+ constructor(command, args, timeoutMs) {
3486
+ super(`\`${display(command, args)}\` was still running after ${Math.round(timeoutMs / 1e3)}s and was stopped. Check what it was waiting on — a registry, the Docker daemon, a prompt — and run it again.`);
3487
+ this.command = command;
3488
+ this.args = args;
3489
+ this.timeoutMs = timeoutMs;
3490
+ this.name = "CommandTimedOut";
3491
+ }
3492
+ };
3493
+ /**
3494
+ * Run `command` with `args`, resolving when it exits 0.
3495
+ *
3496
+ * Rejects with {@link CommandFailed} on any other exit, {@link CommandTimedOut}
3497
+ * once `timeoutMs` passes (after the child is gone), and with the spawn error
3498
+ * itself when the program cannot be started at all — `ENOENT` already names it.
3499
+ */
3500
+ async function run(command, args, options = {}) {
3501
+ await spawnChild(command, args, options, false);
3502
+ }
3503
+ /**
3504
+ * {@link run}, resolving with what the program wrote to stdout — for a value
3505
+ * the CLI reads back, such as the digest `docker inspect` reports. stderr still
3506
+ * reaches the terminal, so a failure explains itself the same way.
3507
+ */
3508
+ function runOutput(command, args, options = {}) {
3509
+ return spawnChild(command, args, options, true);
3510
+ }
3511
+ function spawnChild(command, args, options, capture) {
3512
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, ...spawnOptions } = options;
3513
+ return new Promise((resolve, reject) => {
3514
+ const child = spawn(command, [...args], {
3515
+ stdio: "inherit",
3516
+ ...spawnOptions,
3517
+ ...capture ? { stdio: [
3518
+ "ignore",
3519
+ "pipe",
3520
+ "inherit"
3521
+ ] } : {},
3522
+ shell: false
3523
+ });
3524
+ let stdout = "";
3525
+ child.stdout?.on("data", (chunk) => {
3526
+ stdout += chunk.toString();
3527
+ });
3528
+ let timedOut = false;
3529
+ let killer;
3530
+ const timer = setTimeout(() => {
3531
+ timedOut = true;
3532
+ child.kill("SIGTERM");
3533
+ killer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
3534
+ killer.unref?.();
3535
+ }, timeoutMs);
3536
+ timer.unref?.();
3537
+ const settle = () => {
3538
+ clearTimeout(timer);
3539
+ if (killer) clearTimeout(killer);
3540
+ };
3541
+ child.on("error", (error) => {
3542
+ settle();
3543
+ reject(spawnOptions.signal?.aborted ? spawnOptions.signal.reason : error);
3544
+ });
3545
+ child.on("close", (code, signal) => {
3546
+ settle();
3547
+ if (spawnOptions.signal?.aborted) reject(spawnOptions.signal.reason);
3548
+ else if (timedOut) reject(new CommandTimedOut(command, args, timeoutMs));
3549
+ else if (code === 0) resolve(stdout);
3550
+ else reject(new CommandFailed(command, args, code, signal));
3551
+ });
3552
+ });
3553
+ }
3554
+ //#endregion
3555
+ //#region src/secrets/encryption.ts
3556
+ var encryption_exports = /* @__PURE__ */ __exportAll({
3557
+ encryptSecrets: () => encryptSecrets,
3558
+ generateDefineOptions: () => generateDefineOptions,
3559
+ keyFingerprint: () => keyFingerprint
3560
+ });
3561
+ /** AES-256-GCM configuration */
3562
+ const ALGORITHM = "aes-256-gcm";
3563
+ const KEY_LENGTH = 32;
3564
+ const IV_LENGTH = 12;
3565
+ /**
3566
+ * Encrypt secrets for embedding in a bundle.
3567
+ * Uses AES-256-GCM with a randomly generated ephemeral key.
3568
+ *
3569
+ * @param secrets - Key-value pairs to encrypt
3570
+ * @returns Encrypted payload with ephemeral master key
3571
+ */
3572
+ function encryptSecrets(secrets) {
3573
+ const masterKey = randomBytes(KEY_LENGTH);
3574
+ const iv = randomBytes(IV_LENGTH);
3575
+ const plaintext = JSON.stringify(secrets);
3576
+ const cipher = createCipheriv(ALGORITHM, masterKey, iv);
3577
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
3578
+ const authTag = cipher.getAuthTag();
3579
+ return {
3580
+ encrypted: Buffer.concat([ciphertext, authTag]).toString("base64"),
3581
+ iv: iv.toString("hex"),
3582
+ masterKey: masterKey.toString("hex")
3583
+ };
3584
+ }
3585
+ /**
3586
+ * Generate the define options for tsdown/esbuild.
3587
+ * These will be injected at build time.
3588
+ */
3589
+ function generateDefineOptions(payload) {
3590
+ return {
3591
+ __GKM_ENCRYPTED_CREDENTIALS__: JSON.stringify(payload.encrypted),
3592
+ __GKM_CREDENTIALS_IV__: JSON.stringify(payload.iv)
3593
+ };
3594
+ }
3595
+ /**
3596
+ * A short, one-way name for a master key: the first 8 hex characters of its
3597
+ * SHA-256.
3598
+ *
3599
+ * Printed where the key itself used to be, so a person can tell which key a
3600
+ * build used — and match it against the one they hold — without the output
3601
+ * carrying a value that decrypts every secret of the stage.
3602
+ */
3603
+ function keyFingerprint(masterKey) {
3604
+ return createHash("sha256").update(masterKey).digest("hex").slice(0, 8);
3605
+ }
3606
+ //#endregion
3607
+ //#region src/deploy/dokploy-api.ts
3608
+ var dokploy_api_exports = /* @__PURE__ */ __exportAll({
3609
+ DEFAULT_DOKPLOY_TIMEOUT_MS: () => DEFAULT_DOKPLOY_TIMEOUT_MS,
3610
+ DokployApi: () => DokployApi,
3611
+ DokployApiError: () => DokployApiError,
3612
+ DokployRequestTimedOut: () => DokployRequestTimedOut
3613
+ });
3614
+ /** Per request, so a stalled server fails the deploy, not the CI job. */
3615
+ const DEFAULT_DOKPLOY_TIMEOUT_MS = 3e4;
3616
+ /**
3617
+ * Dokploy accepted the connection but did not answer in time.
3618
+ *
3619
+ * Not retried: a request that arrived and stalled may still be acted on, and
3620
+ * sending it again could repeat a deploy or a create the server is already
3621
+ * doing.
3622
+ */
3623
+ var DokployRequestTimedOut = class extends Error {
3624
+ baseUrl;
3625
+ endpoint;
3626
+ timeoutMs;
3627
+ constructor(baseUrl, endpoint, timeoutMs) {
3628
+ super(`Dokploy at ${baseUrl} did not answer ${endpoint} within ${timeoutMs}ms. Check that the Dokploy server is healthy, or raise \`timeoutMs\` if this call is known to be slow.`);
3629
+ this.baseUrl = baseUrl;
3630
+ this.endpoint = endpoint;
3631
+ this.timeoutMs = timeoutMs;
3632
+ this.name = "DokployRequestTimedOut";
3633
+ }
3634
+ };
3635
+ /**
3636
+ * Waits `ms` between retries, or rejects with the signal's reason the moment
3637
+ * it aborts — a caller who gave up should not sit through the backoff.
3638
+ */
3639
+ function pause(ms, signal) {
3640
+ return new Promise((resolve, reject) => {
3641
+ if (signal?.aborted) {
3642
+ reject(signal.reason);
3643
+ return;
3644
+ }
3645
+ const onAbort = () => {
3646
+ clearTimeout(timer);
3647
+ reject(signal?.reason);
3648
+ };
3649
+ const timer = setTimeout(() => {
3650
+ signal?.removeEventListener("abort", onAbort);
3651
+ resolve();
3652
+ }, ms);
3653
+ signal?.addEventListener("abort", onAbort, { once: true });
3654
+ });
3655
+ }
3656
+ var DokployApiError = class extends Error {
3657
+ status;
3658
+ statusText;
3659
+ issues;
3660
+ constructor(message, status, statusText, issues) {
3661
+ super(message);
3662
+ this.status = status;
3663
+ this.statusText = statusText;
3664
+ this.issues = issues;
3665
+ this.name = "DokployApiError";
3666
+ }
3667
+ };
3668
+ /**
3669
+ * Dokploy API client
3670
+ */
3671
+ var DokployApi = class {
3672
+ baseUrl;
3673
+ token;
3674
+ timeoutMs;
3675
+ signal;
3676
+ constructor(options) {
3677
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
3678
+ this.token = options.token;
3679
+ this.timeoutMs = options.timeoutMs ?? 3e4;
3680
+ this.signal = options.signal;
3681
+ }
3682
+ /**
3683
+ * Make a GET request to the Dokploy API
3684
+ */
3685
+ async get(endpoint) {
3686
+ return this.request("GET", endpoint);
3687
+ }
3688
+ /**
3689
+ * Make a POST request to the Dokploy API
3690
+ */
3691
+ async post(endpoint, body) {
3692
+ return this.request("POST", endpoint, body);
3693
+ }
3694
+ /**
3695
+ * Make a request to the Dokploy API
3696
+ */
3697
+ async request(method, endpoint, body) {
3698
+ const url = `${this.baseUrl}/api/${endpoint}`;
3699
+ let response;
3700
+ let timeout;
3701
+ let lastError;
3702
+ for (let attempt = 1; attempt <= 3; attempt++) {
3703
+ this.signal?.throwIfAborted();
3704
+ timeout = AbortSignal.timeout(this.timeoutMs);
3705
+ try {
3706
+ response = await fetch(url, {
3707
+ method,
3708
+ headers: {
3709
+ "Content-Type": "application/json",
3710
+ "x-api-key": this.token
3711
+ },
3712
+ body: body ? JSON.stringify(body) : void 0,
3713
+ signal: this.signal ? AbortSignal.any([this.signal, timeout]) : timeout
3714
+ });
3715
+ break;
3716
+ } catch (error) {
3717
+ const stopped = this.stopped(timeout, endpoint);
3718
+ if (stopped) throw stopped;
3719
+ lastError = error;
3720
+ if (attempt === 3) break;
3721
+ await pause(attempt * 750, this.signal);
3722
+ }
3723
+ }
3724
+ if (!response || !timeout) {
3725
+ const detail = lastError instanceof Error ? lastError.message : String(lastError);
3726
+ throw new DokployApiError(`Could not reach Dokploy at ${this.baseUrl} (${endpoint}): ${detail}`, 0, "Network error");
3727
+ }
3728
+ try {
3729
+ return await this.read(response);
3730
+ } catch (error) {
3731
+ throw this.stopped(timeout, endpoint) ?? error;
3732
+ }
3733
+ }
3734
+ /**
3735
+ * What a request cut short should reject with: the caller's own reason
3736
+ * when they aborted, `DokployRequestTimedOut` when the deadline passed,
3737
+ * nothing when it was neither.
3738
+ */
3739
+ stopped(timeout, endpoint) {
3740
+ if (this.signal?.aborted) return this.signal.reason;
3741
+ if (timeout.aborted) return new DokployRequestTimedOut(this.baseUrl, endpoint, this.timeoutMs);
3742
+ }
3743
+ /** Turns a response into its JSON body, or a `DokployApiError`. */
3744
+ async read(response) {
3745
+ if (!response.ok) {
3746
+ let errorMessage = `Dokploy API error: ${response.status} ${response.statusText}`;
3747
+ let issues;
3748
+ try {
3749
+ const errorBody = await response.json();
3750
+ if (errorBody.message) errorMessage = `Dokploy API error: ${errorBody.message}`;
3751
+ if (errorBody.issues?.length) {
3752
+ issues = errorBody.issues;
3753
+ errorMessage += `\n Issues: ${errorBody.issues.map((i) => i.message).join(", ")}`;
3754
+ }
3755
+ } catch {}
3756
+ throw new DokployApiError(errorMessage, response.status, response.statusText, issues);
3757
+ }
3758
+ const text = await response.text();
3759
+ if (!text || text.trim() === "") return;
3760
+ return JSON.parse(text);
3761
+ }
3762
+ /**
3763
+ * Every external port published on this server, across every project.
3764
+ *
3765
+ * Dokploy knows what it has bound; guessing does not. A port that looks free
3766
+ * from here can be held by a service in a project this deploy has never
3767
+ * heard of — which surfaces as `Port 5432 is already in use by container
3768
+ * "…"`, naming something the caller cannot see.
3769
+ *
3770
+ * `project.all` is a summary, so each project is fetched: the per-service
3771
+ * `externalPort` only appears on `project.one`.
3772
+ */
3773
+ async publishedPorts() {
3774
+ const ports = /* @__PURE__ */ new Set();
3775
+ const projects = await this.listProjects();
3776
+ for (const project of projects) {
3777
+ const details = await this.getProject(project.projectId);
3778
+ for (const environment of details.environments ?? []) {
3779
+ const resources = [
3780
+ ...environment.postgres ?? [],
3781
+ ...environment.redis ?? [],
3782
+ ...environment.applications ?? []
3783
+ ];
3784
+ for (const resource of resources) if (typeof resource.externalPort === "number") ports.add(resource.externalPort);
3785
+ }
3786
+ }
3787
+ return ports;
3788
+ }
3789
+ /**
3790
+ * Validate the API token by making a test request
3791
+ */
3792
+ async validateToken() {
3793
+ try {
3794
+ await this.get("project.all");
3795
+ return true;
3796
+ } catch {
3797
+ return false;
3798
+ }
3799
+ }
3800
+ /**
3801
+ * List all projects
3802
+ */
3803
+ async listProjects() {
3804
+ return this.get("project.all");
3805
+ }
3806
+ /**
3807
+ * Get a single project by ID
3808
+ */
3809
+ async getProject(projectId) {
3810
+ return this.get(`project.one?projectId=${projectId}`);
3811
+ }
3812
+ /**
3813
+ * Create a new project
3814
+ */
3815
+ async createProject(name, description) {
3816
+ return this.post("project.create", {
3817
+ name,
3818
+ description: description ?? `Created by gkm CLI`
3819
+ });
3820
+ }
3821
+ /**
3822
+ * Change a project's name or description.
3823
+ *
3824
+ * The name is sent with the description because `project.update` validates
3825
+ * the whole project on some Dokploy versions, and a missing name fails it.
3826
+ */
3827
+ async updateProject(projectId, updates) {
3828
+ await this.post("project.update", {
3829
+ projectId,
3830
+ ...updates
3831
+ });
3832
+ }
3833
+ /**
3834
+ * Delete a project and all its resources
3835
+ */
3836
+ async deleteProject(projectId) {
3837
+ await this.post("project.remove", { projectId });
3838
+ }
3839
+ /**
3840
+ * Create an environment in a project
3841
+ */
3842
+ async createEnvironment(projectId, name, description) {
3843
+ return this.post("environment.create", {
3844
+ projectId,
3845
+ name,
3846
+ description: description ?? `${name} environment`
3847
+ });
3848
+ }
3849
+ /**
3850
+ * List all applications in a project
3851
+ */
3852
+ async listApplications(projectId) {
3853
+ return this.listInProject(projectId, (env) => env.applications);
3854
+ }
3855
+ /**
3856
+ * Everything of one kind in a project, across its environments.
3857
+ *
3858
+ * From `project.one`, because the per-kind `*.all?projectId=` endpoints
3859
+ * return **404** on current Dokploy: resources live under environments now.
3860
+ * Those calls used to be wrapped in `catch { return [] }`, which turned "I
3861
+ * could not ask" into "there are none" — and for a find-or-create that is
3862
+ * the worst available answer. Every deploy created a second application, a
3863
+ * second Postgres and a second Redis, silently, for as long as the project
3864
+ * existed.
3865
+ *
3866
+ * A failure here is not swallowed: creating a duplicate is worse than
3867
+ * stopping.
3868
+ */
3869
+ async listInProject(projectId, pick) {
3870
+ return ((await this.getProject(projectId)).environments ?? []).flatMap((environment) => (pick(environment) ?? []).map((resource) => ({
3871
+ ...resource,
3872
+ environmentId: environment.environmentId
3873
+ })));
3874
+ }
3875
+ /**
3876
+ * Find an application by name in a project
3877
+ */
3878
+ async findApplicationByName(projectId, name, environmentId) {
3879
+ const applications = await this.listApplications(projectId);
3880
+ const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
3881
+ return applications.find((app) => (!environmentId || app.environmentId === environmentId) && (app.name === name || app.appName === normalizedName));
3882
+ }
3883
+ /**
3884
+ * Create a new application
3885
+ */
3886
+ async createApplication(name, projectId, environmentId) {
3887
+ return this.post("application.create", {
3888
+ name,
3889
+ projectId,
3890
+ environmentId,
3891
+ appName: name.toLowerCase().replace(/[^a-z0-9-]/g, "-")
3892
+ });
3893
+ }
3894
+ /**
3895
+ * Find or create an application by name
3896
+ */
3897
+ async findOrCreateApplication(name, projectId, environmentId) {
3898
+ const existing = await this.findApplicationByName(projectId, name, environmentId);
3899
+ if (existing) return {
3900
+ application: existing,
3901
+ created: false
3902
+ };
3903
+ return {
3904
+ application: await this.createApplication(name, projectId, environmentId),
3905
+ created: true
3906
+ };
3907
+ }
3908
+ /**
3909
+ * Get an application by ID
3910
+ */
3911
+ async getApplication(applicationId) {
3912
+ try {
3913
+ return await this.get(`application.one?applicationId=${applicationId}`);
3914
+ } catch {
3915
+ return null;
3916
+ }
3917
+ }
3918
+ /**
3919
+ * Update an application
3920
+ */
3921
+ async updateApplication(applicationId, updates) {
3922
+ await this.post("application.update", {
3923
+ applicationId,
3924
+ ...updates
3925
+ });
3926
+ }
3927
+ /**
3928
+ * Save environment variables for an application
3929
+ */
3930
+ async saveApplicationEnv(applicationId, env, options) {
3931
+ await this.post("application.saveEnvironment", {
3932
+ applicationId,
3933
+ env,
3934
+ buildArgs: options?.buildArgs ?? null,
3935
+ buildSecrets: options?.buildSecrets ?? null,
3936
+ createEnvFile: false
3937
+ });
3938
+ }
3939
+ /**
3940
+ * Configure application to use Docker provider (pull from registry)
3941
+ *
3942
+ * For private registries, either:
3943
+ * - Use `registryId` if the registry is configured in Dokploy
3944
+ * - Or provide `username`, `password`, and `registryUrl` directly
3945
+ */
3946
+ async saveDockerProvider(applicationId, dockerImage, options) {
3947
+ await this.post("application.saveDockerProvider", {
3948
+ applicationId,
3949
+ dockerImage,
3950
+ ...options,
3951
+ username: options?.username ?? null,
3952
+ password: options?.password ?? null,
3953
+ registryUrl: options?.registryUrl ?? null
3954
+ });
3955
+ }
3956
+ /**
3957
+ * Deploy an application
3958
+ */
3959
+ async deployApplication(applicationId) {
3960
+ await this.post("application.deploy", { applicationId });
3961
+ }
3962
+ /**
3963
+ * Delete an application
3964
+ */
3965
+ async deleteApplication(applicationId) {
3966
+ await this.post("application.remove", { applicationId });
3967
+ }
3968
+ /**
3969
+ * List all registries
3970
+ */
3971
+ async listRegistries() {
3972
+ return this.get("registry.all");
3973
+ }
3974
+ /**
3975
+ * Create a new registry
3976
+ */
3977
+ async createRegistry(registryName, registryUrl, username, password, options) {
3978
+ return this.post("registry.create", {
3979
+ registryName,
3980
+ registryUrl,
3981
+ username,
3982
+ password,
3983
+ imagePrefix: options?.imagePrefix
3984
+ });
3985
+ }
3986
+ /**
3987
+ * Get a registry by ID
3988
+ */
3989
+ async getRegistry(registryId) {
3990
+ return this.get(`registry.one?registryId=${registryId}`);
3991
+ }
3992
+ /**
3993
+ * Update a registry
3994
+ */
3995
+ async updateRegistry(registryId, updates) {
3996
+ await this.post("registry.update", {
3997
+ registryId,
3998
+ ...updates
3999
+ });
4000
+ }
4001
+ /**
4002
+ * Delete a registry
4003
+ */
4004
+ async deleteRegistry(registryId) {
4005
+ await this.post("registry.remove", { registryId });
4006
+ }
4007
+ /**
4008
+ * List all Postgres databases in a project
4009
+ */
4010
+ async listPostgres(projectId) {
4011
+ return this.listInProject(projectId, (env) => env.postgres);
4012
+ }
4013
+ /**
4014
+ * Find a Postgres database by name in a project
4015
+ */
4016
+ async findPostgresByName(projectId, name) {
4017
+ const databases = await this.listPostgres(projectId);
4018
+ const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
4019
+ const match = databases.find((db) => db.name === name || db.appName === normalizedName);
4020
+ if (!match) return void 0;
4021
+ return this.getPostgres(match.postgresId);
4022
+ }
4023
+ /**
4024
+ * Create a new Postgres database
4025
+ */
4026
+ async createPostgres(name, projectId, environmentId, options) {
4027
+ return this.post("postgres.create", {
4028
+ name,
4029
+ projectId,
4030
+ environmentId,
4031
+ appName: options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
4032
+ databaseName: options?.databaseName,
4033
+ databaseUser: options?.databaseUser ?? "postgres",
4034
+ databasePassword: options?.databasePassword,
4035
+ dockerImage: options?.dockerImage ?? "postgres:18",
4036
+ description: options?.description ?? `Postgres database for ${name}`
4037
+ });
4038
+ }
4039
+ /**
4040
+ * Find or create a Postgres database by name
4041
+ */
4042
+ async findOrCreatePostgres(name, projectId, environmentId, options) {
4043
+ const existing = await this.findPostgresByName(projectId, name);
4044
+ if (existing) return {
4045
+ postgres: existing,
4046
+ created: false
4047
+ };
4048
+ return {
4049
+ postgres: await this.createPostgres(name, projectId, environmentId, options),
4050
+ created: true
4051
+ };
4052
+ }
4053
+ /**
4054
+ * Get a Postgres database by ID
4055
+ */
4056
+ async getPostgres(postgresId) {
4057
+ return this.get(`postgres.one?postgresId=${postgresId}`);
4058
+ }
4059
+ /**
4060
+ * Deploy a Postgres database
4061
+ */
4062
+ async deployPostgres(postgresId) {
4063
+ await this.post("postgres.deploy", { postgresId });
4064
+ }
4065
+ /**
4066
+ * Save environment variables for Postgres
4067
+ */
4068
+ async savePostgresEnv(postgresId, env) {
4069
+ await this.post("postgres.saveEnvironment", {
4070
+ postgresId,
4071
+ env
4072
+ });
4073
+ }
4074
+ /**
4075
+ * Set external port for Postgres (for external access)
4076
+ */
4077
+ async savePostgresExternalPort(postgresId, externalPort) {
4078
+ await this.post("postgres.saveExternalPort", {
4079
+ postgresId,
4080
+ externalPort
4081
+ });
4082
+ }
4083
+ /**
4084
+ * Update Postgres configuration
4085
+ */
4086
+ async updatePostgres(postgresId, updates) {
4087
+ await this.post("postgres.update", {
4088
+ postgresId,
4089
+ ...updates
4090
+ });
4091
+ }
4092
+ /**
4093
+ * Delete a Postgres database
4094
+ */
4095
+ async deletePostgres(postgresId) {
4096
+ await this.post("postgres.remove", { postgresId });
4097
+ }
4098
+ /**
4099
+ * The compose stacks in a project.
4100
+ *
4101
+ * Dokploy has first-class primitives for Postgres and Redis and none for
4102
+ * MinIO, so an object store is a compose stack — the one kind whose contents
4103
+ * this target writes rather than configures.
4104
+ */
4105
+ async listCompose(projectId) {
4106
+ return this.listInProject(projectId, (env) => env.compose);
4107
+ }
4108
+ /** A compose stack by name, hydrated — `project.one` returns summaries. */
4109
+ async findComposeByName(projectId, name) {
4110
+ const summary = (await this.listCompose(projectId)).find((compose) => compose.name === name);
4111
+ return summary ? this.getCompose(summary.composeId) : void 0;
4112
+ }
4113
+ async getCompose(composeId) {
4114
+ return this.get(`compose.one?composeId=${composeId}`);
4115
+ }
4116
+ /**
4117
+ * A compose stack carrying the file this target wrote.
4118
+ *
4119
+ * Created empty and then updated, because `compose.create` takes no file:
4120
+ * it defaults to `sourceType: 'github'` and expects a repository. `raw` is
4121
+ * what makes the file ours.
4122
+ */
4123
+ async findOrCreateCompose(name, projectId, environmentId, composeFile) {
4124
+ const existing = await this.findComposeByName(projectId, name);
4125
+ const compose = existing ?? await this.post("compose.create", {
4126
+ name,
4127
+ environmentId,
4128
+ composeType: "docker-compose"
4129
+ });
4130
+ await this.post("compose.update", {
4131
+ composeId: compose.composeId,
4132
+ sourceType: "raw",
4133
+ composeFile
4134
+ });
4135
+ return {
4136
+ compose,
4137
+ created: !existing
4138
+ };
4139
+ }
4140
+ async deployCompose(composeId) {
4141
+ await this.post("compose.deploy", { composeId });
4142
+ }
4143
+ async deleteCompose(composeId) {
4144
+ await this.post("compose.delete", {
4145
+ composeId,
4146
+ deleteVolumes: false
4147
+ });
4148
+ }
4149
+ /**
4150
+ * List all Redis instances in a project
4151
+ */
4152
+ async listRedis(projectId) {
4153
+ return this.listInProject(projectId, (env) => env.redis);
4154
+ }
4155
+ /**
4156
+ * Find a Redis instance by name in a project
4157
+ */
4158
+ async findRedisByName(projectId, name) {
4159
+ const instances = await this.listRedis(projectId);
4160
+ const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
4161
+ const match = instances.find((redis) => redis.name === name || redis.appName === normalizedName);
4162
+ return match ? this.getRedis(match.redisId) : void 0;
4163
+ }
4164
+ /**
4165
+ * Create a new Redis instance
4166
+ */
4167
+ async createRedis(name, projectId, environmentId, options) {
4168
+ return this.post("redis.create", {
4169
+ name,
4170
+ projectId,
4171
+ environmentId,
4172
+ appName: options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
4173
+ databasePassword: options?.databasePassword,
4174
+ dockerImage: options?.dockerImage ?? "redis:8",
4175
+ description: options?.description ?? `Redis instance for ${name}`
4176
+ });
4177
+ }
4178
+ /**
4179
+ * Find or create a Redis instance by name
4180
+ */
4181
+ async findOrCreateRedis(name, projectId, environmentId, options) {
4182
+ const existing = await this.findRedisByName(projectId, name);
4183
+ if (existing) return {
4184
+ redis: existing,
4185
+ created: false
4186
+ };
4187
+ return {
4188
+ redis: await this.createRedis(name, projectId, environmentId, options),
4189
+ created: true
4190
+ };
4191
+ }
4192
+ /**
4193
+ * Get a Redis instance by ID
4194
+ */
4195
+ async getRedis(redisId) {
4196
+ return this.get(`redis.one?redisId=${redisId}`);
4197
+ }
4198
+ /**
4199
+ * Deploy a Redis instance
4200
+ */
4201
+ async deployRedis(redisId) {
4202
+ await this.post("redis.deploy", { redisId });
4203
+ }
4204
+ /**
4205
+ * Save environment variables for Redis
4206
+ */
4207
+ async saveRedisEnv(redisId, env) {
4208
+ await this.post("redis.saveEnvironment", {
4209
+ redisId,
4210
+ env
4211
+ });
4212
+ }
4213
+ /**
4214
+ * Set external port for Redis (for external access)
4215
+ */
4216
+ async saveRedisExternalPort(redisId, externalPort) {
4217
+ await this.post("redis.saveExternalPort", {
4218
+ redisId,
4219
+ externalPort
4220
+ });
4221
+ }
4222
+ /**
4223
+ * Update Redis configuration
4224
+ */
4225
+ async updateRedis(redisId, updates) {
4226
+ await this.post("redis.update", {
4227
+ redisId,
4228
+ ...updates
4229
+ });
4230
+ }
4231
+ /**
4232
+ * Delete a Redis instance
4233
+ */
4234
+ async deleteRedis(redisId) {
4235
+ await this.post("redis.remove", { redisId });
4236
+ }
4237
+ /**
4238
+ * Create a new domain for an application
4239
+ */
4240
+ async createDomain(options) {
4241
+ return this.post("domain.create", options);
4242
+ }
4243
+ /**
4244
+ * Update an existing domain
4245
+ */
4246
+ async updateDomain(domainId, updates) {
4247
+ await this.post("domain.update", {
4248
+ domainId,
4249
+ ...updates
4250
+ });
4251
+ }
4252
+ /**
4253
+ * Delete a domain
4254
+ */
4255
+ async deleteDomain(domainId) {
4256
+ await this.post("domain.delete", { domainId });
4257
+ }
4258
+ /**
4259
+ * Get a domain by ID
4260
+ */
4261
+ async getDomain(domainId) {
4262
+ return this.get(`domain.one?domainId=${domainId}`);
4263
+ }
4264
+ /**
4265
+ * Get all domains for an application
4266
+ */
4267
+ async getDomainsByApplicationId(applicationId) {
4268
+ return this.get(`domain.byApplicationId?applicationId=${applicationId}`);
4269
+ }
4270
+ /**
4271
+ * Validate a domain and trigger SSL certificate generation
4272
+ *
4273
+ * This should be called after DNS records are created and propagated.
4274
+ * It triggers Let's Encrypt certificate generation for HTTPS domains.
4275
+ *
4276
+ * @param domain - The domain hostname to validate (e.g., 'api.example.com')
4277
+ */
4278
+ async validateDomain(domain) {
4279
+ return this.post("domain.validateDomain", { domain });
4280
+ }
4281
+ /**
4282
+ * Auto-generate a domain name for an application
4283
+ */
4284
+ async generateDomain(appName, serverId) {
4285
+ return this.post("domain.generateDomain", {
4286
+ appName,
4287
+ serverId
4288
+ });
4289
+ }
4290
+ /**
4291
+ * List all backup destinations
4292
+ */
4293
+ async listDestinations() {
4294
+ return this.get("destination.all");
4295
+ }
4296
+ /**
4297
+ * Get a destination by ID
4298
+ */
4299
+ async getDestination(destinationId) {
4300
+ return this.get(`destination.one?destinationId=${destinationId}`);
4301
+ }
4302
+ /**
4303
+ * Create a new S3 backup destination
4304
+ */
4305
+ async createDestination(options) {
4306
+ return this.post("destination.create", { ...options });
4307
+ }
4308
+ /**
4309
+ * Find a destination by name
4310
+ */
4311
+ async findDestinationByName(name) {
4312
+ return (await this.listDestinations()).find((d) => d.name === name);
4313
+ }
4314
+ /**
4315
+ * Find or create a destination by name
4316
+ */
4317
+ async findOrCreateDestination(name, options) {
4318
+ const existing = await this.findDestinationByName(name);
4319
+ if (existing) return {
4320
+ destination: existing,
4321
+ created: false
4322
+ };
4323
+ return {
4324
+ destination: await this.createDestination({
4325
+ name,
4326
+ ...options
4327
+ }),
4328
+ created: true
4329
+ };
4330
+ }
4331
+ /**
4332
+ * Update a destination
4333
+ */
4334
+ async updateDestination(destinationId, updates) {
4335
+ await this.post("destination.update", {
4336
+ destinationId,
4337
+ ...updates
4338
+ });
4339
+ }
4340
+ /**
4341
+ * Delete a destination
4342
+ */
4343
+ async deleteDestination(destinationId) {
4344
+ await this.post("destination.remove", { destinationId });
4345
+ }
4346
+ /**
4347
+ * Test connection to a destination
4348
+ */
4349
+ async testDestinationConnection(destinationId) {
4350
+ return this.post("destination.testConnection", { destinationId });
4351
+ }
4352
+ /**
4353
+ * Create a backup schedule for postgres
4354
+ */
4355
+ async createPostgresBackup(options) {
4356
+ return this.post("backup.create", {
4357
+ ...options,
4358
+ databaseType: "postgres"
4359
+ });
4360
+ }
4361
+ /**
4362
+ * List backups for a postgres database
4363
+ */
4364
+ async listPostgresBackups(postgresId) {
4365
+ return this.get(`backup.all?postgresId=${postgresId}&databaseType=postgres`);
4366
+ }
4367
+ /**
4368
+ * Get a backup by ID
4369
+ */
4370
+ async getBackup(backupId) {
4371
+ return this.get(`backup.one?backupId=${backupId}`);
4372
+ }
4373
+ /**
4374
+ * Update a backup schedule
4375
+ */
4376
+ async updateBackup(backupId, updates) {
4377
+ await this.post("backup.update", {
4378
+ backupId,
4379
+ ...updates
4380
+ });
4381
+ }
4382
+ /**
4383
+ * Delete a backup schedule
4384
+ */
4385
+ async deleteBackup(backupId) {
4386
+ await this.post("backup.remove", { backupId });
4387
+ }
4388
+ /**
4389
+ * Trigger a manual backup run
4390
+ */
4391
+ async runBackupManually(backupId) {
4392
+ await this.post("backup.manualBackup", { backupId });
4393
+ }
4394
+ };
4395
+ //#endregion
4396
+ //#region src/deploy/StateProvider.ts
4397
+ /**
4398
+ * Check if value is a StateProvider implementation.
4399
+ */
4400
+ function isStateProvider(value) {
4401
+ return typeof value === "object" && value !== null && typeof value.read === "function" && typeof value.write === "function";
4402
+ }
4403
+ //#endregion
4404
+ //#region src/deploy/StateStore.ts
4405
+ /**
4406
+ * Deploy state store.
4407
+ *
4408
+ * `StateProvider` reads and writes a whole stage at once, with nothing to stop
4409
+ * two runs of the same stage from overwriting each other and nowhere to record
4410
+ * a resource the moment it exists. A `StateStore` adds the three things a
4411
+ * deploy needs to be safe against crashes and concurrent runs:
4412
+ *
4413
+ * - `lock(stage)`: one run per stage. A second gets `StateLocked`, naming the
4414
+ * holder, instead of racing the first.
4415
+ * - Versioned `read` and conditional `write`: a write names the version it was
4416
+ * based on, and raises `StateVersionConflict` when the stored state moved on
4417
+ * underneath it, so nothing is lost silently even without the lock.
4418
+ * - Per-resource records: `putResource` journals one resource at a time, so a
4419
+ * run that dies half way leaves behind the ids of everything it created.
4420
+ *
4421
+ * Built-in stores: `LocalStateStore`, `SSMStateStore`, `S3StateStore`. A
4422
+ * custom `StateProvider` is wrapped by `LegacyStateStore`, which keeps it
4423
+ * working but cannot lock.
4424
+ */
4425
+ var StateLocked = class extends Error {
4426
+ stage;
4427
+ holder;
4428
+ location;
4429
+ constructor(stage, holder, location) {
4430
+ const by = holder ? `${holder.owner}@${holder.host} (pid ${holder.pid}) since ${holder.acquiredAt}${holder.operation ? ` for '${holder.operation}'` : ""}` : "a run that did not record who it was";
4431
+ super(`Deploy state for stage '${stage}' is locked by ${by} (${location}). Wait for that run to finish; if it crashed, release the lock with \`gkm state:unlock --stage ${stage}\`.`);
4432
+ this.stage = stage;
4433
+ this.holder = holder;
4434
+ this.location = location;
4435
+ this.name = "StateLocked";
4436
+ }
4437
+ };
4438
+ var StateVersionConflict = class extends Error {
4439
+ stage;
4440
+ expectedVersion;
4441
+ actualVersion;
4442
+ constructor(stage, expectedVersion, actualVersion) {
4443
+ super(`Deploy state for stage '${stage}' changed since it was read (expected ${expectedVersion ? `version ${expectedVersion}` : "no state"}, found ${actualVersion ? `version ${actualVersion}` : "no state"}). Another run wrote it: read the state again and re-apply the change, and hold the stage's lock so runs take turns.`);
4444
+ this.stage = stage;
4445
+ this.expectedVersion = expectedVersion;
4446
+ this.actualVersion = actualVersion;
4447
+ this.name = "StateVersionConflict";
4448
+ }
4449
+ };
4450
+ var StageStateMissing = class extends Error {
4451
+ stage;
4452
+ constructor(stage) {
4453
+ super(`There is no deploy state for stage '${stage}' to record a resource in. Write the stage's state first (write with expectedVersion: null).`);
4454
+ this.stage = stage;
4455
+ this.name = "StageStateMissing";
4456
+ }
4457
+ };
4458
+ var StateUnreadable = class extends Error {
4459
+ stage;
4460
+ location;
4461
+ constructor(stage, location, cause) {
4462
+ super(`Deploy state for stage '${stage}' at ${location} is not valid JSON. Restore it from a backup (or the provider's history) rather than deploying without it — a deploy without state recreates every resource.`, { cause });
4463
+ this.stage = stage;
4464
+ this.location = location;
4465
+ this.name = "StateUnreadable";
4466
+ }
4467
+ };
4468
+ var StateSchemaTooNew = class extends Error {
4469
+ stage;
4470
+ schemaVersion;
4471
+ constructor(stage, schemaVersion) {
4472
+ super(`Deploy state for stage '${stage}' has schema version ${String(schemaVersion)}, newer than this @geekmidas/cli understands (2). Upgrade @geekmidas/cli to the version that wrote it.`);
4473
+ this.stage = stage;
4474
+ this.schemaVersion = schemaVersion;
4475
+ this.name = "StateSchemaTooNew";
4476
+ }
4477
+ };
4478
+ /**
4479
+ * Not thrown: emitted (once per store) when a custom `StateProvider` stands
4480
+ * in for a store, so a deploy that cannot be protected from a concurrent one
4481
+ * says so.
4482
+ */
4483
+ var StateStoreWithoutLocking = class extends Error {
4484
+ provider;
4485
+ constructor(provider) {
4486
+ super(`The custom state provider '${provider}' has no lock and no versions, so two deploys of the same stage can still overwrite each other. Implement StateStore (lock, versioned read, conditional write) or use the 'local', 'ssm' or 's3' state provider.`);
4487
+ this.provider = provider;
4488
+ this.name = "StateStoreWithoutLocking";
4489
+ }
4490
+ };
4491
+ var StateStoreNeedsWorkspaceName = class extends Error {
4492
+ provider;
4493
+ constructor(provider) {
4494
+ super(`The '${provider}' state provider keys state by workspace name. Set "name" in gkm.config.ts.`);
4495
+ this.provider = provider;
4496
+ this.name = "StateStoreNeedsWorkspaceName";
4497
+ }
4498
+ };
4499
+ var UnknownStateProvider = class extends Error {
4500
+ config;
4501
+ constructor(config) {
4502
+ super(`Unknown state provider ${JSON.stringify(config)}. Use 'local', 'ssm', 's3', or an object implementing StateStore or StateProvider.`);
4503
+ this.config = config;
4504
+ this.name = "UnknownStateProvider";
4505
+ }
4506
+ };
4507
+ /** A lock or write that could not get at the state backend in time. */
4508
+ var StateStoreBusy = class extends Error {
4509
+ stage;
4510
+ location;
4511
+ constructor(stage, location) {
4512
+ super(`Timed out waiting to write deploy state for stage '${stage}' (${location}). If no other gkm process is running, remove ${location}.`);
4513
+ this.stage = stage;
4514
+ this.location = location;
4515
+ this.name = "StateStoreBusy";
4516
+ }
4517
+ };
4518
+ function decodeDocument(stage, body, location) {
4519
+ let parsed;
4520
+ try {
4521
+ parsed = JSON.parse(body);
4522
+ } catch (error) {
4523
+ throw new StateUnreadable(stage, location, error);
4524
+ }
4525
+ if (typeof parsed !== "object" || parsed === null) throw new StateUnreadable(stage, location, parsed);
4526
+ const schemaVersion = parsed.schemaVersion;
4527
+ if (schemaVersion === void 0) return {
4528
+ schemaVersion: 1,
4529
+ state: parsed
4530
+ };
4531
+ if (schemaVersion === 2) return {
4532
+ schemaVersion: 2,
4533
+ document: parsed
4534
+ };
4535
+ throw new StateSchemaTooNew(stage, schemaVersion);
4536
+ }
4537
+ function encodeDocument(document) {
4538
+ return JSON.stringify(document, null, 2);
4539
+ }
4540
+ /**
4541
+ * v1 state as a v2 document. The ids v1 kept are seeded as `ready` records,
4542
+ * so the first journaled deploy after an upgrade adopts them rather than
4543
+ * treating them as unknown.
4544
+ */
4545
+ function migrateV1(stage, state) {
4546
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4547
+ const resources = {};
4548
+ const seed = (type, name, id) => {
4549
+ if (!id) return;
4550
+ const key = name ? `${type}:${name}` : type;
4551
+ resources[key] = {
4552
+ key,
4553
+ type,
4554
+ id,
4555
+ status: "ready",
4556
+ updatedAt: now
4557
+ };
4558
+ };
4559
+ seed("project", null, state.projectId);
4560
+ seed("environment", null, state.environmentId);
4561
+ for (const [app, id] of Object.entries(state.applications ?? {})) seed("application", app, id);
4562
+ seed("postgres", null, state.services?.postgresId);
4563
+ seed("redis", null, state.services?.redisId);
4564
+ seed("backup-destination", null, state.backups?.destinationId);
4565
+ return {
4566
+ schemaVersion: 2,
4567
+ stage,
4568
+ serial: 1,
4569
+ state,
4570
+ resources,
4571
+ updatedAt: now
4572
+ };
4573
+ }
4574
+ function newLockHolder(options) {
4575
+ let owner = "unknown";
4576
+ try {
4577
+ owner = userInfo().username;
4578
+ } catch {}
4579
+ return {
4580
+ id: randomUUID(),
4581
+ owner,
4582
+ host: hostname(),
4583
+ pid: process.pid,
4584
+ acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
4585
+ operation: options?.operation
4586
+ };
4587
+ }
4588
+ function contentVersion(body) {
4589
+ return createHash("sha256").update(body).digest("hex").slice(0, 16);
4590
+ }
4591
+ /**
4592
+ * A store over a backend that can read and conditionally write one blob per
4593
+ * stage, and create a lock object only if it does not exist. Migration and
4594
+ * resource records are the same for every such backend, so they live here.
4595
+ */
4596
+ var DocumentStateStore = class {
4597
+ async lock(stage, options) {
4598
+ const holder = newLockHolder(options);
4599
+ await this.createLock(stage, holder);
4600
+ let released = false;
4601
+ return {
4602
+ stage,
4603
+ holder,
4604
+ release: async () => {
4605
+ if (released) return;
4606
+ released = true;
4607
+ await this.removeLock(stage, holder);
4608
+ }
4609
+ };
4610
+ }
4611
+ async forceUnlock(stage) {
4612
+ const holder = await this.readLock(stage);
4613
+ await this.removeLock(stage, null);
4614
+ return holder;
4615
+ }
4616
+ async read(stage) {
4617
+ const current = await this.readDocument(stage);
4618
+ if (!current) return null;
4619
+ return {
4620
+ state: current.document.state,
4621
+ resources: current.document.resources,
4622
+ version: current.version
4623
+ };
4624
+ }
4625
+ async write(stage, state, options) {
4626
+ if (options.expectedVersion === null) {
4627
+ const document = {
4628
+ schemaVersion: 2,
4629
+ stage,
4630
+ serial: 1,
4631
+ state,
4632
+ resources: {},
4633
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4634
+ };
4635
+ return this.writeRaw(stage, encodeDocument(document), null);
4636
+ }
4637
+ return this.mutate(stage, options.expectedVersion, (document) => ({
4638
+ ...document,
4639
+ state
4640
+ }));
4641
+ }
4642
+ async putResource(stage, record, options) {
4643
+ return this.mutate(stage, options?.expectedVersion, (document) => ({
4644
+ ...document,
4645
+ resources: {
4646
+ ...document.resources,
4647
+ [record.key]: {
4648
+ ...record,
4649
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4650
+ }
4651
+ }
4652
+ }));
4653
+ }
4654
+ async deleteResource(stage, key, options) {
4655
+ return this.mutate(stage, options?.expectedVersion, (document) => {
4656
+ const { [key]: _removed, ...resources } = document.resources;
4657
+ return {
4658
+ ...document,
4659
+ resources
4660
+ };
4661
+ });
4662
+ }
4663
+ async mutate(stage, expectedVersion, change) {
4664
+ const current = await this.readDocument(stage);
4665
+ if (!current) {
4666
+ if (expectedVersion !== void 0) throw new StateVersionConflict(stage, expectedVersion, null);
4667
+ throw new StageStateMissing(stage);
4668
+ }
4669
+ if (expectedVersion !== void 0 && current.version !== expectedVersion) throw new StateVersionConflict(stage, expectedVersion, current.version);
4670
+ const next = change(current.document);
4671
+ next.serial = current.document.serial + 1;
4672
+ next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4673
+ return this.writeRaw(stage, encodeDocument(next), current.version);
4674
+ }
4675
+ /**
4676
+ * Reads the stage's document, migrating v1 in place on the way. The
4677
+ * migration is itself a conditional write: if another run migrated (or
4678
+ * wrote) first, its result is read instead.
4679
+ */
4680
+ async readDocument(stage, attempt = 0) {
4681
+ const raw = await this.readRaw(stage);
4682
+ if (!raw) return null;
4683
+ const decoded = decodeDocument(stage, raw.body, this.location(stage));
4684
+ if (decoded.schemaVersion === 2) return {
4685
+ document: decoded.document,
4686
+ version: raw.version
4687
+ };
4688
+ await this.writeV1Backup(stage, raw.body);
4689
+ const document = migrateV1(stage, decoded.state);
4690
+ try {
4691
+ return {
4692
+ document,
4693
+ version: await this.writeRaw(stage, encodeDocument(document), raw.version)
4694
+ };
4695
+ } catch (error) {
4696
+ if (error instanceof StateVersionConflict && attempt < 2) return this.readDocument(stage, attempt + 1);
4697
+ throw error;
4698
+ }
4699
+ }
4700
+ };
4701
+ function isStateStore(value) {
4702
+ return typeof value === "object" && value !== null && typeof value.lock === "function" && typeof value.read === "function" && typeof value.write === "function" && typeof value.putResource === "function";
4703
+ }
4704
+ /**
4705
+ * The store for a workspace's `state` config:
4706
+ *
4707
+ * - none / `'local'`: `LocalStateStore` under `.gkm/`
4708
+ * - `'ssm'`: `SSMStateStore`, with no local cache — SSM is read directly, so
4709
+ * a stale local copy can never win over it
4710
+ * - `'s3'`: `S3StateStore`
4711
+ * - a `StateStore` object: used as is
4712
+ * - a `StateProvider` object: wrapped by `LegacyStateStore`
4713
+ */
4714
+ async function createStateStore(options) {
4715
+ const { config, workspaceRoot, workspaceName } = options;
4716
+ if (!config || config.provider === "local") {
4717
+ const { LocalStateStore } = await import("./LocalStateStore-BFLiOocU.mjs").then((n) => n.n);
4718
+ return new LocalStateStore(workspaceRoot);
4719
+ }
4720
+ if (isStateStore(config.provider)) return config.provider;
4721
+ if (isStateProvider(config.provider)) {
4722
+ const { LegacyStateStore } = await import("./LegacyStateStore-CToa2hga.mjs");
4723
+ return new LegacyStateStore(config.provider, { warn: options.warn });
4724
+ }
4725
+ if (config.provider === "ssm") {
4726
+ if (!workspaceName) throw new StateStoreNeedsWorkspaceName("ssm");
4727
+ const { SSMStateStore } = await import("./SSMStateStore-BllaJKGo.mjs");
4728
+ const ssm = config;
4729
+ return SSMStateStore.create({
4730
+ workspaceName,
4731
+ region: ssm.region,
4732
+ profile: ssm.profile
4733
+ });
4734
+ }
4735
+ if (config.provider === "s3") {
4736
+ if (!workspaceName) throw new StateStoreNeedsWorkspaceName("s3");
4737
+ const { S3StateStore } = await import("./S3StateStore-aaGkjcxO.mjs");
4738
+ const s3 = config;
4739
+ return S3StateStore.create({
4740
+ workspaceName,
4741
+ bucket: s3.bucket,
4742
+ prefix: s3.prefix,
4743
+ region: s3.region,
4744
+ profile: s3.profile
4745
+ });
4746
+ }
4747
+ throw new UnknownStateProvider(config);
4748
+ }
4749
+ //#endregion
4750
+ //#region src/deploy/domain.ts
4751
+ /** A stage was deployed that `deploy.domains` gives no domain. */
4752
+ var NoDomainForStage = class extends Error {
4753
+ stage;
4754
+ constructor(stage) {
4755
+ super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
4756
+ this.stage = stage;
4757
+ this.name = "NoDomainForStage";
4758
+ }
4759
+ };
4760
+ /**
4761
+ * The hostname an app answers on for a stage.
4762
+ *
4763
+ * 1. An explicit `app.domain` — a string, or one per stage — wins.
4764
+ * 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
4765
+ * answers on the domain itself, and every other app on
4766
+ * `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
4767
+ *
4768
+ * @throws {NoDomainForStage} when `deploy.domains` names no domain for it
4769
+ */
4770
+ function resolveHost(appName, app, stage, domains, isMainFrontend) {
4771
+ if (app.domain) {
4772
+ if (typeof app.domain === "string") return app.domain;
4773
+ if (app.domain[stage]) return app.domain[stage];
4774
+ }
4775
+ const baseDomain = domains?.[stage];
4776
+ if (!baseDomain) throw new NoDomainForStage(stage);
4777
+ if (isMainFrontend) return baseDomain;
4778
+ return `${app.subdomain ?? appName}.${baseDomain}`;
4779
+ }
4780
+ /**
4781
+ * Whether this app is the site the base domain points at — see `rootSite`,
4782
+ * which the local edge asks too.
4783
+ *
4784
+ * @throws {AmbiguousRootSite} when several sites could be the root and none says it is
4785
+ */
4786
+ function isMainFrontendApp(appName, app, allApps) {
4787
+ if (app.type !== "web") return false;
4788
+ return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
4789
+ name,
4790
+ root: a.root
4791
+ }))) === appName;
4792
+ }
4793
+ /**
4794
+ * Get public URL arg names from app dependencies.
4795
+ *
4796
+ * @param app - The web/mobile app configuration
4797
+ * @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
4798
+ * without a public prefix (e.g. Remix).
4799
+ */
4800
+ function getPublicUrlArgNames(app) {
4801
+ const prefix = getPublicEnvPrefix(app.framework);
4802
+ if (!prefix) return [];
4803
+ return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
4804
+ }
4805
+ //#endregion
4806
+ //#region src/docker/imageRef.ts
4807
+ /**
4808
+ * An image reference, checked before it reaches `docker`.
4809
+ *
4810
+ * The parts come from the workspace — a registry in `gkm.config.ts`, an image
4811
+ * name from the project, a tag from `--tag` — and an argument array already
4812
+ * keeps them out of a shell. What it cannot do is stop `docker` reading a value
4813
+ * that starts with `-` as one of its own flags, so a ref is held to Docker's
4814
+ * own grammar (distribution/reference) and anything else is refused by name
4815
+ * before a build starts.
4816
+ */
4817
+ const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
4818
+ const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
4819
+ const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
4820
+ 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,})?$`);
4821
+ /** Docker refuses a repository name longer than this. */
4822
+ const MAX_NAME_LENGTH = 255;
4823
+ /** A value that is not an image reference was about to be built or pushed. */
4824
+ var ImageRefInvalid = class extends Error {
4825
+ ref;
4826
+ constructor(ref) {
4827
+ 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.`);
4828
+ this.ref = ref;
4829
+ this.name = "ImageRefInvalid";
4830
+ }
4831
+ };
4832
+ /** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
4833
+ function validateImageRef(ref) {
4834
+ const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
4835
+ if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
4836
+ return ref;
4837
+ }
4838
+ //#endregion
4839
+ //#region src/docker/templates.ts
4840
+ const LOCKFILES = [
4841
+ ["pnpm-lock.yaml", "pnpm"],
4842
+ ["bun.lockb", "bun"],
4843
+ ["yarn.lock", "yarn"],
4844
+ ["package-lock.json", "npm"]
4845
+ ];
4846
+ /**
4847
+ * Detect package manager from lockfiles
4848
+ * Walks up the directory tree to find lockfile (for monorepos)
4849
+ */
4850
+ function detectPackageManager(cwd = process.cwd()) {
4851
+ let dir = cwd;
4852
+ const root = parse(dir).root;
4853
+ while (dir !== root) {
4854
+ for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
4855
+ dir = dirname(dir);
4856
+ }
4857
+ for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
4858
+ return "pnpm";
4859
+ }
4860
+ /**
4861
+ * Find the lockfile path by walking up the directory tree
4862
+ * Returns the full path to the lockfile, or null if not found
4863
+ */
4864
+ function findLockfilePath(cwd = process.cwd()) {
4865
+ let dir = cwd;
4866
+ const root = parse(dir).root;
4867
+ while (dir !== root) {
4868
+ for (const [lockfile] of LOCKFILES) {
4869
+ const lockfilePath = join(dir, lockfile);
4870
+ if (existsSync(lockfilePath)) return lockfilePath;
4871
+ }
4872
+ dir = dirname(dir);
4873
+ }
4874
+ for (const [lockfile] of LOCKFILES) {
4875
+ const lockfilePath = join(root, lockfile);
4876
+ if (existsSync(lockfilePath)) return lockfilePath;
4877
+ }
4878
+ return null;
4879
+ }
4880
+ /**
4881
+ * Check if we're in a monorepo (lockfile is in a parent directory)
4882
+ */
4883
+ function isMonorepo(cwd = process.cwd()) {
4884
+ const lockfilePath = findLockfilePath(cwd);
4885
+ if (!lockfilePath) return false;
4886
+ return dirname(lockfilePath) !== cwd;
4887
+ }
4888
+ /**
4889
+ * Check if turbo.json exists (walks up directory tree)
4890
+ */
4891
+ function hasTurboConfig(cwd = process.cwd()) {
4892
+ let dir = cwd;
4893
+ const root = parse(dir).root;
4894
+ while (dir !== root) {
4895
+ if (existsSync(join(dir, "turbo.json"))) return true;
4896
+ dir = dirname(dir);
4897
+ }
4898
+ return existsSync(join(root, "turbo.json"));
4899
+ }
4900
+ /**
4901
+ * Get install command for turbo builds (without frozen lockfile)
4902
+ * Turbo prune creates a subset that may not perfectly match the lockfile
4903
+ */
4904
+ function getTurboInstallCmd(pm) {
4905
+ return {
4906
+ pnpm: "pnpm install",
4907
+ npm: "npm install",
4908
+ yarn: "yarn install",
4909
+ bun: "bun install"
4910
+ }[pm];
4911
+ }
4912
+ /**
4913
+ * Get package manager specific commands and paths
4914
+ */
4915
+ function getPmConfig(pm) {
4916
+ return {
4917
+ pnpm: {
4918
+ install: "corepack enable && corepack prepare pnpm@latest --activate",
4919
+ lockfile: "pnpm-lock.yaml",
4920
+ fetch: "pnpm fetch",
4921
+ installCmd: "pnpm install --frozen-lockfile --offline",
4922
+ cacheTarget: "/root/.local/share/pnpm/store",
4923
+ cacheId: "pnpm",
4924
+ run: "pnpm",
4925
+ exec: "pnpm exec",
4926
+ dlx: "pnpm dlx",
4927
+ addGlobal: "pnpm add -g"
4928
+ },
4929
+ npm: {
4930
+ install: "",
4931
+ lockfile: "package-lock.json",
4932
+ fetch: "",
4933
+ installCmd: "npm ci",
4934
+ cacheTarget: "/root/.npm",
4935
+ cacheId: "npm",
4936
+ run: "npm run",
4937
+ exec: "npx",
4938
+ dlx: "npx",
4939
+ addGlobal: "npm install -g"
4940
+ },
4941
+ yarn: {
4942
+ install: "corepack enable && corepack prepare yarn@stable --activate",
4943
+ lockfile: "yarn.lock",
4944
+ fetch: "",
4945
+ installCmd: "yarn install --frozen-lockfile",
4946
+ cacheTarget: "/root/.yarn/cache",
4947
+ cacheId: "yarn",
4948
+ run: "yarn",
4949
+ exec: "yarn exec",
4950
+ dlx: "yarn dlx",
4951
+ addGlobal: "yarn global add"
4952
+ },
4953
+ bun: {
4954
+ install: "npm install -g bun",
4955
+ lockfile: "bun.lockb",
4956
+ fetch: "",
4957
+ installCmd: "bun install --frozen-lockfile",
4958
+ cacheTarget: "/root/.bun/install/cache",
4959
+ cacheId: "bun",
4960
+ run: "bun run",
4961
+ exec: "bunx",
4962
+ dlx: "bunx",
4963
+ addGlobal: "bun add -g"
4964
+ }
4965
+ }[pm];
4966
+ }
4967
+ /**
4968
+ * Generate a multi-stage Dockerfile for building from source
4969
+ * Optimized for build speed with:
4970
+ * - BuildKit cache mounts for package manager store
4971
+ * - pnpm fetch for better layer caching (when using pnpm)
4972
+ * - Optional turbo prune for monorepos
4973
+ */
4974
+ function generateMultiStageDockerfile(options) {
4975
+ const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
4976
+ if (turbo) return generateTurboDockerfile({
4977
+ ...options,
4978
+ turboPackage: turboPackage ?? "api"
4979
+ });
4980
+ const pm = getPmConfig(packageManager);
4981
+ return `# syntax=docker/dockerfile:1
4982
+ # Stage 1: Dependencies
4983
+ FROM ${baseImage} AS deps
4984
+
4985
+ WORKDIR /app
4986
+ ${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
4987
+ ${packageManager === "pnpm" ? `# Copy lockfile first for better caching
4988
+ COPY ${pm.lockfile} ./
4989
+
4990
+ # Fetch dependencies (downloads to virtual store, cached separately)
4991
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4992
+ ${pm.fetch}
4993
+
4994
+ # Copy package.json after fetch
4995
+ COPY package.json ./
4996
+
4997
+ # Install from cache (fast - no network needed)
4998
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
4999
+ ${pm.installCmd}` : `# Copy package files
5000
+ COPY package.json ${pm.lockfile} ./
5001
+
5002
+ # Install dependencies with cache
5003
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5004
+ ${pm.installCmd}`}
5005
+
5006
+ # Stage 2: Build
5007
+ FROM deps AS builder
5008
+
5009
+ WORKDIR /app
5010
+
5011
+ # Copy source (deps already installed)
5012
+ COPY . .
5013
+
5014
+ # Build any workspace packages the app depends on.
5015
+ #
5016
+ # A no-op for a project whose dependencies come from a registry, and required
5017
+ # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
5018
+ # workspace package's \`dist\` never reaches the image and its bin has nothing to
5019
+ # import. \`--if-present\` is what makes the same Dockerfile correct for both.
5020
+ RUN ${pm.run} --if-present build
5021
+
5022
+ # Build production server using gkm
5023
+ RUN ${pm.exec} gkm build --provider server --production
5024
+
5025
+ # Stage 3: Production
5026
+ FROM ${baseImage} AS runner
5027
+
5028
+ WORKDIR /app
5029
+
5030
+ # Install tini for proper signal handling as PID 1
5031
+ RUN apk add --no-cache tini
5032
+
5033
+ # Create non-root user
5034
+ RUN addgroup --system --gid 1001 nodejs && \\
5035
+ adduser --system --uid 1001 hono
5036
+
5037
+ # Copy bundled server
5038
+ COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
5039
+
5040
+ # Environment
5041
+ ENV NODE_ENV=production
5042
+ ENV PORT=${port}
5043
+
5044
+ # Health check
5045
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
5046
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
5047
+
5048
+ # Switch to non-root user
5049
+ USER hono
5050
+
5051
+ EXPOSE ${port}
5052
+
5053
+ # Use tini as entrypoint to handle PID 1 responsibilities
5054
+ ENTRYPOINT ["/sbin/tini", "--"]
5055
+ CMD ["node", "server.mjs"]
5056
+ `;
5057
+ }
5058
+ /**
5059
+ * Generate a Dockerfile optimized for Turbo monorepos
5060
+ * Uses turbo prune to create minimal Docker context
5061
+ */
5062
+ function generateTurboDockerfile(options) {
5063
+ const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
5064
+ const pm = getPmConfig(packageManager);
5065
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
5066
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
5067
+ return `# syntax=docker/dockerfile:1
5068
+ # Stage 1: Prune monorepo
5069
+ FROM ${baseImage} AS pruner
5070
+
5071
+ WORKDIR /app
5072
+
5073
+ ${installPm}
5074
+
5075
+ COPY . .
5076
+
5077
+ # Prune to only include necessary packages
5078
+ RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
5079
+
5080
+ # Stage 2: Install dependencies
5081
+ FROM ${baseImage} AS deps
5082
+
5083
+ WORKDIR /app
5084
+
5085
+ ${installPm}
5086
+
5087
+ # Copy pruned lockfile and package.jsons
5088
+ COPY --from=pruner /app/out/${pm.lockfile} ./
5089
+ COPY --from=pruner /app/out/json/ ./
5090
+
5091
+ # Install dependencies (no frozen-lockfile since turbo prune creates a subset)
5092
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5093
+ ${turboInstallCmd}
5094
+
5095
+ # Stage 3: Build
5096
+ FROM deps AS builder
5097
+
5098
+ WORKDIR /app
5099
+
5100
+ # Copy pruned source
5101
+ COPY --from=pruner /app/out/full/ ./
5102
+
5103
+ # Build any workspace packages the app depends on.
5104
+ #
5105
+ # A no-op for a project whose dependencies come from a registry, and required
5106
+ # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
5107
+ # workspace package's \`dist\` never reaches the image and its bin has nothing to
5108
+ # import. \`--if-present\` is what makes the same Dockerfile correct for both.
5109
+ RUN ${pm.run} --if-present build
5110
+
5111
+ # Build production server using gkm
5112
+ RUN ${pm.exec} gkm build --provider server --production
5113
+
5114
+ # Stage 4: Production
5115
+ FROM ${baseImage} AS runner
5116
+
5117
+ WORKDIR /app
5118
+
5119
+ RUN apk add --no-cache tini
5120
+
5121
+ RUN addgroup --system --gid 1001 nodejs && \\
5122
+ adduser --system --uid 1001 hono
5123
+
5124
+ COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
5125
+
5126
+ ENV NODE_ENV=production
5127
+ ENV PORT=${port}
5128
+
5129
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
5130
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
5131
+
5132
+ USER hono
5133
+
5134
+ EXPOSE ${port}
5135
+
5136
+ ENTRYPOINT ["/sbin/tini", "--"]
5137
+ CMD ["node", "server.mjs"]
5138
+ `;
5139
+ }
5140
+ /**
5141
+ * Generate a slim Dockerfile for pre-built bundles
5142
+ */
5143
+ function generateSlimDockerfile(options) {
5144
+ const { baseImage, port, healthCheckPath } = options;
5145
+ return `# Slim Dockerfile for pre-built production bundle
5146
+ FROM ${baseImage}
5147
+
5148
+ WORKDIR /app
5149
+
5150
+ # Install tini for proper signal handling as PID 1
5151
+ # Handles SIGTERM propagation and zombie process reaping
5152
+ RUN apk add --no-cache tini
5153
+
5154
+ # Create non-root user
5155
+ RUN addgroup --system --gid 1001 nodejs && \\
5156
+ adduser --system --uid 1001 hono
5157
+
5158
+ # Copy pre-built bundle
5159
+ COPY .gkm/server/dist/server.mjs ./
5160
+
5161
+ # Environment
5162
+ ENV NODE_ENV=production
5163
+ ENV PORT=${port}
5164
+
5165
+ # Health check
5166
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
5167
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
5168
+
5169
+ # Switch to non-root user
5170
+ USER hono
5171
+
5172
+ EXPOSE ${port}
5173
+
5174
+ # Use tini as entrypoint to handle PID 1 responsibilities
5175
+ ENTRYPOINT ["/sbin/tini", "--"]
5176
+ CMD ["node", "server.mjs"]
5177
+ `;
5178
+ }
5179
+ /**
5180
+ * Generate .dockerignore file
5181
+ */
5182
+ function generateDockerignore() {
5183
+ return `# Dependencies
5184
+ node_modules
5185
+ .pnpm-store
5186
+
5187
+ # Build output (except what we need)
5188
+ .gkm/aws*
5189
+ .gkm/server/*.ts
5190
+ !.gkm/server/dist
5191
+
5192
+ # The key gkm build --stage encrypted secrets with: runtime env only, never
5193
+ # the build context
5194
+ .gkm/server/master.key
5195
+
5196
+ # IDE and editor
5197
+ .idea
5198
+ .vscode
5199
+ *.swp
5200
+ *.swo
5201
+
5202
+ # Git
5203
+ .git
5204
+ .gitignore
5205
+
5206
+ # Logs
5207
+ *.log
5208
+ npm-debug.log*
5209
+ pnpm-debug.log*
5210
+
5211
+ # Test files
5212
+ **/*.test.ts
5213
+ **/*.spec.ts
5214
+ **/__tests__
5215
+ coverage
5216
+
5217
+ # Documentation
5218
+ docs
5219
+ *.md
5220
+ !README.md
5221
+
5222
+ # Environment files (handle secrets separately)
5223
+ .env
5224
+ .env.*
5225
+ !.env.example
5226
+
5227
+ # Docker files (don't copy recursively)
5228
+ Dockerfile*
5229
+ docker-compose*
5230
+ .dockerignore
5231
+ `;
5232
+ }
5233
+ /**
5234
+ * Generate docker-entrypoint.sh for custom startup logic
5235
+ */
5236
+ function generateDockerEntrypoint() {
5237
+ return `#!/bin/sh
5238
+ set -e
5239
+
5240
+ # Run any custom startup scripts here
5241
+ # Example: wait for database
5242
+ # until nc -z $DB_HOST $DB_PORT; do
5243
+ # echo "Waiting for database..."
5244
+ # sleep 1
5245
+ # done
5246
+
5247
+ # Execute the main command
5248
+ exec "$@"
5249
+ `;
5250
+ }
5251
+ /**
5252
+ * Resolve Docker configuration from GkmConfig with defaults
5253
+ */
5254
+ function resolveDockerConfig(config) {
5255
+ const docker = config.docker ?? {};
5256
+ return {
5257
+ registry: docker.registry ?? "",
5258
+ imageName: docker.imageName ?? config.name ?? "api",
5259
+ baseImage: docker.baseImage ?? "node:22-alpine",
5260
+ port: docker.port ?? 3e3
5261
+ };
5262
+ }
5263
+ /**
5264
+ * Generate a Dockerfile for Next.js frontend apps using standalone output.
5265
+ * Uses turbo prune for monorepo optimization.
5266
+ * @internal Exported for testing
5267
+ */
5268
+ function generateNextjsDockerfile(options) {
5269
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
5270
+ const pm = getPmConfig(packageManager);
5271
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
5272
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
5273
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
5274
+ const publicUrlArgDeclarations = publicUrlArgs.map((arg) => `ARG ${arg}=""`).join("\n");
5275
+ const publicUrlEnvDeclarations = publicUrlArgs.map((arg) => `ENV ${arg}=$${arg}`).join("\n");
5276
+ return `# syntax=docker/dockerfile:1
5277
+ # Next.js standalone Dockerfile with turbo prune optimization
5278
+
5279
+ # Stage 1: Prune monorepo
5280
+ FROM ${baseImage} AS pruner
5281
+
5282
+ WORKDIR /app
5283
+
5284
+ ${installPm}
5285
+
5286
+ COPY . .
5287
+
5288
+ # Prune to only include necessary packages
5289
+ RUN ${turboCmd} prune ${turboPackage} --docker
5290
+
5291
+ # Stage 2: Install dependencies
5292
+ FROM ${baseImage} AS deps
5293
+
5294
+ WORKDIR /app
5295
+
5296
+ ${installPm}
5297
+
5298
+ # Copy pruned lockfile and package.jsons
5299
+ COPY --from=pruner /app/out/${pm.lockfile} ./
5300
+ COPY --from=pruner /app/out/json/ ./
5301
+
5302
+ # Install dependencies
5303
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5304
+ ${turboInstallCmd}
5305
+
5306
+ # Stage 3: Build
5307
+ FROM deps AS builder
5308
+
5309
+ WORKDIR /app
5310
+
5311
+ # Build-time args for public API URLs (populated by gkm deploy)
5312
+ # These get baked into the Next.js build as public environment variables
5313
+ ${publicUrlArgDeclarations}
5314
+
5315
+ # Convert ARGs to ENVs for Next.js build
5316
+ ${publicUrlEnvDeclarations}
5317
+
5318
+ # Copy pruned source
5319
+ COPY --from=pruner /app/out/full/ ./
5320
+
5321
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
5322
+ # Using wildcard to make it optional for single-app projects
5323
+ COPY --from=pruner /app/tsconfig.* ./
5324
+
5325
+ # Ensure public directory exists (may be empty for scaffolded projects)
5326
+ RUN mkdir -p ${appPath}/public
5327
+
5328
+ # Set Next.js to produce standalone output
5329
+ ENV NEXT_TELEMETRY_DISABLED=1
5330
+
5331
+ # Build the application
5332
+ RUN ${turboCmd} run build --filter=${turboPackage}
5333
+
5334
+ # Stage 4: Production
5335
+ FROM ${baseImage} AS runner
5336
+
5337
+ WORKDIR /app
5338
+
5339
+ # Install tini for proper signal handling
5340
+ RUN apk add --no-cache tini
5341
+
5342
+ # Create non-root user
5343
+ RUN addgroup --system --gid 1001 nodejs && \\
5344
+ adduser --system --uid 1001 nextjs
5345
+
5346
+ # Set environment
5347
+ ENV NODE_ENV=production
5348
+ ENV NEXT_TELEMETRY_DISABLED=1
5349
+ ENV PORT=${port}
5350
+ ENV HOSTNAME="0.0.0.0"
5351
+
5352
+ # Copy static files and standalone output
5353
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
5354
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
5355
+ COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
5356
+
5357
+ USER nextjs
5358
+
5359
+ EXPOSE ${port}
5360
+
5361
+ ENTRYPOINT ["/sbin/tini", "--"]
5362
+ CMD ["node", "${appPath}/server.js"]
5363
+ `;
5364
+ }
5365
+ /**
5366
+ * Generate a Dockerfile for backend apps in a workspace.
5367
+ * Uses turbo prune for monorepo optimization.
5368
+ * @internal Exported for testing
5369
+ */
5370
+ function generateBackendDockerfile(options) {
5371
+ const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
5372
+ const pm = getPmConfig(packageManager);
5373
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
5374
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
5375
+ return `# syntax=docker/dockerfile:1
5376
+ # Backend Dockerfile with turbo prune optimization
5377
+
5378
+ # Stage 1: Prune monorepo
5379
+ FROM ${baseImage} AS pruner
5380
+
5381
+ WORKDIR /app
5382
+
5383
+ ${installPm}
5384
+
5385
+ COPY . .
5386
+
5387
+ # Prune to only include necessary packages
5388
+ RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
5389
+
5390
+ # Stage 2: Install dependencies
5391
+ FROM ${baseImage} AS deps
5392
+
5393
+ WORKDIR /app
5394
+
5395
+ ${installPm}
5396
+
5397
+ # Copy pruned lockfile and package.jsons
5398
+ COPY --from=pruner /app/out/${pm.lockfile} ./
5399
+ COPY --from=pruner /app/out/json/ ./
5400
+
5401
+ # Install dependencies
5402
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5403
+ ${turboInstallCmd}
5404
+
5405
+ # Stage 3: Build
5406
+ FROM deps AS builder
5407
+
5408
+ WORKDIR /app
5409
+
5410
+ # Copy pruned source
5411
+ COPY --from=pruner /app/out/full/ ./
5412
+
5413
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
5414
+ # Using wildcard to make it optional for single-app projects
5415
+ COPY --from=pruner /app/gkm.config.* ./
5416
+ COPY --from=pruner /app/tsconfig.* ./
5417
+
5418
+ # Write encrypted credentials for gkm build to embed.
5419
+ # A build secret (\`docker build --secret id=gkm_credentials,src=…\`), not an
5420
+ # ARG: an ARG is recorded in the image history. Two lines, ciphertext then IV;
5421
+ # absent when the app has no secrets.
5422
+ RUN --mount=type=secret,id=gkm_credentials,required=false \
5423
+ if [ -s /run/secrets/gkm_credentials ]; then \
5424
+ mkdir -p ${appPath}/.gkm && \
5425
+ sed -n 1p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.enc && \
5426
+ sed -n 2p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.iv; \
5427
+ fi
5428
+
5429
+ # Build any workspace packages the app depends on.
5430
+ #
5431
+ # A no-op for a project whose dependencies come from a registry, and required
5432
+ # for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
5433
+ # workspace package's \`dist\` never reaches the image and its bin has nothing to
5434
+ # import. \`--if-present\` is what makes the same Dockerfile correct for both.
5435
+ RUN ${pm.run} --if-present build
5436
+
5437
+ # Build production server using gkm
5438
+ RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
5439
+
5440
+ # Stage 4: Production
5441
+ FROM ${baseImage} AS runner
5442
+
5443
+ WORKDIR /app
5444
+
5445
+ RUN apk add --no-cache tini
5446
+
5447
+ RUN addgroup --system --gid 1001 nodejs && \\
5448
+ adduser --system --uid 1001 hono
5449
+
5450
+ # Copy bundled server
5451
+ COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
5452
+
5453
+ ENV NODE_ENV=production
5454
+ ENV PORT=${port}
5455
+
5456
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
5457
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
5458
+
5459
+ USER hono
5460
+
5461
+ EXPOSE ${port}
5462
+
5463
+ ENTRYPOINT ["/sbin/tini", "--"]
5464
+ CMD ["node", "server.mjs"]
5465
+ `;
5466
+ }
5467
+ /**
5468
+ * Generate a Dockerfile for apps with a custom entry point.
5469
+ * Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
5470
+ * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
5471
+ * @internal Exported for testing
5472
+ */
5473
+ function generateEntryDockerfile(options) {
5474
+ const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
5475
+ const pm = getPmConfig(packageManager);
5476
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
5477
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
5478
+ return `# syntax=docker/dockerfile:1
5479
+ # Entry-based Dockerfile with turbo prune + tsdown bundling
5480
+
5481
+ # Stage 1: Prune monorepo
5482
+ FROM ${baseImage} AS pruner
5483
+
5484
+ WORKDIR /app
5485
+
5486
+ ${installPm}
5487
+
5488
+ COPY . .
5489
+
5490
+ # Prune to only include necessary packages
5491
+ RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
5492
+
5493
+ # Stage 2: Install dependencies
5494
+ FROM ${baseImage} AS deps
5495
+
5496
+ WORKDIR /app
5497
+
5498
+ ${installPm}
5499
+
5500
+ # Copy pruned lockfile and package.jsons
5501
+ COPY --from=pruner /app/out/${pm.lockfile} ./
5502
+ COPY --from=pruner /app/out/json/ ./
5503
+
5504
+ # Install dependencies
5505
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5506
+ ${turboInstallCmd}
5507
+
5508
+ # Stage 3: Build with tsdown
5509
+ FROM deps AS builder
5510
+
5511
+ WORKDIR /app
5512
+
5513
+ # Copy pruned source
5514
+ COPY --from=pruner /app/out/full/ ./
5515
+
5516
+ # Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
5517
+ # Using wildcard to make it optional for single-app projects
5518
+ COPY --from=pruner /app/tsconfig.* ./
5519
+
5520
+ # Bundle entry point with esbuild (outputs to dist/index.mjs)
5521
+ # Creates a fully standalone bundle with all dependencies included
5522
+ # Use define to embed credentials if present.
5523
+ # They arrive as a build secret (\`docker build --secret id=gkm_credentials,src=…\`),
5524
+ # not an ARG: an ARG is recorded in the image history. Two lines, ciphertext
5525
+ # then IV; absent when the app has no secrets.
5526
+ RUN --mount=type=secret,id=gkm_credentials,required=false \
5527
+ cd ${appPath} && \
5528
+ if [ -s /run/secrets/gkm_credentials ]; then \
5529
+ CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \
5530
+ IV=$(sed -n 2p /run/secrets/gkm_credentials) && \
5531
+ npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
5532
+ --outfile=dist/index.mjs --packages=bundle \
5533
+ --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
5534
+ --define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
5535
+ --define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
5536
+ else \
5537
+ npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
5538
+ --outfile=dist/index.mjs --packages=bundle \
5539
+ --banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
5540
+ fi
5541
+
5542
+ # Stage 4: Production
5543
+ FROM ${baseImage} AS runner
5544
+
5545
+ WORKDIR /app
5546
+
5547
+ RUN apk add --no-cache tini
5548
+
5549
+ RUN addgroup --system --gid 1001 nodejs && \\
5550
+ adduser --system --uid 1001 app
5551
+
5552
+ # Copy bundled output only (no node_modules needed - fully bundled)
5553
+ COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
5554
+
5555
+ ENV NODE_ENV=production
5556
+ ENV PORT=${port}
5557
+
5558
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
5559
+ CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
5560
+
5561
+ USER app
5562
+
5563
+ EXPOSE ${port}
5564
+
5565
+ ENTRYPOINT ["/sbin/tini", "--"]
5566
+ CMD ["node", "index.mjs"]
5567
+ `;
5568
+ }
5569
+ /**
5570
+ * Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
5571
+ * Remix). Builds with turbo, then runs the framework's production start
5572
+ * script via `npm start`.
5573
+ *
5574
+ * Public URL build args are declared so framework bundlers (Vite, Vinxi)
5575
+ * can inline them at build time. Per-framework prefix is the caller's job.
5576
+ * @internal Exported for testing
5577
+ */
5578
+ function generateNodeWebDockerfile(options) {
5579
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
5580
+ const pm = getPmConfig(packageManager);
5581
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
5582
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
5583
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
5584
+ const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
5585
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
5586
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
5587
+ return `# syntax=docker/dockerfile:1
5588
+ # Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
5589
+
5590
+ FROM ${baseImage} AS pruner
5591
+ WORKDIR /app
5592
+ ${installPm}
5593
+ COPY . .
5594
+ RUN ${turboCmd} prune ${turboPackage} --docker
5595
+
5596
+ FROM ${baseImage} AS deps
5597
+ WORKDIR /app
5598
+ ${installPm}
5599
+ COPY --from=pruner /app/out/${pm.lockfile} ./
5600
+ COPY --from=pruner /app/out/json/ ./
5601
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5602
+ ${turboInstallCmd}
5603
+
5604
+ FROM deps AS builder
5605
+ WORKDIR /app
5606
+ ${argDecls}
5607
+ ${envDecls}
5608
+ COPY --from=pruner /app/out/full/ ./
5609
+ COPY --from=pruner /app/tsconfig.* ./
5610
+ RUN ${turboCmd} run build --filter=${turboPackage}
5611
+
5612
+ FROM ${baseImage} AS runner
5613
+ WORKDIR /app
5614
+ RUN apk add --no-cache tini
5615
+ RUN addgroup --system --gid 1001 nodejs && \\
5616
+ adduser --system --uid 1001 app
5617
+
5618
+ # Copy the whole built workspace — frameworks differ on output paths
5619
+ COPY --from=builder --chown=app:nodejs /app/ ./
5620
+
5621
+ ENV NODE_ENV=production
5622
+ ENV PORT=${port}
5623
+ ENV HOSTNAME="0.0.0.0"
5624
+
5625
+ USER app
5626
+ EXPOSE ${port}
5627
+
5628
+ ENTRYPOINT ["/sbin/tini", "--"]
5629
+ CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
5630
+ `;
5631
+ }
5632
+ /**
5633
+ * Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
5634
+ * turbo, then serves it via nginx.
5635
+ * @internal Exported for testing
5636
+ */
5637
+ function generateViteStaticDockerfile(options) {
5638
+ const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
5639
+ const pm = getPmConfig(packageManager);
5640
+ const installPm = pm.install ? `RUN ${pm.install}` : "";
5641
+ const turboInstallCmd = getTurboInstallCmd(packageManager);
5642
+ const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
5643
+ const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
5644
+ const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
5645
+ return `# syntax=docker/dockerfile:1
5646
+ # Vite SPA Dockerfile — builds static assets, serves via nginx
5647
+
5648
+ FROM ${baseImage} AS pruner
5649
+ WORKDIR /app
5650
+ ${installPm}
5651
+ COPY . .
5652
+ RUN ${turboCmd} prune ${turboPackage} --docker
5653
+
5654
+ FROM ${baseImage} AS deps
5655
+ WORKDIR /app
5656
+ ${installPm}
5657
+ COPY --from=pruner /app/out/${pm.lockfile} ./
5658
+ COPY --from=pruner /app/out/json/ ./
5659
+ RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
5660
+ ${turboInstallCmd}
5661
+
5662
+ FROM deps AS builder
5663
+ WORKDIR /app
5664
+ ${argDecls}
5665
+ ${envDecls}
5666
+ COPY --from=pruner /app/out/full/ ./
5667
+ COPY --from=pruner /app/tsconfig.* ./
5668
+ RUN ${turboCmd} run build --filter=${turboPackage}
5669
+
5670
+ FROM nginx:alpine AS runner
5671
+ COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
5672
+ 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' \\
5673
+ > /etc/nginx/conf.d/default.conf
5674
+ EXPOSE ${port}
5675
+ CMD ["nginx", "-g", "daemon off;"]
5676
+ `;
5677
+ }
5678
+ //#endregion
5679
+ //#region src/docker/index.ts
5680
+ const logger = output;
5681
+ /** `docker build` failed; what docker printed above says why. */
5682
+ var DockerBuildFailed = class extends Error {
5683
+ imageRef;
5684
+ constructor(imageRef, cause) {
5685
+ super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
5686
+ this.imageRef = imageRef;
5687
+ this.name = "DockerBuildFailed";
5688
+ }
5689
+ };
5690
+ /** `docker push` failed — usually the registry login or its permissions. */
5691
+ var DockerPushFailed = class extends Error {
5692
+ imageRef;
5693
+ constructor(imageRef, cause) {
5694
+ super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
5695
+ this.imageRef = imageRef;
5696
+ this.name = "DockerPushFailed";
5697
+ }
5698
+ };
5699
+ /** `gkm docker --push` was asked for with nowhere to push to. */
5700
+ var PushNeedsRegistry = class extends Error {
5701
+ imageName;
5702
+ constructor(imageName) {
5703
+ super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
5704
+ this.imageName = imageName;
5705
+ this.name = "PushNeedsRegistry";
5706
+ }
5707
+ };
5708
+ /**
5709
+ * Docker command implementation
5710
+ * Generates Dockerfile, docker-compose.yml, and related files
5711
+ *
5712
+ * Default: Multi-stage Dockerfile that builds from source inside Docker
5713
+ * --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
5714
+ */
5715
+ async function dockerCommand(options) {
5716
+ const cwd = options.cwd ?? process.cwd();
5717
+ const loadedConfig = await loadWorkspaceConfig(cwd);
5718
+ if (loadedConfig.type === "workspace") {
5719
+ logger.log("📦 Detected workspace configuration");
5720
+ return workspaceDockerCommand(loadedConfig.workspace);
5721
+ }
5722
+ const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
5723
+ const healthCheckPath = "/health";
5724
+ const useSlim = options.slim === true;
5725
+ if (useSlim) {
5726
+ const distDir = join(cwd, ".gkm", "server", "dist");
5727
+ 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.");
5728
+ }
5729
+ const dockerDir = join(cwd, ".gkm", "docker");
5730
+ await mkdir(dockerDir, { recursive: true });
5731
+ const packageManager = detectPackageManager(cwd);
5732
+ const inMonorepo = isMonorepo(cwd);
5733
+ const hasTurbo = hasTurboConfig(cwd);
5734
+ let useTurbo = options.turbo ?? false;
5735
+ if (inMonorepo && !useSlim) {
5736
+ if (hasTurbo) {
5737
+ useTurbo = true;
5738
+ logger.log(" Detected monorepo with turbo.json - using turbo prune");
5739
+ } 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");
5740
+ }
5741
+ let turboPackage = options.turboPackage ?? dockerConfig.imageName;
5742
+ if (useTurbo && !options.turboPackage) try {
5743
+ const pkg = __require(`${cwd}/package.json`);
5744
+ if (pkg.name) {
5745
+ turboPackage = pkg.name;
5746
+ logger.log(` Turbo package: ${turboPackage}`);
5747
+ }
5748
+ } catch {}
5749
+ const templateOptions = {
5750
+ imageName: dockerConfig.imageName,
5751
+ baseImage: dockerConfig.baseImage,
5752
+ port: dockerConfig.port,
5753
+ healthCheckPath,
5754
+ prebuilt: useSlim,
5755
+ turbo: useTurbo,
5756
+ turboPackage,
5757
+ packageManager
5758
+ };
5759
+ const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
5760
+ const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
5761
+ const dockerfilePath = join(dockerDir, "Dockerfile");
5762
+ await writeFile(dockerfilePath, dockerfile);
5763
+ logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
5764
+ const composePath = await writeConstructsCompose(loadedConfig.workspace);
5765
+ const dockerignore = generateDockerignore();
5766
+ const dockerignorePath = join(cwd, ".dockerignore");
5767
+ await writeFile(dockerignorePath, dockerignore);
5768
+ logger.log("Generated: .dockerignore (project root)");
5769
+ const entrypoint = generateDockerEntrypoint();
5770
+ const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
5771
+ await writeFile(entrypointPath, entrypoint);
5772
+ logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
5773
+ const result = {
5774
+ dockerfile: dockerfilePath,
5775
+ dockerCompose: composePath,
5776
+ dockerignore: dockerignorePath,
5777
+ entrypoint: entrypointPath
5778
+ };
5779
+ if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
5780
+ if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
5781
+ return result;
5782
+ }
5783
+ /**
5784
+ * Ensure lockfile exists in the build context
5785
+ * For monorepos, copies from workspace root if needed
5786
+ * Returns cleanup function if file was copied
5787
+ */
5788
+ function ensureLockfile(cwd) {
5789
+ const lockfilePath = findLockfilePath(cwd);
5790
+ if (!lockfilePath) {
5791
+ logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
5792
+ return null;
5793
+ }
5794
+ const lockfileName = basename(lockfilePath);
5795
+ const localLockfile = join(cwd, lockfileName);
5796
+ if (lockfilePath === localLockfile) return null;
5797
+ logger.log(` Copying ${lockfileName} from monorepo root...`);
5798
+ copyFileSync(lockfilePath, localLockfile);
5799
+ return () => {
5800
+ try {
5801
+ unlinkSync(localLockfile);
5802
+ } catch {}
5803
+ };
5804
+ }
5805
+ /**
5806
+ * Build Docker image
5807
+ * Uses BuildKit for cache mount support
5808
+ */
5809
+ async function buildDockerImage(imageName, options, cwd) {
5810
+ const tag = options.tag ?? "latest";
5811
+ const registry = options.registry;
5812
+ const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
5813
+ logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
5814
+ const cleanup = ensureLockfile(cwd);
5815
+ try {
5816
+ await run("docker", [
5817
+ "build",
5818
+ "--file=.gkm/docker/Dockerfile",
5819
+ `--tag=${fullImageName}`,
5820
+ "."
5821
+ ], {
5822
+ cwd,
5823
+ env: {
5824
+ ...process.env,
5825
+ DOCKER_BUILDKIT: "1"
5826
+ }
5827
+ });
5828
+ logger.log(`✅ Docker image built: ${fullImageName}`);
5829
+ } catch (error) {
5830
+ throw new DockerBuildFailed(fullImageName, error);
5831
+ } finally {
5832
+ cleanup?.();
5833
+ }
5834
+ }
5835
+ /**
5836
+ * Push Docker image to registry
5837
+ */
5838
+ async function pushDockerImage(imageName, options, cwd) {
5839
+ const tag = options.tag ?? "latest";
5840
+ const registry = options.registry;
5841
+ if (!registry) throw new PushNeedsRegistry(imageName);
5842
+ const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
5843
+ logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
5844
+ try {
5845
+ await run("docker", ["push", fullImageName], { cwd });
5846
+ logger.log(`✅ Docker image pushed: ${fullImageName}`);
5847
+ } catch (error) {
5848
+ throw new DockerPushFailed(fullImageName, error);
5849
+ }
5850
+ }
5851
+ /**
5852
+ * Get the package name from package.json in an app directory.
5853
+ */
5854
+ function getAppPackageName(appPath) {
5855
+ try {
5856
+ const pkgPath = join(appPath, "package.json");
5857
+ if (!existsSync(pkgPath)) return;
5858
+ const content = readFileSync(pkgPath, "utf-8");
5859
+ return JSON.parse(content).name;
5860
+ } catch {
5861
+ return;
5862
+ }
5863
+ }
5864
+ /**
5865
+ * Generate Dockerfiles for all apps in a workspace.
5866
+ * @internal Exported for testing
5867
+ */
5868
+ async function workspaceDockerCommand(workspace) {
5869
+ const results = [];
5870
+ const apps = Object.entries(workspace.apps);
5871
+ logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
5872
+ const dockerDir = join(workspace.root, ".gkm", "docker");
5873
+ await mkdir(dockerDir, { recursive: true });
5874
+ const packageManager = detectPackageManager(workspace.root);
5875
+ logger.log(` Package manager: ${packageManager}`);
5876
+ for (const [appName, app] of apps) {
5877
+ if (app.type === "mobile") {
5878
+ logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
5879
+ continue;
5880
+ }
5881
+ const appPath = app.path;
5882
+ const turboPackage = getAppPackageName(join(workspace.root, appPath)) ?? appName;
5883
+ const imageName = appName;
5884
+ const buildType = !!app.entry ? "entry" : app.type;
5885
+ logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
5886
+ let dockerfile;
5887
+ if (app.type === "web") {
5888
+ const publicUrlArgs = getPublicUrlArgNames(app);
5889
+ const webOpts = {
5890
+ imageName,
5891
+ baseImage: "node:22-alpine",
5892
+ port: app.port,
5893
+ appPath,
5894
+ turboPackage,
5895
+ packageManager,
5896
+ publicUrlArgs
5897
+ };
5898
+ switch (app.framework) {
5899
+ case "vite":
5900
+ dockerfile = generateViteStaticDockerfile(webOpts);
5901
+ break;
5902
+ case "tanstack-start":
5903
+ case "remix":
5904
+ dockerfile = generateNodeWebDockerfile(webOpts);
5905
+ break;
5906
+ default: dockerfile = generateNextjsDockerfile(webOpts);
5907
+ }
5908
+ } else if (app.entry) dockerfile = generateEntryDockerfile({
5909
+ imageName,
5910
+ baseImage: "node:22-alpine",
5911
+ port: app.port,
5912
+ appPath,
5913
+ entry: app.entry,
5914
+ turboPackage,
5915
+ packageManager,
5916
+ healthCheckPath: "/health"
5917
+ });
5918
+ else dockerfile = generateBackendDockerfile({
5919
+ imageName,
5920
+ baseImage: "node:22-alpine",
5921
+ port: app.port,
5922
+ appPath,
5923
+ turboPackage,
5924
+ packageManager,
5925
+ healthCheckPath: "/health"
5926
+ });
5927
+ const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
5928
+ await writeFile(dockerfilePath, dockerfile);
5929
+ logger.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
5930
+ results.push({
5931
+ appName,
5932
+ type: app.type,
5933
+ dockerfile: dockerfilePath,
5934
+ imageName
5935
+ });
5936
+ }
5937
+ const dockerignore = generateDockerignore();
5938
+ const dockerignorePath = join(workspace.root, ".dockerignore");
5939
+ await writeFile(dockerignorePath, dockerignore);
5940
+ logger.log(`\n Generated: .dockerignore (workspace root)`);
5941
+ const composePath = await writeConstructsCompose(workspace);
5942
+ logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
5943
+ logger.log("\n📋 Build commands:");
5944
+ for (const result of results) {
5945
+ const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
5946
+ logger.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
5947
+ }
5948
+ printRunInstructions(workspace);
5949
+ return {
5950
+ apps: results,
5951
+ dockerCompose: composePath,
5952
+ dockerignore: dockerignorePath
5953
+ };
5954
+ }
5955
+ /**
5956
+ * Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
5957
+ * the apps beside the containers their constructs derive.
5958
+ *
5959
+ * Through reconcile rather than a second generator, so what `gkm docker`
5960
+ * describes and what `gkm dev` runs cannot disagree. Nothing is started.
5961
+ */
5962
+ async function writeConstructsCompose(workspace) {
5963
+ await reconcileWorkspace(workspace, {
5964
+ stage: workspace.stages.local,
5965
+ start: false
5966
+ });
5967
+ logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
5968
+ return join(workspace.root, COMPOSE_PATH);
5969
+ }
5970
+ function printRunInstructions(workspace) {
5971
+ const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
5972
+ logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
5973
+ logger.log(" gkm setup # starts the containers and creates what they hold");
5974
+ logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
5975
+ }
5976
+ //#endregion
5977
+ //#region src/deploy/sniffer.ts
5978
+ const __filename = fileURLToPath(import.meta.url);
5979
+ const __dirname = dirname(__filename);
5980
+ /**
5981
+ * Resolve the tsx package path from the CLI package's dependencies.
5982
+ * This ensures tsx is available regardless of whether the target project has it installed.
5983
+ */
5984
+ function resolveTsxPath() {
5985
+ return createRequire(import.meta.url).resolve("tsx");
5986
+ }
5987
+ /**
5988
+ * Resolve the path to a sniffer helper file.
5989
+ * Handles both dev (.ts with tsx) and production (.mjs from dist).
5990
+ *
5991
+ * In production: sniffer.ts is bundled into dist/index.mjs, but sniffer helper
5992
+ * files are output to dist/deploy/ as standalone modules for subprocess loading.
5993
+ *
5994
+ * In development: All files are in src/deploy/ and loaded via tsx.
5995
+ */
5996
+ function resolveSnifferFile(baseName) {
5997
+ const deployMjsPath = resolve(__dirname, "deploy", `${baseName}.mjs`);
5998
+ if (existsSync(deployMjsPath)) return deployMjsPath;
5999
+ const mjsPath = resolve(__dirname, `${baseName}.mjs`);
6000
+ if (existsSync(mjsPath)) return mjsPath;
6001
+ const tsPath = resolve(__dirname, `${baseName}.ts`);
6002
+ if (existsSync(tsPath)) return tsPath;
6003
+ return tsPath;
6004
+ }
6005
+ /**
6006
+ * Get required environment variables for an app.
6007
+ *
6008
+ * Detection strategy (in order):
6009
+ * 1. Frontend apps: Returns empty (no server secrets)
6010
+ * 2. Apps with `requiredEnv`: Uses explicit list from config
6011
+ * 3. Entry apps: Imports entry file in subprocess to capture config.parse() calls
6012
+ * 4. Route-based apps: Loads route files and calls getEnvironment() on each construct
6013
+ * 5. Apps with `envParser` (no routes): Runs SnifferEnvironmentParser to detect usage
6014
+ * 6. Apps with neither: Returns empty
6015
+ *
6016
+ * This function handles "fire and forget" async operations gracefully,
6017
+ * capturing errors and unhandled rejections without failing the build.
6018
+ *
6019
+ * @param app - The normalized app configuration
6020
+ * @param appName - The name of the app
6021
+ * @param workspacePath - Absolute path to the workspace root
6022
+ * @param options - Optional configuration for sniffing behavior
6023
+ * @returns The sniffed environment with required variables
6024
+ */
6025
+ async function sniffAppEnvironment(app, appName, workspacePath, options = {}) {
6026
+ const { logWarnings = true, markOptional = false } = options;
6027
+ if (app.type === "web" || app.type === "mobile") {
6028
+ const publicPrefix = getPublicEnvPrefix(app.framework);
6029
+ const depVars = publicPrefix ? (app.dependencies ?? []).map((dep) => `${publicPrefix}${dep.toUpperCase()}_URL`) : [];
6030
+ if (app.config) {
6031
+ const sniffedVars = [];
6032
+ const sniffedOptional = [];
6033
+ const configPaths = [];
6034
+ if (app.config.client) configPaths.push(app.config.client);
6035
+ if (app.config.server) configPaths.push(app.config.server);
6036
+ for (const configPath of configPaths) {
6037
+ const result = await sniffEntryFile(configPath, app.path, workspacePath);
6038
+ if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Config file "${configPath}" threw error during sniffing (env vars still captured): ${result.error.message}`);
6039
+ sniffedVars.push(...result.envVars);
6040
+ sniffedOptional.push(...result.optionalEnvVars);
6041
+ }
6042
+ const optionalEnvVars = [...new Set(sniffedOptional)];
6043
+ return {
6044
+ appName,
6045
+ requiredEnvVars: applyMarkOptional([.../* @__PURE__ */ new Set([...depVars, ...sniffedVars])], optionalEnvVars, markOptional),
6046
+ optionalEnvVars
6047
+ };
6048
+ }
6049
+ return {
6050
+ appName,
6051
+ requiredEnvVars: depVars,
6052
+ optionalEnvVars: []
6053
+ };
6054
+ }
6055
+ if (app.entry) {
6056
+ const result = await sniffEntryFile(app.entry, app.path, workspacePath);
6057
+ if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Entry file threw error during sniffing (env vars still captured): ${result.error.message}`);
6058
+ return {
6059
+ appName,
6060
+ requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
6061
+ optionalEnvVars: result.optionalEnvVars
6062
+ };
6063
+ }
6064
+ if (app.routes) {
6065
+ const result = await sniffRouteFiles(normalizeRoutes(app.routes), app.path, workspacePath);
6066
+ if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Route sniffing threw error (env vars still captured): ${result.error.message}`);
6067
+ return {
6068
+ appName,
6069
+ requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
6070
+ optionalEnvVars: result.optionalEnvVars
6071
+ };
6072
+ }
6073
+ if (app.envParser) {
6074
+ const result = await sniffEnvParser(app.envParser, app.path, workspacePath);
6075
+ if (logWarnings) {
6076
+ if (result.error) output.warn(`[sniffer] ${appName}: envParser threw error during sniffing (env vars still captured): ${result.error.message}`);
6077
+ if (result.unhandledRejections.length > 0) output.warn(`[sniffer] ${appName}: Fire-and-forget rejections during sniffing (suppressed): ${result.unhandledRejections.map((e) => e.message).join(", ")}`);
6078
+ }
6079
+ return {
6080
+ appName,
6081
+ requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
6082
+ optionalEnvVars: result.optionalEnvVars
6083
+ };
6084
+ }
6085
+ return {
6086
+ appName,
6087
+ requiredEnvVars: [],
6088
+ optionalEnvVars: []
6089
+ };
6090
+ }
6091
+ /**
6092
+ * Apply the `?` suffix to optional variables in an env var list.
6093
+ * Only modifies the list when `markOptional` is true and there are optional vars.
6094
+ */
6095
+ function applyMarkOptional(envVars, optionalEnvVars, markOptional) {
6096
+ if (!markOptional || optionalEnvVars.length === 0) return envVars;
6097
+ const optionalSet = new Set(optionalEnvVars);
6098
+ return envVars.map((v) => optionalSet.has(v) ? `${v}?` : v);
6099
+ }
6100
+ /**
6101
+ * Sniff an entry file by importing it in a subprocess.
6102
+ *
6103
+ * Entry apps call `config.parse()` at module load time. To capture which
6104
+ * env vars are accessed, we:
6105
+ * 1. Spawn a subprocess with a module loader hook
6106
+ * 2. The loader intercepts `@geekmidas/envkit` and replaces EnvironmentParser
6107
+ * with SnifferEnvironmentParser
6108
+ * 3. Import the entry file (triggers config.parse())
6109
+ * 4. Capture and return the accessed env var names
6110
+ *
6111
+ * This approach provides process isolation - each app is sniffed in its own
6112
+ * subprocess, preventing module cache pollution.
6113
+ *
6114
+ * @param entryPath - Relative path to the entry file (e.g., './src/index.ts')
6115
+ * @param appPath - The app's path relative to workspace (e.g., 'apps/auth')
6116
+ * @param workspacePath - Absolute path to workspace root
6117
+ * @returns EntrySniffResult with env vars and optional error
6118
+ */
6119
+ async function sniffEntryFile(entryPath, appPath, workspacePath) {
6120
+ const fullEntryPath = resolve(workspacePath, appPath, entryPath);
6121
+ const loaderPath = resolveSnifferFile("sniffer-loader");
6122
+ const workerPath = resolveSnifferFile("sniffer-worker");
6123
+ return new Promise((resolvePromise) => {
6124
+ const child = spawn("node", [
6125
+ "--import",
6126
+ loaderPath,
6127
+ workerPath,
6128
+ fullEntryPath
6129
+ ], {
6130
+ cwd: resolve(workspacePath, appPath),
6131
+ stdio: [
6132
+ "ignore",
6133
+ "pipe",
6134
+ "pipe"
6135
+ ],
6136
+ env: {
6137
+ ...process.env,
6138
+ NODE_OPTIONS: withOwningTsconfigJsx("--import=tsx")
6139
+ }
6140
+ });
6141
+ let stdout = "";
6142
+ let stderr = "";
6143
+ child.stdout.on("data", (data) => {
6144
+ stdout += data.toString();
6145
+ });
6146
+ child.stderr.on("data", (data) => {
6147
+ stderr += data.toString();
6148
+ });
6149
+ child.on("close", (code) => {
6150
+ try {
6151
+ const jsonMatch = stdout.match(/\{[^{}]*"envVars"[^{}]*\}[^{]*$/);
6152
+ if (jsonMatch) {
6153
+ const result = JSON.parse(jsonMatch[0]);
6154
+ resolvePromise({
6155
+ envVars: result.envVars || [],
6156
+ optionalEnvVars: result.optionalEnvVars || [],
6157
+ error: result.error ? new Error(result.error) : void 0
6158
+ });
6159
+ return;
6160
+ }
6161
+ } catch {}
6162
+ resolvePromise({
6163
+ envVars: [],
6164
+ optionalEnvVars: [],
6165
+ error: /* @__PURE__ */ new Error(`Failed to sniff entry file (exit code ${code}): ${stderr || stdout || "No output"}`)
6166
+ });
6167
+ });
6168
+ child.on("error", (err) => {
6169
+ resolvePromise({
6170
+ envVars: [],
6171
+ optionalEnvVars: [],
6172
+ error: err
6173
+ });
6174
+ });
6175
+ });
6176
+ }
6177
+ /**
6178
+ * Sniff route files by loading constructs and calling getEnvironment().
6179
+ *
6180
+ * Route-based apps have endpoints, functions, crons, and subscribers that
6181
+ * use services. Each service's register() method accesses environment variables.
6182
+ *
6183
+ * This runs in a subprocess with tsx loader to properly handle TypeScript
6184
+ * compilation and path alias resolution (e.g., `src/...` imports).
6185
+ *
6186
+ * @param routes - Glob pattern(s) for route files
6187
+ * @param appPath - The app's path relative to workspace (e.g., 'apps/api')
6188
+ * @param workspacePath - Absolute path to workspace root
6189
+ * @returns EntrySniffResult with env vars and optional error
6190
+ */
6191
+ async function sniffRouteFiles(routes, appPath, workspacePath) {
6192
+ const fullAppPath = resolve(workspacePath, appPath);
6193
+ const workerPath = resolveSnifferFile("sniffer-routes-worker");
6194
+ const tsxPath = resolveTsxPath();
6195
+ const pattern = (Array.isArray(routes) ? routes : [routes])[0];
6196
+ if (!pattern) return {
6197
+ envVars: [],
6198
+ optionalEnvVars: [],
6199
+ error: /* @__PURE__ */ new Error("No route patterns provided")
6200
+ };
6201
+ return new Promise((resolvePromise) => {
6202
+ const child = spawn("node", [
6203
+ "--import",
6204
+ tsxPath,
6205
+ workerPath,
6206
+ fullAppPath,
6207
+ pattern
6208
+ ], {
6209
+ cwd: fullAppPath,
6210
+ stdio: [
6211
+ "ignore",
6212
+ "pipe",
6213
+ "pipe"
6214
+ ],
6215
+ env: { ...process.env }
6216
+ });
6217
+ let stdout = "";
6218
+ let stderr = "";
6219
+ child.stdout.on("data", (data) => {
6220
+ stdout += data.toString();
6221
+ });
6222
+ child.stderr.on("data", (data) => {
6223
+ stderr += data.toString();
6224
+ });
6225
+ child.on("close", (code) => {
6226
+ if (stderr) stderr.split("\n").filter((line) => line.trim()).forEach((line) => output.warn(line));
6227
+ try {
6228
+ const jsonMatch = stdout.match(/\{[^{}]*"envVars"[^{}]*\}[^{]*$/);
6229
+ if (jsonMatch) {
6230
+ const result = JSON.parse(jsonMatch[0]);
6231
+ resolvePromise({
6232
+ envVars: result.envVars || [],
6233
+ optionalEnvVars: result.optionalEnvVars || [],
6234
+ error: result.error ? new Error(result.error) : void 0
6235
+ });
6236
+ return;
6237
+ }
6238
+ } catch {}
6239
+ resolvePromise({
6240
+ envVars: [],
6241
+ optionalEnvVars: [],
6242
+ error: /* @__PURE__ */ new Error(`Failed to sniff route files (exit code ${code}): ${stderr || stdout || "No output"}`)
6243
+ });
6244
+ });
6245
+ child.on("error", (err) => {
6246
+ resolvePromise({
6247
+ envVars: [],
6248
+ optionalEnvVars: [],
6249
+ error: err
6250
+ });
6251
+ });
6252
+ });
6253
+ }
6254
+ /**
6255
+ * Run the SnifferEnvironmentParser on an envParser module to detect
6256
+ * which environment variables it accesses.
6257
+ *
6258
+ * This function handles "fire and forget" async operations by using
6259
+ * the shared sniffWithFireAndForget utility from @geekmidas/envkit.
6260
+ *
6261
+ * @param envParserPath - The envParser config (e.g., './src/config/env#envParser')
6262
+ * @param appPath - The app's path relative to workspace
6263
+ * @param workspacePath - Absolute path to workspace root
6264
+ * @returns SniffResult with env vars and any errors encountered
6265
+ */
6266
+ async function sniffEnvParser(envParserPath, appPath, workspacePath) {
6267
+ const [modulePath, exportName = "default"] = envParserPath.split("#");
6268
+ if (!modulePath) return {
6269
+ envVars: [],
6270
+ optionalEnvVars: [],
6271
+ unhandledRejections: []
6272
+ };
6273
+ const fullPath = resolve(workspacePath, appPath, modulePath);
6274
+ let SnifferEnvironmentParser;
6275
+ let sniffWithFireAndForget;
6276
+ try {
6277
+ const envkitModule = await import("@geekmidas/envkit/sniffer");
6278
+ SnifferEnvironmentParser = envkitModule.SnifferEnvironmentParser;
6279
+ sniffWithFireAndForget = envkitModule.sniffWithFireAndForget;
6280
+ } catch (error) {
6281
+ const message = error instanceof Error ? error.message : String(error);
6282
+ output.warn(`[sniffer] Failed to import SnifferEnvironmentParser: ${message}`);
6283
+ return {
6284
+ envVars: [],
6285
+ optionalEnvVars: [],
6286
+ unhandledRejections: []
6287
+ };
6288
+ }
6289
+ const sniffer = new SnifferEnvironmentParser();
6290
+ return sniffWithFireAndForget(sniffer, async () => {
6291
+ const envParser = (await import(pathToFileURL(fullPath).href))[exportName];
6292
+ if (typeof envParser !== "function") {
6293
+ output.warn(`[sniffer] Export "${exportName}" from "${modulePath}" is not a function`);
6294
+ return;
6295
+ }
6296
+ const result = envParser(sniffer);
6297
+ if (result && typeof result.parse === "function") try {
6298
+ result.parse();
6299
+ } catch {}
6300
+ });
6301
+ }
6302
+ /**
6303
+ * Sniff environment requirements for multiple apps.
6304
+ *
6305
+ * @param apps - Map of app name to app config
6306
+ * @param workspacePath - Absolute path to workspace root
6307
+ * @param options - Optional configuration for sniffing behavior
6308
+ * @returns Map of app name to sniffed environment
6309
+ */
6310
+ async function sniffAllApps(apps, workspacePath, options = {}) {
6311
+ const results = /* @__PURE__ */ new Map();
6312
+ for (const [appName, app] of Object.entries(apps)) {
6313
+ const sniffed = await sniffAppEnvironment(app, appName, workspacePath, options);
6314
+ results.set(appName, sniffed);
6315
+ }
6316
+ return results;
6317
+ }
6318
+ //#endregion
6319
+ export { loginCommand as $, workspace_exports as A, providerOf as B, run as C, constructGlobs as D, backendsOf as E, quoteIdentifier as F, withOwningTsconfigJsx as G, describeServices as H, pgClient as I, findAvailablePort as J, createCredentialsPreload as K, resourceName as L, forgetState as M, applyPostgres as N, derivedContainers as O, postgresDatabaseNames as P, prepareEntryCredentials as Q, cacheBackendFor as R, keyFingerprint as S, FAKE_ENV as T, fakeKey as U, storageBackendFor as V, primaryPortKey as W, loadEnvFiles as X, isPortAvailable as Y, loadSecretsForApp as Z, newLockHolder as _, dockerCommand as a, getDokployToken as at, encryptSecrets as b, resolveHost as c, storeDokployCredentials as ct, StateLocked as d, logoutCommand as et, StateStoreBusy as f, createStateStore as g, contentVersion as h, DockerPushFailed as i, getDokployRegistryId as it, LOCAL_CA_PATH as j, reconcileWorkspace as k, DocumentStateStore as l, canPrompt as lt, StateVersionConflict as m, sniffAppEnvironment as n, whoamiCommand as nt, validateImageRef as o, getHostingerToken as ot, StateStoreWithoutLocking as p, createEntryWrapper as q, DockerBuildFailed as r, getDokployCredentials as rt, isMainFrontendApp as s, readCredentials as st, sniffAllApps as t, validateDokployToken as tt, StageStateMissing as u, prompt as ut, DokployApi as v, runOutput as w, encryption_exports as x, DokployApiError as y, eventsBackendFor as z };
3030
6320
 
3031
- //# sourceMappingURL=workspace-BRZ14o-o.mjs.map
6321
+ //# sourceMappingURL=sniffer-BM8fpCas.mjs.map