@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/src/init/index.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  generateDbPassword,
10
10
  generateDbUrl,
11
11
  } from '../setup/fullstack-secrets.js';
12
- import type { ComposeServiceName } from '../types.js';
12
+ import type { ComposeServiceName, EventsBackend } from '../types.js';
13
13
  import { generateAuthAppFiles } from './generators/auth.js';
14
14
  import { generateConfigFiles } from './generators/config.js';
15
15
  import {
@@ -27,6 +27,7 @@ import { generateWebAppFiles } from './generators/web.js';
27
27
  import {
28
28
  type DeployTarget,
29
29
  deployTargetChoices,
30
+ eventsBackendChoices,
30
31
  getTemplate,
31
32
  isFullstackTemplate,
32
33
  loggerTypeChoices,
@@ -110,6 +111,13 @@ export async function initCommand(
110
111
  choices: servicesChoices.map((c) => ({ ...c, selected: true })),
111
112
  hint: '- Space to select. Return to submit',
112
113
  },
114
+ {
115
+ type: options.yes ? null : 'select',
116
+ name: 'eventsBackend',
117
+ message: 'Event backend:',
118
+ choices: eventsBackendChoices,
119
+ initial: 0,
120
+ },
113
121
  {
114
122
  type: options.yes ? null : 'select',
115
123
  name: 'packageManager',
@@ -183,13 +191,27 @@ export async function initCommand(
183
191
  const servicesArray: string[] = options.yes
184
192
  ? ['db', 'cache', 'mail', 'storage']
185
193
  : answers.services || [];
194
+
195
+ // Determine events backend (default to pgboss for fullstack with --yes)
196
+ const eventsBackend: EventsBackend | undefined = options.yes
197
+ ? isFullstack
198
+ ? 'pgboss'
199
+ : undefined
200
+ : answers.eventsBackend;
201
+
186
202
  const services: ServicesSelection = {
187
203
  db: servicesArray.includes('db'),
188
204
  cache: servicesArray.includes('cache'),
189
205
  mail: servicesArray.includes('mail'),
190
206
  storage: servicesArray.includes('storage'),
207
+ events: eventsBackend,
191
208
  };
192
209
 
210
+ // pgboss requires postgres
211
+ if (services.events === 'pgboss') {
212
+ services.db = true;
213
+ }
214
+
193
215
  const pkgManager: PackageManager = options.pm
194
216
  ? options.pm
195
217
  : options.yes
@@ -333,9 +355,12 @@ export async function initCommand(
333
355
  if (services.cache) secretServices.push('redis');
334
356
  if (services.storage) secretServices.push('minio');
335
357
  if (services.mail) secretServices.push('mailpit');
358
+ if (services.events === 'sns') secretServices.push('localstack');
359
+ if (services.events === 'rabbitmq') secretServices.push('rabbitmq');
336
360
 
337
361
  const devSecrets = createStageSecrets('development', secretServices, {
338
362
  projectName: name,
363
+ eventsBackend: services.events,
339
364
  });
340
365
 
341
366
  // Add common custom secrets
@@ -39,6 +39,7 @@ export interface ServicesSelection {
39
39
  cache: boolean;
40
40
  mail: boolean;
41
41
  storage: boolean;
42
+ events?: import('../../types.js').EventsBackend;
42
43
  }
43
44
 
44
45
  /**
@@ -255,6 +256,33 @@ export const servicesChoices = [
255
256
  },
256
257
  ];
257
258
 
259
+ /**
260
+ * Event backend choices for prompts
261
+ */
262
+ export const eventsBackendChoices = [
263
+ {
264
+ title: 'pg-boss',
265
+ value: 'pgboss' as const,
266
+ description:
267
+ 'PostgreSQL-based job queue (reuses postgres, no extra container)',
268
+ },
269
+ {
270
+ title: 'SNS/SQS',
271
+ value: 'sns' as const,
272
+ description: 'AWS SNS+SQS via LocalStack for local dev',
273
+ },
274
+ {
275
+ title: 'RabbitMQ',
276
+ value: 'rabbitmq' as const,
277
+ description: 'AMQP message broker',
278
+ },
279
+ {
280
+ title: 'None',
281
+ value: undefined,
282
+ description: 'Skip event backend',
283
+ },
284
+ ];
285
+
258
286
  /**
259
287
  * Get a template by name
260
288
  */
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  createStageSecrets,
4
4
  generateConnectionUrls,
5
+ generateEventConnectionStrings,
6
+ generateLocalStackAccessKeyId,
7
+ generateLocalStackCredentials,
5
8
  generateMinioEndpoint,
9
+ generatePgBossUrl,
6
10
  generatePostgresUrl,
7
11
  generateRabbitmqUrl,
8
12
  generateRedisUrl,
@@ -385,3 +389,182 @@ describe('rotateServicePassword', () => {
385
389
  expect(rotated.services.rabbitmq).toEqual(original.services.rabbitmq);
386
390
  });
387
391
  });
392
+
393
+ describe('generateLocalStackAccessKeyId', () => {
394
+ it('should start with LSIA prefix', () => {
395
+ const keyId = generateLocalStackAccessKeyId();
396
+ expect(keyId).toMatch(/^LSIA/);
397
+ });
398
+
399
+ it('should be at least 20 characters', () => {
400
+ const keyId = generateLocalStackAccessKeyId();
401
+ expect(keyId.length).toBeGreaterThanOrEqual(20);
402
+ });
403
+
404
+ it('should generate unique keys', () => {
405
+ const key1 = generateLocalStackAccessKeyId();
406
+ const key2 = generateLocalStackAccessKeyId();
407
+ expect(key1).not.toBe(key2);
408
+ });
409
+ });
410
+
411
+ describe('generatePgBossUrl', () => {
412
+ it('should generate pgboss connection URL', () => {
413
+ const creds: ServiceCredentials = {
414
+ host: 'localhost',
415
+ port: 5432,
416
+ username: 'pgboss',
417
+ password: 'secret',
418
+ database: 'myapp_dev',
419
+ };
420
+
421
+ const url = generatePgBossUrl(creds);
422
+ expect(url).toBe(
423
+ 'pgboss://pgboss:secret@localhost:5432/myapp_dev?schema=pgboss',
424
+ );
425
+ });
426
+
427
+ it('should encode special characters in password', () => {
428
+ const creds: ServiceCredentials = {
429
+ host: 'localhost',
430
+ port: 5432,
431
+ username: 'pgboss',
432
+ password: 'p@ss/word',
433
+ database: 'app',
434
+ };
435
+
436
+ const url = generatePgBossUrl(creds);
437
+ expect(url).toContain('p%40ss%2Fword');
438
+ });
439
+ });
440
+
441
+ describe('generateLocalStackCredentials', () => {
442
+ it('should generate credentials with LSIA-prefixed access key', () => {
443
+ const creds = generateLocalStackCredentials();
444
+ expect(creds.accessKeyId).toMatch(/^LSIA/);
445
+ expect(creds.host).toBe('localhost');
446
+ expect(creds.port).toBe(4566);
447
+ expect(creds.region).toBe('us-east-1');
448
+ expect(creds.password).toHaveLength(32);
449
+ });
450
+ });
451
+
452
+ describe('generateEventConnectionStrings', () => {
453
+ it('should generate pgboss connection strings', () => {
454
+ const services: StageSecrets['services'] = {
455
+ pgboss: {
456
+ host: 'localhost',
457
+ port: 5432,
458
+ username: 'pgboss',
459
+ password: 'secret',
460
+ database: 'myapp_dev',
461
+ },
462
+ };
463
+
464
+ const result = generateEventConnectionStrings('pgboss', services);
465
+ expect(result.publisher).toContain('pgboss://');
466
+ expect(result.subscriber).toContain('pgboss://');
467
+ expect(result.publisher).toBe(result.subscriber);
468
+ });
469
+
470
+ it('should generate sns/sqs connection strings', () => {
471
+ const services: StageSecrets['services'] = {
472
+ localstack: {
473
+ host: 'localhost',
474
+ port: 4566,
475
+ username: 'localstack',
476
+ password: 'secret',
477
+ accessKeyId: 'LSIAtest1234567890xx',
478
+ region: 'us-east-1',
479
+ },
480
+ };
481
+
482
+ const result = generateEventConnectionStrings('sns', services);
483
+ expect(result.publisher).toContain('sns://');
484
+ expect(result.subscriber).toContain('sqs://');
485
+ expect(result.publisher).toContain('LSIAtest1234567890xx');
486
+ });
487
+
488
+ it('should generate rabbitmq connection strings', () => {
489
+ const services: StageSecrets['services'] = {
490
+ rabbitmq: {
491
+ host: 'localhost',
492
+ port: 5672,
493
+ username: 'app',
494
+ password: 'secret',
495
+ vhost: '/',
496
+ },
497
+ };
498
+
499
+ const result = generateEventConnectionStrings('rabbitmq', services);
500
+ expect(result.publisher).toContain('amqp://');
501
+ expect(result.subscriber).toContain('amqp://');
502
+ expect(result.publisher).toBe(result.subscriber);
503
+ });
504
+
505
+ it('should throw if pgboss credentials missing', () => {
506
+ expect(() => generateEventConnectionStrings('pgboss', {})).toThrow(
507
+ 'pgboss credentials required',
508
+ );
509
+ });
510
+
511
+ it('should throw if localstack credentials missing', () => {
512
+ expect(() => generateEventConnectionStrings('sns', {})).toThrow(
513
+ 'localstack credentials required',
514
+ );
515
+ });
516
+ });
517
+
518
+ describe('createStageSecrets with events', () => {
519
+ it('should create pgboss credentials when eventsBackend is pgboss', () => {
520
+ const secrets = createStageSecrets('development', ['postgres'], {
521
+ eventsBackend: 'pgboss',
522
+ });
523
+
524
+ expect(secrets.eventsBackend).toBe('pgboss');
525
+ expect(secrets.services.pgboss).toBeDefined();
526
+ expect(secrets.services.pgboss!.username).toBe('pgboss');
527
+ expect(secrets.services.pgboss!.host).toBe(secrets.services.postgres!.host);
528
+ expect(secrets.services.pgboss!.database).toBe(
529
+ secrets.services.postgres!.database,
530
+ );
531
+ expect(secrets.urls.EVENT_PUBLISHER_CONNECTION_STRING).toContain(
532
+ 'pgboss://',
533
+ );
534
+ expect(secrets.urls.EVENT_SUBSCRIBER_CONNECTION_STRING).toContain(
535
+ 'pgboss://',
536
+ );
537
+ });
538
+
539
+ it('should create localstack credentials when eventsBackend is sns', () => {
540
+ const secrets = createStageSecrets('development', [], {
541
+ eventsBackend: 'sns',
542
+ });
543
+
544
+ expect(secrets.eventsBackend).toBe('sns');
545
+ expect(secrets.services.localstack).toBeDefined();
546
+ expect(secrets.services.localstack!.accessKeyId).toMatch(/^LSIA/);
547
+ expect(secrets.urls.EVENT_PUBLISHER_CONNECTION_STRING).toContain('sns://');
548
+ expect(secrets.urls.EVENT_SUBSCRIBER_CONNECTION_STRING).toContain('sqs://');
549
+ });
550
+
551
+ it('should use rabbitmq credentials when eventsBackend is rabbitmq', () => {
552
+ const secrets = createStageSecrets('development', ['rabbitmq'], {
553
+ eventsBackend: 'rabbitmq',
554
+ });
555
+
556
+ expect(secrets.eventsBackend).toBe('rabbitmq');
557
+ expect(secrets.urls.EVENT_PUBLISHER_CONNECTION_STRING).toContain('amqp://');
558
+ expect(secrets.urls.EVENT_SUBSCRIBER_CONNECTION_STRING).toContain(
559
+ 'amqp://',
560
+ );
561
+ });
562
+
563
+ it('should not create event URLs without eventsBackend', () => {
564
+ const secrets = createStageSecrets('development', ['postgres']);
565
+
566
+ expect(secrets.eventsBackend).toBeUndefined();
567
+ expect(secrets.urls.EVENT_PUBLISHER_CONNECTION_STRING).toBeUndefined();
568
+ expect(secrets.urls.EVENT_SUBSCRIBER_CONNECTION_STRING).toBeUndefined();
569
+ });
570
+ });
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import type { ComposeServiceName } from '../types';
2
+ import type { ComposeServiceName, EventsBackend } from '../types';
3
3
  import type { ServiceCredentials, StageSecrets } from './types';
4
4
 
5
5
  /**
@@ -45,6 +45,20 @@ const SERVICE_DEFAULTS: Record<
45
45
  port: 1025,
46
46
  username: 'app',
47
47
  },
48
+ localstack: {
49
+ host: 'localhost',
50
+ port: 4566,
51
+ username: 'localstack',
52
+ region: 'us-east-1',
53
+ },
54
+ };
55
+
56
+ /** Default credentials for pgboss (not a Docker service, reuses postgres) */
57
+ const PGBOSS_DEFAULTS: Omit<ServiceCredentials, 'password'> = {
58
+ host: 'localhost',
59
+ port: 5432,
60
+ username: 'pgboss',
61
+ database: 'app',
48
62
  };
49
63
 
50
64
  /**
@@ -108,11 +122,72 @@ export function generateMinioEndpoint(creds: ServiceCredentials): string {
108
122
  return `http://${host}:${port}`;
109
123
  }
110
124
 
125
+ /**
126
+ * Generate a LocalStack-compatible access key ID.
127
+ * Must start with 'LSIA' prefix and be at least 20 characters.
128
+ * @see https://docs.localstack.cloud/aws/capabilities/config/credentials/
129
+ */
130
+ export function generateLocalStackAccessKeyId(): string {
131
+ const suffix = randomBytes(12).toString('base64url').slice(0, 16);
132
+ return `LSIA${suffix}`;
133
+ }
134
+
135
+ /**
136
+ * Generate connection URL for pg-boss (uses PostgreSQL protocol).
137
+ * Format: pgboss://user:pass@host:port/db?schema=pgboss
138
+ */
139
+ export function generatePgBossUrl(creds: ServiceCredentials): string {
140
+ const { username, password, host, port, database } = creds;
141
+ return `pgboss://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=pgboss`;
142
+ }
143
+
144
+ /**
145
+ * Generate event connection strings based on the events backend.
146
+ */
147
+ export function generateEventConnectionStrings(
148
+ eventsBackend: EventsBackend,
149
+ services: StageSecrets['services'],
150
+ ): { publisher: string; subscriber: string } {
151
+ switch (eventsBackend) {
152
+ case 'pgboss': {
153
+ const creds = services.pgboss;
154
+ if (!creds) {
155
+ throw new Error('pgboss credentials required for pgboss events');
156
+ }
157
+ const url = generatePgBossUrl(creds);
158
+ return { publisher: url, subscriber: url };
159
+ }
160
+ case 'sns': {
161
+ const creds = services.localstack;
162
+ if (!creds) {
163
+ throw new Error('localstack credentials required for sns events');
164
+ }
165
+ const endpoint = `http://${creds.host}:${creds.port}`;
166
+ const region = creds.region ?? 'us-east-1';
167
+ const accessKeyId = creds.accessKeyId ?? creds.username;
168
+ const secretKey = encodeURIComponent(creds.password);
169
+ return {
170
+ publisher: `sns://${accessKeyId}:${secretKey}@${creds.host}:${creds.port}?region=${region}&endpoint=${encodeURIComponent(endpoint)}`,
171
+ subscriber: `sqs://${accessKeyId}:${secretKey}@${creds.host}:${creds.port}?region=${region}&endpoint=${encodeURIComponent(endpoint)}`,
172
+ };
173
+ }
174
+ case 'rabbitmq': {
175
+ const creds = services.rabbitmq;
176
+ if (!creds) {
177
+ throw new Error('rabbitmq credentials required for rabbitmq events');
178
+ }
179
+ const url = generateRabbitmqUrl(creds);
180
+ return { publisher: url, subscriber: url };
181
+ }
182
+ }
183
+ }
184
+
111
185
  /**
112
186
  * Generate connection URLs from service credentials.
113
187
  */
114
188
  export function generateConnectionUrls(
115
189
  services: StageSecrets['services'],
190
+ eventsBackend?: EventsBackend,
116
191
  ): StageSecrets['urls'] {
117
192
  const urls: StageSecrets['urls'] = {};
118
193
 
@@ -132,6 +207,12 @@ export function generateConnectionUrls(
132
207
  urls.STORAGE_ENDPOINT = generateMinioEndpoint(services.minio);
133
208
  }
134
209
 
210
+ if (eventsBackend) {
211
+ const eventUrls = generateEventConnectionStrings(eventsBackend, services);
212
+ urls.EVENT_PUBLISHER_CONNECTION_STRING = eventUrls.publisher;
213
+ urls.EVENT_SUBSCRIBER_CONNECTION_STRING = eventUrls.subscriber;
214
+ }
215
+
135
216
  if (services.mailpit) {
136
217
  urls.SMTP_HOST = services.mailpit.host;
137
218
  urls.SMTP_PORT = String(services.mailpit.port);
@@ -140,17 +221,30 @@ export function generateConnectionUrls(
140
221
  return urls;
141
222
  }
142
223
 
224
+ /**
225
+ * Generate LocalStack service credentials with LSIA-prefixed access key.
226
+ */
227
+ export function generateLocalStackCredentials(): ServiceCredentials {
228
+ const defaults = SERVICE_DEFAULTS.localstack;
229
+ return {
230
+ ...defaults,
231
+ password: generateSecurePassword(),
232
+ accessKeyId: generateLocalStackAccessKeyId(),
233
+ };
234
+ }
235
+
143
236
  /**
144
237
  * Create a new StageSecrets object with generated credentials.
145
238
  * @param stage - The deployment stage (e.g., 'development', 'production')
146
239
  * @param services - List of services to generate credentials for
147
240
  * @param options - Optional configuration
148
241
  * @param options.projectName - Project name used to derive the database name (e.g., 'myapp' → 'myapp_dev')
242
+ * @param options.eventsBackend - Event backend type (pgboss, sns, rabbitmq)
149
243
  */
150
244
  export function createStageSecrets(
151
245
  stage: string,
152
246
  services: ComposeServiceName[],
153
- options?: { projectName?: string },
247
+ options?: { projectName?: string; eventsBackend?: EventsBackend },
154
248
  ): StageSecrets {
155
249
  const now = new Date().toISOString();
156
250
  const serviceCredentials = generateServicesCredentials(services);
@@ -166,12 +260,30 @@ export function createStageSecrets(
166
260
  }
167
261
  }
168
262
 
169
- const urls = generateConnectionUrls(serviceCredentials);
263
+ // Generate event-specific credentials
264
+ const eventsBackend = options?.eventsBackend;
265
+ if (eventsBackend === 'pgboss' && serviceCredentials.postgres) {
266
+ // pgboss reuses postgres host/port/database but with dedicated user
267
+ serviceCredentials.pgboss = {
268
+ ...PGBOSS_DEFAULTS,
269
+ password: generateSecurePassword(),
270
+ host: serviceCredentials.postgres.host,
271
+ port: serviceCredentials.postgres.port,
272
+ database: serviceCredentials.postgres.database,
273
+ };
274
+ }
275
+ if (eventsBackend === 'sns') {
276
+ // LocalStack credentials with LSIA-prefixed access key
277
+ serviceCredentials.localstack = generateLocalStackCredentials();
278
+ }
279
+
280
+ const urls = generateConnectionUrls(serviceCredentials, eventsBackend);
170
281
 
171
282
  return {
172
283
  stage,
173
284
  createdAt: now,
174
285
  updatedAt: now,
286
+ eventsBackend,
175
287
  services: serviceCredentials,
176
288
  urls,
177
289
  custom: {},
@@ -204,6 +316,6 @@ export function rotateServicePassword(
204
316
  ...secrets,
205
317
  updatedAt: new Date().toISOString(),
206
318
  services: newServices,
207
- urls: generateConnectionUrls(newServices),
319
+ urls: generateConnectionUrls(newServices, secrets.eventsBackend),
208
320
  };
209
321
  }
@@ -223,6 +223,18 @@ export function toEmbeddableSecrets(secrets: StageSecrets): EmbeddableSecrets {
223
223
  SMTP_SECURE: 'false',
224
224
  MAIL_FROM: 'noreply@localhost',
225
225
  }),
226
+ ...(secrets.services.localstack && {
227
+ AWS_ACCESS_KEY_ID:
228
+ secrets.services.localstack.accessKeyId ??
229
+ secrets.services.localstack.username,
230
+ AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
231
+ AWS_REGION: secrets.services.localstack.region ?? 'us-east-1',
232
+ AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`,
233
+ }),
234
+ ...(secrets.services.pgboss && {
235
+ PGBOSS_DB_USER: secrets.services.pgboss.username,
236
+ PGBOSS_DB_PASSWORD: secrets.services.pgboss.password,
237
+ }),
226
238
  };
227
239
  }
228
240
 
@@ -1,4 +1,4 @@
1
- import type { ComposeServiceName } from '../types';
1
+ import type { ComposeServiceName, EventsBackend } from '../types';
2
2
 
3
3
  /** Credentials for a specific service */
4
4
  export interface ServiceCredentials {
@@ -12,6 +12,10 @@ export interface ServiceCredentials {
12
12
  vhost?: string;
13
13
  /** Bucket name (for minio) */
14
14
  bucket?: string;
15
+ /** Access key ID (for localstack) */
16
+ accessKeyId?: string;
17
+ /** Region (for localstack) */
18
+ region?: string;
15
19
  }
16
20
 
17
21
  /** Stage secrets configuration */
@@ -22,6 +26,8 @@ export interface StageSecrets {
22
26
  createdAt: string;
23
27
  /** ISO timestamp when secrets were last updated */
24
28
  updatedAt: string;
29
+ /** Event backend type (if events are enabled) */
30
+ eventsBackend?: EventsBackend;
25
31
  /** Service-specific credentials */
26
32
  services: {
27
33
  postgres?: ServiceCredentials;
@@ -29,6 +35,8 @@ export interface StageSecrets {
29
35
  rabbitmq?: ServiceCredentials;
30
36
  minio?: ServiceCredentials;
31
37
  mailpit?: ServiceCredentials;
38
+ localstack?: ServiceCredentials;
39
+ pgboss?: ServiceCredentials;
32
40
  };
33
41
  /** Generated connection URLs */
34
42
  urls: {
@@ -38,6 +46,8 @@ export interface StageSecrets {
38
46
  STORAGE_ENDPOINT?: string;
39
47
  SMTP_HOST?: string;
40
48
  SMTP_PORT?: string;
49
+ EVENT_PUBLISHER_CONNECTION_STRING?: string;
50
+ EVENT_SUBSCRIBER_CONNECTION_STRING?: string;
41
51
  };
42
52
  /** Custom user-defined secrets */
43
53
  custom: Record<string, string>;
@@ -340,3 +340,89 @@ describe('generateFullstackCustomSecrets', () => {
340
340
  expect(result.BETTER_AUTH_TRUSTED_ORIGINS).toBeUndefined();
341
341
  });
342
342
  });
343
+
344
+ describe('reconcileSecrets - events', () => {
345
+ it('should add pgboss credentials when events is pgboss', () => {
346
+ const workspace = createWorkspace({
347
+ services: { db: true, cache: false, events: 'pgboss' },
348
+ });
349
+ const secrets = createSecrets();
350
+
351
+ const result = reconcileSecrets(secrets, workspace);
352
+
353
+ expect(result).not.toBeNull();
354
+ expect(result!.eventsBackend).toBe('pgboss');
355
+ expect(result!.services.pgboss).toBeDefined();
356
+ expect(result!.services.pgboss!.username).toBe('pgboss');
357
+ expect(result!.services.pgboss!.host).toBe('localhost');
358
+ expect(result!.services.pgboss!.database).toBe('test_dev');
359
+ expect(result!.urls.EVENT_PUBLISHER_CONNECTION_STRING).toContain(
360
+ 'pgboss://',
361
+ );
362
+ expect(result!.urls.EVENT_SUBSCRIBER_CONNECTION_STRING).toContain(
363
+ 'pgboss://',
364
+ );
365
+ });
366
+
367
+ it('should add localstack credentials when events is sns', () => {
368
+ const workspace = createWorkspace({
369
+ services: { db: true, cache: false, events: 'sns' },
370
+ });
371
+ const secrets = createSecrets();
372
+
373
+ const result = reconcileSecrets(secrets, workspace);
374
+
375
+ expect(result).not.toBeNull();
376
+ expect(result!.eventsBackend).toBe('sns');
377
+ expect(result!.services.localstack).toBeDefined();
378
+ expect(result!.services.localstack!.accessKeyId).toMatch(/^LSIA/);
379
+ expect(result!.urls.EVENT_PUBLISHER_CONNECTION_STRING).toContain('sns://');
380
+ expect(result!.urls.EVENT_SUBSCRIBER_CONNECTION_STRING).toContain('sqs://');
381
+ });
382
+
383
+ it('should add rabbitmq credentials when events is rabbitmq', () => {
384
+ const workspace = createWorkspace({
385
+ services: { db: true, cache: false, events: 'rabbitmq' },
386
+ });
387
+ const secrets = createSecrets();
388
+
389
+ const result = reconcileSecrets(secrets, workspace);
390
+
391
+ expect(result).not.toBeNull();
392
+ expect(result!.eventsBackend).toBe('rabbitmq');
393
+ expect(result!.services.rabbitmq).toBeDefined();
394
+ expect(result!.urls.EVENT_PUBLISHER_CONNECTION_STRING).toContain('amqp://');
395
+ });
396
+
397
+ it('should not add duplicate pgboss credentials when already present', () => {
398
+ const workspace = createWorkspace({
399
+ services: { db: true, cache: false, events: 'pgboss' },
400
+ });
401
+ const secrets: StageSecrets = {
402
+ ...createSecrets(),
403
+ eventsBackend: 'pgboss',
404
+ services: {
405
+ ...createSecrets().services,
406
+ pgboss: {
407
+ host: 'localhost',
408
+ port: 5432,
409
+ username: 'pgboss',
410
+ password: 'existing-pass',
411
+ database: 'test_dev',
412
+ },
413
+ },
414
+ urls: {
415
+ ...createSecrets().urls,
416
+ EVENT_PUBLISHER_CONNECTION_STRING: 'pgboss://existing',
417
+ EVENT_SUBSCRIBER_CONNECTION_STRING: 'pgboss://existing',
418
+ },
419
+ };
420
+
421
+ const result = reconcileSecrets(secrets, workspace);
422
+
423
+ // pgboss credentials should be preserved (not regenerated)
424
+ if (result) {
425
+ expect(result.services.pgboss!.password).toBe('existing-pass');
426
+ }
427
+ });
428
+ });