@geekmidas/cli 1.5.0 → 1.6.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 (119) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/{HostingerProvider-B9N-TKbp.mjs → HostingerProvider-402UdK89.mjs} +34 -1
  3. package/dist/HostingerProvider-402UdK89.mjs.map +1 -0
  4. package/dist/{HostingerProvider-DUV9-Tzg.cjs → HostingerProvider-BiXdHjiq.cjs} +34 -1
  5. package/dist/HostingerProvider-BiXdHjiq.cjs.map +1 -0
  6. package/dist/{Route53Provider-C8mS0zY6.mjs → Route53Provider-DbBo7Uz5.mjs} +53 -1
  7. package/dist/Route53Provider-DbBo7Uz5.mjs.map +1 -0
  8. package/dist/{Route53Provider-Bs7Arms9.cjs → Route53Provider-kfJ77LmL.cjs} +53 -1
  9. package/dist/Route53Provider-kfJ77LmL.cjs.map +1 -0
  10. package/dist/backup-provisioner-B5e-F6zX.cjs +164 -0
  11. package/dist/backup-provisioner-B5e-F6zX.cjs.map +1 -0
  12. package/dist/backup-provisioner-BIArpmTr.mjs +163 -0
  13. package/dist/backup-provisioner-BIArpmTr.mjs.map +1 -0
  14. package/dist/{config-ZQM1vBoz.cjs → config-6JHOwLCx.cjs} +30 -2
  15. package/dist/{config-ZQM1vBoz.cjs.map → config-6JHOwLCx.cjs.map} +1 -1
  16. package/dist/{config-DfCJ29PQ.mjs → config-DxASSNjr.mjs} +25 -3
  17. package/dist/{config-DfCJ29PQ.mjs.map → config-DxASSNjr.mjs.map} +1 -1
  18. package/dist/config.cjs +3 -2
  19. package/dist/config.d.cts +14 -2
  20. package/dist/config.d.cts.map +1 -1
  21. package/dist/config.d.mts +15 -3
  22. package/dist/config.d.mts.map +1 -1
  23. package/dist/config.mjs +3 -3
  24. package/dist/{dokploy-api-z0833e7r.mjs → dokploy-api-2ldYoN3i.mjs} +131 -1
  25. package/dist/dokploy-api-2ldYoN3i.mjs.map +1 -0
  26. package/dist/dokploy-api-C93pveuy.mjs +3 -0
  27. package/dist/dokploy-api-CbDh4o93.cjs +3 -0
  28. package/dist/{dokploy-api-CQvhV6Hd.cjs → dokploy-api-DLgvEQlr.cjs} +131 -1
  29. package/dist/dokploy-api-DLgvEQlr.cjs.map +1 -0
  30. package/dist/{index-C0SpUT9Y.d.mts → index-C-KxSGGK.d.mts} +133 -31
  31. package/dist/index-C-KxSGGK.d.mts.map +1 -0
  32. package/dist/{index-B58qjyBd.d.cts → index-Cyk2rTyj.d.cts} +132 -30
  33. package/dist/index-Cyk2rTyj.d.cts.map +1 -0
  34. package/dist/index.cjs +662 -152
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.mjs +626 -116
  37. package/dist/index.mjs.map +1 -1
  38. package/dist/{openapi-BcSjLfWq.mjs → openapi-BYlyAbH3.mjs} +6 -5
  39. package/dist/openapi-BYlyAbH3.mjs.map +1 -0
  40. package/dist/{openapi-D6Hcfov0.cjs → openapi-CnvwSRDU.cjs} +6 -5
  41. package/dist/openapi-CnvwSRDU.cjs.map +1 -0
  42. package/dist/openapi.cjs +3 -3
  43. package/dist/openapi.d.cts +1 -0
  44. package/dist/openapi.d.cts.map +1 -1
  45. package/dist/openapi.d.mts +2 -1
  46. package/dist/openapi.d.mts.map +1 -1
  47. package/dist/openapi.mjs +3 -3
  48. package/dist/{types-B9UZ7fOG.d.mts → types-CZg5iUgD.d.mts} +1 -1
  49. package/dist/{types-B9UZ7fOG.d.mts.map → types-CZg5iUgD.d.mts.map} +1 -1
  50. package/dist/workspace/index.cjs +1 -1
  51. package/dist/workspace/index.d.cts +1 -1
  52. package/dist/workspace/index.d.mts +2 -2
  53. package/dist/workspace/index.mjs +1 -1
  54. package/dist/{workspace-BW2iU37P.mjs → workspace-9IQIjwkQ.mjs} +20 -4
  55. package/dist/workspace-9IQIjwkQ.mjs.map +1 -0
  56. package/dist/{workspace-2Do2YcGZ.cjs → workspace-D2ocAlpl.cjs} +20 -4
  57. package/dist/workspace-D2ocAlpl.cjs.map +1 -0
  58. package/examples/cron-example.ts +6 -6
  59. package/examples/function-example.ts +1 -1
  60. package/package.json +6 -3
  61. package/src/config.ts +44 -0
  62. package/src/deploy/__tests__/backup-provisioner.spec.ts +428 -0
  63. package/src/deploy/__tests__/createDnsProvider.spec.ts +23 -0
  64. package/src/deploy/__tests__/env-resolver.spec.ts +1 -1
  65. package/src/deploy/__tests__/undeploy.spec.ts +758 -0
  66. package/src/deploy/backup-provisioner.ts +316 -0
  67. package/src/deploy/dns/DnsProvider.ts +39 -1
  68. package/src/deploy/dns/HostingerProvider.ts +74 -0
  69. package/src/deploy/dns/Route53Provider.ts +81 -0
  70. package/src/deploy/dns/index.ts +25 -0
  71. package/src/deploy/dokploy-api.ts +237 -0
  72. package/src/deploy/index.ts +71 -13
  73. package/src/deploy/state.ts +171 -0
  74. package/src/deploy/undeploy.ts +407 -0
  75. package/src/dev/__tests__/index.spec.ts +490 -0
  76. package/src/dev/index.ts +313 -18
  77. package/src/generators/FunctionGenerator.ts +1 -1
  78. package/src/generators/Generator.ts +4 -1
  79. package/src/init/__tests__/generators.spec.ts +167 -18
  80. package/src/init/__tests__/init.spec.ts +66 -3
  81. package/src/init/generators/auth.ts +6 -5
  82. package/src/init/generators/config.ts +49 -7
  83. package/src/init/generators/docker.ts +8 -8
  84. package/src/init/generators/index.ts +1 -0
  85. package/src/init/generators/models.ts +3 -5
  86. package/src/init/generators/package.ts +4 -0
  87. package/src/init/generators/test.ts +133 -0
  88. package/src/init/generators/ui.ts +13 -12
  89. package/src/init/generators/web.ts +9 -8
  90. package/src/init/index.ts +2 -0
  91. package/src/init/templates/api.ts +6 -6
  92. package/src/init/templates/minimal.ts +2 -2
  93. package/src/init/templates/worker.ts +2 -2
  94. package/src/init/versions.ts +3 -3
  95. package/src/openapi.ts +6 -2
  96. package/src/test/__tests__/__fixtures__/workspace.ts +104 -0
  97. package/src/test/__tests__/api.spec.ts +199 -0
  98. package/src/test/__tests__/auth.spec.ts +162 -0
  99. package/src/test/__tests__/index.spec.ts +323 -0
  100. package/src/test/__tests__/web.spec.ts +210 -0
  101. package/src/test/index.ts +165 -14
  102. package/src/workspace/__tests__/index.spec.ts +3 -0
  103. package/src/workspace/index.ts +4 -2
  104. package/src/workspace/schema.ts +26 -0
  105. package/src/workspace/types.ts +14 -37
  106. package/dist/HostingerProvider-B9N-TKbp.mjs.map +0 -1
  107. package/dist/HostingerProvider-DUV9-Tzg.cjs.map +0 -1
  108. package/dist/Route53Provider-Bs7Arms9.cjs.map +0 -1
  109. package/dist/Route53Provider-C8mS0zY6.mjs.map +0 -1
  110. package/dist/dokploy-api-CQvhV6Hd.cjs.map +0 -1
  111. package/dist/dokploy-api-CWc02yyg.cjs +0 -3
  112. package/dist/dokploy-api-DSJYNx88.mjs +0 -3
  113. package/dist/dokploy-api-z0833e7r.mjs.map +0 -1
  114. package/dist/index-B58qjyBd.d.cts.map +0 -1
  115. package/dist/index-C0SpUT9Y.d.mts.map +0 -1
  116. package/dist/openapi-BcSjLfWq.mjs.map +0 -1
  117. package/dist/openapi-D6Hcfov0.cjs.map +0 -1
  118. package/dist/workspace-2Do2YcGZ.cjs.map +0 -1
  119. package/dist/workspace-BW2iU37P.mjs.map +0 -1
