@geekmidas/cli 1.10.15 → 1.10.17

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 (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{bundler-BWsVDer6.mjs → bundler-B4AackW5.mjs} +2 -2
  3. package/dist/{bundler-BWsVDer6.mjs.map → bundler-B4AackW5.mjs.map} +1 -1
  4. package/dist/{bundler-Drh5KoN5.cjs → bundler-BhhfkI9T.cjs} +2 -2
  5. package/dist/{bundler-Drh5KoN5.cjs.map → bundler-BhhfkI9T.cjs.map} +1 -1
  6. package/dist/config.d.cts +2 -2
  7. package/dist/config.d.mts +2 -2
  8. package/dist/{fullstack-secrets-D9rjTNyx.cjs → fullstack-secrets-DOHBU4Rp.cjs} +110 -4
  9. package/dist/fullstack-secrets-DOHBU4Rp.cjs.map +1 -0
  10. package/dist/{fullstack-secrets-BIFFv4UZ.mjs → fullstack-secrets-x2Kffx7-.mjs} +99 -5
  11. package/dist/fullstack-secrets-x2Kffx7-.mjs.map +1 -0
  12. package/dist/{index-UCsZ_Vkw.d.cts → index-BkibYzso.d.cts} +15 -4
  13. package/dist/index-BkibYzso.d.cts.map +1 -0
  14. package/dist/{index-gXAGDSGu.d.mts → index-CY-ieuRp.d.mts} +15 -4
  15. package/dist/index-CY-ieuRp.d.mts.map +1 -0
  16. package/dist/index.cjs +332 -62
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +332 -62
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/openapi-BYxAWwok.cjs.map +1 -1
  21. package/dist/openapi-DenF-okj.mjs.map +1 -1
  22. package/dist/openapi.d.cts +1 -1
  23. package/dist/openapi.d.mts +1 -1
  24. package/dist/{reconcile-DxTEausy.mjs → reconcile-BLh6rswz.mjs} +2 -2
  25. package/dist/{reconcile-DxTEausy.mjs.map → reconcile-BLh6rswz.mjs.map} +1 -1
  26. package/dist/{reconcile-LaaJkFlO.cjs → reconcile-Ch7sIcf8.cjs} +2 -2
  27. package/dist/{reconcile-LaaJkFlO.cjs.map → reconcile-Ch7sIcf8.cjs.map} +1 -1
  28. package/dist/{storage-Bu44pwPJ.cjs → storage-B1wvztiJ.cjs} +11 -1
  29. package/dist/{storage-clMAp4sc.mjs.map → storage-B1wvztiJ.cjs.map} +1 -1
  30. package/dist/{storage-CauTheT9.mjs → storage-Cs4WBsc4.mjs} +1 -1
  31. package/dist/{storage-DpqzcjQ5.cjs → storage-DOEtT2Hr.cjs} +1 -1
  32. package/dist/{storage-clMAp4sc.mjs → storage-dbb9RyBl.mjs} +11 -1
  33. package/dist/{storage-Bu44pwPJ.cjs.map → storage-dbb9RyBl.mjs.map} +1 -1
  34. package/dist/{sync-BkalF65h.mjs → sync-COnAugP-.mjs} +1 -1
  35. package/dist/sync-D1Pa30oV.cjs +4 -0
  36. package/dist/{sync-BeiI5rFC.cjs → sync-DGXXSk2v.cjs} +2 -2
  37. package/dist/{sync-BeiI5rFC.cjs.map → sync-DGXXSk2v.cjs.map} +1 -1
  38. package/dist/{sync-CWJ6tL0s.mjs → sync-D_NowTkZ.mjs} +2 -2
  39. package/dist/{sync-CWJ6tL0s.mjs.map → sync-D_NowTkZ.mjs.map} +1 -1
  40. package/dist/{types-DiV9Mbvc.d.mts → types-DdHfUbxk.d.cts} +13 -3
  41. package/dist/types-DdHfUbxk.d.cts.map +1 -0
  42. package/dist/{types-JvWj5Ckc.d.cts → types-OszPdw9m.d.mts} +13 -3
  43. package/dist/types-OszPdw9m.d.mts.map +1 -0
  44. package/dist/workspace/index.d.cts +2 -2
  45. package/dist/workspace/index.d.mts +2 -2
  46. package/dist/workspace-4SP3Gx4Y.cjs.map +1 -1
  47. package/dist/workspace-D4z4A4cq.mjs.map +1 -1
  48. package/package.json +4 -4
  49. package/src/dev/__tests__/entry.spec.ts +3 -5
  50. package/src/dev/__tests__/index.spec.ts +73 -5
  51. package/src/dev/index.ts +33 -25
  52. package/src/docker/compose.ts +130 -2
  53. package/src/init/__tests__/generators.spec.ts +84 -0
  54. package/src/init/generators/docker.ts +128 -16
  55. package/src/init/index.ts +26 -1
  56. package/src/init/templates/index.ts +28 -0
  57. package/src/init/versions.ts +1 -1
  58. package/src/secrets/__tests__/generator.spec.ts +183 -0
  59. package/src/secrets/generator.ts +116 -4
  60. package/src/secrets/storage.ts +12 -0
  61. package/src/secrets/types.ts +11 -1
  62. package/src/setup/__tests__/reconcile-secrets.spec.ts +86 -0
  63. package/src/setup/index.ts +64 -1
  64. package/src/test/__tests__/index.spec.ts +1 -4
  65. package/src/types.ts +13 -1
  66. package/src/workspace/types.ts +13 -2
  67. package/dist/fullstack-secrets-BIFFv4UZ.mjs.map +0 -1
  68. package/dist/fullstack-secrets-D9rjTNyx.cjs.map +0 -1
  69. package/dist/index-UCsZ_Vkw.d.cts.map +0 -1
  70. package/dist/index-gXAGDSGu.d.mts.map +0 -1
  71. package/dist/sync-Bp8xRcuQ.cjs +0 -4
  72. package/dist/types-DiV9Mbvc.d.mts.map +0 -1
  73. package/dist/types-JvWj5Ckc.d.cts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.15",
3
+ "version": "1.10.17",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,11 +56,11 @@
56
56
  "prompts": "~2.4.2",
57
57
  "tsx": "~4.20.3",
58
58
  "yaml": "~2.8.2",
59
- "@geekmidas/envkit": "~1.0.3",
59
+ "@geekmidas/envkit": "~1.0.4",
60
60
  "@geekmidas/constructs": "~3.0.2",
61
- "@geekmidas/schema": "~1.0.0",
62
61
  "@geekmidas/errors": "~1.0.0",
63
- "@geekmidas/logger": "~1.0.0"
62
+ "@geekmidas/logger": "~1.0.0",
63
+ "@geekmidas/schema": "~1.0.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/lodash.kebabcase": "^4.1.9",
@@ -97,11 +97,9 @@ describe('createEntryWrapper', () => {
97
97
 
98
98
  const content = await readFile(wrapperPath, 'utf-8');
99
99
 
100
- expect(content).toContain(
101
- "import { Credentials } from '@geekmidas/envkit/credentials'",
102
- );
100
+ expect(content).toContain('globalThis.__gkm_credentials__');
103
101
  expect(content).toContain(secretsPath);
104
- expect(content).toContain('Object.assign(Credentials');
102
+ expect(content).toContain('Object.assign(process.env');
105
103
  expect(content).toContain("await import('/path/to/entry.ts')");
106
104
  });
107
105
 
@@ -114,7 +112,7 @@ describe('createEntryWrapper', () => {
114
112
 
115
113
  const content = await readFile(wrapperPath, 'utf-8');
116
114
 
117
- const credentialsIndex = content.indexOf('Credentials');
115
+ const credentialsIndex = content.indexOf('__gkm_credentials__');
118
116
  const importIndex = content.indexOf("await import('/path/to/entry.ts')");
119
117
 
120
118
  expect(credentialsIndex).toBeGreaterThan(-1);
@@ -1237,6 +1237,28 @@ describe('replacePortInUrl', () => {
1237
1237
  // Only the :5432 before / should be replaced
1238
1238
  expect(result).toBe('postgresql://app:pass5432@localhost:5433/db5432');
1239
1239
  });
1240
+
1241
+ it('should replace port followed by query string', () => {
1242
+ const url = 'pgboss://pgboss:pass@localhost:5432/mydb?schema=pgboss';
1243
+ expect(replacePortInUrl(url, 5432, 5433)).toBe(
1244
+ 'pgboss://pgboss:pass@localhost:5433/mydb?schema=pgboss',
1245
+ );
1246
+ });
1247
+
1248
+ it('should replace port in SNS URL with query params and no path', () => {
1249
+ const url = 'sns://key:secret@localhost:4566?region=us-east-1';
1250
+ expect(replacePortInUrl(url, 4566, 4567)).toBe(
1251
+ 'sns://key:secret@localhost:4567?region=us-east-1',
1252
+ );
1253
+ });
1254
+
1255
+ it('should replace URL-encoded port in query params', () => {
1256
+ const url =
1257
+ 'sns://key:secret@localhost:4566?region=us-east-1&endpoint=http%3A%2F%2Flocalhost%3A4566';
1258
+ expect(replacePortInUrl(url, 4566, 4567)).toBe(
1259
+ 'sns://key:secret@localhost:4567?region=us-east-1&endpoint=http%3A%2F%2Flocalhost%3A4567',
1260
+ );
1261
+ });
1240
1262
  });
1241
1263
 
1242
1264
  describe('rewriteUrlsWithPorts', () => {
@@ -1384,6 +1406,52 @@ describe('rewriteUrlsWithPorts', () => {
1384
1406
  expect(result.POSTGRES_HOST).toBe('localhost');
1385
1407
  });
1386
1408
 
1409
+ it('should rewrite EVENT_PUBLISHER_CONNECTION_STRING and EVENT_SUBSCRIBER_CONNECTION_STRING', () => {
1410
+ const secrets = {
1411
+ EVENT_PUBLISHER_CONNECTION_STRING:
1412
+ 'pgboss://pgboss:pass@localhost:5432/mydb?schema=pgboss',
1413
+ EVENT_SUBSCRIBER_CONNECTION_STRING:
1414
+ 'pgboss://pgboss:pass@localhost:5432/mydb?schema=pgboss',
1415
+ };
1416
+ const result = rewriteUrlsWithPorts(secrets, {
1417
+ dockerEnv: { POSTGRES_HOST_PORT: '5433' },
1418
+ ports: { POSTGRES_HOST_PORT: 5433 },
1419
+ mappings: [pgMapping],
1420
+ });
1421
+ expect(result.EVENT_PUBLISHER_CONNECTION_STRING).toBe(
1422
+ 'pgboss://pgboss:pass@localhost:5433/mydb?schema=pgboss',
1423
+ );
1424
+ expect(result.EVENT_SUBSCRIBER_CONNECTION_STRING).toBe(
1425
+ 'pgboss://pgboss:pass@localhost:5433/mydb?schema=pgboss',
1426
+ );
1427
+ });
1428
+
1429
+ it('should rewrite SNS connection strings with encoded endpoint port', () => {
1430
+ const localstackMapping = {
1431
+ service: 'localstack',
1432
+ envVar: 'LOCALSTACK_PORT',
1433
+ defaultPort: 4566,
1434
+ containerPort: 4566,
1435
+ };
1436
+ const secrets = {
1437
+ EVENT_PUBLISHER_CONNECTION_STRING:
1438
+ 'sns://LSIAkey:secret@localhost:4566?region=us-east-1&endpoint=http%3A%2F%2Flocalhost%3A4566',
1439
+ EVENT_SUBSCRIBER_CONNECTION_STRING:
1440
+ 'sqs://LSIAkey:secret@localhost:4566?region=us-east-1&endpoint=http%3A%2F%2Flocalhost%3A4566',
1441
+ };
1442
+ const result = rewriteUrlsWithPorts(secrets, {
1443
+ dockerEnv: { LOCALSTACK_PORT: '4567' },
1444
+ ports: { LOCALSTACK_PORT: 4567 },
1445
+ mappings: [localstackMapping],
1446
+ });
1447
+ expect(result.EVENT_PUBLISHER_CONNECTION_STRING).toBe(
1448
+ 'sns://LSIAkey:secret@localhost:4567?region=us-east-1&endpoint=http%3A%2F%2Flocalhost%3A4567',
1449
+ );
1450
+ expect(result.EVENT_SUBSCRIBER_CONNECTION_STRING).toBe(
1451
+ 'sqs://LSIAkey:secret@localhost:4567?region=us-east-1&endpoint=http%3A%2F%2Flocalhost%3A4567',
1452
+ );
1453
+ });
1454
+
1387
1455
  it('should return empty for no mappings', () => {
1388
1456
  const result = rewriteUrlsWithPorts(
1389
1457
  {},
@@ -1799,23 +1867,23 @@ describe('generateServerEntryContent', () => {
1799
1867
  expect(content).not.toMatch(/^import.*createApp/m);
1800
1868
  });
1801
1869
 
1802
- it('should inject Credentials assignment before dynamic import', () => {
1870
+ it('should inject credentials via globalThis before dynamic import', () => {
1803
1871
  const content = generateServerEntryContent({
1804
1872
  secretsJsonPath: '/tmp/dev-secrets.json',
1805
1873
  });
1806
1874
 
1807
- const credentialsAssignIdx = content.indexOf('Object.assign(Credentials');
1875
+ const credentialsIdx = content.indexOf('__gkm_credentials__');
1808
1876
  const dynamicImportIdx = content.indexOf("await import('./app.js')");
1809
1877
 
1810
- expect(credentialsAssignIdx).toBeGreaterThan(-1);
1878
+ expect(credentialsIdx).toBeGreaterThan(-1);
1811
1879
  expect(dynamicImportIdx).toBeGreaterThan(-1);
1812
- expect(credentialsAssignIdx).toBeLessThan(dynamicImportIdx);
1880
+ expect(credentialsIdx).toBeLessThan(dynamicImportIdx);
1813
1881
  });
1814
1882
 
1815
1883
  it('should not include credentials injection when no secrets path', () => {
1816
1884
  const content = generateServerEntryContent({});
1817
1885
 
1818
- expect(content).not.toContain('Object.assign(Credentials');
1886
+ expect(content).not.toContain('__gkm_credentials__');
1819
1887
  expect(content).not.toContain('existsSync');
1820
1888
  });
1821
1889
 
package/src/dev/index.ts CHANGED
@@ -348,7 +348,17 @@ export function replacePortInUrl(
348
348
  newPort: number,
349
349
  ): string {
350
350
  if (oldPort === newPort) return url;
351
- return url.replace(new RegExp(`:${oldPort}(?=/|$)`, 'g'), `:${newPort}`);
351
+ // Replace literal :port (in authority section)
352
+ let result = url.replace(
353
+ new RegExp(`:${oldPort}(?=[/?#]|$)`, 'g'),
354
+ `:${newPort}`,
355
+ );
356
+ // Replace URL-encoded :port (e.g., in query params like endpoint=http%3A%2F%2Flocalhost%3A4566)
357
+ result = result.replace(
358
+ new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, 'gi'),
359
+ `%3A${newPort}`,
360
+ );
361
+ return result;
352
362
  }
353
363
 
354
364
  /**
@@ -402,6 +412,7 @@ export function rewriteUrlsWithPorts(
402
412
  if (
403
413
  !key.endsWith('_URL') &&
404
414
  !key.endsWith('_ENDPOINT') &&
415
+ !key.endsWith('_CONNECTION_STRING') &&
405
416
  key !== 'DATABASE_URL'
406
417
  )
407
418
  continue;
@@ -1623,17 +1634,16 @@ export function findSecretsRoot(startDir: string): string {
1623
1634
  * @internal
1624
1635
  */
1625
1636
  function generateCredentialsInjection(secretsJsonPath: string): string {
1626
- return `import { Credentials } from '@geekmidas/envkit/credentials';
1627
- import { existsSync, readFileSync } from 'node:fs';
1637
+ return `import { existsSync, readFileSync } from 'node:fs';
1628
1638
 
1629
- // Inject dev secrets into Credentials and process.env
1639
+ // Inject dev secrets via globalThis and process.env
1640
+ // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
1641
+ // Object.assign on the Credentials export only mutates one module copy.
1630
1642
  const secretsPath = '${secretsJsonPath}';
1631
1643
  if (existsSync(secretsPath)) {
1632
1644
  const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1633
- Object.assign(Credentials, secrets);
1645
+ globalThis.__gkm_credentials__ = secrets;
1634
1646
  Object.assign(process.env, secrets);
1635
- // Debug: uncomment to verify preload is running
1636
- // console.log('[gkm preload] Injected', Object.keys(secrets).length, 'credentials');
1637
1647
  }
1638
1648
  `;
1639
1649
  }
@@ -1958,13 +1968,14 @@ export function generateServerEntryContent(options: {
1958
1968
  } = options;
1959
1969
 
1960
1970
  const credentialsInjection = secretsJsonPath
1961
- ? `import { Credentials } from '@geekmidas/envkit/credentials';
1962
- import { existsSync, readFileSync } from 'node:fs';
1971
+ ? `import { existsSync, readFileSync } from 'node:fs';
1963
1972
 
1964
- // Inject dev secrets into Credentials (must happen before app import)
1973
+ // Inject dev secrets via globalThis (must happen before app import)
1965
1974
  const secretsPath = '${secretsJsonPath}';
1966
1975
  if (existsSync(secretsPath)) {
1967
- Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1976
+ const __secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1977
+ globalThis.__gkm_credentials__ = __secrets;
1978
+ Object.assign(process.env, __secrets);
1968
1979
  }
1969
1980
 
1970
1981
  `
@@ -2246,20 +2257,17 @@ export async function execCommand(
2246
2257
  logger.log(`🔐 Loaded ${secretCount} secret(s)`);
2247
2258
  }
2248
2259
 
2249
- // Rewrite URLs with resolved Docker ports (from gkm dev)
2250
- const composePath = join(secretsRoot, 'docker-compose.yml');
2251
- const mappings = parseComposePortMappings(composePath);
2252
- if (mappings.length > 0) {
2253
- const ports = await loadPortState(secretsRoot);
2254
- if (Object.keys(ports).length > 0) {
2255
- const rewritten = rewriteUrlsWithPorts(credentials, {
2256
- dockerEnv: {},
2257
- ports,
2258
- mappings,
2259
- });
2260
- Object.assign(credentials, rewritten);
2261
- logger.log(`🔌 Applied ${Object.keys(ports).length} port mapping(s)`);
2262
- }
2260
+ // Resolve actual Docker ports from running containers (not just saved state)
2261
+ const resolvedPorts = await resolveServicePorts(secretsRoot);
2262
+ if (
2263
+ resolvedPorts.mappings.length > 0 &&
2264
+ Object.keys(resolvedPorts.ports).length > 0
2265
+ ) {
2266
+ const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
2267
+ Object.assign(credentials, rewritten);
2268
+ logger.log(
2269
+ `🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`,
2270
+ );
2263
2271
  }
2264
2272
 
2265
2273
  // Inject dependency URLs (works for both frontend and backend apps)
@@ -15,6 +15,7 @@ export const DEFAULT_SERVICE_IMAGES: Record<ComposeServiceName, string> = {
15
15
  rabbitmq: 'rabbitmq',
16
16
  minio: 'minio/minio',
17
17
  mailpit: 'axllent/mailpit',
18
+ localstack: 'localstack/localstack',
18
19
  };
19
20
 
20
21
  /** Default Docker image versions for services */
@@ -24,6 +25,7 @@ export const DEFAULT_SERVICE_VERSIONS: Record<ComposeServiceName, string> = {
24
25
  rabbitmq: '3-management-alpine',
25
26
  minio: 'latest',
26
27
  mailpit: 'latest',
28
+ localstack: 'latest',
27
29
  };
28
30
 
29
31
  export interface ComposeOptions {
@@ -145,6 +147,14 @@ services:
145
147
  `;
146
148
  }
147
149
 
150
+ if (serviceMap.has('localstack')) {
151
+ yaml += ` - AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-localstack}
152
+ - AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-localstack}
153
+ - AWS_REGION=\${AWS_REGION:-us-east-1}
154
+ - AWS_ENDPOINT_URL=\${AWS_ENDPOINT_URL:-http://localstack:4566}
155
+ `;
156
+ }
157
+
148
158
  yaml += ` healthcheck:
149
159
  test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
150
160
  interval: 30s
@@ -283,6 +293,32 @@ services:
283
293
  `;
284
294
  }
285
295
 
296
+ const localstackImage = serviceMap.get('localstack');
297
+ if (localstackImage) {
298
+ yaml += `
299
+ localstack:
300
+ image: ${localstackImage}
301
+ container_name: localstack
302
+ restart: unless-stopped
303
+ environment:
304
+ SERVICES: sns,sqs
305
+ AWS_DEFAULT_REGION: \${AWS_REGION:-us-east-1}
306
+ AWS_ACCESS_KEY_ID: \${AWS_ACCESS_KEY_ID:-localstack}
307
+ AWS_SECRET_ACCESS_KEY: \${AWS_SECRET_ACCESS_KEY:-localstack}
308
+ ports:
309
+ - "\${LOCALSTACK_PORT:-4566}:4566"
310
+ volumes:
311
+ - localstack_data:/var/lib/localstack
312
+ healthcheck:
313
+ test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
314
+ interval: 10s
315
+ timeout: 5s
316
+ retries: 5
317
+ networks:
318
+ - app-network
319
+ `;
320
+ }
321
+
286
322
  // Add volumes
287
323
  yaml += `
288
324
  volumes:
@@ -308,6 +344,11 @@ volumes:
308
344
  `;
309
345
  }
310
346
 
347
+ if (serviceMap.has('localstack')) {
348
+ yaml += ` localstack_data:
349
+ `;
350
+ }
351
+
311
352
  // Add networks
312
353
  yaml += `
313
354
  networks:
@@ -384,6 +425,11 @@ export function generateWorkspaceCompose(
384
425
  const hasRedis = services.cache !== undefined && services.cache !== false;
385
426
  const hasMail = services.mail !== undefined && services.mail !== false;
386
427
  const hasMinio = services.storage !== undefined && services.storage !== false;
428
+ const eventsBackend = services.events;
429
+ const hasLocalStack = eventsBackend === 'sns';
430
+ const hasRabbitMQ =
431
+ eventsBackend === 'rabbitmq' ||
432
+ (services as Record<string, unknown>).rabbitmq !== undefined;
387
433
 
388
434
  // Get image versions from config
389
435
  const postgresImage = getInfraServiceImage('postgres', services.db);
@@ -406,6 +452,7 @@ services:
406
452
  hasRedis,
407
453
  hasMinio,
408
454
  hasMail,
455
+ eventsBackend,
409
456
  });
410
457
  }
411
458
 
@@ -497,6 +544,55 @@ services:
497
544
  `;
498
545
  }
499
546
 
547
+ if (hasLocalStack) {
548
+ yaml += `
549
+ localstack:
550
+ image: localstack/localstack:latest
551
+ container_name: ${workspace.name}-localstack
552
+ restart: unless-stopped
553
+ environment:
554
+ SERVICES: sns,sqs
555
+ AWS_DEFAULT_REGION: \${AWS_REGION:-us-east-1}
556
+ AWS_ACCESS_KEY_ID: \${AWS_ACCESS_KEY_ID:-localstack}
557
+ AWS_SECRET_ACCESS_KEY: \${AWS_SECRET_ACCESS_KEY:-localstack}
558
+ ports:
559
+ - "\${LOCALSTACK_PORT:-4566}:4566"
560
+ volumes:
561
+ - localstack_data:/var/lib/localstack
562
+ healthcheck:
563
+ test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
564
+ interval: 10s
565
+ timeout: 5s
566
+ retries: 5
567
+ networks:
568
+ - workspace-network
569
+ `;
570
+ }
571
+
572
+ if (hasRabbitMQ) {
573
+ yaml += `
574
+ rabbitmq:
575
+ image: rabbitmq:3-management-alpine
576
+ container_name: ${workspace.name}-rabbitmq
577
+ restart: unless-stopped
578
+ environment:
579
+ RABBITMQ_DEFAULT_USER: \${RABBITMQ_USER:-guest}
580
+ RABBITMQ_DEFAULT_PASS: \${RABBITMQ_PASSWORD:-guest}
581
+ ports:
582
+ - "\${RABBITMQ_HOST_PORT:-5672}:5672"
583
+ - "\${RABBITMQ_MGMT_HOST_PORT:-15672}:15672"
584
+ volumes:
585
+ - rabbitmq_data:/var/lib/rabbitmq
586
+ healthcheck:
587
+ test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
588
+ interval: 10s
589
+ timeout: 5s
590
+ retries: 5
591
+ networks:
592
+ - workspace-network
593
+ `;
594
+ }
595
+
500
596
  // Add volumes section
501
597
  yaml += `
502
598
  volumes:
@@ -517,6 +613,16 @@ volumes:
517
613
  `;
518
614
  }
519
615
 
616
+ if (hasLocalStack) {
617
+ yaml += ` localstack_data:
618
+ `;
619
+ }
620
+
621
+ if (hasRabbitMQ) {
622
+ yaml += ` rabbitmq_data:
623
+ `;
624
+ }
625
+
520
626
  // Add networks section
521
627
  yaml += `
522
628
  networks:
@@ -575,10 +681,18 @@ function generateAppService(
575
681
  hasRedis: boolean;
576
682
  hasMinio: boolean;
577
683
  hasMail: boolean;
684
+ eventsBackend?: import('../types').EventsBackend;
578
685
  },
579
686
  ): string {
580
- const { registry, projectName, hasPostgres, hasRedis, hasMinio, hasMail } =
581
- options;
687
+ const {
688
+ registry,
689
+ projectName,
690
+ hasPostgres,
691
+ hasRedis,
692
+ hasMinio,
693
+ hasMail,
694
+ eventsBackend,
695
+ } = options;
582
696
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
583
697
 
584
698
  // Health check path - frontends use /, backends use /health
@@ -640,6 +754,18 @@ function generateAppService(
640
754
  - MAIL_FROM=\${MAIL_FROM:-noreply@localhost}
641
755
  `;
642
756
  }
757
+ if (eventsBackend) {
758
+ yaml += ` - EVENT_PUBLISHER_CONNECTION_STRING=\${EVENT_PUBLISHER_CONNECTION_STRING}
759
+ - EVENT_SUBSCRIBER_CONNECTION_STRING=\${EVENT_SUBSCRIBER_CONNECTION_STRING}
760
+ `;
761
+ if (eventsBackend === 'sns') {
762
+ yaml += ` - AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-localstack}
763
+ - AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-localstack}
764
+ - AWS_REGION=\${AWS_REGION:-us-east-1}
765
+ - AWS_ENDPOINT_URL=\${AWS_ENDPOINT_URL:-http://localstack:4566}
766
+ `;
767
+ }
768
+ }
643
769
  }
644
770
 
645
771
  yaml += ` healthcheck:
@@ -656,6 +782,8 @@ function generateAppService(
656
782
  if (hasRedis) dependencies.push('redis');
657
783
  if (hasMinio) dependencies.push('minio');
658
784
  if (hasMail) dependencies.push('mailpit');
785
+ if (eventsBackend === 'sns') dependencies.push('localstack');
786
+ if (eventsBackend === 'rabbitmq') dependencies.push('rabbitmq');
659
787
  }
660
788
 
661
789
  if (dependencies.length > 0) {
@@ -295,6 +295,90 @@ describe('generateDockerFiles', () => {
295
295
  expect(files[0].content).not.toContain('mailpit');
296
296
  expect(files[0].content).not.toContain('minio');
297
297
  });
298
+
299
+ it('should include localstack when events is sns', () => {
300
+ const options = {
301
+ ...baseOptions,
302
+ services: {
303
+ db: true,
304
+ cache: true,
305
+ mail: false,
306
+ storage: false,
307
+ events: 'sns' as const,
308
+ },
309
+ };
310
+ const files = generateDockerFiles(options, minimalTemplate);
311
+ const compose = files[0].content;
312
+ expect(compose).toContain('localstack');
313
+ expect(compose).toContain('SERVICES: sns,sqs');
314
+ expect(compose).toContain('LOCALSTACK_PORT');
315
+ });
316
+
317
+ it('should include rabbitmq when events is rabbitmq', () => {
318
+ const options = {
319
+ ...baseOptions,
320
+ services: {
321
+ db: true,
322
+ cache: true,
323
+ mail: false,
324
+ storage: false,
325
+ events: 'rabbitmq' as const,
326
+ },
327
+ };
328
+ const files = generateDockerFiles(options, minimalTemplate);
329
+ const compose = files[0].content;
330
+ expect(compose).toContain('rabbitmq');
331
+ expect(compose).toContain('RABBITMQ_HOST_PORT');
332
+ });
333
+
334
+ it('should not add extra container for pgboss events', () => {
335
+ const options = {
336
+ ...baseOptions,
337
+ services: {
338
+ db: true,
339
+ cache: true,
340
+ mail: false,
341
+ storage: false,
342
+ events: 'pgboss' as const,
343
+ },
344
+ };
345
+ const files = generateDockerFiles(options, minimalTemplate);
346
+ const compose = files[0].content;
347
+ expect(compose).not.toContain('localstack');
348
+ // pgboss reuses postgres, no separate container
349
+ });
350
+
351
+ it('should generate idempotent postgres init script with pgboss', () => {
352
+ const options = {
353
+ ...baseOptions,
354
+ template: 'fullstack' as const,
355
+ monorepo: true,
356
+ apiPath: 'apps/api',
357
+ services: {
358
+ db: true,
359
+ cache: true,
360
+ mail: false,
361
+ storage: false,
362
+ events: 'pgboss' as const,
363
+ },
364
+ };
365
+ const dbApps = [
366
+ { name: 'api', password: 'api-pass' },
367
+ { name: 'auth', password: 'auth-pass' },
368
+ ];
369
+ const files = generateDockerFiles(options, apiTemplate, dbApps);
370
+ const initScript = files.find((f) => f.path === 'docker/postgres/init.sh');
371
+ expect(initScript).toBeDefined();
372
+ expect(initScript!.content).toContain('IF NOT EXISTS');
373
+ expect(initScript!.content).toContain('pgboss');
374
+ expect(initScript!.content).toContain('PGBOSS_DB_PASSWORD');
375
+ expect(initScript!.content).toContain('CREATE SCHEMA IF NOT EXISTS pgboss');
376
+
377
+ // Docker env should include pgboss password
378
+ const envFile = files.find((f) => f.path === 'docker/.env');
379
+ expect(envFile).toBeDefined();
380
+ expect(envFile!.content).toContain('PGBOSS_DB_PASSWORD');
381
+ });
298
382
  });
299
383
 
300
384
  describe('generateMonorepoFiles', () => {