@geekmidas/cli 0.54.0 → 1.0.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 (152) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +26 -5
  3. package/dist/CachedStateProvider-D73dCqfH.cjs +60 -0
  4. package/dist/CachedStateProvider-D73dCqfH.cjs.map +1 -0
  5. package/dist/CachedStateProvider-DVyKfaMm.mjs +54 -0
  6. package/dist/CachedStateProvider-DVyKfaMm.mjs.map +1 -0
  7. package/dist/CachedStateProvider-D_uISMmJ.cjs +3 -0
  8. package/dist/CachedStateProvider-OiFUGr7p.mjs +3 -0
  9. package/dist/HostingerProvider-DUV9-Tzg.cjs +210 -0
  10. package/dist/HostingerProvider-DUV9-Tzg.cjs.map +1 -0
  11. package/dist/HostingerProvider-DqUq6e9i.mjs +210 -0
  12. package/dist/HostingerProvider-DqUq6e9i.mjs.map +1 -0
  13. package/dist/LocalStateProvider-CdspeSVL.cjs +43 -0
  14. package/dist/LocalStateProvider-CdspeSVL.cjs.map +1 -0
  15. package/dist/LocalStateProvider-DxoSaWUV.mjs +42 -0
  16. package/dist/LocalStateProvider-DxoSaWUV.mjs.map +1 -0
  17. package/dist/Route53Provider-CpRIqu69.cjs +157 -0
  18. package/dist/Route53Provider-CpRIqu69.cjs.map +1 -0
  19. package/dist/Route53Provider-KUAX3vz9.mjs +156 -0
  20. package/dist/Route53Provider-KUAX3vz9.mjs.map +1 -0
  21. package/dist/SSMStateProvider-BxAPU99a.cjs +53 -0
  22. package/dist/SSMStateProvider-BxAPU99a.cjs.map +1 -0
  23. package/dist/SSMStateProvider-C4wp4AZe.mjs +52 -0
  24. package/dist/SSMStateProvider-C4wp4AZe.mjs.map +1 -0
  25. package/dist/{bundler-DGry2vaR.mjs → bundler-BqTN5Dj5.mjs} +3 -3
  26. package/dist/{bundler-DGry2vaR.mjs.map → bundler-BqTN5Dj5.mjs.map} +1 -1
  27. package/dist/{bundler-BB-kETMd.cjs → bundler-tHLLwYuU.cjs} +3 -3
  28. package/dist/{bundler-BB-kETMd.cjs.map → bundler-tHLLwYuU.cjs.map} +1 -1
  29. package/dist/{config-HYiM3iQJ.cjs → config-BGeJsW1r.cjs} +2 -2
  30. package/dist/{config-HYiM3iQJ.cjs.map → config-BGeJsW1r.cjs.map} +1 -1
  31. package/dist/{config-C3LSBNSl.mjs → config-C6awcFBx.mjs} +2 -2
  32. package/dist/{config-C3LSBNSl.mjs.map → config-C6awcFBx.mjs.map} +1 -1
  33. package/dist/config.cjs +2 -2
  34. package/dist/config.d.cts +1 -1
  35. package/dist/config.d.mts +2 -2
  36. package/dist/config.mjs +2 -2
  37. package/dist/credentials-C8DWtnMY.cjs +174 -0
  38. package/dist/credentials-C8DWtnMY.cjs.map +1 -0
  39. package/dist/credentials-DT1dSxIx.mjs +126 -0
  40. package/dist/credentials-DT1dSxIx.mjs.map +1 -0
  41. package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -1
  42. package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -1
  43. package/dist/deploy/sniffer-loader.cjs +1 -1
  44. package/dist/{dokploy-api-94KzmTVf.mjs → dokploy-api-7k3t7_zd.mjs} +1 -1
  45. package/dist/{dokploy-api-94KzmTVf.mjs.map → dokploy-api-7k3t7_zd.mjs.map} +1 -1
  46. package/dist/dokploy-api-CHa8G51l.mjs +3 -0
  47. package/dist/{dokploy-api-YD8WCQfW.cjs → dokploy-api-CQvhV6Hd.cjs} +1 -1
  48. package/dist/{dokploy-api-YD8WCQfW.cjs.map → dokploy-api-CQvhV6Hd.cjs.map} +1 -1
  49. package/dist/dokploy-api-CWc02yyg.cjs +3 -0
  50. package/dist/{encryption-DaCB_NmS.cjs → encryption-BE0UOb8j.cjs} +1 -1
  51. package/dist/{encryption-DaCB_NmS.cjs.map → encryption-BE0UOb8j.cjs.map} +1 -1
  52. package/dist/{encryption-Biq0EZ4m.cjs → encryption-Cv3zips0.cjs} +1 -1
  53. package/dist/{encryption-BC4MAODn.mjs → encryption-JtMsiGNp.mjs} +1 -1
  54. package/dist/{encryption-BC4MAODn.mjs.map → encryption-JtMsiGNp.mjs.map} +1 -1
  55. package/dist/encryption-UUmaWAmz.mjs +3 -0
  56. package/dist/{index-pOA56MWT.d.cts → index-B5rGIc4g.d.cts} +553 -196
  57. package/dist/index-B5rGIc4g.d.cts.map +1 -0
  58. package/dist/{index-A70abJ1m.d.mts → index-KFEbMIRa.d.mts} +554 -197
  59. package/dist/index-KFEbMIRa.d.mts.map +1 -0
  60. package/dist/index.cjs +2223 -606
  61. package/dist/index.cjs.map +1 -1
  62. package/dist/index.mjs +2200 -583
  63. package/dist/index.mjs.map +1 -1
  64. package/dist/{openapi-C3C-BzIZ.mjs → openapi-BMFmLnX6.mjs} +51 -7
  65. package/dist/openapi-BMFmLnX6.mjs.map +1 -0
  66. package/dist/{openapi-D7WwlpPF.cjs → openapi-D1KXv2Ml.cjs} +51 -7
  67. package/dist/openapi-D1KXv2Ml.cjs.map +1 -0
  68. package/dist/{openapi-react-query-C_MxpBgF.cjs → openapi-react-query-BeXvk-wa.cjs} +1 -1
  69. package/dist/{openapi-react-query-C_MxpBgF.cjs.map → openapi-react-query-BeXvk-wa.cjs.map} +1 -1
  70. package/dist/{openapi-react-query-ZoP9DPbY.mjs → openapi-react-query-DGEkD39r.mjs} +1 -1
  71. package/dist/{openapi-react-query-ZoP9DPbY.mjs.map → openapi-react-query-DGEkD39r.mjs.map} +1 -1
  72. package/dist/openapi-react-query.cjs +1 -1
  73. package/dist/openapi-react-query.mjs +1 -1
  74. package/dist/openapi.cjs +3 -3
  75. package/dist/openapi.d.cts +1 -1
  76. package/dist/openapi.d.mts +2 -2
  77. package/dist/openapi.mjs +3 -3
  78. package/dist/{storage-Dhst7BhI.mjs → storage-BMW6yLu3.mjs} +1 -1
  79. package/dist/{storage-Dhst7BhI.mjs.map → storage-BMW6yLu3.mjs.map} +1 -1
  80. package/dist/{storage-fOR8dMu5.cjs → storage-C7pmBq1u.cjs} +1 -1
  81. package/dist/{storage-BPRgh3DU.cjs → storage-CoCNe0Pt.cjs} +1 -1
  82. package/dist/{storage-BPRgh3DU.cjs.map → storage-CoCNe0Pt.cjs.map} +1 -1
  83. package/dist/{storage-DNj_I11J.mjs → storage-D8XzjVaO.mjs} +1 -1
  84. package/dist/{types-BtGL-8QS.d.mts → types-BldpmqQX.d.mts} +1 -1
  85. package/dist/{types-BtGL-8QS.d.mts.map → types-BldpmqQX.d.mts.map} +1 -1
  86. package/dist/workspace/index.cjs +1 -1
  87. package/dist/workspace/index.d.cts +1 -1
  88. package/dist/workspace/index.d.mts +2 -2
  89. package/dist/workspace/index.mjs +1 -1
  90. package/dist/{workspace-CaVW6j2q.cjs → workspace-BFRUOOrh.cjs} +309 -25
  91. package/dist/workspace-BFRUOOrh.cjs.map +1 -0
  92. package/dist/{workspace-DLFRaDc-.mjs → workspace-DAxG3_H2.mjs} +309 -25
  93. package/dist/workspace-DAxG3_H2.mjs.map +1 -0
  94. package/package.json +12 -8
  95. package/src/build/__tests__/handler-templates.spec.ts +115 -47
  96. package/src/deploy/CachedStateProvider.ts +86 -0
  97. package/src/deploy/LocalStateProvider.ts +57 -0
  98. package/src/deploy/SSMStateProvider.ts +93 -0
  99. package/src/deploy/StateProvider.ts +171 -0
  100. package/src/deploy/__tests__/CachedStateProvider.spec.ts +228 -0
  101. package/src/deploy/__tests__/HostingerProvider.spec.ts +347 -0
  102. package/src/deploy/__tests__/LocalStateProvider.spec.ts +126 -0
  103. package/src/deploy/__tests__/Route53Provider.spec.ts +402 -0
  104. package/src/deploy/__tests__/SSMStateProvider.spec.ts +177 -0
  105. package/src/deploy/__tests__/__fixtures__/env-parsers/throwing-env-parser.ts +1 -3
  106. package/src/deploy/__tests__/__fixtures__/route-apps/services.ts +28 -19
  107. package/src/deploy/__tests__/createDnsProvider.spec.ts +172 -0
  108. package/src/deploy/__tests__/createStateProvider.spec.ts +116 -0
  109. package/src/deploy/__tests__/dns-orchestration.spec.ts +192 -0
  110. package/src/deploy/__tests__/dns-verification.spec.ts +2 -2
  111. package/src/deploy/__tests__/env-resolver.spec.ts +37 -15
  112. package/src/deploy/__tests__/sniffer.spec.ts +4 -20
  113. package/src/deploy/__tests__/state.spec.ts +13 -5
  114. package/src/deploy/dns/DnsProvider.ts +163 -0
  115. package/src/deploy/dns/HostingerProvider.ts +100 -0
  116. package/src/deploy/dns/Route53Provider.ts +256 -0
  117. package/src/deploy/dns/index.ts +257 -165
  118. package/src/deploy/env-resolver.ts +12 -5
  119. package/src/deploy/index.ts +16 -13
  120. package/src/deploy/sniffer-envkit-patch.ts +3 -1
  121. package/src/deploy/sniffer-routes-worker.ts +104 -0
  122. package/src/deploy/sniffer.ts +77 -55
  123. package/src/deploy/state-commands.ts +274 -0
  124. package/src/dev/__tests__/entry.spec.ts +8 -2
  125. package/src/dev/__tests__/index.spec.ts +1 -3
  126. package/src/dev/index.ts +9 -3
  127. package/src/docker/__tests__/templates.spec.ts +3 -1
  128. package/src/index.ts +88 -0
  129. package/src/init/__tests__/generators.spec.ts +273 -0
  130. package/src/init/__tests__/init.spec.ts +3 -3
  131. package/src/init/generators/auth.ts +1 -0
  132. package/src/init/generators/config.ts +2 -0
  133. package/src/init/generators/models.ts +6 -1
  134. package/src/init/generators/monorepo.ts +3 -0
  135. package/src/init/generators/ui.ts +1472 -0
  136. package/src/init/generators/web.ts +134 -87
  137. package/src/init/index.ts +22 -3
  138. package/src/init/templates/api.ts +109 -3
  139. package/src/openapi.ts +99 -13
  140. package/src/workspace/__tests__/schema.spec.ts +107 -0
  141. package/src/workspace/schema.ts +314 -4
  142. package/src/workspace/types.ts +22 -36
  143. package/dist/dokploy-api-CItuaWTq.mjs +0 -3
  144. package/dist/dokploy-api-DBNE8MDt.cjs +0 -3
  145. package/dist/encryption-CQXBZGkt.mjs +0 -3
  146. package/dist/index-A70abJ1m.d.mts.map +0 -1
  147. package/dist/index-pOA56MWT.d.cts.map +0 -1
  148. package/dist/openapi-C3C-BzIZ.mjs.map +0 -1
  149. package/dist/openapi-D7WwlpPF.cjs.map +0 -1
  150. package/dist/workspace-CaVW6j2q.cjs.map +0 -1
  151. package/dist/workspace-DLFRaDc-.mjs.map +0 -1
  152. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,402 @@
