@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.
- package/CHANGELOG.md +6 -0
- package/dist/{bundler-BWsVDer6.mjs → bundler-B4AackW5.mjs} +2 -2
- package/dist/{bundler-BWsVDer6.mjs.map → bundler-B4AackW5.mjs.map} +1 -1
- package/dist/{bundler-Drh5KoN5.cjs → bundler-BhhfkI9T.cjs} +2 -2
- package/dist/{bundler-Drh5KoN5.cjs.map → bundler-BhhfkI9T.cjs.map} +1 -1
- package/dist/config.d.cts +2 -2
- package/dist/config.d.mts +2 -2
- package/dist/{fullstack-secrets-D9rjTNyx.cjs → fullstack-secrets-DOHBU4Rp.cjs} +110 -4
- package/dist/fullstack-secrets-DOHBU4Rp.cjs.map +1 -0
- package/dist/{fullstack-secrets-BIFFv4UZ.mjs → fullstack-secrets-x2Kffx7-.mjs} +99 -5
- package/dist/fullstack-secrets-x2Kffx7-.mjs.map +1 -0
- package/dist/{index-UCsZ_Vkw.d.cts → index-BkibYzso.d.cts} +15 -4
- package/dist/index-BkibYzso.d.cts.map +1 -0
- package/dist/{index-gXAGDSGu.d.mts → index-CY-ieuRp.d.mts} +15 -4
- package/dist/index-CY-ieuRp.d.mts.map +1 -0
- package/dist/index.cjs +322 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +322 -46
- package/dist/index.mjs.map +1 -1
- package/dist/openapi-BYxAWwok.cjs.map +1 -1
- package/dist/openapi-DenF-okj.mjs.map +1 -1
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/{reconcile-DxTEausy.mjs → reconcile-BLh6rswz.mjs} +2 -2
- package/dist/{reconcile-DxTEausy.mjs.map → reconcile-BLh6rswz.mjs.map} +1 -1
- package/dist/{reconcile-LaaJkFlO.cjs → reconcile-Ch7sIcf8.cjs} +2 -2
- package/dist/{reconcile-LaaJkFlO.cjs.map → reconcile-Ch7sIcf8.cjs.map} +1 -1
- package/dist/{storage-Bu44pwPJ.cjs → storage-B1wvztiJ.cjs} +11 -1
- package/dist/{storage-clMAp4sc.mjs.map → storage-B1wvztiJ.cjs.map} +1 -1
- package/dist/{storage-CauTheT9.mjs → storage-Cs4WBsc4.mjs} +1 -1
- package/dist/{storage-DpqzcjQ5.cjs → storage-DOEtT2Hr.cjs} +1 -1
- package/dist/{storage-clMAp4sc.mjs → storage-dbb9RyBl.mjs} +11 -1
- package/dist/{storage-Bu44pwPJ.cjs.map → storage-dbb9RyBl.mjs.map} +1 -1
- package/dist/{sync-BkalF65h.mjs → sync-COnAugP-.mjs} +1 -1
- package/dist/sync-D1Pa30oV.cjs +4 -0
- package/dist/{sync-BeiI5rFC.cjs → sync-DGXXSk2v.cjs} +2 -2
- package/dist/{sync-BeiI5rFC.cjs.map → sync-DGXXSk2v.cjs.map} +1 -1
- package/dist/{sync-CWJ6tL0s.mjs → sync-D_NowTkZ.mjs} +2 -2
- package/dist/{sync-CWJ6tL0s.mjs.map → sync-D_NowTkZ.mjs.map} +1 -1
- package/dist/{types-DiV9Mbvc.d.mts → types-DdHfUbxk.d.cts} +13 -3
- package/dist/types-DdHfUbxk.d.cts.map +1 -0
- package/dist/{types-JvWj5Ckc.d.cts → types-OszPdw9m.d.mts} +13 -3
- package/dist/types-OszPdw9m.d.mts.map +1 -0
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace-4SP3Gx4Y.cjs.map +1 -1
- package/dist/workspace-D4z4A4cq.mjs.map +1 -1
- package/package.json +3 -3
- package/src/dev/__tests__/entry.spec.ts +3 -5
- package/src/dev/__tests__/index.spec.ts +5 -5
- package/src/dev/index.ts +10 -10
- package/src/docker/compose.ts +130 -2
- package/src/init/__tests__/generators.spec.ts +84 -0
- package/src/init/generators/docker.ts +128 -16
- package/src/init/index.ts +26 -1
- package/src/init/templates/index.ts +28 -0
- package/src/secrets/__tests__/generator.spec.ts +183 -0
- package/src/secrets/generator.ts +116 -4
- package/src/secrets/storage.ts +12 -0
- package/src/secrets/types.ts +11 -1
- package/src/setup/__tests__/reconcile-secrets.spec.ts +86 -0
- package/src/setup/index.ts +64 -1
- package/src/test/__tests__/index.spec.ts +1 -4
- package/src/types.ts +13 -1
- package/src/workspace/types.ts +13 -2
- package/dist/fullstack-secrets-BIFFv4UZ.mjs.map +0 -1
- package/dist/fullstack-secrets-D9rjTNyx.cjs.map +0 -1
- package/dist/index-UCsZ_Vkw.d.cts.map +0 -1
- package/dist/index-gXAGDSGu.d.mts.map +0 -1
- package/dist/sync-Bp8xRcuQ.cjs +0 -4
- package/dist/types-DiV9Mbvc.d.mts.map +0 -1
- 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
|
+
});
|
package/src/secrets/generator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/secrets/storage.ts
CHANGED
|
@@ -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
|
|
package/src/secrets/types.ts
CHANGED
|
@@ -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
|
+
});
|