@geekmidas/cli 0.13.0 → 0.15.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 (80) hide show
  1. package/dist/{bundler-DskIqW2t.mjs → bundler-D7cM_FWw.mjs} +34 -10
  2. package/dist/bundler-D7cM_FWw.mjs.map +1 -0
  3. package/dist/{bundler-B1qy9b-j.cjs → bundler-Nuew7Xcn.cjs} +33 -9
  4. package/dist/bundler-Nuew7Xcn.cjs.map +1 -0
  5. package/dist/config.d.cts +1 -1
  6. package/dist/config.d.mts +1 -1
  7. package/dist/dokploy-api-B7KxOQr3.cjs +3 -0
  8. package/dist/dokploy-api-C7F9VykY.cjs +317 -0
  9. package/dist/dokploy-api-C7F9VykY.cjs.map +1 -0
  10. package/dist/dokploy-api-CaETb2L6.mjs +305 -0
  11. package/dist/dokploy-api-CaETb2L6.mjs.map +1 -0
  12. package/dist/dokploy-api-DHvfmWbi.mjs +3 -0
  13. package/dist/{encryption-Dyf_r1h-.cjs → encryption-D7Efcdi9.cjs} +1 -1
  14. package/dist/{encryption-Dyf_r1h-.cjs.map → encryption-D7Efcdi9.cjs.map} +1 -1
  15. package/dist/{encryption-C8H-38Yy.mjs → encryption-h4Nb6W-M.mjs} +1 -1
  16. package/dist/{encryption-C8H-38Yy.mjs.map → encryption-h4Nb6W-M.mjs.map} +1 -1
  17. package/dist/index.cjs +1508 -1073
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.mjs +1508 -1073
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/{openapi-Bt_1FDpT.cjs → openapi-C89hhkZC.cjs} +3 -3
  22. package/dist/{openapi-Bt_1FDpT.cjs.map → openapi-C89hhkZC.cjs.map} +1 -1
  23. package/dist/{openapi-BfFlOBCG.mjs → openapi-CZVcfxk-.mjs} +3 -3
  24. package/dist/{openapi-BfFlOBCG.mjs.map → openapi-CZVcfxk-.mjs.map} +1 -1
  25. package/dist/{openapi-react-query-B6XTeGqS.mjs → openapi-react-query-CM2_qlW9.mjs} +1 -1
  26. package/dist/{openapi-react-query-B6XTeGqS.mjs.map → openapi-react-query-CM2_qlW9.mjs.map} +1 -1
  27. package/dist/{openapi-react-query-B-sNWHFU.cjs → openapi-react-query-iKjfLzff.cjs} +1 -1
  28. package/dist/{openapi-react-query-B-sNWHFU.cjs.map → openapi-react-query-iKjfLzff.cjs.map} +1 -1
  29. package/dist/openapi-react-query.cjs +1 -1
  30. package/dist/openapi-react-query.mjs +1 -1
  31. package/dist/openapi.cjs +1 -1
  32. package/dist/openapi.d.cts +1 -1
  33. package/dist/openapi.d.mts +1 -1
  34. package/dist/openapi.mjs +1 -1
  35. package/dist/{storage-kSxTjkNb.mjs → storage-BaOP55oq.mjs} +16 -2
  36. package/dist/storage-BaOP55oq.mjs.map +1 -0
  37. package/dist/{storage-Bj1E26lU.cjs → storage-Bn3K9Ccu.cjs} +21 -1
  38. package/dist/storage-Bn3K9Ccu.cjs.map +1 -0
  39. package/dist/storage-UfyTn7Zm.cjs +7 -0
  40. package/dist/storage-nkGIjeXt.mjs +3 -0
  41. package/dist/{types-BhkZc-vm.d.cts → types-BgaMXsUa.d.cts} +3 -1
  42. package/dist/{types-BR0M2v_c.d.mts.map → types-BgaMXsUa.d.cts.map} +1 -1
  43. package/dist/{types-BR0M2v_c.d.mts → types-iFk5ms7y.d.mts} +3 -1
  44. package/dist/{types-BhkZc-vm.d.cts.map → types-iFk5ms7y.d.mts.map} +1 -1
  45. package/package.json +4 -4
  46. package/src/auth/__tests__/credentials.spec.ts +127 -0
  47. package/src/auth/__tests__/index.spec.ts +69 -0
  48. package/src/auth/credentials.ts +33 -0
  49. package/src/auth/index.ts +57 -50
  50. package/src/build/__tests__/bundler.spec.ts +5 -4
  51. package/src/build/__tests__/endpoint-analyzer.spec.ts +623 -0
  52. package/src/build/__tests__/handler-templates.spec.ts +272 -0
  53. package/src/build/bundler.ts +61 -8
  54. package/src/build/index.ts +21 -0
  55. package/src/build/types.ts +6 -0
  56. package/src/deploy/__tests__/docker.spec.ts +44 -6
  57. package/src/deploy/__tests__/dokploy-api.spec.ts +698 -0
  58. package/src/deploy/__tests__/dokploy.spec.ts +196 -6
  59. package/src/deploy/__tests__/index.spec.ts +401 -0
  60. package/src/deploy/__tests__/init.spec.ts +147 -16
  61. package/src/deploy/docker.ts +109 -5
  62. package/src/deploy/dokploy-api.ts +581 -0
  63. package/src/deploy/dokploy.ts +66 -93
  64. package/src/deploy/index.ts +630 -32
  65. package/src/deploy/init.ts +192 -249
  66. package/src/deploy/types.ts +24 -2
  67. package/src/dev/__tests__/index.spec.ts +95 -0
  68. package/src/docker/__tests__/templates.spec.ts +144 -0
  69. package/src/docker/index.ts +96 -6
  70. package/src/docker/templates.ts +114 -27
  71. package/src/generators/EndpointGenerator.ts +2 -2
  72. package/src/index.ts +34 -13
  73. package/src/secrets/storage.ts +15 -0
  74. package/src/types.ts +2 -0
  75. package/dist/bundler-B1qy9b-j.cjs.map +0 -1
  76. package/dist/bundler-DskIqW2t.mjs.map +0 -1
  77. package/dist/storage-BOOpAF8N.cjs +0 -5
  78. package/dist/storage-Bj1E26lU.cjs.map +0 -1
  79. package/dist/storage-kSxTjkNb.mjs.map +0 -1
  80. package/dist/storage-tgZSUnKl.mjs +0 -3