1
+ import {
2
+ ChangeResourceRecordSetsCommand,
3
+ CreateHostedZoneCommand,
4
+ DeleteHostedZoneCommand,
5
+ ListResourceRecordSetsCommand,
6
+ Route53Client,
7
+ } from '@aws-sdk/client-route-53';
8
+ import {
9
+ afterAll,
10
+ afterEach,
11
+ beforeAll,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ it,
16
+ } from 'vitest';
17
+ import { Route53Provider } from '../dns/Route53Provider';
18
+
19
+ /**
20
+ * Route53Provider Tests
21
+ *
22
+ * These tests require LocalStack to be running with Route53 enabled.
23
+ * Run: docker compose up -d localstack
24
+ */
25
+ describe('Route53Provider', () => {
26
+ const LOCALSTACK_ENDPOINT = 'http://localhost:4566';
27
+ const TEST_DOMAIN = 'test-example.com';
28
+ let client: Route53Client;
29
+ let provider: Route53Provider;
30
+ let hostedZoneId: string;
31
+
32
+ beforeAll(async () => {
33
+ process.env.AWS_ACCESS_KEY_ID = 'test';
34
+ process.env.AWS_SECRET_ACCESS_KEY = 'test';
35
+ process.env.AWS_REGION = 'us-east-1';
36
+
37
+ client = new Route53Client({
38
+ region: 'us-east-1',
39
+ endpoint: LOCALSTACK_ENDPOINT,
40
+ credentials: {
41
+ accessKeyId: 'test',
42
+ secretAccessKey: 'test',
43
+ },
44
+ });
45
+
46
+ // Create a hosted zone for testing
47
+ const createResponse = await client.send(
48
+ new CreateHostedZoneCommand({
49
+ Name: TEST_DOMAIN,
50
+ CallerReference: `test-${Date.now()}`,
51
+ }),
52
+ );
53
+
54
+ hostedZoneId = createResponse.HostedZone!.Id!.replace('/hostedzone/', '');
55
+ });
56
+
57
+ beforeEach(() => {
58
+ provider = new Route53Provider({
59
+ endpoint: LOCALSTACK_ENDPOINT,
60
+ hostedZoneId,
61
+ });
62
+ });
63
+
64
+ afterEach(async () => {
65
+ // Clean up any test records (excluding NS and SOA which are auto-created)
66
+ try {
67
+ const response = await client.send(
68
+ new ListResourceRecordSetsCommand({
69
+ HostedZoneId: hostedZoneId,
70
+ }),
71
+ );
72
+
73
+ const recordsToDelete = (response.ResourceRecordSets ?? []).filter(
74
+ (record) => record.Type !== 'NS' && record.Type !== 'SOA',
75
+ );
76
+
77
+ if (recordsToDelete.length > 0) {
78
+ await client.send(
79
+ new ChangeResourceRecordSetsCommand({
80
+ HostedZoneId: hostedZoneId,
81
+ ChangeBatch: {
82
+ Changes: recordsToDelete.map((record) => ({
83
+ Action: 'DELETE',
84
+ ResourceRecordSet: record,
85
+ })),
86
+ },
87
+ }),
88
+ );
89
+ }
90
+ } catch {
91
+ // Ignore errors during cleanup
92
+ }
93
+ });
94
+
95
+ afterAll(async () => {
96
+ // Delete the hosted zone - need to remove all non-default records first
97
+ try {
98
+ const response = await client.send(
99
+ new ListResourceRecordSetsCommand({
100
+ HostedZoneId: hostedZoneId,
101
+ }),
102
+ );
103
+
104
+ const recordsToDelete = (response.ResourceRecordSets ?? []).filter(
105
+ (record) => record.Type !== 'NS' && record.Type !== 'SOA',
106
+ );
107
+
108
+ if (recordsToDelete.length > 0) {
109
+ await client.send(
110
+ new ChangeResourceRecordSetsCommand({
111
+ HostedZoneId: hostedZoneId,
112
+ ChangeBatch: {
113
+ Changes: recordsToDelete.map((record) => ({
114
+ Action: 'DELETE',
115
+ ResourceRecordSet: record,
116
+ })),
117
+ },
118
+ }),
119
+ );
120
+ }
121
+
122
+ await client.send(
123
+ new DeleteHostedZoneCommand({
124
+ Id: hostedZoneId,
125
+ }),
126
+ );
127
+ } catch {
128
+ // Ignore cleanup errors
129
+ }
130
+
131
+ client.destroy();
132
+ });
133
+
134
+ describe('name', () => {
135
+ it('should have name "route53"', () => {
136
+ expect(provider.name).toBe('route53');
137
+ });
138
+ });
139
+
140
+ describe('getRecords', () => {
141
+ it('should return empty array for domain with no custom records', async () => {
142
+ const records = await provider.getRecords(TEST_DOMAIN);
143
+
144
+ // Should be empty - NS and SOA are filtered out
145
+ expect(records).toEqual([]);
146
+ });
147
+
148
+ it('should return A records', async () => {
149
+ // Create a test record (use UPSERT to handle idempotency)
150
+ await client.send(
151
+ new ChangeResourceRecordSetsCommand({
152
+ HostedZoneId: hostedZoneId,
153
+ ChangeBatch: {
154
+ Changes: [
155
+ {
156
+ Action: 'UPSERT',
157
+ ResourceRecordSet: {
158
+ Name: `api.${TEST_DOMAIN}`,
159
+ Type: 'A',
160
+ TTL: 300,
161
+ ResourceRecords: [{ Value: '1.2.3.4' }],
162
+ },
163
+ },
164
+ ],
165
+ },
166
+ }),
167
+ );
168
+
169
+ const records = await provider.getRecords(TEST_DOMAIN);
170
+
171
+ expect(records).toHaveLength(1);
172
+ expect(records[0]).toEqual({
173
+ name: 'api',
174
+ type: 'A',
175
+ ttl: 300,
176
+ values: ['1.2.3.4'],
177
+ });
178
+ });
179
+
180
+ it('should handle root domain records (@)', async () => {
181
+ // Create a root domain record (use UPSERT to handle idempotency)
182
+ await client.send(
183
+ new ChangeResourceRecordSetsCommand({
184
+ HostedZoneId: hostedZoneId,
185
+ ChangeBatch: {
186
+ Changes: [
187
+ {
188
+ Action: 'UPSERT',
189
+ ResourceRecordSet: {
190
+ Name: TEST_DOMAIN,
191
+ Type: 'A',
192
+ TTL: 300,
193
+ ResourceRecords: [{ Value: '1.2.3.4' }],
194
+ },
195
+ },
196
+ ],
197
+ },
198
+ }),
199
+ );
200
+
201
+ const records = await provider.getRecords(TEST_DOMAIN);
202
+
203
+ expect(records).toHaveLength(1);
204
+ expect(records[0]?.name).toBe('@');
205
+ });
206
+
207
+ it('should return multiple records', async () => {
208
+ // Create multiple test records (use UPSERT to handle idempotency)
209
+ await client.send(
210
+ new ChangeResourceRecordSetsCommand({
211
+ HostedZoneId: hostedZoneId,
212
+ ChangeBatch: {
213
+ Changes: [
214
+ {
215
+ Action: 'UPSERT',
216
+ ResourceRecordSet: {
217
+ Name: `api.${TEST_DOMAIN}`,
218
+ Type: 'A',
219
+ TTL: 300,
220
+ ResourceRecords: [{ Value: '1.2.3.4' }],
221
+ },
222
+ },
223
+ {
224
+ Action: 'UPSERT',
225
+ ResourceRecordSet: {
226
+ Name: `www.${TEST_DOMAIN}`,
227
+ Type: 'CNAME',
228
+ TTL: 300,
229
+ ResourceRecords: [{ Value: TEST_DOMAIN }],
230
+ },
231
+ },
232
+ ],
233
+ },
234
+ }),
235
+ );
236
+
237
+ const records = await provider.getRecords(TEST_DOMAIN);
238
+
239
+ expect(records).toHaveLength(2);
240
+ expect(records.find((r) => r.name === 'api')).toBeDefined();
241
+ expect(records.find((r) => r.name === 'www')).toBeDefined();
242
+ });
243
+ });
244
+
245
+ describe('upsertRecords', () => {
246
+ it('should create new records', async () => {
247
+ const results = await provider.upsertRecords(TEST_DOMAIN, [
248
+ { name: 'api', type: 'A', ttl: 300, value: '1.2.3.4' },
249
+ ]);
250
+
251
+ expect(results).toHaveLength(1);
252
+ expect(results[0]).toEqual({
253
+ record: { name: 'api', type: 'A', ttl: 300, value: '1.2.3.4' },
254
+ created: true,
255
+ unchanged: false,
256
+ });
257
+
258
+ // Verify record was created
259
+ const records = await provider.getRecords(TEST_DOMAIN);
260
+ expect(records.find((r) => r.name === 'api')).toBeDefined();
261
+ });
262
+
263
+ it('should handle root domain records (@)', async () => {
264
+ const results = await provider.upsertRecords(TEST_DOMAIN, [
265
+ { name: '@', type: 'A', ttl: 300, value: '1.2.3.4' },
266
+ ]);
267
+
268
+ expect(results).toHaveLength(1);
269
+ expect(results[0]?.created).toBe(true);
270
+
271
+ // Verify record was created at root
272
+ const records = await provider.getRecords(TEST_DOMAIN);
273
+ expect(records.find((r) => r.name === '@')).toBeDefined();
274
+ });
275
+
276
+ it('should mark unchanged when record exists with same value', async () => {
277
+ // Create initial record (use UPSERT to handle idempotency)
278
+ await client.send(
279
+ new ChangeResourceRecordSetsCommand({
280
+ HostedZoneId: hostedZoneId,
281
+ ChangeBatch: {
282
+ Changes: [
283
+ {
284
+ Action: 'UPSERT',
285
+ ResourceRecordSet: {
286
+ Name: `api.${TEST_DOMAIN}`,
287
+ Type: 'A',
288
+ TTL: 300,
289
+ ResourceRecords: [{ Value: '1.2.3.4' }],
290
+ },
291
+ },
292
+ ],
293
+ },
294
+ }),
295
+ );
296
+
297
+ const results = await provider.upsertRecords(TEST_DOMAIN, [
298
+ { name: 'api', type: 'A', ttl: 300, value: '1.2.3.4' },
299
+ ]);
300
+
301
+ expect(results).toHaveLength(1);
302
+ expect(results[0]).toEqual({
303
+ record: { name: 'api', type: 'A', ttl: 300, value: '1.2.3.4' },
304
+ created: false,
305
+ unchanged: true,
306
+ });
307
+ });
308
+
309
+ it('should update record when value changes', async () => {
310
+ // Create initial record (use UPSERT to handle idempotency)
311
+ await client.send(
312
+ new ChangeResourceRecordSetsCommand({
313
+ HostedZoneId: hostedZoneId,
314
+ ChangeBatch: {
315
+ Changes: [
316
+ {
317
+ Action: 'UPSERT',
318
+ ResourceRecordSet: {
319
+ Name: `api.${TEST_DOMAIN}`,
320
+ Type: 'A',
321
+ TTL: 300,
322
+ ResourceRecords: [{ Value: '1.2.3.4' }],
323
+ },
324
+ },
325
+ ],
326
+ },
327
+ }),
328
+ );
329
+
330
+ const results = await provider.upsertRecords(TEST_DOMAIN, [
331
+ { name: 'api', type: 'A', ttl: 300, value: '5.6.7.8' },
332
+ ]);
333
+
334
+ expect(results).toHaveLength(1);
335
+ expect(results[0]).toEqual({
336
+ record: { name: 'api', type: 'A', ttl: 300, value: '5.6.7.8' },
337
+ created: false,
338
+ unchanged: false,
339
+ });
340
+
341
+ // Verify record was updated
342
+ const records = await provider.getRecords(TEST_DOMAIN);
343
+ const apiRecord = records.find((r) => r.name === 'api');
344
+ expect(apiRecord?.values[0]).toBe('5.6.7.8');
345
+ });
346
+
347
+ it('should handle multiple records with mixed states', async () => {
348
+ // Create initial record (use UPSERT to handle idempotency)
349
+ await client.send(
350
+ new ChangeResourceRecordSetsCommand({
351
+ HostedZoneId: hostedZoneId,
352
+ ChangeBatch: {
353
+ Changes: [
354
+ {
355
+ Action: 'UPSERT',
356
+ ResourceRecordSet: {
357
+ Name: `api.${TEST_DOMAIN}`,
358
+ Type: 'A',
359
+ TTL: 300,
360
+ ResourceRecords: [{ Value: '1.2.3.4' }],
361
+ },
362
+ },
363
+ ],
364
+ },
365
+ }),
366
+ );
367
+
368
+ const results = await provider.upsertRecords(TEST_DOMAIN, [
369
+ { name: 'api', type: 'A', ttl: 300, value: '1.2.3.4' }, // Unchanged
370
+ { name: 'www', type: 'A', ttl: 300, value: '1.2.3.4' }, // New
371
+ ]);
372
+
373
+ expect(results).toHaveLength(2);
374
+ expect(results[0]?.unchanged).toBe(true);
375
+ expect(results[1]?.created).toBe(true);
376
+ });
377
+ });
378
+
379
+ describe('hosted zone auto-detection', () => {
380
+ it('should auto-detect hosted zone from domain', async () => {
381
+ // Create provider without hostedZoneId
382
+ const autoProvider = new Route53Provider({
383
+ endpoint: LOCALSTACK_ENDPOINT,
384
+ });
385
+
386
+ const records = await autoProvider.getRecords(TEST_DOMAIN);
387
+
388
+ // Should work without error
389
+ expect(Array.isArray(records)).toBe(true);
390
+ });
391
+
392
+ it('should throw error when hosted zone not found', async () => {
393
+ const autoProvider = new Route53Provider({
394
+ endpoint: LOCALSTACK_ENDPOINT,
395
+ });
396
+
397
+ await expect(
398
+ autoProvider.getRecords('nonexistent-domain.com'),
399
+ ).rejects.toThrow('No hosted zone found for domain');
400
+ });
401
+ });
402
+ });
@@ -0,0 +1,177 @@
1
+ import {
2
+ DeleteParameterCommand,
3
+ GetParameterCommand,
4
+ PutParameterCommand,
5
+ SSMClient,
6
+ } from '@aws-sdk/client-ssm';
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeAll,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ } from 'vitest';
16
+ import { SSMStateProvider } from '../SSMStateProvider';
17
+ import type { DokployStageState } from '../state';
18
+
19
+ /**
20
+ * SSMStateProvider Tests
21
+ *
22
+ * These tests require LocalStack to be running with SSM enabled.
23
+ * Run: docker compose up -d localstack
24
+ */
25
+ describe('SSMStateProvider', () => {
26
+ const LOCALSTACK_ENDPOINT = 'http://localhost:4566';
27
+ let client: SSMClient;
28
+ let provider: SSMStateProvider;
29
+ const workspaceName = 'test-workspace';
30
+ const testStage = 'test-stage';
31
+
32
+ beforeAll(() => {
33
+ process.env.AWS_ACCESS_KEY_ID = 'test';
34
+ process.env.AWS_SECRET_ACCESS_KEY = 'test';
35
+ });
36
+
37
+ beforeEach(() => {
38
+ client = new SSMClient({
39
+ region: 'us-east-1',
40
+ endpoint: LOCALSTACK_ENDPOINT,
41
+ credentials: {
42
+ accessKeyId: 'test',
43
+ secretAccessKey: 'test',
44
+ },
45
+ });
46
+
47
+ provider = new SSMStateProvider({
48
+ workspaceName,
49
+ region: 'us-east-1',
50
+ });
51
+
52
+ // Override the client's endpoint for localstack
53
+ // @ts-expect-error - accessing private property for testing
54
+ provider.client = client;
55
+ });
56
+
57
+ afterEach(async () => {
58
+ try {
59
+ await client.send(
60
+ new DeleteParameterCommand({
61
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
62
+ }),
63
+ );
64
+ } catch {
65
+ // Ignore if parameter doesn't exist
66
+ }
67
+ });
68
+
69
+ afterAll(() => {
70
+ client.destroy();
71
+ });
72
+
73
+ describe('read', () => {
74
+ it('should return null when parameter does not exist', async () => {
75
+ const state = await provider.read('nonexistent-stage');
76
+ expect(state).toBeNull();
77
+ });
78
+
79
+ it('should read existing parameter', async () => {
80
+ const stateData: DokployStageState = {
81
+ provider: 'dokploy',
82
+ stage: testStage,
83
+ environmentId: 'env_123',
84
+ applications: { api: 'app_123' },
85
+ services: { postgresId: 'pg_123' },
86
+ lastDeployedAt: '2024-01-01T00:00:00.000Z',
87
+ };
88
+
89
+ await client.send(
90
+ new PutParameterCommand({
91
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
92
+ Value: JSON.stringify(stateData),
93
+ Type: 'SecureString',
94
+ Overwrite: true,
95
+ }),
96
+ );
97
+
98
+ const state = await provider.read(testStage);
99
+ expect(state).toEqual(stateData);
100
+ });
101
+ });
102
+
103
+ describe('write', () => {
104
+ it('should create new parameter', async () => {
105
+ const state: DokployStageState = {
106
+ provider: 'dokploy',
107
+ stage: testStage,
108
+ environmentId: 'env_123',
109
+ applications: { api: 'app_123' },
110
+ services: {},
111
+ lastDeployedAt: '2024-01-01T00:00:00.000Z',
112
+ };
113
+
114
+ await provider.write(testStage, state);
115
+
116
+ const response = await client.send(
117
+ new GetParameterCommand({
118
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
119
+ WithDecryption: true,
120
+ }),
121
+ );
122
+
123
+ const stored = JSON.parse(response.Parameter!.Value!);
124
+ expect(stored.applications).toEqual({ api: 'app_123' });
125
+ });
126
+
127
+ it('should update existing parameter', async () => {
128
+ const state1: DokployStageState = {
129
+ provider: 'dokploy',
130
+ stage: testStage,
131
+ environmentId: 'env_123',
132
+ applications: { api: 'app_old' },
133
+ services: {},
134
+ lastDeployedAt: '2024-01-01T00:00:00.000Z',
135
+ };
136
+
137
+ const state2: DokployStageState = {
138
+ provider: 'dokploy',
139
+ stage: testStage,
140
+ environmentId: 'env_123',
141
+ applications: { api: 'app_new' },
142
+ services: {},
143
+ lastDeployedAt: '2024-01-01T00:00:00.000Z',
144
+ };
145
+
146
+ await provider.write(testStage, state1);
147
+ await provider.write(testStage, state2);
148
+
149
+ const response = await client.send(
150
+ new GetParameterCommand({
151
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
152
+ WithDecryption: true,
153
+ }),
154
+ );
155
+
156
+ const stored = JSON.parse(response.Parameter!.Value!);
157
+ expect(stored.applications.api).toBe('app_new');
158
+ });
159
+
160
+ it('should update lastDeployedAt timestamp', async () => {
161
+ const state: DokployStageState = {
162
+ provider: 'dokploy',
163
+ stage: testStage,
164
+ environmentId: 'env_123',
165
+ applications: {},
166
+ services: {},
167
+ lastDeployedAt: '2024-01-01T00:00:00.000Z',
168
+ };
169
+
170
+ const originalTimestamp = state.lastDeployedAt;
171
+ await new Promise((resolve) => setTimeout(resolve, 10));
172
+ await provider.write(testStage, state);
173
+
174
+ expect(state.lastDeployedAt).not.toBe(originalTimestamp);
175
+ });
176
+ });
177
+ });
@@ -4,15 +4,13 @@
4
4
  import type { SnifferEnvironmentParser } from '@geekmidas/envkit/sniffer';