@@ -1,12 +1,12 @@
1
- import { cron } from '@geekmidas/constructs/crons';
1
+ import { c } from '@geekmidas/constructs/crons';
2
2
 
3
3
  /**
4
4
  * Example cron that generates a daily report at 9 AM UTC
5
5
  */
6
- export const dailyReport = cron
6
+ export const dailyReport = c
7
7
  .schedule('cron(0 9 * * ? *)')
8
8
  .timeout(600000) // 10 minutes
9
- .handle(async ({ services, logger }) => {
9
+ .handle(async ({ logger }) => {
10
10
  logger.info('Generating daily report');
11
11
 
12
12
  const reportDate = new Date().toISOString().split('T')[0];
@@ -22,7 +22,7 @@ export const dailyReport = cron
22
22
  ],
23
23
  };
24
24
 
25
- logger.info('Daily report generated', reportData);
25
+ logger.info(reportData, 'Daily report generated');
26
26
 
27
27
  return reportData;
28
28
  });
@@ -30,10 +30,10 @@ export const dailyReport = cron
30
30
  /**
31
31
  * Example cron that runs every hour
32
32
  */
33
- export const hourlyCleanup = cron
33
+ export const hourlyCleanup = c
34
34
  .schedule('rate(1 hour)')
35
35
  .timeout(300000) // 5 minutes