@@ -2,11 +2,13 @@ import { HttpResponse, http } from 'msw';
2
2
  import { setupServer } from 'msw/node';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { deployDokploy, validateDokployConfig } from '../dokploy';
5
+ import { generateTag } from '../index';
5
6
  import type { DokployDeployConfig } from '../types';
6
7
 
7
- // Mock getDokployToken to return a test token
8
+ // Mock auth functions
8
9
  vi.mock('../../auth', () => ({
9
10
  getDokployToken: vi.fn().mockResolvedValue('test-api-token'),
11
+ getDokployRegistryId: vi.fn().mockResolvedValue(null),
10
12
  }));
11
13
 
12
14
  // MSW server for mocking Dokploy API
@@ -112,7 +114,17 @@ describe('deployDokploy', () => {
112
114
  });
113
115
 
114
116
  it('should deploy successfully without master key', async () => {
117
+ const saveDockerCalls: unknown[] = [];
118
+
115
119
  server.use(
120
+ http.post(
121
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
122
+ async ({ request }) => {
123
+ const body = await request.json();
124
+ saveDockerCalls.push(body);
125
+ return HttpResponse.json({ success: true });
126
+ },
127
+ ),
116
128
  http.post('https://dokploy.example.com/api/application.deploy', () => {
117
129
  return HttpResponse.json({ success: true });
118
130
  }),
@@ -132,17 +144,28 @@ describe('deployDokploy', () => {
132
144
  expect(result.imageRef).toBe('ghcr.io/myorg/app:v1.0.0');
133
145
  expect(result.masterKey).toBeUndefined();
134
146
  expect(result.url).toBe('https://dokploy.example.com/project/proj_123');
147
+ expect(saveDockerCalls).toHaveLength(1);
148
+ expect(saveDockerCalls[0]).toMatchObject({
149
+ applicationId: 'app_456',
150
+ dockerImage: 'ghcr.io/myorg/app:v1.0.0',
151
+ });
135
152
  });
136
153
 
137
154
  it('should deploy with master key and update environment', async () => {
138
- const updateCalls: unknown[] = [];
155
+ const envCalls: unknown[] = [];
139
156
 
140
157
  server.use(
141
158
  http.post(
142
- 'https://dokploy.example.com/api/application.update',
159
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
160
+ () => {
161
+ return HttpResponse.json({ success: true });
162
+ },
163
+ ),
164
+ http.post(
165
+ 'https://dokploy.example.com/api/application.saveEnvironment',
143
166
  async ({ request }) => {
144
167
  const body = await request.json();
145
- updateCalls.push(body);
168
+ envCalls.push(body);
146
169
  return HttpResponse.json({ success: true });
147
170
  },
148
171
  ),
@@ -164,8 +187,8 @@ describe('deployDokploy', () => {
164
187
  });
165
188
 
166
189
  expect(result.masterKey).toBe('secret-master-key');
167
- expect(updateCalls).toHaveLength(1);
168
- expect(updateCalls[0]).toMatchObject({
190
+ expect(envCalls).toHaveLength(1);
191
+ expect(envCalls[0]).toMatchObject({
169
192
  applicationId: 'app_456',
170
193
  env: 'GKM_MASTER_KEY=secret-master-key',
171
194
  });
@@ -173,6 +196,12 @@ describe('deployDokploy', () => {
173
196
 
174
197
  it('should handle API error with message', async () => {
175
198
  server.use(
199
+ http.post(
200
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
201
+ () => {
202
+ return HttpResponse.json({ success: true });
203
+ },
204
+ ),
176
205
  http.post('https://dokploy.example.com/api/application.deploy', () => {
177
206
  return HttpResponse.json(
178
207
  { message: 'Application not found' },
@@ -197,6 +226,12 @@ describe('deployDokploy', () => {
197
226
 
198
227
  it('should handle API error with issues', async () => {
199
228
  server.use(
229
+ http.post(
230
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
231
+ () => {
232
+ return HttpResponse.json({ success: true });
233
+ },
234
+ ),
200
235
  http.post('https://dokploy.example.com/api/application.deploy', () => {
201
236
  return HttpResponse.json(
202
237
  {
@@ -224,6 +259,12 @@ describe('deployDokploy', () => {
224
259
 
225
260
  it('should handle API error without JSON body', async () => {
226
261
  server.use(
262
+ http.post(
263
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
264
+ () => {
265
+ return HttpResponse.json({ success: true });
266
+ },
267
+ ),
227
268
  http.post('https://dokploy.example.com/api/application.deploy', () => {
228
269
  return new HttpResponse('Internal Server Error', { status: 500 });
229
270
  }),
@@ -242,4 +283,153 @@ describe('deployDokploy', () => {
242
283
  }),
243
284
  ).rejects.toThrow('Dokploy API error: 500');
244
285
  });
286
+
287
+ it('should use registryId from config', async () => {
288
+ const saveDockerCalls: unknown[] = [];
289
+
290
+ server.use(
291
+ http.post(
292
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
293
+ async ({ request }) => {
294
+ const body = await request.json();
295
+ saveDockerCalls.push(body);
296
+ return HttpResponse.json({ success: true });
297
+ },
298
+ ),
299
+ http.post('https://dokploy.example.com/api/application.deploy', () => {
300
+ return HttpResponse.json({ success: true });
301
+ }),
302
+ );
303
+
304
+ await deployDokploy({
305
+ stage: 'production',
306
+ tag: 'v1.0.0',
307
+ imageRef: 'ghcr.io/myorg/app:v1.0.0',
308
+ config: {
309
+ endpoint: 'https://dokploy.example.com',
310
+ projectId: 'proj_123',
311
+ applicationId: 'app_456',
312
+ registryId: 'reg_789',
313
+ },
314
+ });
315
+
316
+ expect(saveDockerCalls).toHaveLength(1);
317
+ expect(saveDockerCalls[0]).toMatchObject({
318
+ applicationId: 'app_456',
319
+ dockerImage: 'ghcr.io/myorg/app:v1.0.0',
320
+ registryId: 'reg_789',
321
+ });
322
+ });
323
+
324
+ it('should use stored registryId when not in config', async () => {
325
+ const { getDokployRegistryId } = await import('../../auth');
326
+ vi.mocked(getDokployRegistryId).mockResolvedValueOnce('stored_reg_123');
327
+
328
+ const saveDockerCalls: unknown[] = [];
329
+
330
+ server.use(
331
+ http.post(
332
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
333
+ async ({ request }) => {
334
+ const body = await request.json();
335
+ saveDockerCalls.push(body);
336
+ return HttpResponse.json({ success: true });
337
+ },
338
+ ),
339
+ http.post('https://dokploy.example.com/api/application.deploy', () => {
340
+ return HttpResponse.json({ success: true });
341
+ }),
342
+ );
343
+
344
+ await deployDokploy({
345
+ stage: 'production',
346
+ tag: 'v1.0.0',
347
+ imageRef: 'ghcr.io/myorg/app:v1.0.0',
348
+ config: {
349
+ endpoint: 'https://dokploy.example.com',
350
+ projectId: 'proj_123',
351
+ applicationId: 'app_456',
352
+ },
353
+ });
354
+
355
+ expect(saveDockerCalls).toHaveLength(1);
356
+ expect(saveDockerCalls[0]).toMatchObject({
357
+ applicationId: 'app_456',
358
+ dockerImage: 'ghcr.io/myorg/app:v1.0.0',
359
+ registryId: 'stored_reg_123',
360
+ });
361
+ });
362
+
363
+ it('should use registryCredentials from config', async () => {
364
+ const saveDockerCalls: unknown[] = [];
365
+
366
+ server.use(
367
+ http.post(
368
+ 'https://dokploy.example.com/api/application.saveDockerProvider',
369
+ async ({ request }) => {
370
+ const body = await request.json();
371
+ saveDockerCalls.push(body);
372
+ return HttpResponse.json({ success: true });
373
+ },
374
+ ),
375
+ http.post('https://dokploy.example.com/api/application.deploy', () => {
376
+ return HttpResponse.json({ success: true });
377
+ }),
378
+ );
379
+
380
+ await deployDokploy({
381
+ stage: 'production',
382
+ tag: 'v1.0.0',
383
+ imageRef: 'ghcr.io/myorg/app:v1.0.0',
384
+ config: {
385
+ endpoint: 'https://dokploy.example.com',
386
+ projectId: 'proj_123',
387
+ applicationId: 'app_456',
388
+ registryCredentials: {
389
+ registryUrl: 'ghcr.io',
390
+ username: 'myuser',
391
+ password: 'mytoken',
392
+ },
393
+ },
394
+ });
395
+
396
+ expect(saveDockerCalls).toHaveLength(1);
397
+ expect(saveDockerCalls[0]).toMatchObject({
398
+ applicationId: 'app_456',
399
+ dockerImage: 'ghcr.io/myorg/app:v1.0.0',
400
+ username: 'myuser',
401
+ password: 'mytoken',
402
+ registryUrl: 'ghcr.io',
403
+ });
404
+ });
405
+ });
406
+
407
+ describe('generateTag', () => {
408
+ it('should generate tag with stage and timestamp', () => {
409
+ const tag = generateTag('production');
410
+
411
+ expect(tag).toMatch(/^production-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/);
412
+ });
413
+
414
+ it('should use stage prefix', () => {
415
+ const tag = generateTag('staging');
416
+
417
+ expect(tag.startsWith('staging-')).toBe(true);
418
+ });
419
+
420
+ it('should generate unique tags', () => {
421
+ const tag1 = generateTag('dev');
422
+ const tag2 = generateTag('dev');
423
+
424
+ // Tags generated at the same second should be equal
425
+ // But the format should be consistent
426
+ expect(tag1).toMatch(/^dev-/);
427
+ expect(tag2).toMatch(/^dev-/);
428
+ });
429
+
430
+ it('should handle different stage names', () => {
431
+ expect(generateTag('prod')).toMatch(/^prod-/);
432
+ expect(generateTag('development')).toMatch(/^development-/);
433
+ expect(generateTag('test-env')).toMatch(/^test-env-/);
434
+ });
245
435
  });
@@ -0,0 +1,401 @@
1
+ import { HttpResponse, http } from 'msw';
2
+ import { setupServer } from 'msw/node';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { DokployApi } from '../dokploy-api';
5
+ import { generateTag, provisionServices } from '../index';
6
+
7
+ const BASE_URL = 'https://dokploy.example.com';
8
+
9
+ // MSW server for mocking Dokploy API calls
10
+ const server = setupServer();
11
+
12
+ describe('generateTag', () => {
13
+ it('should generate tag with stage prefix', () => {
14
+ const tag = generateTag('production');
15
+ expect(tag).toMatch(/^production-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/);
16
+ });
17
+
18
+ it('should generate tag with staging prefix', () => {
19
+ const tag = generateTag('staging');
20
+ expect(tag).toMatch(/^staging-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/);
21
+ });
22
+
23
+ it('should generate unique tags', () => {
24
+ const tag1 = generateTag('test');
25
+ // Small delay to ensure different timestamp
26
+ const tag2 = generateTag('test');
27
+ // Tags should start with same prefix but could be same in fast execution
28
+ expect(tag1).toMatch(/^test-/);
29
+ expect(tag2).toMatch(/^test-/);
30
+ });
31
+
32
+ it('should replace colons and periods in timestamp', () => {
33
+ const tag = generateTag('dev');
34
+ expect(tag).not.toContain(':');
35
+ expect(tag).not.toContain('.');
36
+ });
37
+ });
38
+
39
+ describe('provisionServices', () => {
40
+ beforeEach(() => {
41
+ server.listen({ onUnhandledRequest: 'bypass' });
42
+ });
43
+
44
+ afterEach(() => {
45
+ server.resetHandlers();
46
+ server.close();
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it('should return undefined when no services configured', async () => {
51
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
52
+
53
+ const result = await provisionServices(
54
+ api,
55
+ 'proj_1',
56
+ 'env_1',
57
+ 'myapp',
58
+ undefined,
59
+ );
60
+
61
+ expect(result).toBeUndefined();
62
+ });
63
+
64
+ it('should return undefined when no environmentId', async () => {
65
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
66
+
67
+ const result = await provisionServices(api, 'proj_1', undefined, 'myapp', {
68
+ postgres: true,
69
+ });
70
+
71
+ expect(result).toBeUndefined();
72
+ });
73
+
74
+ it('should skip postgres when DATABASE_URL already exists', async () => {
75
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
76
+
77
+ const result = await provisionServices(
78
+ api,
79
+ 'proj_1',
80
+ 'env_1',
81
+ 'myapp',
82
+ { postgres: true },
83
+ { DATABASE_URL: 'postgresql://existing:5432/db' },
84
+ );
85
+
86
+ // Should return undefined since nothing new was provisioned
87
+ expect(result).toBeUndefined();
88
+ });
89
+
90
+ it('should skip redis when REDIS_URL already exists', async () => {
91
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
92
+
93
+ const result = await provisionServices(
94
+ api,
95
+ 'proj_1',
96
+ 'env_1',
97
+ 'myapp',
98
+ { redis: true },
99
+ { REDIS_URL: 'redis://existing:6379' },
100
+ );
101
+
102
+ expect(result).toBeUndefined();
103
+ });
104
+
105
+ it('should provision postgres and return DATABASE_URL', async () => {
106
+ server.use(
107
+ http.post(`${BASE_URL}/api/postgres.create`, async ({ request }) => {
108
+ const body = (await request.json()) as { databasePassword?: string };
109
+ return HttpResponse.json({
110
+ postgresId: 'pg_123',
111
+ name: 'myapp-db',
112
+ appName: 'myapp-db',
113
+ databaseName: 'app',
114
+ databaseUser: 'postgres',
115
+ databasePassword: body.databasePassword,
116
+ applicationStatus: 'idle',
117
+ });
118
+ }),
119
+ http.post(`${BASE_URL}/api/postgres.deploy`, () => {
120
+ return HttpResponse.json({ success: true });
121
+ }),
122
+ );
123
+
124
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
125
+
126
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
127
+ postgres: true,
128
+ });
129
+
130
+ expect(result).toBeDefined();
131
+ expect(result?.DATABASE_URL).toMatch(
132
+ /^postgresql:\/\/postgres:[a-f0-9]{32}@myapp-db:5432\/app$/,
133
+ );
134
+ });
135
+
136
+ it('should provision postgres and return individual connection parameters', async () => {
137
+ server.use(
138
+ http.post(`${BASE_URL}/api/postgres.create`, async ({ request }) => {
139
+ const body = (await request.json()) as { databasePassword?: string };
140
+ return HttpResponse.json({
141
+ postgresId: 'pg_123',
142
+ name: 'myapp-db',
143
+ appName: 'myapp-db',
144
+ databaseName: 'mydb',
145
+ databaseUser: 'dbuser',
146
+ databasePassword: body.databasePassword,
147
+ applicationStatus: 'idle',
148
+ });
149
+ }),
150
+ http.post(`${BASE_URL}/api/postgres.deploy`, () => {
151
+ return HttpResponse.json({ success: true });
152
+ }),
153
+ );
154
+
155
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
156
+
157
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
158
+ postgres: true,
159
+ });
160
+
161
+ expect(result).toBeDefined();
162
+ expect(result?.DATABASE_HOST).toBe('myapp-db');
163
+ expect(result?.DATABASE_PORT).toBe('5432');
164
+ expect(result?.DATABASE_NAME).toBe('mydb');
165
+ expect(result?.DATABASE_USER).toBe('dbuser');
166
+ expect(result?.DATABASE_PASSWORD).toMatch(/^[a-f0-9]{32}$/);
167
+ });
168
+
169
+ it('should provision redis and return REDIS_URL', async () => {
170
+ server.use(
171
+ http.post(`${BASE_URL}/api/redis.create`, async ({ request }) => {
172
+ const body = (await request.json()) as { databasePassword?: string };
173
+ return HttpResponse.json({
174
+ redisId: 'redis_123',
175
+ name: 'myapp-cache',
176
+ appName: 'myapp-cache',
177
+ databasePassword: body.databasePassword,
178
+ applicationStatus: 'idle',
179
+ });
180
+ }),
181
+ http.post(`${BASE_URL}/api/redis.deploy`, () => {
182
+ return HttpResponse.json({ success: true });
183
+ }),
184
+ );
185
+
186
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
187
+
188
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
189
+ redis: true,
190
+ });
191
+
192
+ expect(result).toBeDefined();
193
+ expect(result?.REDIS_URL).toMatch(
194
+ /^redis:\/\/:[a-f0-9]{32}@myapp-cache:6379$/,
195
+ );
196
+ });
197
+
198
+ it('should provision redis and return individual connection parameters', async () => {
199
+ server.use(
200
+ http.post(`${BASE_URL}/api/redis.create`, async ({ request }) => {
201
+ const body = (await request.json()) as { databasePassword?: string };
202
+ return HttpResponse.json({
203
+ redisId: 'redis_123',
204
+ name: 'myapp-cache',
205
+ appName: 'myapp-cache',
206
+ databasePassword: body.databasePassword,
207
+ applicationStatus: 'idle',
208
+ });
209
+ }),
210
+ http.post(`${BASE_URL}/api/redis.deploy`, () => {
211
+ return HttpResponse.json({ success: true });
212
+ }),
213
+ );
214
+
215
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
216
+
217
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
218
+ redis: true,
219
+ });
220
+
221
+ expect(result).toBeDefined();
222
+ expect(result?.REDIS_HOST).toBe('myapp-cache');
223
+ expect(result?.REDIS_PORT).toBe('6379');
224
+ expect(result?.REDIS_PASSWORD).toMatch(/^[a-f0-9]{32}$/);
225
+ });
226
+
227
+ it('should provision both postgres and redis', async () => {
228
+ server.use(
229
+ http.post(`${BASE_URL}/api/postgres.create`, async ({ request }) => {
230
+ const body = (await request.json()) as { databasePassword?: string };
231
+ return HttpResponse.json({
232
+ postgresId: 'pg_123',
233
+ name: 'myapp-db',
234
+ appName: 'myapp-db',
235
+ databaseName: 'app',
236
+ databaseUser: 'postgres',
237
+ databasePassword: body.databasePassword,
238
+ });
239
+ }),
240
+ http.post(`${BASE_URL}/api/postgres.deploy`, () => {
241
+ return HttpResponse.json({ success: true });
242
+ }),
243
+ http.post(`${BASE_URL}/api/redis.create`, async ({ request }) => {
244
+ const body = (await request.json()) as { databasePassword?: string };
245
+ return HttpResponse.json({
246
+ redisId: 'redis_123',
247
+ name: 'myapp-cache',
248
+ appName: 'myapp-cache',
249
+ databasePassword: body.databasePassword,
250
+ });
251
+ }),
252
+ http.post(`${BASE_URL}/api/redis.deploy`, () => {
253
+ return HttpResponse.json({ success: true });
254
+ }),
255
+ );
256
+
257
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
258
+
259
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
260
+ postgres: true,
261
+ redis: true,
262
+ });
263
+
264
+ expect(result).toBeDefined();
265
+ expect(result?.DATABASE_URL).toBeDefined();
266
+ expect(result?.REDIS_URL).toBeDefined();
267
+ });
268
+
269
+ it('should handle postgres already exists error gracefully', async () => {
270
+ server.use(
271
+ http.post(`${BASE_URL}/api/postgres.create`, () => {
272
+ return HttpResponse.json(
273
+ { message: 'Resource already exists' },
274
+ { status: 400 },
275
+ );
276
+ }),
277
+ );
278
+
279
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
280
+
281
+ // Should not throw, just return undefined for that service
282
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
283
+ postgres: true,
284
+ });
285
+
286
+ expect(result).toBeUndefined();
287
+ });
288
+
289
+ it('should handle redis already exists error gracefully', async () => {
290
+ server.use(
291
+ http.post(`${BASE_URL}/api/redis.create`, () => {
292
+ return HttpResponse.json(
293
+ { message: 'duplicate key error' },
294
+ { status: 400 },
295
+ );
296
+ }),
297
+ );
298
+
299
+ const api = new DokployApi({ baseUrl: BASE_URL, token: 'test-token' });
300
+
301
+ const result = await provisionServices(api, 'proj_1', 'env_1', 'myapp', {
302
+ redis: true,
303
+ });
304
+
305
+ expect(result).toBeUndefined();
306
+ });
307
+ });
308
+
309
+ describe('DockerComposeServices parsing', () => {
310
+ it('should parse array format', () => {
311
+ const composeServices = ['postgres', 'redis'];
312
+ const dockerServices = {
313
+ postgres: composeServices.includes('postgres'),
314
+ redis: composeServices.includes('redis'),
315
+ rabbitmq: composeServices.includes('rabbitmq'),
316
+ };
317
+
318
+ expect(dockerServices).toEqual({
319
+ postgres: true,
320
+ redis: true,
321
+ rabbitmq: false,
322
+ });
323
+ });
324
+
325
+ it('should parse object format', () => {
326
+ const composeServices = { postgres: true, redis: false };
327
+ const dockerServices = {
328
+ postgres: Boolean(composeServices.postgres),
329
+ redis: Boolean(composeServices.redis),
330
+ rabbitmq: false,
331
+ };
332
+
333
+ expect(dockerServices).toEqual({
334
+ postgres: true,
335
+ redis: false,
336
+ rabbitmq: false,
337
+ });
338
+ });
339
+
340
+ it('should handle undefined', () => {
341
+ const composeServices = undefined;
342
+ const dockerServices = composeServices ? {} : undefined;
343
+
344
+ expect(dockerServices).toBeUndefined();
345
+ });
346
+ });
347
+
348
+ describe('image reference construction', () => {
349
+ it('should construct image ref with registry', () => {
350
+ const registry = 'ghcr.io/myorg';
351
+ const imageName = 'myapp';
352
+ const imageTag = 'v1.0.0';
353
+ const imageRef = registry
354
+ ? `${registry}/${imageName}:${imageTag}`
355
+ : `${imageName}:${imageTag}`;
356
+
357
+ expect(imageRef).toBe('ghcr.io/myorg/myapp:v1.0.0');
358
+ });
359
+
360
+ it('should construct image ref without registry', () => {
361
+ const registry = undefined;
362
+ const imageName = 'myapp';
363
+ const imageTag = 'v1.0.0';
364
+ const imageRef = registry
365
+ ? `${registry}/${imageName}:${imageTag}`
366
+ : `${imageName}:${imageTag}`;
367
+
368
+ expect(imageRef).toBe('myapp:v1.0.0');
369
+ });
370
+
371
+ it('should handle different registry formats', () => {
372
+ // Docker Hub
373
+ expect(`docker.io/myapp:latest`).toBe('docker.io/myapp:latest');
374
+ // GCR
375
+ expect(`gcr.io/project/myapp:v1`).toBe('gcr.io/project/myapp:v1');
376
+ // ECR
377
+ expect(`123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest`).toBe(
378
+ '123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest',
379
+ );
380
+ });
381
+ });
382
+
383
+ describe('generateTag edge cases', () => {
384
+ it('should handle stage with special characters', () => {
385
+ const tag = generateTag('prod-us-east');
386
+ expect(tag).toMatch(/^prod-us-east-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/);
387
+ });
388
+
389
+ it('should generate consistent format', () => {
390
+ const tags = [
391
+ generateTag('dev'),
392
+ generateTag('staging'),
393
+ generateTag('production'),
394
+ ];
395
+
396
+ for (const tag of tags) {
397
+ // Should match ISO 8601 format with dashes instead of colons/periods
398
+ expect(tag).toMatch(/^[a-z-]+-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/);
399
+ }
400
+ });
401
+ });