5
5
 
6
6
  export function envParser(parser: SnifferEnvironmentParser) {
7
- const config = parser.create((get) => ({
7
+ const _config = parser.create((get) => ({
8
8
  port: get('PORT').string(),
9
9
  apiKey: get('API_KEY').string(),
10
10
  }));
11
11
 
12
12
  // Throw after creating the parser but before returning
13
13
  throw new Error('EnvParser initialization failed');
14
-
15
- return config;
16
14
  }
17
15
 
18
16
  export default envParser;
@@ -2,45 +2,54 @@
2
2
  * Test services for route-based app sniffing fixtures.
3
3
  * These services access environment variables via envParser.create().
4
4
  */
5
- import type { Service } from '@geekmidas/services';
5
+ import type { EnvironmentParser } from '@geekmidas/envkit';
6
+
7
+ // Simple Service type for test fixtures (avoids importing @geekmidas/services)
8
+ type TestService<TName extends string, TInstance> = {
9
+ serviceName: TName;
10
+ register(ctx: {
11
+ envParser: EnvironmentParser<Record<string, string | undefined>>;
12
+ }): Promise<TInstance>;
13
+ };
6
14
 
7
15
  // Database service - requires DATABASE_URL
8
- export const databaseService = {
16
+ export const databaseService: TestService<'database', { url: string }> = {
9
17
  serviceName: 'database' as const,
10
18
  async register({ envParser }) {
11
19
  const config = envParser
12
- .create((get: any) => ({
20
+ .create((get) => ({
13
21
  url: get('DATABASE_URL').string(),
14
22
  poolSize: get('DB_POOL_SIZE').string().transform(Number).optional(),
15
23
  }))
16
24
  .parse();
17
25
  return { url: config.url };
18
26
  },
19
- } satisfies Service<'database', { url: string }>;
27
+ };
20
28
 
21
29
  // Cache service - requires REDIS_URL
22
- export const cacheService = {
30
+ export const cacheService: TestService<'cache', { url: string }> = {
23
31
  serviceName: 'cache' as const,
24
32
  async register({ envParser }) {
25
33
  const config = envParser
26
- .create((get: any) => ({
34
+ .create((get) => ({
27
35
  url: get('REDIS_URL').string(),
28
36
  }))
29
37
  .parse();
30
38
  return { url: config.url };
31
39
  },
32
- } satisfies Service<'cache', { url: string }>;
40
+ };
33
41
 
34
42
  // Auth service - requires AUTH_SECRET and AUTH_URL
35
- export const authService = {
36
- serviceName: 'auth' as const,
37
- async register({ envParser }) {
38
- const config = envParser
39
- .create((get: any) => ({
40
- secret: get('AUTH_SECRET').string(),
41
- url: get('AUTH_URL').string(),
42
- }))
43
- .parse();
44
- return { secret: config.secret, url: config.url };
45
- },
46
- } satisfies Service<'auth', { secret: string; url: string }>;
43
+ export const authService: TestService<'auth', { secret: string; url: string }> =
44
+ {
45
+ serviceName: 'auth' as const,
46
+ async register({ envParser }) {
47
+ const config = envParser
48
+ .create((get) => ({
49
+ secret: get('AUTH_SECRET').string(),
50
+ url: get('AUTH_URL').string(),
51
+ }))
52
+ .parse();
53
+ return { secret: config.secret, url: config.url };
54
+ },
55
+ };