@geekmidas/cli 0.39.0 → 0.41.0

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 (81) hide show
  1. package/dist/{bundler-CyHg1v_T.cjs → bundler-BB-kETMd.cjs} +20 -49
  2. package/dist/bundler-BB-kETMd.cjs.map +1 -0
  3. package/dist/{bundler-DQIuE3Kn.mjs → bundler-DGry2vaR.mjs} +22 -51
  4. package/dist/bundler-DGry2vaR.mjs.map +1 -0
  5. package/dist/{config-BC5n1a2D.mjs → config-C0b0jdmU.mjs} +2 -2
  6. package/dist/{config-BC5n1a2D.mjs.map → config-C0b0jdmU.mjs.map} +1 -1
  7. package/dist/{config-BAE9LFC1.cjs → config-xVZsRjN7.cjs} +2 -2
  8. package/dist/{config-BAE9LFC1.cjs.map → config-xVZsRjN7.cjs.map} +1 -1
  9. package/dist/config.cjs +2 -2
  10. package/dist/config.d.cts +1 -1
  11. package/dist/config.d.mts +2 -2
  12. package/dist/config.mjs +2 -2
  13. package/dist/dokploy-api-Bdmk5ImW.cjs +3 -0
  14. package/dist/{dokploy-api-C5czOZoc.cjs → dokploy-api-BdxOMH_V.cjs} +43 -1
  15. package/dist/{dokploy-api-C5czOZoc.cjs.map → dokploy-api-BdxOMH_V.cjs.map} +1 -1
  16. package/dist/{dokploy-api-B9qR2Yn1.mjs → dokploy-api-DWsqNjwP.mjs} +43 -1
  17. package/dist/{dokploy-api-B9qR2Yn1.mjs.map → dokploy-api-DWsqNjwP.mjs.map} +1 -1
  18. package/dist/dokploy-api-tZSZaHd9.mjs +3 -0
  19. package/dist/{encryption-JtMsiGNp.mjs → encryption-BC4MAODn.mjs} +1 -1
  20. package/dist/{encryption-JtMsiGNp.mjs.map → encryption-BC4MAODn.mjs.map} +1 -1
  21. package/dist/encryption-Biq0EZ4m.cjs +4 -0
  22. package/dist/encryption-CQXBZGkt.mjs +3 -0
  23. package/dist/{encryption-BAz0xQ1Q.cjs → encryption-DaCB_NmS.cjs} +13 -3
  24. package/dist/{encryption-BAz0xQ1Q.cjs.map → encryption-DaCB_NmS.cjs.map} +1 -1
  25. package/dist/{index-C7TkoYmt.d.mts → index-CXa3odEw.d.mts} +68 -7
  26. package/dist/index-CXa3odEw.d.mts.map +1 -0
  27. package/dist/{index-CpchsC9w.d.cts → index-E8Nu2Rxl.d.cts} +67 -6
  28. package/dist/index-E8Nu2Rxl.d.cts.map +1 -0
  29. package/dist/index.cjs +698 -127
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +677 -106
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/{openapi-CjYeF-Tg.mjs → openapi-D3pA6FfZ.mjs} +2 -2
  34. package/dist/{openapi-CjYeF-Tg.mjs.map → openapi-D3pA6FfZ.mjs.map} +1 -1
  35. package/dist/{openapi-a-e3Y8WA.cjs → openapi-DhcCtKzM.cjs} +2 -2
  36. package/dist/{openapi-a-e3Y8WA.cjs.map → openapi-DhcCtKzM.cjs.map} +1 -1
  37. package/dist/{openapi-react-query-DvNpdDpM.cjs → openapi-react-query-C_MxpBgF.cjs} +1 -1
  38. package/dist/{openapi-react-query-DvNpdDpM.cjs.map → openapi-react-query-C_MxpBgF.cjs.map} +1 -1
  39. package/dist/{openapi-react-query-5rSortLH.mjs → openapi-react-query-ZoP9DPbY.mjs} +1 -1
  40. package/dist/{openapi-react-query-5rSortLH.mjs.map → openapi-react-query-ZoP9DPbY.mjs.map} +1 -1
  41. package/dist/openapi-react-query.cjs +1 -1
  42. package/dist/openapi-react-query.mjs +1 -1
  43. package/dist/openapi.cjs +3 -3
  44. package/dist/openapi.d.mts +1 -1
  45. package/dist/openapi.mjs +3 -3
  46. package/dist/{types-K2uQJ-FO.d.mts → types-BtGL-8QS.d.mts} +1 -1
  47. package/dist/{types-K2uQJ-FO.d.mts.map → types-BtGL-8QS.d.mts.map} +1 -1
  48. package/dist/workspace/index.cjs +1 -1
  49. package/dist/workspace/index.d.cts +2 -2
  50. package/dist/workspace/index.d.mts +3 -3
  51. package/dist/workspace/index.mjs +1 -1
  52. package/dist/{workspace-My0A4IRO.cjs → workspace-BDAhr6Kb.cjs} +33 -4
  53. package/dist/{workspace-My0A4IRO.cjs.map → workspace-BDAhr6Kb.cjs.map} +1 -1
  54. package/dist/{workspace-DFJ3sWfY.mjs → workspace-D_6ZCaR_.mjs} +33 -4
  55. package/dist/{workspace-DFJ3sWfY.mjs.map → workspace-D_6ZCaR_.mjs.map} +1 -1
  56. package/package.json +5 -5
  57. package/src/build/bundler.ts +27 -79
  58. package/src/deploy/__tests__/domain.spec.ts +231 -0
  59. package/src/deploy/__tests__/secrets.spec.ts +300 -0
  60. package/src/deploy/__tests__/sniffer.spec.ts +221 -0
  61. package/src/deploy/docker.ts +40 -11
  62. package/src/deploy/dokploy-api.ts +99 -0
  63. package/src/deploy/domain.ts +125 -0
  64. package/src/deploy/index.ts +366 -148
  65. package/src/deploy/secrets.ts +182 -0
  66. package/src/deploy/sniffer.ts +180 -0
  67. package/src/dev/index.ts +11 -0
  68. package/src/docker/index.ts +24 -5
  69. package/src/docker/templates.ts +187 -1
  70. package/src/init/templates/api.ts +4 -4
  71. package/src/init/versions.ts +2 -2
  72. package/src/workspace/index.ts +2 -0
  73. package/src/workspace/schema.ts +32 -6
  74. package/src/workspace/types.ts +64 -2
  75. package/tsconfig.tsbuildinfo +1 -1
  76. package/dist/bundler-CyHg1v_T.cjs.map +0 -1
  77. package/dist/bundler-DQIuE3Kn.mjs.map +0 -1
  78. package/dist/dokploy-api-B0w17y4_.mjs +0 -3
  79. package/dist/dokploy-api-BnGeUqN4.cjs +0 -3
  80. package/dist/index-C7TkoYmt.d.mts.map +0 -1
  81. package/dist/index-CpchsC9w.d.cts.map +0 -1
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { StageSecrets } from '../../secrets/types';
3
+ import {
4
+ encryptSecretsForApp,
5
+ filterSecretsForApp,
6
+ generateSecretsReport,
7
+ prepareSecretsForAllApps,
8
+ prepareSecretsForApp,
9
+ } from '../secrets';
10
+ import type { SniffedEnvironment } from '../sniffer';
11
+
12
+ describe('filterSecretsForApp', () => {
13
+ const createStageSecrets = (
14
+ custom: Record<string, string> = {},
15
+ ): StageSecrets => ({
16
+ stage: 'production',
17
+ createdAt: '2024-01-01T00:00:00Z',
18
+ updatedAt: '2024-01-01T00:00:00Z',
19
+ services: {},
20
+ urls: {
21
+ DATABASE_URL: 'postgresql://localhost:5432/db',
22
+ REDIS_URL: 'redis://localhost:6379',
23
+ },
24
+ custom,
25
+ });
26
+
27
+ it('should filter secrets to only required env vars', () => {
28
+ const secrets = createStageSecrets({ API_KEY: 'secret123' });
29
+ const sniffed: SniffedEnvironment = {
30
+ appName: 'api',
31
+ requiredEnvVars: ['DATABASE_URL', 'API_KEY'],
32
+ };
33
+
34
+ const result = filterSecretsForApp(secrets, sniffed);
35
+
36
+ expect(result.appName).toBe('api');
37
+ expect(result.secrets).toEqual({
38
+ DATABASE_URL: 'postgresql://localhost:5432/db',
39
+ API_KEY: 'secret123',
40
+ });
41
+ expect(result.found).toEqual(['API_KEY', 'DATABASE_URL']);
42
+ expect(result.missing).toEqual([]);
43
+ });
44
+
45
+ it('should track missing secrets', () => {
46
+ const secrets = createStageSecrets();
47
+ const sniffed: SniffedEnvironment = {
48
+ appName: 'api',
49
+ requiredEnvVars: ['DATABASE_URL', 'STRIPE_KEY', 'JWT_SECRET'],
50
+ };
51
+
52
+ const result = filterSecretsForApp(secrets, sniffed);
53
+
54
+ expect(result.found).toEqual(['DATABASE_URL']);
55
+ expect(result.missing).toEqual(['JWT_SECRET', 'STRIPE_KEY']);
56
+ });
57
+
58
+ it('should return empty secrets when no env vars required', () => {
59
+ const secrets = createStageSecrets({ API_KEY: 'secret' });
60
+ const sniffed: SniffedEnvironment = {
61
+ appName: 'web',
62
+ requiredEnvVars: [],
63
+ };
64
+
65
+ const result = filterSecretsForApp(secrets, sniffed);
66
+
67
+ expect(result.secrets).toEqual({});
68
+ expect(result.found).toEqual([]);
69
+ expect(result.missing).toEqual([]);
70
+ });
71
+
72
+ it('should include service credentials when referenced', () => {
73
+ const secrets: StageSecrets = {
74
+ stage: 'production',
75
+ createdAt: '2024-01-01T00:00:00Z',
76
+ updatedAt: '2024-01-01T00:00:00Z',
77
+ services: {
78
+ postgres: {
79
+ host: 'localhost',
80
+ port: 5432,
81
+ username: 'user',
82
+ password: 'pass',
83
+ database: 'mydb',
84
+ },
85
+ },
86
+ urls: { DATABASE_URL: 'postgresql://user:pass@localhost:5432/mydb' },
87
+ custom: {},
88
+ };
89
+ const sniffed: SniffedEnvironment = {
90
+ appName: 'api',
91
+ requiredEnvVars: ['DATABASE_URL', 'POSTGRES_PASSWORD'],
92
+ };
93
+
94
+ const result = filterSecretsForApp(secrets, sniffed);
95
+
96
+ expect(result.secrets.DATABASE_URL).toBe(
97
+ 'postgresql://user:pass@localhost:5432/mydb',
98
+ );
99
+ expect(result.secrets.POSTGRES_PASSWORD).toBe('pass');
100
+ expect(result.found).toContain('DATABASE_URL');
101
+ expect(result.found).toContain('POSTGRES_PASSWORD');
102
+ });
103
+ });
104
+
105
+ describe('encryptSecretsForApp', () => {
106
+ it('should encrypt filtered secrets and return master key', () => {
107
+ const filtered = {
108
+ appName: 'api',
109
+ secrets: { DATABASE_URL: 'postgresql://localhost:5432/db' },
110
+ found: ['DATABASE_URL'],
111
+ missing: [],
112
+ };
113
+
114
+ const result = encryptSecretsForApp(filtered);
115
+
116
+ expect(result.appName).toBe('api');
117
+ expect(result.masterKey).toHaveLength(64); // 32 bytes hex
118
+ expect(result.payload.encrypted).toBeTruthy();
119
+ expect(result.payload.iv).toBeTruthy();
120
+ expect(result.secretCount).toBe(1);
121
+ expect(result.missingSecrets).toEqual([]);
122
+ });
123
+
124
+ it('should track missing secrets in result', () => {
125
+ const filtered = {
126
+ appName: 'api',
127
+ secrets: { DATABASE_URL: 'postgresql://localhost:5432/db' },
128
+ found: ['DATABASE_URL'],
129
+ missing: ['STRIPE_KEY', 'JWT_SECRET'],
130
+ };
131
+
132
+ const result = encryptSecretsForApp(filtered);
133
+
134
+ expect(result.missingSecrets).toEqual(['STRIPE_KEY', 'JWT_SECRET']);
135
+ });
136
+
137
+ it('should handle empty secrets', () => {
138
+ const filtered = {
139
+ appName: 'web',
140
+ secrets: {},
141
+ found: [],
142
+ missing: [],
143
+ };
144
+
145
+ const result = encryptSecretsForApp(filtered);
146
+
147
+ expect(result.secretCount).toBe(0);
148
+ expect(result.masterKey).toHaveLength(64);
149
+ });
150
+ });
151
+
152
+ describe('prepareSecretsForApp', () => {
153
+ it('should filter and encrypt in one step', () => {
154
+ const secrets: StageSecrets = {
155
+ stage: 'production',
156
+ createdAt: '2024-01-01T00:00:00Z',
157
+ updatedAt: '2024-01-01T00:00:00Z',
158
+ services: {},
159
+ urls: { DATABASE_URL: 'postgresql://localhost:5432/db' },
160
+ custom: { API_KEY: 'key123' },
161
+ };
162
+ const sniffed: SniffedEnvironment = {
163
+ appName: 'api',
164
+ requiredEnvVars: ['DATABASE_URL', 'API_KEY'],
165
+ };
166
+
167
+ const result = prepareSecretsForApp(secrets, sniffed);
168
+
169
+ expect(result.appName).toBe('api');
170
+ expect(result.secretCount).toBe(2);
171
+ expect(result.masterKey).toHaveLength(64);
172
+ });
173
+ });
174
+
175
+ describe('prepareSecretsForAllApps', () => {
176
+ const secrets: StageSecrets = {
177
+ stage: 'production',
178
+ createdAt: '2024-01-01T00:00:00Z',
179
+ updatedAt: '2024-01-01T00:00:00Z',
180
+ services: {},
181
+ urls: {
182
+ DATABASE_URL: 'postgresql://localhost:5432/db',
183
+ REDIS_URL: 'redis://localhost:6379',
184
+ },
185
+ custom: { BETTER_AUTH_SECRET: 'auth-secret' },
186
+ };
187
+
188
+ it('should prepare secrets for multiple apps', () => {
189
+ const sniffedApps = new Map<string, SniffedEnvironment>([
190
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL', 'REDIS_URL'] }],
191
+ [
192
+ 'auth',
193
+ {
194
+ appName: 'auth',
195
+ requiredEnvVars: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
196
+ },
197
+ ],
198
+ ]);
199
+
200
+ const results = prepareSecretsForAllApps(secrets, sniffedApps);
201
+
202
+ expect(results.size).toBe(2);
203
+ expect(results.get('api')?.secretCount).toBe(2);
204
+ expect(results.get('auth')?.secretCount).toBe(2);
205
+ });
206
+
207
+ it('should skip apps with no required env vars', () => {
208
+ const sniffedApps = new Map<string, SniffedEnvironment>([
209
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
210
+ ['web', { appName: 'web', requiredEnvVars: [] }], // Frontend - no secrets
211
+ ]);
212
+
213
+ const results = prepareSecretsForAllApps(secrets, sniffedApps);
214
+
215
+ expect(results.size).toBe(1);
216
+ expect(results.has('api')).toBe(true);
217
+ expect(results.has('web')).toBe(false);
218
+ });
219
+
220
+ it('should generate unique master keys per app', () => {
221
+ const sniffedApps = new Map<string, SniffedEnvironment>([
222
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
223
+ ['auth', { appName: 'auth', requiredEnvVars: ['DATABASE_URL'] }],
224
+ ]);
225
+
226
+ const results = prepareSecretsForAllApps(secrets, sniffedApps);
227
+
228
+ const apiKey = results.get('api')?.masterKey;
229
+ const authKey = results.get('auth')?.masterKey;
230
+
231
+ expect(apiKey).not.toBe(authKey);
232
+ });
233
+ });
234
+
235
+ describe('generateSecretsReport', () => {
236
+ it('should generate report for apps with and without secrets', () => {
237
+ const sniffedApps = new Map<string, SniffedEnvironment>([
238
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
239
+ ['auth', { appName: 'auth', requiredEnvVars: ['DATABASE_URL'] }],
240
+ ['web', { appName: 'web', requiredEnvVars: [] }],
241
+ ]);
242
+
243
+ const encryptedApps = new Map([
244
+ [
245
+ 'api',
246
+ {
247
+ appName: 'api',
248
+ payload: { encrypted: '', iv: '', masterKey: '' },
249
+ masterKey: 'key1',
250
+ secretCount: 1,
251
+ missingSecrets: [],
252
+ },
253
+ ],
254
+ [
255
+ 'auth',
256
+ {
257
+ appName: 'auth',
258
+ payload: { encrypted: '', iv: '', masterKey: '' },
259
+ masterKey: 'key2',
260
+ secretCount: 1,
261
+ missingSecrets: ['MISSING_VAR'],
262
+ },
263
+ ],
264
+ ]);
265
+
266
+ const report = generateSecretsReport(encryptedApps, sniffedApps);
267
+
268
+ expect(report.totalApps).toBe(3);
269
+ expect(report.appsWithSecrets).toEqual(['api', 'auth']);
270
+ expect(report.appsWithoutSecrets).toEqual(['web']);
271
+ expect(report.appsWithMissingSecrets).toEqual([
272
+ { appName: 'auth', missing: ['MISSING_VAR'] },
273
+ ]);
274
+ });
275
+
276
+ it('should handle all apps having secrets', () => {
277
+ const sniffedApps = new Map<string, SniffedEnvironment>([
278
+ ['api', { appName: 'api', requiredEnvVars: ['DATABASE_URL'] }],
279
+ ]);
280
+
281
+ const encryptedApps = new Map([
282
+ [
283
+ 'api',
284
+ {
285
+ appName: 'api',
286
+ payload: { encrypted: '', iv: '', masterKey: '' },
287
+ masterKey: 'key1',
288
+ secretCount: 1,
289
+ missingSecrets: [],
290
+ },
291
+ ],
292
+ ]);
293
+
294
+ const report = generateSecretsReport(encryptedApps, sniffedApps);
295
+
296
+ expect(report.appsWithSecrets).toEqual(['api']);
297
+ expect(report.appsWithoutSecrets).toEqual([]);
298
+ expect(report.appsWithMissingSecrets).toEqual([]);
299
+ });
300
+ });
@@ -0,0 +1,221 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { NormalizedAppConfig } from '../../workspace/types';
3
+ import { sniffAllApps, sniffAppEnvironment, type SniffResult } from '../sniffer';
4
+
5
+ describe('sniffAppEnvironment', () => {
6
+ const workspacePath = '/test/workspace';
7
+
8
+ const createApp = (
9
+ overrides: Partial<NormalizedAppConfig> = {},
10
+ ): NormalizedAppConfig => ({
11
+ type: 'backend',
12
+ path: 'apps/api',
13
+ port: 3000,
14
+ dependencies: [],
15
+ resolvedDeployTarget: 'dokploy',
16
+ ...overrides,
17
+ });
18
+
19
+ describe('frontend apps', () => {
20
+ it('should return empty env vars for frontend apps', async () => {
21
+ const app = createApp({ type: 'frontend' });
22
+
23
+ const result = await sniffAppEnvironment(app, 'web', workspacePath);
24
+
25
+ expect(result.appName).toBe('web');
26
+ expect(result.requiredEnvVars).toEqual([]);
27
+ });
28
+
29
+ it('should ignore requiredEnv for frontend apps', async () => {
30
+ const app = createApp({
31
+ type: 'frontend',
32
+ requiredEnv: ['API_KEY', 'SECRET'], // Should be ignored
33
+ });
34
+
35
+ const result = await sniffAppEnvironment(app, 'web', workspacePath);
36
+
37
+ expect(result.requiredEnvVars).toEqual([]);
38
+ });
39
+ });
40
+
41
+ describe('entry-based apps with requiredEnv', () => {
42
+ it('should return requiredEnv list for entry-based apps', async () => {
43
+ const app = createApp({
44
+ entry: './src/index.ts',
45
+ requiredEnv: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
46
+ });
47
+
48
+ const result = await sniffAppEnvironment(app, 'auth', workspacePath);
49
+
50
+ expect(result.appName).toBe('auth');
51
+ expect(result.requiredEnvVars).toEqual([
52
+ 'DATABASE_URL',
53
+ 'BETTER_AUTH_SECRET',
54
+ ]);
55
+ });
56
+
57
+ it('should return copy of requiredEnv (not reference)', async () => {
58
+ const requiredEnv = ['DATABASE_URL'];
59
+ const app = createApp({ requiredEnv });
60
+
61
+ const result = await sniffAppEnvironment(app, 'api', workspacePath);
62
+
63
+ // Modify the result and verify original is unchanged
64
+ result.requiredEnvVars.push('MODIFIED');
65
+ expect(requiredEnv).toEqual(['DATABASE_URL']);
66
+ });
67
+
68
+ it('should return empty when requiredEnv is empty array', async () => {
69
+ const app = createApp({ requiredEnv: [] });
70
+
71
+ const result = await sniffAppEnvironment(app, 'api', workspacePath);
72
+
73
+ expect(result.requiredEnvVars).toEqual([]);
74
+ });
75
+ });
76
+
77
+ describe('apps with envParser', () => {
78
+ it('should return empty when envParser module cannot be loaded', async () => {
79
+ const app = createApp({
80
+ envParser: './src/nonexistent/env#parser',
81
+ });
82
+
83
+ // This will fail to load the module and return empty
84
+ // Suppress warnings for this test
85
+ const result = await sniffAppEnvironment(app, 'api', workspacePath, {
86
+ logWarnings: false,
87
+ });
88
+
89
+ expect(result.requiredEnvVars).toEqual([]);
90
+ });
91
+
92
+ it('should gracefully handle errors without failing the build', async () => {
93
+ const app = createApp({
94
+ envParser: './src/invalid/path#nonexistent',
95
+ });
96
+
97
+ // Should not throw, just return empty
98
+ const result = await sniffAppEnvironment(app, 'api', workspacePath, {
99
+ logWarnings: false,
100
+ });
101
+
102
+ expect(result.appName).toBe('api');
103
+ expect(result.requiredEnvVars).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe('apps without env detection', () => {
108
+ it('should return empty when no envParser or requiredEnv', async () => {
109
+ const app = createApp({
110
+ // No envParser or requiredEnv
111
+ });
112
+
113
+ const result = await sniffAppEnvironment(app, 'api', workspacePath);
114
+
115
+ expect(result.requiredEnvVars).toEqual([]);
116
+ });
117
+ });
118
+ });
119
+
120
+ describe('sniffAllApps', () => {
121
+ const workspacePath = '/test/workspace';
122
+
123
+ it('should sniff all apps in workspace', async () => {
124
+ const apps: Record<string, NormalizedAppConfig> = {
125
+ api: {
126
+ type: 'backend',
127
+ path: 'apps/api',
128
+ port: 3000,
129
+ dependencies: [],
130
+ resolvedDeployTarget: 'dokploy',
131
+ requiredEnv: ['DATABASE_URL', 'REDIS_URL'],
132
+ },
133
+ auth: {
134
+ type: 'backend',
135
+ path: 'apps/auth',
136
+ port: 3002,
137
+ dependencies: [],
138
+ resolvedDeployTarget: 'dokploy',
139
+ requiredEnv: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
140
+ },
141
+ web: {
142
+ type: 'frontend',
143
+ path: 'apps/web',
144
+ port: 3001,
145
+ dependencies: ['api', 'auth'],
146
+ resolvedDeployTarget: 'dokploy',
147
+ },
148
+ };
149
+
150
+ const results = await sniffAllApps(apps, workspacePath);
151
+
152
+ expect(results.size).toBe(3);
153
+
154
+ expect(results.get('api')).toEqual({
155
+ appName: 'api',
156
+ requiredEnvVars: ['DATABASE_URL', 'REDIS_URL'],
157
+ });
158
+
159
+ expect(results.get('auth')).toEqual({
160
+ appName: 'auth',
161
+ requiredEnvVars: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
162
+ });
163
+
164
+ expect(results.get('web')).toEqual({
165
+ appName: 'web',
166
+ requiredEnvVars: [], // Frontend - no secrets
167
+ });
168
+ });
169
+
170
+ it('should handle empty apps record', async () => {
171
+ const apps: Record<string, NormalizedAppConfig> = {};
172
+
173
+ const results = await sniffAllApps(apps, workspacePath);
174
+
175
+ expect(results.size).toBe(0);
176
+ });
177
+
178
+ it('should pass options to individual app sniffing', async () => {
179
+ const apps: Record<string, NormalizedAppConfig> = {
180
+ api: {
181
+ type: 'backend',
182
+ path: 'apps/api',
183
+ port: 3000,
184
+ dependencies: [],
185
+ resolvedDeployTarget: 'dokploy',
186
+ envParser: './src/nonexistent/env#parser', // Will fail but shouldn't log
187
+ },
188
+ };
189
+
190
+ // Should not throw, and suppress warnings
191
+ const results = await sniffAllApps(apps, workspacePath, {
192
+ logWarnings: false,
193
+ });
194
+
195
+ expect(results.size).toBe(1);
196
+ expect(results.get('api')?.requiredEnvVars).toEqual([]);
197
+ });
198
+ });
199
+
200
+ describe('fire-and-forget handling', () => {
201
+ it('captures environment variables even when envParser throws', async () => {
202
+ // The sniffer should capture env vars that were accessed before an error
203
+ // This is the "fire and forget" pattern - errors don't stop env detection
204
+ const app: NormalizedAppConfig = {
205
+ type: 'backend',
206
+ path: 'apps/api',
207
+ port: 3000,
208
+ dependencies: [],
209
+ resolvedDeployTarget: 'dokploy',
210
+ envParser: './src/invalid#missing',
211
+ };
212
+
213
+ const result = await sniffAppEnvironment(app, 'api', '/test/workspace', {
214
+ logWarnings: false,
215
+ });
216
+
217
+ // Should return gracefully without throwing
218
+ expect(result.appName).toBe('api');
219
+ expect(Array.isArray(result.requiredEnvVars)).toBe(true);
220
+ });
221
+ });
@@ -76,6 +76,16 @@ export interface DockerDeployOptions {
76
76
  masterKey?: string;
77
77
  /** Docker config from gkm.config */
78
78
  config: DockerDeployConfig;
79
+ /**
80
+ * Build arguments to pass to docker build.
81
+ * Format: ['KEY=value', 'KEY2=value2']
82
+ */
83
+ buildArgs?: string[];
84
+ /**
85
+ * Public URL argument names for frontend Dockerfile generation.
86
+ * Used to ensure the Dockerfile declares these as ARG/ENV.
87
+ */
88
+ publicUrlArgs?: string[];
79
89
  }
80
90
 
81
91
  /**
@@ -96,8 +106,13 @@ export function getImageRef(
96
106
  * Build Docker image
97
107
  * @param imageRef - Full image reference (registry/name:tag)
98
108
  * @param appName - Name of the app (used for Dockerfile.{appName} in workspaces)
109
+ * @param buildArgs - Build arguments to pass to docker build
99
110
  */
100
- async function buildImage(imageRef: string, appName?: string): Promise<void> {
111
+ async function buildImage(
112
+ imageRef: string,
113
+ appName?: string,
114
+ buildArgs?: string[],
115
+ ): Promise<void> {
101
116
  logger.log(`\n🔨 Building Docker image: ${imageRef}`);
102
117
 
103
118
  const cwd = process.cwd();
@@ -125,16 +140,30 @@ async function buildImage(imageRef: string, appName?: string): Promise<void> {
125
140
  logger.log(` Building from workspace root: ${buildCwd}`);
126
141
  }
127
142
 
143
+ // Build the build args string
144
+ const buildArgsString =
145
+ buildArgs && buildArgs.length > 0
146
+ ? buildArgs.map((arg) => `--build-arg "${arg}"`).join(' ')
147
+ : '';
148
+
128
149
  try {
129
150
  // Build for linux/amd64 to ensure compatibility with most cloud servers
130
- execSync(
131
- `DOCKER_BUILDKIT=1 docker build --platform linux/amd64 -f ${dockerfilePath} -t ${imageRef} .`,
132
- {
133
- cwd: buildCwd,
134
- stdio: 'inherit',
135
- env: { ...process.env, DOCKER_BUILDKIT: '1' },
136
- },
137
- );
151
+ const cmd = [
152
+ 'DOCKER_BUILDKIT=1 docker build',
153
+ '--platform linux/amd64',
154
+ `-f ${dockerfilePath}`,
155
+ `-t ${imageRef}`,
156
+ buildArgsString,
157
+ '.',
158
+ ]
159
+ .filter(Boolean)
160
+ .join(' ');
161
+
162
+ execSync(cmd, {
163
+ cwd: buildCwd,
164
+ stdio: 'inherit',
165
+ env: { ...process.env, DOCKER_BUILDKIT: '1' },
166
+ });
138
167
  logger.log(`✅ Image built: ${imageRef}`);
139
168
  } catch (error) {
140
169
  throw new Error(
@@ -168,14 +197,14 @@ async function pushImage(imageRef: string): Promise<void> {
168
197
  export async function deployDocker(
169
198
  options: DockerDeployOptions,
170
199
  ): Promise<DeployResult> {
171
- const { stage, tag, skipPush, masterKey, config } = options;
200
+ const { stage, tag, skipPush, masterKey, config, buildArgs } = options;
172
201
 
173
202
  // imageName should always be set by resolveDockerConfig
174
203
  const imageName = config.imageName!;
175
204
  const imageRef = getImageRef(config.registry, imageName, tag);
176
205
 
177
206
  // Build image (pass appName for workspace Dockerfile selection)
178
- await buildImage(imageRef, config.appName);
207
+ await buildImage(imageRef, config.appName, buildArgs);
179
208
 
180
209
  // Push to registry if not skipped
181
210
  if (!skipPush) {