@geekmidas/cli 1.5.0 → 1.5.1

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 (82) hide show
  1. package/CHANGELOG.md +6 -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-BYn5yUt5.cjs} +2 -2
  15. package/dist/{config-ZQM1vBoz.cjs.map → config-BYn5yUt5.cjs.map} +1 -1
  16. package/dist/{config-DfCJ29PQ.mjs → config-dLNQIvDR.mjs} +2 -2
  17. package/dist/{config-DfCJ29PQ.mjs.map → config-dLNQIvDR.mjs.map} +1 -1
  18. package/dist/config.cjs +2 -2
  19. package/dist/config.d.cts +1 -1
  20. package/dist/config.d.mts +2 -2
  21. package/dist/config.mjs +2 -2
  22. package/dist/{dokploy-api-z0833e7r.mjs → dokploy-api-2ldYoN3i.mjs} +131 -1
  23. package/dist/dokploy-api-2ldYoN3i.mjs.map +1 -0
  24. package/dist/dokploy-api-C93pveuy.mjs +3 -0
  25. package/dist/dokploy-api-CbDh4o93.cjs +3 -0
  26. package/dist/{dokploy-api-CQvhV6Hd.cjs → dokploy-api-DLgvEQlr.cjs} +131 -1
  27. package/dist/dokploy-api-DLgvEQlr.cjs.map +1 -0
  28. package/dist/{index-B58qjyBd.d.cts → index-Ba21_lNt.d.cts} +131 -29
  29. package/dist/index-Ba21_lNt.d.cts.map +1 -0
  30. package/dist/{index-C0SpUT9Y.d.mts → index-Bj5VNxEL.d.mts} +132 -30
  31. package/dist/index-Bj5VNxEL.d.mts.map +1 -0
  32. package/dist/index.cjs +119 -25
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.mjs +119 -25
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/{openapi-BcSjLfWq.mjs → openapi-CMTyaIJJ.mjs} +2 -2
  37. package/dist/{openapi-BcSjLfWq.mjs.map → openapi-CMTyaIJJ.mjs.map} +1 -1
  38. package/dist/{openapi-D6Hcfov0.cjs → openapi-CqblwJZ4.cjs} +2 -2
  39. package/dist/{openapi-D6Hcfov0.cjs.map → openapi-CqblwJZ4.cjs.map} +1 -1
  40. package/dist/openapi.cjs +3 -3
  41. package/dist/openapi.d.mts +1 -1
  42. package/dist/openapi.mjs +3 -3
  43. package/dist/{types-B9UZ7fOG.d.mts → types-CZg5iUgD.d.mts} +1 -1
  44. package/dist/{types-B9UZ7fOG.d.mts.map → types-CZg5iUgD.d.mts.map} +1 -1
  45. package/dist/workspace/index.cjs +1 -1
  46. package/dist/workspace/index.d.cts +1 -1
  47. package/dist/workspace/index.d.mts +2 -2
  48. package/dist/workspace/index.mjs +1 -1
  49. package/dist/{workspace-2Do2YcGZ.cjs → workspace-DIMnYaYt.cjs} +16 -2
  50. package/dist/{workspace-2Do2YcGZ.cjs.map → workspace-DIMnYaYt.cjs.map} +1 -1
  51. package/dist/{workspace-BW2iU37P.mjs → workspace-Dy8k7Wru.mjs} +16 -2
  52. package/dist/{workspace-BW2iU37P.mjs.map → workspace-Dy8k7Wru.mjs.map} +1 -1
  53. package/examples/cron-example.ts +6 -6
  54. package/examples/function-example.ts +1 -1
  55. package/package.json +7 -5
  56. package/src/deploy/__tests__/backup-provisioner.spec.ts +428 -0
  57. package/src/deploy/__tests__/createDnsProvider.spec.ts +23 -0
  58. package/src/deploy/__tests__/env-resolver.spec.ts +1 -1
  59. package/src/deploy/__tests__/undeploy.spec.ts +758 -0
  60. package/src/deploy/backup-provisioner.ts +316 -0
  61. package/src/deploy/dns/DnsProvider.ts +39 -1
  62. package/src/deploy/dns/HostingerProvider.ts +74 -0
  63. package/src/deploy/dns/Route53Provider.ts +81 -0
  64. package/src/deploy/dns/index.ts +25 -0
  65. package/src/deploy/dokploy-api.ts +237 -0
  66. package/src/deploy/index.ts +71 -13
  67. package/src/deploy/state.ts +171 -0
  68. package/src/deploy/undeploy.ts +407 -0
  69. package/src/generators/FunctionGenerator.ts +1 -1
  70. package/src/init/versions.ts +2 -2
  71. package/src/workspace/schema.ts +26 -0
  72. package/src/workspace/types.ts +14 -37
  73. package/dist/HostingerProvider-B9N-TKbp.mjs.map +0 -1
  74. package/dist/HostingerProvider-DUV9-Tzg.cjs.map +0 -1
  75. package/dist/Route53Provider-Bs7Arms9.cjs.map +0 -1
  76. package/dist/Route53Provider-C8mS0zY6.mjs.map +0 -1
  77. package/dist/dokploy-api-CQvhV6Hd.cjs.map +0 -1
  78. package/dist/dokploy-api-CWc02yyg.cjs +0 -3
  79. package/dist/dokploy-api-DSJYNx88.mjs +0 -3
  80. package/dist/dokploy-api-z0833e7r.mjs.map +0 -1
  81. package/dist/index-B58qjyBd.d.cts.map +0 -1
  82. package/dist/index-C0SpUT9Y.d.mts.map +0 -1
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Backup Destination Provisioner
3
+ *
4
+ * Creates AWS resources (S3 bucket, IAM user, access keys) and configures
5
+ * Dokploy backup destinations for database backups.
6
+ */
7
+
8
+ import {
9
+ CreateAccessKeyCommand,
10
+ CreateUserCommand,
11
+ GetUserCommand,
12
+ IAMClient,
13
+ type IAMClientConfig,
14
+ PutUserPolicyCommand,
15
+ } from '@aws-sdk/client-iam';
16
+ import {
17
+ type BucketLocationConstraint,
18
+ CreateBucketCommand,
19
+ HeadBucketCommand,
20
+ PutBucketVersioningCommand,
21
+ S3Client,
22
+ type S3ClientConfig,
23
+ } from '@aws-sdk/client-s3';
24
+ import type { BackupsConfig } from '../workspace/types.js';
25
+ import type { DokployApi } from './dokploy-api.js';
26
+ import type { BackupState } from './state.js';
27
+
28
+ export interface ProvisionBackupOptions {
29
+ /** Dokploy API client */
30
+ api: DokployApi;
31
+ /** Dokploy project ID */
32
+ projectId: string;
33
+ /** Workspace name (used for resource naming) */
34
+ projectName: string;
35
+ /** Deploy stage (e.g., 'production', 'staging') */
36
+ stage: string;
37
+ /** Backup configuration */
38
+ config: BackupsConfig;
39
+ /** Existing backup state (if any) */
40
+ existingState?: BackupState;
41
+ /** Logger for progress output */
42
+ logger: { log: (msg: string) => void };
43
+ /** AWS endpoint override (for testing with LocalStack) */
44
+ awsEndpoint?: string;
45
+ }
46
+
47
+ /**
48
+ * Generate a random suffix for unique resource names
49
+ */
50
+ function randomSuffix(): string {
51
+ return Math.random().toString(36).substring(2, 8);
52
+ }
53
+
54
+ /**
55
+ * Sanitize a name for AWS resources (lowercase alphanumeric and hyphens)
56
+ */
57
+ function sanitizeName(name: string): string {
58
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
59
+ }
60
+
61
+ /**
62
+ * Create AWS clients with optional profile credentials
63
+ */
64
+ async function createAwsClients(
65
+ region: string,
66
+ profile?: string,
67
+ endpoint?: string,
68
+ ): Promise<{ s3: S3Client; iam: IAMClient }> {
69
+ const config: S3ClientConfig & IAMClientConfig = { region };
70
+
71
+ if (profile) {
72
+ const { fromIni } = await import('@aws-sdk/credential-providers');
73
+ config.credentials = fromIni({ profile });
74
+ }
75
+
76
+ // Support custom endpoint for testing (e.g., LocalStack)
77
+ if (endpoint) {
78
+ config.endpoint = endpoint;
79
+ (config as S3ClientConfig).forcePathStyle = true;
80
+ // Use test credentials when endpoint is specified
81
+ config.credentials = {
82
+ accessKeyId: 'test',
83
+ secretAccessKey: 'test',
84
+ };
85
+ }
86
+
87
+ return {
88
+ s3: new S3Client(config),
89
+ iam: new IAMClient(config),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Check if an S3 bucket exists
95
+ */
96
+ async function bucketExists(
97
+ s3: S3Client,
98
+ bucketName: string,
99
+ ): Promise<boolean> {
100
+ try {
101
+ await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
102
+ return true;
103
+ } catch (error) {
104
+ if ((error as { name?: string }).name === 'NotFound') {
105
+ return false;
106
+ }
107
+ // 403 means bucket exists but we don't have access
108
+ if (
109
+ (error as { $metadata?: { httpStatusCode?: number } }).$metadata
110
+ ?.httpStatusCode === 403
111
+ ) {
112
+ return true;
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Check if an IAM user exists
120
+ */
121
+ async function userExists(iam: IAMClient, userName: string): Promise<boolean> {
122
+ try {
123
+ await iam.send(new GetUserCommand({ UserName: userName }));
124
+ return true;
125
+ } catch (error) {
126
+ const errorName = (error as { name?: string }).name;
127
+ // AWS returns 'NoSuchEntity', LocalStack returns 'NoSuchEntityException'
128
+ if (errorName === 'NoSuchEntity' || errorName === 'NoSuchEntityException') {
129
+ return false;
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Provision backup destination for a deployment.
137
+ *
138
+ * Creates AWS resources (S3 bucket, IAM user) and Dokploy destination if needed.
139
+ * Reuses existing resources from state when possible.
140
+ */
141
+ export async function provisionBackupDestination(
142
+ options: ProvisionBackupOptions,
143
+ ): Promise<BackupState> {
144
+ const {
145
+ api,
146
+ projectName,
147
+ stage,
148
+ config,
149
+ existingState,
150
+ logger,
151
+ awsEndpoint,
152
+ } = options;
153
+
154
+ // If we have existing state, verify the Dokploy destination still exists
155
+ if (existingState?.destinationId) {
156
+ try {
157
+ await api.getDestination(existingState.destinationId);
158
+ logger.log(' Using existing backup destination');
159
+ return existingState;
160
+ } catch {
161
+ logger.log(' Existing destination not found, recreating...');
162
+ }
163
+ }
164
+
165
+ // Create AWS clients
166
+ const aws = await createAwsClients(
167
+ config.region,
168
+ config.profile,
169
+ awsEndpoint,
170
+ );
171
+ const sanitizedProject = sanitizeName(projectName);
172
+
173
+ // 1. Create or verify S3 bucket
174
+ const bucketName =
175
+ existingState?.bucketName ??
176
+ `${sanitizedProject}-${stage}-backups-${randomSuffix()}`;
177
+
178
+ const bucketAlreadyExists = await bucketExists(aws.s3, bucketName);
179
+ if (!bucketAlreadyExists) {
180
+ logger.log(` Creating S3 bucket: ${bucketName}`);
181
+
182
+ // CreateBucket needs LocationConstraint for non-us-east-1 regions
183
+ const createBucketParams: {
184
+ Bucket: string;
185
+ CreateBucketConfiguration?: {
186
+ LocationConstraint: BucketLocationConstraint;
187
+ };
188
+ } = {
189
+ Bucket: bucketName,
190
+ };
191
+ if (config.region !== 'us-east-1') {
192
+ createBucketParams.CreateBucketConfiguration = {
193
+ LocationConstraint: config.region as BucketLocationConstraint,
194
+ };
195
+ }
196
+
197
+ await aws.s3.send(new CreateBucketCommand(createBucketParams));
198
+
199
+ // Enable versioning for backup integrity
200
+ await aws.s3.send(
201
+ new PutBucketVersioningCommand({
202
+ Bucket: bucketName,
203
+ VersioningConfiguration: { Status: 'Enabled' },
204
+ }),
205
+ );
206
+ } else {
207
+ logger.log(` Using existing S3 bucket: ${bucketName}`);
208
+ }
209
+
210
+ // 2. Create or verify IAM user
211
+ const iamUserName =
212
+ existingState?.iamUserName ?? `dokploy-backup-${sanitizedProject}-${stage}`;
213
+
214
+ const iamUserAlreadyExists = await userExists(aws.iam, iamUserName);
215
+ if (!iamUserAlreadyExists) {
216
+ logger.log(` Creating IAM user: ${iamUserName}`);
217
+ await aws.iam.send(new CreateUserCommand({ UserName: iamUserName }));
218
+ } else {
219
+ logger.log(` Using existing IAM user: ${iamUserName}`);
220
+ }
221
+
222
+ // 3. Attach bucket policy to IAM user
223
+ const policyDocument = {
224
+ Version: '2012-10-17',
225
+ Statement: [
226
+ {
227
+ Effect: 'Allow',
228
+ Action: [
229
+ 's3:GetObject',
230
+ 's3:PutObject',
231
+ 's3:DeleteObject',
232
+ 's3:ListBucket',
233
+ 's3:GetBucketLocation',
234
+ ],
235
+ Resource: [
236
+ `arn:aws:s3:::${bucketName}`,
237
+ `arn:aws:s3:::${bucketName}/*`,
238
+ ],
239
+ },
240
+ ],
241
+ };
242
+
243
+ logger.log(' Updating IAM policy');
244
+ await aws.iam.send(
245
+ new PutUserPolicyCommand({
246
+ UserName: iamUserName,
247
+ PolicyName: 'DokployBackupAccess',
248
+ PolicyDocument: JSON.stringify(policyDocument),
249
+ }),
250
+ );
251
+
252
+ // 4. Create access key (or reuse existing if state has it and destination needs recreation)
253
+ let accessKeyId: string;
254
+ let secretAccessKey: string;
255
+
256
+ if (existingState?.iamAccessKeyId && existingState?.iamSecretAccessKey) {
257
+ // Reuse existing credentials
258
+ logger.log(' Using existing IAM access key');
259
+ accessKeyId = existingState.iamAccessKeyId;
260
+ secretAccessKey = existingState.iamSecretAccessKey;
261
+ } else {
262
+ // Create new access key
263
+ logger.log(' Creating IAM access key');
264
+ const accessKeyResult = await aws.iam.send(
265
+ new CreateAccessKeyCommand({ UserName: iamUserName }),
266
+ );
267
+
268
+ if (!accessKeyResult.AccessKey) {
269
+ throw new Error('Failed to create IAM access key');
270
+ }
271
+
272
+ accessKeyId = accessKeyResult.AccessKey.AccessKeyId!;
273
+ secretAccessKey = accessKeyResult.AccessKey.SecretAccessKey!;
274
+ }
275
+
276
+ // 5. Create Dokploy destination
277
+ const destinationName = `${sanitizedProject}-${stage}-s3`;
278
+ logger.log(` Creating Dokploy destination: ${destinationName}`);
279
+
280
+ const { destination, created } = await api.findOrCreateDestination(
281
+ destinationName,
282
+ {
283
+ accessKey: accessKeyId,
284
+ secretAccessKey: secretAccessKey,
285
+ bucket: bucketName,
286
+ region: config.region,
287
+ },
288
+ );
289
+
290
+ if (created) {
291
+ logger.log(' ✓ Dokploy destination created');
292
+ } else {
293
+ logger.log(' ✓ Using existing Dokploy destination');
294
+ }
295
+
296
+ // 6. Test connection
297
+ try {
298
+ await api.testDestinationConnection(destination.destinationId);
299
+ logger.log(' ✓ Destination connection verified');
300
+ } catch (error) {
301
+ logger.log(
302
+ ` ⚠ Warning: Could not verify destination connection: ${error}`,
303
+ );
304
+ }
305
+
306
+ return {
307
+ bucketName,
308
+ bucketArn: `arn:aws:s3:::${bucketName}`,
309
+ iamUserName,
310
+ iamAccessKeyId: accessKeyId,
311
+ iamSecretAccessKey: secretAccessKey,
312
+ destinationId: destination.destinationId,
313
+ region: config.region,
314
+ createdAt: existingState?.createdAt ?? new Date().toISOString(),
315
+ };
316
+ }
@@ -48,12 +48,37 @@ export type UpsertResult = z.infer<typeof UpsertResultSchema>;
48
48
  // DNS Provider Interface
49
49
  // =============================================================================
50
50
 
51
+ /**
52
+ * A record to delete from DNS.
53
+ */
54
+ export interface DeleteDnsRecord {
55
+ /** Record name/subdomain (e.g., 'api' or '@' for root) */
56
+ name: string;
57
+ /** Record type (A, CNAME, etc.) */
58
+ type: DnsRecordType;
59
+ }
60
+
61
+ /**
62
+ * Result of a delete operation.
63
+ */
64
+ export interface DeleteResult {
65
+ /** The record that was requested for deletion */
66
+ record: DeleteDnsRecord;
67
+ /** Whether the record was deleted */
68
+ deleted: boolean;
69
+ /** Whether the record was not found (already deleted) */
70
+ notFound: boolean;
71
+ /** Error message if deletion failed */
72
+ error?: string;
73
+ }
74
+
51
75
  /**
52
76
  * Interface for DNS providers.
53
77
  *
54
78
  * Implementations must handle:
55
79
  * - Getting all records for a domain
56
80
  * - Creating or updating records for a domain
81
+ * - Deleting records from a domain
57
82
  */
58
83
  export interface DnsProvider {
59
84
  /** Provider name for logging */
@@ -78,6 +103,18 @@ export interface DnsProvider {
78
103
  domain: string,
79
104
  records: UpsertDnsRecord[],
80
105
  ): Promise<UpsertResult[]>;
106
+
107
+ /**
108
+ * Delete DNS records.
109
+ *
110
+ * @param domain - Root domain (e.g., 'example.com')
111
+ * @param records - Records to delete
112
+ * @returns Results of the delete operations
113
+ */
114
+ deleteRecords(
115
+ domain: string,
116
+ records: DeleteDnsRecord[],
117
+ ): Promise<DeleteResult[]>;
81
118
  }
82
119
 
83
120
  // =============================================================================
@@ -105,7 +142,8 @@ export function isDnsProvider(value: unknown): value is DnsProvider {
105
142
  value !== null &&
106
143
  typeof (value as DnsProvider).name === 'string' &&
107
144
  typeof (value as DnsProvider).getRecords === 'function' &&
108
- typeof (value as DnsProvider).upsertRecords === 'function'
145
+ typeof (value as DnsProvider).upsertRecords === 'function' &&
146
+ typeof (value as DnsProvider).deleteRecords === 'function'
109
147
  );
110
148
  }
111
149
 
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { getHostingerToken } from '../../auth/credentials';
8
8
  import type {
9
+ DeleteDnsRecord,
10
+ DeleteResult,
9
11
  DnsProvider,
10
12
  DnsRecord,
11
13
  UpsertDnsRecord,
@@ -97,4 +99,76 @@ export class HostingerProvider implements DnsProvider {
97
99
 
98
100
  return results;
99
101
  }
102
+
103
+ async deleteRecords(
104
+ domain: string,
105
+ records: DeleteDnsRecord[],
106
+ ): Promise<DeleteResult[]> {
107
+ const api = await this.getApi();
108
+ const results: DeleteResult[] = [];
109
+
110
+ // Get existing records to check what exists
111
+ const existingRecords = await api.getRecords(domain);
112
+
113
+ // Filter to only records that exist
114
+ const recordsToDelete = records.filter((record) =>
115
+ existingRecords.some(
116
+ (r) => r.name === record.name && r.type === record.type,
117
+ ),
118
+ );
119
+
120
+ // Delete existing records
121
+ if (recordsToDelete.length > 0) {
122
+ try {
123
+ await api.deleteRecords(
124
+ domain,
125
+ recordsToDelete.map((r) => ({
126
+ name: r.name,
127
+ type: r.type as
128
+ | 'A'
129
+ | 'AAAA'
130
+ | 'CNAME'
131
+ | 'MX'
132
+ | 'TXT'
133
+ | 'SRV'
134
+ | 'CAA',
135
+ })),
136
+ );
137
+
138
+ for (const record of recordsToDelete) {
139
+ results.push({
140
+ record,
141
+ deleted: true,
142
+ notFound: false,
143
+ });
144
+ }
145
+ } catch (error) {
146
+ // If batch delete fails, report error for all records
147
+ for (const record of recordsToDelete) {
148
+ results.push({
149
+ record,
150
+ deleted: false,
151
+ notFound: false,
152
+ error: String(error),
153
+ });
154
+ }
155
+ }
156
+ }
157
+
158
+ // Mark non-existent records as not found
159
+ for (const record of records) {
160
+ const existing = existingRecords.find(
161
+ (r) => r.name === record.name && r.type === record.type,
162
+ );
163
+ if (!existing) {
164
+ results.push({
165
+ record,
166
+ deleted: false,
167
+ notFound: true,
168
+ });
169
+ }
170
+ }
171
+
172
+ return results;
173
+ }
100
174
  }
@@ -13,6 +13,8 @@ import {
13
13
  } from '@aws-sdk/client-route-53';
14
14
  import { fromIni } from '@aws-sdk/credential-providers';
15
15
  import type {
16
+ DeleteDnsRecord,
17
+ DeleteResult,
16
18
  DnsProvider,
17
19
  DnsRecord,
18
20
  DnsRecordType,
@@ -256,4 +258,83 @@ export class Route53Provider implements DnsProvider {
256
258
 
257
259
  return results;
258
260
  }
261
+
262
+ async deleteRecords(
263
+ domain: string,
264
+ records: DeleteDnsRecord[],
265
+ ): Promise<DeleteResult[]> {
266
+ const zoneId = await this.getHostedZoneId(domain);
267
+ const results: DeleteResult[] = [];
268
+
269
+ // Get existing records to find the ones to delete
270
+ const existingRecords = await this.getRecords(domain);
271
+
272
+ // Process records in batches (Route53 allows max 1000 changes per request)
273
+ const batchSize = 100;
274
+ for (let i = 0; i < records.length; i += batchSize) {
275
+ const batch = records.slice(i, i + batchSize);
276
+ const changes = [];
277
+
278
+ for (const record of batch) {
279
+ const existing = existingRecords.find(
280
+ (r) => r.name === record.name && r.type === record.type,
281
+ );
282
+
283
+ if (!existing) {
284
+ // Record doesn't exist - already deleted
285
+ results.push({
286
+ record,
287
+ deleted: false,
288
+ notFound: true,
289
+ });
290
+ continue;
291
+ }
292
+
293
+ // Build full record name
294
+ const recordName =
295
+ record.name === '@' ? domain : `${record.name}.${domain}`;
296
+
297
+ changes.push({
298
+ Action: 'DELETE' as const,
299
+ ResourceRecordSet: {
300
+ Name: recordName,
301
+ Type: record.type,
302
+ TTL: existing.ttl,
303
+ ResourceRecords: existing.values.map((v) => ({ Value: v })),
304
+ },
305
+ });
306
+
307
+ results.push({
308
+ record,
309
+ deleted: true,
310
+ notFound: false,
311
+ });
312
+ }
313
+
314
+ // Execute batch if there are changes
315
+ if (changes.length > 0) {
316
+ try {
317
+ const command = new ChangeResourceRecordSetsCommand({
318
+ HostedZoneId: zoneId,
319
+ ChangeBatch: {
320
+ Comment: 'Delete by gkm undeploy',
321
+ Changes: changes,
322
+ },
323
+ });
324
+
325
+ await this.client.send(command);
326
+ } catch (error) {
327
+ // Mark all records in this batch as failed
328
+ for (const result of results.slice(-changes.length)) {
329
+ if (result.deleted) {
330
+ result.deleted = false;
331
+ result.error = String(error);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ return results;
339
+ }
259
340
  }
@@ -12,6 +12,7 @@ import type {
12
12
  import {
13
13
  type DokployStageState,
14
14
  isDnsVerified,
15
+ setDnsRecord,
15
16
  setDnsVerification,
16
17
  } from '../state';
17
18
  import {
@@ -381,11 +382,17 @@ export async function createDnsRecords(
381
382
  * Supports both legacy single-domain format and new multi-domain format:
382
383
  * - Legacy: { provider: 'hostinger', domain: 'example.com' }
383
384
  * - Multi: { 'example.com': { provider: 'hostinger' }, 'example.dev': { provider: 'route53' } }
385
+ *
386
+ * @param appHostnames - Map of app names to hostnames
387
+ * @param dnsConfig - DNS configuration (legacy or multi-domain)
388
+ * @param dokployEndpoint - Dokploy server endpoint to resolve IP from
389
+ * @param state - Optional state to save created records for later deletion
384
390
  */
385
391
  export async function orchestrateDns(
386
392
  appHostnames: Map<string, string>, // appName -> hostname
387
393
  dnsConfig: DnsConfig | undefined,
388
394
  dokployEndpoint: string,
395
+ state?: DokployStageState,
389
396
  ): Promise<DnsCreationResult | null> {
390
397
  if (!dnsConfig) {
391
398
  return null;
@@ -475,6 +482,24 @@ export async function orchestrateDns(
475
482
  hasFailures = true;
476
483
  }
477
484
 
485
+ // Save created/existing records to state for later deletion during undeploy
486
+ if (state) {
487
+ for (const record of domainRecords) {
488
+ if (record.created || record.existed) {
489
+ setDnsRecord(state, {
490
+ domain: rootDomain,
491
+ name: record.subdomain,
492
+ type: record.type,
493
+ value: record.value,
494
+ ttl:
495
+ 'ttl' in providerConfig && providerConfig.ttl
496
+ ? providerConfig.ttl
497
+ : 300,
498
+ });
499
+ }
500
+ }
501
+ }
502
+
478
503
  // Print summary table for this domain
479
504
  printDnsRecordsTable(domainRecords, rootDomain);
480
505