36
- .handle(async ({ services, logger }) => {
36
+ .handle(async ({ logger }) => {
37
37
  logger.info('Running hourly cleanup');
38
38
 
39
39
  // Cleanup logic here
@@ -24,7 +24,7 @@ export const processOrder = f
24
24
  }),
25
25
  )
26
26
  .timeout(300000) // 5 minutes
27
- .handle(async ({ input, services, logger }) => {
27
+ .handle(async ({ input, logger }) => {
28
28
  logger.info(`Processing order ${input.orderId}`);
29
29
 
30
30
  // Process order logic here
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,7 +40,9 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@apidevtools/swagger-parser": "^10.1.0",
43
+ "@aws-sdk/client-iam": "~3.971.0",
43
44
  "@aws-sdk/client-route-53": "~3.971.0",
45
+ "@aws-sdk/client-s3": "~3.971.0",
44
46
  "@aws-sdk/client-ssm": "~3.971.0",
45
47
  "@aws-sdk/credential-providers": "~3.971.0",
46
48
  "chokidar": "~4.0.3",
@@ -53,9 +55,10 @@
53
55
  "pg": "~8.17.1",
54
56
  "prompts": "~2.4.2",
55
57
  "tsx": "~4.20.3",
56
- "@geekmidas/envkit": "~1.0.0",
58
+ "yaml": "~2.8.2",
59
+ "@geekmidas/constructs": "~1.1.0",
60
+ "@geekmidas/envkit": "~1.0.2",
57
61
  "@geekmidas/errors": "~1.0.0",
58
- "@geekmidas/constructs": "~1.0.0",
59
62
  "@geekmidas/logger": "~1.0.0",
60
63
  "@geekmidas/schema": "~1.0.0"
61
64
  },
package/src/config.ts CHANGED
@@ -284,3 +284,47 @@ export async function loadAppConfig(
284
284
  appRoot: join(workspaceRoot, app.path),
285
285
  };
286
286
  }
287
+
288
+ export interface WorkspaceAppInfo {
289
+ appName: string;
290
+ app: NormalizedAppConfig;
291
+ workspace: NormalizedWorkspace;
292
+ workspaceRoot: string;
293
+ }
294
+
295
+ /**
296
+ * Load workspace info for any app (frontend or backend).
297
+ * Unlike loadAppConfig, this does NOT require the app to have a gkm config
298
+ * (routes, entry, etc.), making it suitable for gkm exec/test from frontend apps.
299
+ */
300
+ export async function loadWorkspaceAppInfo(
301
+ cwd: string = process.cwd(),
302
+ ): Promise<WorkspaceAppInfo> {
303
+ const appName = getAppNameFromCwd(cwd);
304
+
305
+ if (!appName) {
306
+ throw new Error(
307
+ 'Could not determine app name. Ensure package.json exists with a "name" field.',
308
+ );
309
+ }
310
+
311
+ const { config, workspaceRoot } = await loadRawConfig(cwd);
312
+ const loadedConfig = processConfig(config, workspaceRoot);
313
+
314
+ const app = loadedConfig.workspace.apps[appName];
315
+
316
+ if (!app) {
317
+ const availableApps = Object.keys(loadedConfig.workspace.apps).join(', ');
318
+ throw new Error(
319
+ `App "${appName}" not found in workspace config. Available apps: ${availableApps}. ` +
320
+ `Ensure the package.json name matches the app key in gkm.config.ts.`,
321
+ );
322
+ }
323
+
324
+ return {
325
+ appName,
326
+ app,
327
+ workspace: loadedConfig.workspace,
328
+ workspaceRoot,
329
+ };
330
+ }
@@ -0,0 +1,428 @@
1
+ import {
2
+ DeleteAccessKeyCommand,
3
+ DeleteUserCommand,
4
+ DeleteUserPolicyCommand,
5
+ GetUserCommand,
6
+ IAMClient,
7
+ ListAccessKeysCommand,
8
+ } from '@aws-sdk/client-iam';
9
+ import {
10
+ DeleteBucketCommand,
11
+ DeleteObjectsCommand,
12
+ ListObjectsV2Command,
13
+ S3Client,
14
+ } from '@aws-sdk/client-s3';
15
+ import { HttpResponse, http } from 'msw';
16
+ import { setupServer } from 'msw/node';
17
+ import {
18
+ afterAll,
19
+ afterEach,
20
+ beforeAll,
21
+ beforeEach,
22
+ describe,
23
+ expect,
24
+ it,
25
+ } from 'vitest';
26
+ import {
27
+ type ProvisionBackupOptions,
28
+ provisionBackupDestination,
29
+ } from '../backup-provisioner';
30
+ import { DokployApi } from '../dokploy-api';
31
+ import type { BackupState } from '../state';
32
+
33
+ /**
34
+ * Backup Provisioner Tests
35
+ *
36
+ * These tests require LocalStack to be running with S3 and IAM enabled.
37
+ * Run: docker compose up -d localstack
38
+ */
39
+ describe('backup-provisioner', () => {
40
+ const LOCALSTACK_ENDPOINT = 'http://localhost:4566';
41
+ const DOKPLOY_BASE_URL = 'https://dokploy.example.com';
42
+
43
+ let s3Client: S3Client;
44
+ let iamClient: IAMClient;
45
+ let dokployApi: DokployApi;
46
+
47
+ const server = setupServer();
48
+ const logs: string[] = [];
49
+ const logger = { log: (msg: string) => logs.push(msg) };
50
+
51
+ // Track created resources for cleanup
52
+ const createdBuckets: string[] = [];
53
+ const createdUsers: string[] = [];
54
+
55
+ beforeAll(() => {
56
+ process.env.AWS_ACCESS_KEY_ID = 'test';
57
+ process.env.AWS_SECRET_ACCESS_KEY = 'test';
58
+ server.listen({ onUnhandledRequest: 'bypass' });
59
+ });
60
+
61
+ beforeEach(() => {
62
+ logs.length = 0;
63
+
64
+ s3Client = new S3Client({
65
+ region: 'us-east-1',
66
+ endpoint: LOCALSTACK_ENDPOINT,
67
+ forcePathStyle: true,
68
+ credentials: {
69
+ accessKeyId: 'test',
70
+ secretAccessKey: 'test',
71
+ },
72
+ });
73
+
74
+ iamClient = new IAMClient({
75
+ region: 'us-east-1',
76
+ endpoint: LOCALSTACK_ENDPOINT,
77
+ credentials: {
78
+ accessKeyId: 'test',
79
+ secretAccessKey: 'test',
80
+ },
81
+ });
82
+
83
+ dokployApi = new DokployApi({
84
+ baseUrl: DOKPLOY_BASE_URL,
85
+ token: 'test-api-token',
86
+ });
87
+ });
88
+
89
+ afterEach(async () => {
90
+ server.resetHandlers();
91
+
92
+ // Cleanup created buckets
93
+ for (const bucket of createdBuckets) {
94
+ try {
95
+ // First delete all objects in the bucket
96
+ const objects = await s3Client.send(
97
+ new ListObjectsV2Command({ Bucket: bucket }),
98
+ );
99
+ if (objects.Contents?.length) {
100
+ await s3Client.send(
101
+ new DeleteObjectsCommand({
102
+ Bucket: bucket,
103
+ Delete: {
104
+ Objects: objects.Contents.map((o) => ({ Key: o.Key })),
105
+ },
106
+ }),
107
+ );
108
+ }
109
+ await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
110
+ } catch {
111
+ // Ignore cleanup errors
112
+ }
113
+ }
114
+ createdBuckets.length = 0;
115
+
116
+ // Cleanup created IAM users
117
+ for (const userName of createdUsers) {
118
+ try {
119
+ // Delete access keys first
120
+ const keys = await iamClient.send(
121
+ new ListAccessKeysCommand({ UserName: userName }),
122
+ );
123
+ for (const key of keys.AccessKeyMetadata ?? []) {
124
+ await iamClient.send(
125
+ new DeleteAccessKeyCommand({
126
+ UserName: userName,
127
+ AccessKeyId: key.AccessKeyId,
128
+ }),
129
+ );
130
+ }
131
+ // Delete user policy
132
+ await iamClient.send(
133
+ new DeleteUserPolicyCommand({
134
+ UserName: userName,
135
+ PolicyName: 'DokployBackupAccess',
136
+ }),
137
+ );
138
+ // Delete user
139
+ await iamClient.send(new DeleteUserCommand({ UserName: userName }));
140
+ } catch {
141
+ // Ignore cleanup errors
142
+ }
143
+ }
144
+ createdUsers.length = 0;
145
+ });
146
+
147
+ afterAll(() => {
148
+ server.close();
149
+ s3Client.destroy();
150
+ iamClient.destroy();
151
+ });
152
+
153
+ /**
154
+ * Helper to create provision options with LocalStack clients
155
+ */
156
+ function createOptions(
157
+ overrides: Partial<ProvisionBackupOptions> = {},
158
+ ): ProvisionBackupOptions {
159
+ return {
160
+ api: dokployApi,
161
+ projectId: 'proj_test123',
162
+ projectName: 'test-project',
163
+ stage: 'production',
164
+ config: {
165
+ type: 's3',
166
+ region: 'us-east-1',
167
+ },
168
+ logger,
169
+ awsEndpoint: LOCALSTACK_ENDPOINT,
170
+ ...overrides,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Setup mock Dokploy API responses
176
+ */
177
+ function setupDokployMocks(options: {
178
+ existingDestination?: { destinationId: string; name: string };
179
+ createDestinationId?: string;
180
+ }) {
181
+ const handlers = [
182
+ // List destinations
183
+ http.get(`${DOKPLOY_BASE_URL}/api/destination.all`, () => {
184
+ if (options.existingDestination) {
185
+ return HttpResponse.json([options.existingDestination]);
186
+ }
187
+ return HttpResponse.json([]);
188
+ }),
189
+
190
+ // Get destination
191
+ http.get(`${DOKPLOY_BASE_URL}/api/destination.one`, ({ request }) => {
192
+ const url = new URL(request.url);
193
+ const destId = url.searchParams.get('destinationId');
194
+ if (
195
+ options.existingDestination &&
196
+ destId === options.existingDestination.destinationId
197
+ ) {
198
+ return HttpResponse.json(options.existingDestination);
199
+ }
200
+ return HttpResponse.json({ message: 'Not found' }, { status: 404 });
201
+ }),
202
+
203
+ // Create destination
204
+ http.post(`${DOKPLOY_BASE_URL}/api/destination.create`, () => {
205
+ return HttpResponse.json({
206
+ destinationId: options.createDestinationId ?? 'dest_new123',
207
+ name: 'test-project-production-s3',
208
+ });
209
+ }),
210
+
211
+ // Test destination connection
212
+ http.post(`${DOKPLOY_BASE_URL}/api/destination.testConnection`, () => {
213
+ return HttpResponse.json({ success: true });
214
+ }),
215
+ ];
216
+
217
+ server.use(...handlers);
218
+ }
219
+
220
+ describe('provisionBackupDestination', () => {
221
+ it('should create S3 bucket with unique name', async () => {
222
+ setupDokployMocks({ createDestinationId: 'dest_123' });
223
+
224
+ const options = createOptions();
225
+ const result = await provisionBackupDestination(options);
226
+
227
+ // Track for cleanup
228
+ createdBuckets.push(result.bucketName);
229
+ createdUsers.push(result.iamUserName);
230
+
231
+ expect(result.bucketName).toMatch(/^test-project-production-backups-/);
232
+ expect(result.bucketArn).toBe(`arn:aws:s3:::${result.bucketName}`);
233
+ expect(logs.some((l) => l.includes('Creating S3 bucket'))).toBe(true);
234
+ });
235
+
236
+ it('should create IAM user with correct name', async () => {
237
+ setupDokployMocks({ createDestinationId: 'dest_123' });
238
+
239
+ const options = createOptions();
240
+ const result = await provisionBackupDestination(options);
241
+
242
+ // Track for cleanup
243
+ createdBuckets.push(result.bucketName);
244
+ createdUsers.push(result.iamUserName);
245
+
246
+ expect(result.iamUserName).toBe('dokploy-backup-test-project-production');
247
+
248
+ // Verify user exists in IAM
249
+ const userResponse = await iamClient.send(
250
+ new GetUserCommand({ UserName: result.iamUserName }),
251
+ );
252
+ expect(userResponse.User?.UserName).toBe(result.iamUserName);
253
+ });
254
+
255
+ it('should create IAM access key', async () => {
256
+ setupDokployMocks({ createDestinationId: 'dest_123' });
257
+
258
+ const options = createOptions();
259
+ const result = await provisionBackupDestination(options);
260
+
261
+ // Track for cleanup
262
+ createdBuckets.push(result.bucketName);
263
+ createdUsers.push(result.iamUserName);
264
+
265
+ expect(result.iamAccessKeyId).toBeDefined();
266
+ expect(result.iamSecretAccessKey).toBeDefined();
267
+ expect(result.iamAccessKeyId.length).toBeGreaterThan(0);
268
+ expect(result.iamSecretAccessKey.length).toBeGreaterThan(0);
269
+ });
270
+
271
+ it('should create Dokploy destination', async () => {
272
+ setupDokployMocks({ createDestinationId: 'dest_created' });
273
+
274
+ const options = createOptions();
275
+ const result = await provisionBackupDestination(options);
276
+
277
+ // Track for cleanup
278
+ createdBuckets.push(result.bucketName);
279
+ createdUsers.push(result.iamUserName);
280
+
281
+ expect(result.destinationId).toBe('dest_created');
282
+ expect(logs.some((l) => l.includes('Dokploy destination created'))).toBe(
283
+ true,
284
+ );
285
+ });
286
+
287
+ it('should reuse existing state when destination still exists', async () => {
288
+ const existingState: BackupState = {
289
+ bucketName: 'existing-bucket',
290
+ bucketArn: 'arn:aws:s3:::existing-bucket',
291
+ iamUserName: 'existing-user',
292
+ iamAccessKeyId: 'AKIA123',
293
+ iamSecretAccessKey: 'secret123',
294
+ destinationId: 'dest_existing',
295
+ region: 'us-east-1',
296
+ createdAt: '2024-01-01T00:00:00.000Z',
297
+ };
298
+
299
+ setupDokployMocks({
300
+ existingDestination: {
301
+ destinationId: 'dest_existing',
302
+ name: 'existing',
303
+ },
304
+ });
305
+
306
+ const options = createOptions({ existingState });
307
+ const result = await provisionBackupDestination(options);
308
+
309
+ // Should return existing state without creating new resources
310
+ expect(result).toEqual(existingState);
311
+ expect(
312
+ logs.some((l) => l.includes('Using existing backup destination')),
313
+ ).toBe(true);
314
+ });
315
+
316
+ it('should recreate destination if existing one not found', async () => {
317
+ const existingState: BackupState = {
318
+ bucketName: 'existing-bucket',
319
+ bucketArn: 'arn:aws:s3:::existing-bucket',
320
+ iamUserName: 'dokploy-backup-test-project-production',
321
+ iamAccessKeyId: 'AKIA123',
322
+ iamSecretAccessKey: 'secret123',
323
+ destinationId: 'dest_deleted',
324
+ region: 'us-east-1',
325
+ createdAt: '2024-01-01T00:00:00.000Z',
326
+ };
327
+
328
+ // Mock: destination.one returns 404, meaning destination was deleted
329
+ setupDokployMocks({ createDestinationId: 'dest_new' });
330
+
331
+ const options = createOptions({ existingState });
332
+ const result = await provisionBackupDestination(options);
333
+
334
+ // Track for cleanup (uses existing bucket name from state)
335
+ createdBuckets.push(result.bucketName);
336
+ createdUsers.push(result.iamUserName);
337
+
338
+ // Should reuse bucket name from state
339
+ expect(result.bucketName).toBe('existing-bucket');
340
+ // Should reuse IAM user name
341
+ expect(result.iamUserName).toBe('dokploy-backup-test-project-production');
342
+ // Should reuse credentials from state
343
+ expect(result.iamAccessKeyId).toBe('AKIA123');
344
+ // Should create new destination
345
+ expect(result.destinationId).toBe('dest_new');
346
+ expect(
347
+ logs.some((l) => l.includes('Existing destination not found')),
348
+ ).toBe(true);
349
+ });
350
+
351
+ it('should sanitize project name for AWS resources', async () => {
352
+ setupDokployMocks({ createDestinationId: 'dest_123' });
353
+
354
+ const options = createOptions({
355
+ projectName: 'My Project_Name!@#',
356
+ });
357
+ const result = await provisionBackupDestination(options);
358
+
359
+ // Track for cleanup
360
+ createdBuckets.push(result.bucketName);
361
+ createdUsers.push(result.iamUserName);
362
+
363
+ // Should be lowercase with only alphanumeric and hyphens
364
+ // 'My Project_Name!@#' -> 'my-project-name----' (space, underscore, !, @, # all become hyphens)
365
+ expect(result.bucketName).toMatch(
366
+ /^my-project-name----production-backups-/,
367
+ );
368
+ expect(result.iamUserName).toBe(
369
+ 'dokploy-backup-my-project-name----production',
370
+ );
371
+ });
372
+
373
+ it('should return complete BackupState', async () => {
374
+ setupDokployMocks({ createDestinationId: 'dest_complete' });
375
+
376
+ const options = createOptions();
377
+ const result = await provisionBackupDestination(options);
378
+
379
+ // Track for cleanup
380
+ createdBuckets.push(result.bucketName);
381
+ createdUsers.push(result.iamUserName);
382
+
383
+ expect(result).toMatchObject({
384
+ bucketName: expect.stringMatching(/^test-project-production-backups-/),
385
+ bucketArn: expect.stringMatching(/^arn:aws:s3:::/),
386
+ iamUserName: 'dokploy-backup-test-project-production',
387
+ iamAccessKeyId: expect.any(String),
388
+ iamSecretAccessKey: expect.any(String),
389
+ destinationId: 'dest_complete',
390
+ region: 'us-east-1',
391
+ createdAt: expect.any(String),
392
+ });
393
+ });
394
+
395
+ it('should handle connection test failure gracefully', async () => {
396
+ server.use(
397
+ http.get(`${DOKPLOY_BASE_URL}/api/destination.all`, () => {
398
+ return HttpResponse.json([]);
399
+ }),
400
+ http.post(`${DOKPLOY_BASE_URL}/api/destination.create`, () => {
401
+ return HttpResponse.json({
402
+ destinationId: 'dest_123',
403
+ name: 'test',
404
+ });
405
+ }),
406
+ http.post(`${DOKPLOY_BASE_URL}/api/destination.testConnection`, () => {
407
+ return HttpResponse.json(
408
+ { message: 'Connection failed' },
409
+ { status: 500 },
410
+ );
411
+ }),
412
+ );
413
+
414
+ const options = createOptions();
415
+ const result = await provisionBackupDestination(options);
416
+
417
+ // Track for cleanup
418
+ createdBuckets.push(result.bucketName);
419
+ createdUsers.push(result.iamUserName);
420
+
421
+ // Should still succeed but log warning
422
+ expect(result.destinationId).toBe('dest_123');
423
+ expect(
424
+ logs.some((l) => l.includes('Warning: Could not verify destination')),
425
+ ).toBe(true);
426
+ });
427
+ });
428
+ });
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  createDnsProvider,
4
+ type DeleteDnsRecord,
5
+ type DeleteResult,
4
6
  type DnsProvider,
5
7
  type DnsRecord,
6
8
  isDnsProvider,
@@ -14,6 +16,7 @@ describe('isDnsProvider', () => {
14
16
  name: 'test',
15
17
  getRecords: async () => [],
16
18
  upsertRecords: async () => [],
19
+ deleteRecords: async () => [],
17
20
  };
18
21
  expect(isDnsProvider(provider)).toBe(true);
19
22
  });
@@ -126,6 +129,16 @@ describe('createDnsProvider', () => {
126
129
  async upsertRecords(): Promise<UpsertResult[]> {
127
130
  return [];
128
131
  },
132
+ async deleteRecords(
133
+ _domain: string,
134
+ records: DeleteDnsRecord[],
135
+ ): Promise<DeleteResult[]> {
136
+ return records.map((r) => ({
137
+ record: r,
138
+ deleted: true,
139
+ notFound: false,
140
+ }));
141
+ },
129
142
  };
130
143
 
131
144
  const provider = await createDnsProvider({
@@ -157,6 +170,16 @@ describe('createDnsProvider', () => {
157
170
  unchanged: false,
158
171
  }));
159
172
  },
173
+ async deleteRecords(
174
+ _domain: string,
175
+ records: DeleteDnsRecord[],
176
+ ): Promise<DeleteResult[]> {
177
+ return records.map((r) => ({
178
+ record: r,
179
+ deleted: true,
180
+ notFound: false,
181
+ }));
182
+ },
160
183
  };
161
184
 
162
185
  const provider = await createDnsProvider({
@@ -843,7 +843,7 @@ describe('Docker build arg extraction', () => {
843
843
  // DATABASE_URL is missing (no postgres config)
844
844
  expect(missing).toContain('DATABASE_URL');
845
845
 
846
- const { buildArgs, publicUrlArgNames } = extractBuildArgs(resolved);
846
+ const { publicUrlArgNames } = extractBuildArgs(resolved);
847
847
 
848
848
  // Only NEXT_PUBLIC_* should be build args
849
849
  expect(publicUrlArgNames).toHaveLength(3);