@geekmidas/cli 1.10.14 → 1.10.16

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 (72) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{bundler-Di5Gz9Ou.mjs → bundler-B4AackW5.mjs} +2 -2
  3. package/dist/{bundler-Di5Gz9Ou.mjs.map → bundler-B4AackW5.mjs.map} +1 -1
  4. package/dist/{bundler-DVJkwNMQ.cjs → bundler-BhhfkI9T.cjs} +2 -2
  5. package/dist/{bundler-DVJkwNMQ.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 +326 -46
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +326 -46
  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-6GBoLCYF.cjs → storage-B1wvztiJ.cjs} +14 -2
  29. package/dist/{storage-DMf420PP.mjs.map → storage-B1wvztiJ.cjs.map} +1 -1
  30. package/dist/{storage-BFqrVsip.mjs → storage-Cs4WBsc4.mjs} +1 -1
  31. package/dist/{storage-DCqjCiDn.cjs → storage-DOEtT2Hr.cjs} +1 -1
  32. package/dist/{storage-DMf420PP.mjs → storage-dbb9RyBl.mjs} +14 -2
  33. package/dist/{storage-6GBoLCYF.cjs.map → storage-dbb9RyBl.mjs.map} +1 -1
  34. package/dist/{sync-DjD_TeNX.mjs → sync-COnAugP-.mjs} +1 -1
  35. package/dist/sync-D1Pa30oV.cjs +4 -0
  36. package/dist/{sync-DIGGOxCw.cjs → sync-DGXXSk2v.cjs} +2 -2
  37. package/dist/{sync-DIGGOxCw.cjs.map → sync-DGXXSk2v.cjs.map} +1 -1
  38. package/dist/{sync-Do9O7QZ8.mjs → sync-D_NowTkZ.mjs} +2 -2
  39. package/dist/{sync-Do9O7QZ8.mjs.map → sync-D_NowTkZ.mjs.map} +1 -1
  40. package/dist/{types-JvWj5Ckc.d.cts → types-DdHfUbxk.d.cts} +13 -3
  41. package/dist/types-DdHfUbxk.d.cts.map +1 -0
  42. package/dist/{types-DiV9Mbvc.d.mts → 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 +5 -5
  51. package/src/dev/index.ts +10 -10
  52. package/src/docker/compose.ts +134 -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/secrets/__tests__/generator.spec.ts +183 -0
  58. package/src/secrets/generator.ts +116 -4
  59. package/src/secrets/storage.ts +14 -0
  60. package/src/secrets/types.ts +11 -1
  61. package/src/setup/__tests__/reconcile-secrets.spec.ts +86 -0
  62. package/src/setup/index.ts +64 -1
  63. package/src/test/__tests__/index.spec.ts +1 -4
  64. package/src/types.ts +13 -1
  65. package/src/workspace/types.ts +13 -2
  66. package/dist/fullstack-secrets-BIFFv4UZ.mjs.map +0 -1
  67. package/dist/fullstack-secrets-D9rjTNyx.cjs.map +0 -1
  68. package/dist/index-UCsZ_Vkw.d.cts.map +0 -1
  69. package/dist/index-gXAGDSGu.d.mts.map +0 -1
  70. package/dist/sync-BVNso6AA.cjs +0 -4
  71. package/dist/types-DiV9Mbvc.d.mts.map +0 -1
  72. 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.14",
3
+ "version": "1.10.16",
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/constructs": "~3.0.2",
60
59
  "@geekmidas/envkit": "~1.0.3",
61
- "@geekmidas/errors": "~1.0.0",
60
+ "@geekmidas/constructs": "~3.0.2",
61
+ "@geekmidas/logger": "~1.0.0",
62
62
  "@geekmidas/schema": "~1.0.0",
63
- "@geekmidas/logger": "~1.0.0"
63
+ "@geekmidas/errors": "~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);
@@ -1799,23 +1799,23 @@ describe('generateServerEntryContent', () => {
1799
1799
  expect(content).not.toMatch(/^import.*createApp/m);
1800
1800
  });
1801
1801
 
1802
- it('should inject Credentials assignment before dynamic import', () => {
1802
+ it('should inject credentials via globalThis before dynamic import', () => {
1803
1803
  const content = generateServerEntryContent({
1804
1804
  secretsJsonPath: '/tmp/dev-secrets.json',
1805
1805
  });
1806
1806
 
1807
- const credentialsAssignIdx = content.indexOf('Object.assign(Credentials');
1807
+ const credentialsIdx = content.indexOf('__gkm_credentials__');
1808
1808
  const dynamicImportIdx = content.indexOf("await import('./app.js')");
1809
1809
 
1810
- expect(credentialsAssignIdx).toBeGreaterThan(-1);
1810
+ expect(credentialsIdx).toBeGreaterThan(-1);
1811
1811
  expect(dynamicImportIdx).toBeGreaterThan(-1);
1812
- expect(credentialsAssignIdx).toBeLessThan(dynamicImportIdx);
1812
+ expect(credentialsIdx).toBeLessThan(dynamicImportIdx);
1813
1813
  });
1814
1814
 
1815
1815
  it('should not include credentials injection when no secrets path', () => {
1816
1816
  const content = generateServerEntryContent({});
1817
1817
 
1818
- expect(content).not.toContain('Object.assign(Credentials');
1818
+ expect(content).not.toContain('__gkm_credentials__');
1819
1819
  expect(content).not.toContain('existsSync');
1820
1820
  });
1821
1821
 
package/src/dev/index.ts CHANGED
@@ -1623,17 +1623,16 @@ export function findSecretsRoot(startDir: string): string {
1623
1623
  * @internal
1624
1624
  */
1625
1625
  function generateCredentialsInjection(secretsJsonPath: string): string {
1626
- return `import { Credentials } from '@geekmidas/envkit/credentials';
1627
- import { existsSync, readFileSync } from 'node:fs';
1626
+ return `import { existsSync, readFileSync } from 'node:fs';
1628
1627
 
1629
- // Inject dev secrets into Credentials and process.env
1628
+ // Inject dev secrets via globalThis and process.env
1629
+ // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
1630
+ // Object.assign on the Credentials export only mutates one module copy.
1630
1631
  const secretsPath = '${secretsJsonPath}';
1631
1632
  if (existsSync(secretsPath)) {
1632
1633
  const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1633
- Object.assign(Credentials, secrets);
1634
+ globalThis.__gkm_credentials__ = secrets;
1634
1635
  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
1636
  }
1638
1637
  `;
1639
1638
  }
@@ -1958,13 +1957,14 @@ export function generateServerEntryContent(options: {
1958
1957
  } = options;
1959
1958
 
1960
1959
  const credentialsInjection = secretsJsonPath
1961
- ? `import { Credentials } from '@geekmidas/envkit/credentials';
1962
- import { existsSync, readFileSync } from 'node:fs';
1960
+ ? `import { existsSync, readFileSync } from 'node:fs';
1963
1961
 
1964
- // Inject dev secrets into Credentials (must happen before app import)
1962
+ // Inject dev secrets via globalThis (must happen before app import)
1965
1963
  const secretsPath = '${secretsJsonPath}';
1966
1964
  if (existsSync(secretsPath)) {
1967
- Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1965
+ const __secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1966
+ globalThis.__gkm_credentials__ = __secrets;
1967
+ Object.assign(process.env, __secrets);
1968
1968
  }
1969
1969
 
1970
1970
  `
@@ -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 {
@@ -140,6 +142,16 @@ services:
140
142
  - SMTP_PORT=\${SMTP_PORT:-1025}
141
143
  - SMTP_USER=\${SMTP_USER:-${imageName}}
142
144
  - SMTP_PASS=\${SMTP_PASS:-${imageName}}
145
+ - SMTP_SECURE=\${SMTP_SECURE:-false}
146
+ - MAIL_FROM=\${MAIL_FROM:-noreply@localhost}
147
+ `;
148
+ }
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}
143
155
  `;
144
156
  }
145
157
 
@@ -281,6 +293,32 @@ services:
281
293
  `;
282
294
  }
283
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
+
284
322
  // Add volumes
285
323
  yaml += `
286
324
  volumes:
@@ -306,6 +344,11 @@ volumes:
306
344
  `;
307
345
  }
308
346
 
347
+ if (serviceMap.has('localstack')) {
348
+ yaml += ` localstack_data:
349
+ `;
350
+ }
351
+
309
352
  // Add networks
310
353
  yaml += `
311
354
  networks:
@@ -382,6 +425,11 @@ export function generateWorkspaceCompose(
382
425
  const hasRedis = services.cache !== undefined && services.cache !== false;
383
426
  const hasMail = services.mail !== undefined && services.mail !== false;
384
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;
385
433
 
386
434
  // Get image versions from config
387
435
  const postgresImage = getInfraServiceImage('postgres', services.db);
@@ -404,6 +452,7 @@ services:
404
452
  hasRedis,
405
453
  hasMinio,
406
454
  hasMail,
455
+ eventsBackend,
407
456
  });
408
457
  }
409
458
 
@@ -495,6 +544,55 @@ services:
495
544
  `;
496
545
  }
497
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
+
498
596
  // Add volumes section
499
597
  yaml += `
500
598
  volumes:
@@ -515,6 +613,16 @@ volumes:
515
613
  `;
516
614
  }
517
615
 
616
+ if (hasLocalStack) {
617
+ yaml += ` localstack_data:
618
+ `;
619
+ }
620
+
621
+ if (hasRabbitMQ) {
622
+ yaml += ` rabbitmq_data:
623
+ `;
624
+ }
625
+
518
626
  // Add networks section
519
627
  yaml += `
520
628
  networks:
@@ -573,10 +681,18 @@ function generateAppService(
573
681
  hasRedis: boolean;
574
682
  hasMinio: boolean;
575
683
  hasMail: boolean;
684
+ eventsBackend?: import('../types').EventsBackend;
576
685
  },
577
686
  ): string {
578
- const { registry, projectName, hasPostgres, hasRedis, hasMinio, hasMail } =
579
- options;
687
+ const {
688
+ registry,
689
+ projectName,
690
+ hasPostgres,
691
+ hasRedis,
692
+ hasMinio,
693
+ hasMail,
694
+ eventsBackend,
695
+ } = options;
580
696
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
581
697
 
582
698
  // Health check path - frontends use /, backends use /health
@@ -634,8 +750,22 @@ function generateAppService(
634
750
  - SMTP_PORT=\${SMTP_PORT:-1025}
635
751
  - SMTP_USER=\${SMTP_USER:-${projectName}}
636
752
  - SMTP_PASS=\${SMTP_PASS:-${projectName}}
753
+ - SMTP_SECURE=\${SMTP_SECURE:-false}
754
+ - MAIL_FROM=\${MAIL_FROM:-noreply@localhost}
637
755
  `;
638
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
+ }
639
769
  }
640
770
 
641
771
  yaml += ` healthcheck:
@@ -652,6 +782,8 @@ function generateAppService(
652
782
  if (hasRedis) dependencies.push('redis');
653
783
  if (hasMinio) dependencies.push('minio');
654
784
  if (hasMail) dependencies.push('mailpit');
785
+ if (eventsBackend === 'sns') dependencies.push('localstack');
786
+ if (eventsBackend === 'rabbitmq') dependencies.push('rabbitmq');
655
787
  }
656
788
 
657
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', () => {
@@ -1,3 +1,4 @@
1
+ import type { EventsBackend } from '../../types.js';
1
2
  import type {
2
3
  GeneratedFile,
3
4
  TemplateConfig,
@@ -64,13 +65,13 @@ export function generateDockerFiles(
64
65
  if (isFullstack && dbApps?.length) {
65
66
  files.push({
66
67
  path: 'docker/postgres/init.sh',
67
- content: generatePostgresInitScript(dbApps),
68
+ content: generatePostgresInitScript(dbApps, options.services?.events),
68
69
  });
69
70
 
70
71
  // Generate .env file for docker-compose (contains db passwords)
71
72
  files.push({
72
73
  path: 'docker/.env',
73
- content: generateDockerEnv(dbApps),
74
+ content: generateDockerEnv(dbApps, options.services?.events),
74
75
  });
75
76
  }
76
77
  }
@@ -183,6 +184,53 @@ export function generateDockerFiles(
183
184
  volumes.push(' minio_data:');
184
185
  }
185
186
 
187
+ // LocalStack for SNS events
188
+ if (options.services?.events === 'sns') {
189
+ services.push(` localstack:
190
+ image: localstack/localstack:latest
191
+ container_name: ${options.name}-localstack
192
+ restart: unless-stopped
193
+ environment:
194
+ SERVICES: sns,sqs
195
+ AWS_DEFAULT_REGION: \${AWS_REGION:-us-east-1}
196
+ AWS_ACCESS_KEY_ID: \${AWS_ACCESS_KEY_ID:-localstack}
197
+ AWS_SECRET_ACCESS_KEY: \${AWS_SECRET_ACCESS_KEY:-localstack}
198
+ ports:
199
+ - '\${LOCALSTACK_PORT:-4566}:4566'
200
+ volumes:
201
+ - localstack_data:/var/lib/localstack
202
+ healthcheck:
203
+ test: ['CMD', 'curl', '-f', 'http://localhost:4566/_localstack/health']
204
+ interval: 10s
205
+ timeout: 5s
206
+ retries: 5`);
207
+ volumes.push(' localstack_data:');
208
+ }
209
+
210
+ // RabbitMQ for rabbitmq events (when not already added by worker template)
211
+ if (options.services?.events === 'rabbitmq' && !hasWorker) {
212
+ services.push(` rabbitmq:
213
+ image: rabbitmq:3-management-alpine
214
+ container_name: ${options.name}-rabbitmq
215
+ restart: unless-stopped
216
+ ports:
217
+ - '\${RABBITMQ_HOST_PORT:-5672}:5672'
218
+ - '\${RABBITMQ_MGMT_HOST_PORT:-15672}:15672'
219
+ environment:
220
+ RABBITMQ_DEFAULT_USER: guest
221
+ RABBITMQ_DEFAULT_PASS: guest
222
+ volumes:
223
+ - rabbitmq_data:/var/lib/rabbitmq
224
+ healthcheck:
225
+ test: ['CMD', 'rabbitmq-diagnostics', 'check_running']
226
+ interval: 10s
227
+ timeout: 5s
228
+ retries: 5`);
229
+ if (!volumes.includes(' rabbitmq_data:')) {
230
+ volumes.push(' rabbitmq_data:');
231
+ }
232
+ }
233
+
186
234
  // Build docker-compose.yml
187
235
  let dockerCompose = `# Use "gkm dev" or "gkm test" to start services.
188
236
  # Running "docker compose up" directly will not inject secrets or resolve ports.
@@ -209,12 +257,20 @@ ${volumes.join('\n')}
209
257
  /**
210
258
  * Generate .env file for docker-compose with database passwords
211
259
  */
212
- function generateDockerEnv(apps: DatabaseAppConfig[]): string {
260
+ function generateDockerEnv(
261
+ apps: DatabaseAppConfig[],
262
+ eventsBackend?: EventsBackend,
263
+ ): string {
213
264
  const envVars = apps.map((app) => {
214
265
  const envVar = `${app.name.toUpperCase()}_DB_PASSWORD`;
215
266
  return `${envVar}=${app.password}`;
216
267
  });
217
268
 
269
+ // Add pgboss password if events backend is pgboss
270
+ if (eventsBackend === 'pgboss') {
271
+ envVars.push(`PGBOSS_DB_PASSWORD=pgboss-dev-password`);
272
+ }
273
+
218
274
  return `# Auto-generated docker environment file
219
275
  # Contains database passwords for docker-compose postgres init
220
276
  # This file is gitignored - do not commit to version control
@@ -223,12 +279,16 @@ ${envVars.join('\n')}
223
279
  }
224
280
 
225
281
  /**
226
- * Generate PostgreSQL init shell script that creates per-app users with separate schemas
227
- * Uses environment variables for passwords (more secure than hardcoded values)
282
+ * Generate PostgreSQL init shell script that creates per-app users with separate schemas.
283
+ * Uses idempotent DO blocks so the script can be re-run safely.
228
284
  * - api user: uses public schema
229
285
  * - auth user: uses auth schema with search_path=auth
286
+ * - pgboss user: uses pgboss schema (when events === 'pgboss')
230
287
  */
231
- function generatePostgresInitScript(apps: DatabaseAppConfig[]): string {
288
+ function generatePostgresInitScript(
289
+ apps: DatabaseAppConfig[],
290
+ eventsBackend?: EventsBackend,
291
+ ): string {
232
292
  const userCreations = apps.map((app) => {
233
293
  const userName = app.name.replace(/-/g, '_');
234
294
  const envVar = `${app.name.toUpperCase()}_DB_PASSWORD`;
@@ -236,25 +296,39 @@ function generatePostgresInitScript(apps: DatabaseAppConfig[]): string {
236
296
  const schemaName = isApi ? 'public' : userName;
237
297
 
238
298
  if (isApi) {
239
- // API user uses public schema
240
299
  return `
241
- # Create ${app.name} user (uses public schema)
300
+ # Create ${app.name} user (uses public schema) - idempotent
242
301
  echo "Creating user ${userName}..."
243
302
  psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
244
- CREATE USER ${userName} WITH PASSWORD '$${envVar}';
303
+ DO \\$\\$
304
+ BEGIN
305
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${userName}') THEN
306
+ CREATE USER ${userName} WITH PASSWORD '$${envVar}';
307
+ ELSE
308
+ ALTER USER ${userName} WITH PASSWORD '$${envVar}';
309
+ END IF;
310
+ END
311
+ \\$\\$;
245
312
  GRANT ALL ON SCHEMA public TO ${userName};
246
313
  ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${userName};
247
314
  ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${userName};
248
315
  EOSQL
249
316
  `;
250
317
  }
251
- // Other users get their own schema with search_path
252
318
  return `
253
- # Create ${app.name} user with dedicated schema
319
+ # Create ${app.name} user with dedicated schema - idempotent
254
320
  echo "Creating user ${userName} with schema ${schemaName}..."
255
321
  psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
256
- CREATE USER ${userName} WITH PASSWORD '$${envVar}';
257
- CREATE SCHEMA ${schemaName} AUTHORIZATION ${userName};
322
+ DO \\$\\$
323
+ BEGIN
324
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${userName}') THEN
325
+ CREATE USER ${userName} WITH PASSWORD '$${envVar}';
326
+ ELSE
327
+ ALTER USER ${userName} WITH PASSWORD '$${envVar}';
328
+ END IF;
329
+ END
330
+ \\$\\$;
331
+ CREATE SCHEMA IF NOT EXISTS ${schemaName} AUTHORIZATION ${userName};
258
332
  ALTER USER ${userName} SET search_path TO ${schemaName};
259
333
  GRANT USAGE ON SCHEMA ${schemaName} TO ${userName};
260
334
  GRANT ALL ON ALL TABLES IN SCHEMA ${schemaName} TO ${userName};
@@ -265,14 +339,52 @@ EOSQL
265
339
  `;
266
340
  });
267
341
 
342
+ // Add pgboss user and schema if events backend is pgboss
343
+ let pgbossBlock = '';
344
+ if (eventsBackend === 'pgboss') {
345
+ pgbossBlock = `
346
+ # Create pgboss user with dedicated schema - idempotent
347
+ echo "Creating pgboss user and schema..."
348
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
349
+ DO \\$\\$
350
+ BEGIN
351
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pgboss') THEN
352
+ CREATE USER pgboss WITH PASSWORD '$PGBOSS_DB_PASSWORD';
353
+ ELSE
354
+ ALTER USER pgboss WITH PASSWORD '$PGBOSS_DB_PASSWORD';
355
+ END IF;
356
+ END
357
+ \\$\\$;
358
+ CREATE SCHEMA IF NOT EXISTS pgboss AUTHORIZATION pgboss;
359
+ ALTER USER pgboss SET search_path TO pgboss;
360
+ GRANT USAGE ON SCHEMA pgboss TO pgboss;
361
+ GRANT ALL ON ALL TABLES IN SCHEMA pgboss TO pgboss;
362
+ GRANT ALL ON ALL SEQUENCES IN SCHEMA pgboss TO pgboss;
363
+ ALTER DEFAULT PRIVILEGES IN SCHEMA pgboss GRANT ALL ON TABLES TO pgboss;
364
+ ALTER DEFAULT PRIVILEGES IN SCHEMA pgboss GRANT ALL ON SEQUENCES TO pgboss;
365
+ EOSQL
366
+ `;
367
+ }
368
+
369
+ // Add extensions
370
+ const extensions = `
371
+ # Create extensions
372
+ echo "Creating extensions..."
373
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
374
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
375
+ CREATE EXTENSION IF NOT EXISTS citext;
376
+ EOSQL
377
+ `;
378
+
268
379
  return `#!/bin/bash
269
380
  set -e
270
381
 
271
- # Auto-generated PostgreSQL init script
382
+ # Auto-generated PostgreSQL init script (idempotent - safe to re-run)
272
383
  # Creates per-app users with separate schemas in a single database
273
384
  # - api: uses public schema
274
- # - auth: uses auth schema (search_path=auth)
275
- ${userCreations.join('\n')}
385
+ # - auth: uses auth schema (search_path=auth)${eventsBackend === 'pgboss' ? '\n# - pgboss: uses pgboss schema for event processing' : ''}
386
+ ${extensions}
387
+ ${userCreations.join('\n')}${pgbossBlock}
276
388
  echo "Database initialization complete!"
277
389
  `;
278
390
  }