@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.
- package/dist/{bundler-CyHg1v_T.cjs → bundler-BB-kETMd.cjs} +20 -49
- package/dist/bundler-BB-kETMd.cjs.map +1 -0
- package/dist/{bundler-DQIuE3Kn.mjs → bundler-DGry2vaR.mjs} +22 -51
- package/dist/bundler-DGry2vaR.mjs.map +1 -0
- package/dist/{config-BC5n1a2D.mjs → config-C0b0jdmU.mjs} +2 -2
- package/dist/{config-BC5n1a2D.mjs.map → config-C0b0jdmU.mjs.map} +1 -1
- package/dist/{config-BAE9LFC1.cjs → config-xVZsRjN7.cjs} +2 -2
- package/dist/{config-BAE9LFC1.cjs.map → config-xVZsRjN7.cjs.map} +1 -1
- package/dist/config.cjs +2 -2
- package/dist/config.d.cts +1 -1
- package/dist/config.d.mts +2 -2
- package/dist/config.mjs +2 -2
- package/dist/dokploy-api-Bdmk5ImW.cjs +3 -0
- package/dist/{dokploy-api-C5czOZoc.cjs → dokploy-api-BdxOMH_V.cjs} +43 -1
- package/dist/{dokploy-api-C5czOZoc.cjs.map → dokploy-api-BdxOMH_V.cjs.map} +1 -1
- package/dist/{dokploy-api-B9qR2Yn1.mjs → dokploy-api-DWsqNjwP.mjs} +43 -1
- package/dist/{dokploy-api-B9qR2Yn1.mjs.map → dokploy-api-DWsqNjwP.mjs.map} +1 -1
- package/dist/dokploy-api-tZSZaHd9.mjs +3 -0
- package/dist/{encryption-JtMsiGNp.mjs → encryption-BC4MAODn.mjs} +1 -1
- package/dist/{encryption-JtMsiGNp.mjs.map → encryption-BC4MAODn.mjs.map} +1 -1
- package/dist/encryption-Biq0EZ4m.cjs +4 -0
- package/dist/encryption-CQXBZGkt.mjs +3 -0
- package/dist/{encryption-BAz0xQ1Q.cjs → encryption-DaCB_NmS.cjs} +13 -3
- package/dist/{encryption-BAz0xQ1Q.cjs.map → encryption-DaCB_NmS.cjs.map} +1 -1
- package/dist/{index-C7TkoYmt.d.mts → index-CXa3odEw.d.mts} +68 -7
- package/dist/index-CXa3odEw.d.mts.map +1 -0
- package/dist/{index-CpchsC9w.d.cts → index-E8Nu2Rxl.d.cts} +67 -6
- package/dist/index-E8Nu2Rxl.d.cts.map +1 -0
- package/dist/index.cjs +698 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +677 -106
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-CjYeF-Tg.mjs → openapi-D3pA6FfZ.mjs} +2 -2
- package/dist/{openapi-CjYeF-Tg.mjs.map → openapi-D3pA6FfZ.mjs.map} +1 -1
- package/dist/{openapi-a-e3Y8WA.cjs → openapi-DhcCtKzM.cjs} +2 -2
- package/dist/{openapi-a-e3Y8WA.cjs.map → openapi-DhcCtKzM.cjs.map} +1 -1
- package/dist/{openapi-react-query-DvNpdDpM.cjs → openapi-react-query-C_MxpBgF.cjs} +1 -1
- package/dist/{openapi-react-query-DvNpdDpM.cjs.map → openapi-react-query-C_MxpBgF.cjs.map} +1 -1
- package/dist/{openapi-react-query-5rSortLH.mjs → openapi-react-query-ZoP9DPbY.mjs} +1 -1
- package/dist/{openapi-react-query-5rSortLH.mjs.map → openapi-react-query-ZoP9DPbY.mjs.map} +1 -1
- package/dist/openapi-react-query.cjs +1 -1
- package/dist/openapi-react-query.mjs +1 -1
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/{types-K2uQJ-FO.d.mts → types-BtGL-8QS.d.mts} +1 -1
- package/dist/{types-K2uQJ-FO.d.mts.map → types-BtGL-8QS.d.mts.map} +1 -1
- package/dist/workspace/index.cjs +1 -1
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +1 -1
- package/dist/{workspace-My0A4IRO.cjs → workspace-BDAhr6Kb.cjs} +33 -4
- package/dist/{workspace-My0A4IRO.cjs.map → workspace-BDAhr6Kb.cjs.map} +1 -1
- package/dist/{workspace-DFJ3sWfY.mjs → workspace-D_6ZCaR_.mjs} +33 -4
- package/dist/{workspace-DFJ3sWfY.mjs.map → workspace-D_6ZCaR_.mjs.map} +1 -1
- package/package.json +5 -5
- package/src/build/bundler.ts +27 -79
- package/src/deploy/__tests__/domain.spec.ts +231 -0
- package/src/deploy/__tests__/secrets.spec.ts +300 -0
- package/src/deploy/__tests__/sniffer.spec.ts +221 -0
- package/src/deploy/docker.ts +40 -11
- package/src/deploy/dokploy-api.ts +99 -0
- package/src/deploy/domain.ts +125 -0
- package/src/deploy/index.ts +366 -148
- package/src/deploy/secrets.ts +182 -0
- package/src/deploy/sniffer.ts +180 -0
- package/src/dev/index.ts +11 -0
- package/src/docker/index.ts +24 -5
- package/src/docker/templates.ts +187 -1
- package/src/init/templates/api.ts +4 -4
- package/src/init/versions.ts +2 -2
- package/src/workspace/index.ts +2 -0
- package/src/workspace/schema.ts +32 -6
- package/src/workspace/types.ts +64 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/bundler-CyHg1v_T.cjs.map +0 -1
- package/dist/bundler-DQIuE3Kn.mjs.map +0 -1
- package/dist/dokploy-api-B0w17y4_.mjs +0 -3
- package/dist/dokploy-api-BnGeUqN4.cjs +0 -3
- package/dist/index-C7TkoYmt.d.mts.map +0 -1
- 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
|
+
});
|
package/src/deploy/docker.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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) {
|