@geekmidas/cli 1.10.15 → 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 +6 -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 +322 -46
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +322 -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-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 +3 -3
  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 +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/secrets/__tests__/generator.spec.ts +183 -0
  58. package/src/secrets/generator.ts +116 -4
  59. package/src/secrets/storage.ts +12 -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-Bp8xRcuQ.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.15",
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",
@@ -58,9 +58,9 @@
58
58
  "yaml": "~2.8.2",
59
59
  "@geekmidas/envkit": "~1.0.3",
60
60
  "@geekmidas/constructs": "~3.0.2",
61
+ "@geekmidas/logger": "~1.0.0",
61
62
  "@geekmidas/schema": "~1.0.0",
62
- "@geekmidas/errors": "~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 {
@@ -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', () => {
@@ -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
  }