@geekmidas/cli 1.10.7 → 1.10.9

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/README.md +44 -1
  3. package/dist/{bundler-NpfYPBUo.cjs → bundler-Bm3Az_sv.cjs} +2 -2
  4. package/dist/{bundler-NpfYPBUo.cjs.map → bundler-Bm3Az_sv.cjs.map} +1 -1
  5. package/dist/{bundler-DQYjKFPm.mjs → bundler-kk_XJTRp.mjs} +2 -2
  6. package/dist/{bundler-DQYjKFPm.mjs.map → bundler-kk_XJTRp.mjs.map} +1 -1
  7. package/dist/config.d.cts +2 -2
  8. package/dist/config.d.mts +2 -2
  9. package/dist/{fullstack-secrets-ca0Kyrvt.mjs → fullstack-secrets-C2lbdbLZ.mjs} +15 -1
  10. package/dist/fullstack-secrets-C2lbdbLZ.mjs.map +1 -0
  11. package/dist/{fullstack-secrets-BctGaE4E.cjs → fullstack-secrets-CtWIYuI0.cjs} +15 -1
  12. package/dist/fullstack-secrets-CtWIYuI0.cjs.map +1 -0
  13. package/dist/{index-9tjTQjFt.d.mts → index-BdJZKXCJ.d.cts} +4 -2
  14. package/dist/index-BdJZKXCJ.d.cts.map +1 -0
  15. package/dist/{index-VOKKO-lm.d.cts → index-DB9VbcCD.d.mts} +4 -2
  16. package/dist/index-DB9VbcCD.d.mts.map +1 -0
  17. package/dist/index.cjs +177 -61
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.mjs +177 -61
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/openapi-BYxAWwok.cjs.map +1 -1
  22. package/dist/openapi-DenF-okj.mjs.map +1 -1
  23. package/dist/openapi.d.cts +1 -1
  24. package/dist/openapi.d.mts +1 -1
  25. package/dist/{reconcile-C5OyCA7V.mjs → reconcile-BnM6FA6g.mjs} +2 -2
  26. package/dist/{reconcile-C5OyCA7V.mjs.map → reconcile-BnM6FA6g.mjs.map} +1 -1
  27. package/dist/{reconcile-TEBsryVn.cjs → reconcile-D6u4HSg8.cjs} +2 -2
  28. package/dist/{reconcile-TEBsryVn.cjs.map → reconcile-D6u4HSg8.cjs.map} +1 -1
  29. package/dist/{storage-DmCbr6DI.mjs → storage-B7H2PPCS.mjs} +8 -1
  30. package/dist/{storage-DmCbr6DI.mjs.map → storage-B7H2PPCS.mjs.map} +1 -1
  31. package/dist/{storage-Dx_jZbq6.mjs → storage-C1FNm2EP.mjs} +1 -1
  32. package/dist/{storage-CoCNe0Pt.cjs → storage-Cs13jkJ9.cjs} +8 -1
  33. package/dist/{storage-CoCNe0Pt.cjs.map → storage-Cs13jkJ9.cjs.map} +1 -1
  34. package/dist/{storage-C7pmBq1u.cjs → storage-D6BGLgWf.cjs} +1 -1
  35. package/dist/{sync-6FoT41G3.mjs → sync-CyGe5f1I.mjs} +1 -1
  36. package/dist/{sync-CbeKrnQV.mjs → sync-CzXruMzP.mjs} +2 -2
  37. package/dist/{sync-CbeKrnQV.mjs.map → sync-CzXruMzP.mjs.map} +1 -1
  38. package/dist/sync-DLlwsrBs.cjs +4 -0
  39. package/dist/{sync-DdkKaHqP.cjs → sync-oCqELfeA.cjs} +2 -2
  40. package/dist/{sync-DdkKaHqP.cjs.map → sync-oCqELfeA.cjs.map} +1 -1
  41. package/dist/{types-C7QJJl9f.d.cts → types-D4MLWXSL.d.cts} +2 -2
  42. package/dist/{types-C7QJJl9f.d.cts.map → types-D4MLWXSL.d.cts.map} +1 -1
  43. package/dist/{types-Iqsq_FIG.d.mts → types-DwpLq_fp.d.mts} +2 -2
  44. package/dist/{types-Iqsq_FIG.d.mts.map → types-DwpLq_fp.d.mts.map} +1 -1
  45. package/dist/workspace/index.d.cts +2 -2
  46. package/dist/workspace/index.d.mts +2 -2
  47. package/dist/workspace-4SP3Gx4Y.cjs.map +1 -1
  48. package/dist/workspace-D4z4A4cq.mjs.map +1 -1
  49. package/package.json +5 -5
  50. package/src/dev/__tests__/index.spec.ts +142 -0
  51. package/src/dev/index.ts +67 -33
  52. package/src/docker/__tests__/compose.spec.ts +151 -2
  53. package/src/docker/compose.ts +105 -8
  54. package/src/init/generators/docker.ts +3 -1
  55. package/src/init/index.ts +1 -0
  56. package/src/init/versions.ts +1 -1
  57. package/src/secrets/__tests__/generator.spec.ts +68 -0
  58. package/src/secrets/__tests__/storage.spec.ts +30 -0
  59. package/src/secrets/generator.ts +18 -0
  60. package/src/secrets/index.ts +9 -0
  61. package/src/secrets/storage.ts +7 -0
  62. package/src/secrets/types.ts +4 -0
  63. package/src/setup/index.ts +1 -0
  64. package/src/test/__tests__/index.spec.ts +115 -0
  65. package/src/test/index.ts +41 -21
  66. package/src/types.ts +1 -1
  67. package/src/workspace/types.ts +2 -0
  68. package/dist/fullstack-secrets-BctGaE4E.cjs.map +0 -1
  69. package/dist/fullstack-secrets-ca0Kyrvt.mjs.map +0 -1
  70. package/dist/index-9tjTQjFt.d.mts.map +0 -1
  71. package/dist/index-VOKKO-lm.d.cts.map +0 -1
  72. package/dist/sync-RsnjXYwG.cjs +0 -4
@@ -13,6 +13,7 @@ export const DEFAULT_SERVICE_IMAGES: Record<ComposeServiceName, string> = {
13
13
  postgres: 'postgres',
14
14
  redis: 'redis',
15
15
  rabbitmq: 'rabbitmq',
16
+ minio: 'minio/minio',
16
17
  };
17
18
 
18
19
  /** Default Docker image versions for services */
@@ -20,6 +21,7 @@ export const DEFAULT_SERVICE_VERSIONS: Record<ComposeServiceName, string> = {
20
21
  postgres: '18-alpine',
21
22
  redis: '7-alpine',
22
23
  rabbitmq: '3-management-alpine',
24
+ minio: 'latest',
23
25
  };
24
26
 
25
27
  export interface ComposeOptions {
@@ -87,7 +89,9 @@ export function generateDockerCompose(options: ComposeOptions): string {
87
89
 
88
90
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
89
91
 
90
- let yaml = `version: '3.8'
92
+ let yaml = `# Use "gkm dev" or "gkm test" to start services.
93
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
94
+ version: '3.8'
91
95
 
92
96
  services:
93
97
  api:
@@ -119,6 +123,16 @@ services:
119
123
  `;
120
124
  }
121
125
 
126
+ if (serviceMap.has('minio')) {
127
+ yaml += ` - S3_ENDPOINT=\${S3_ENDPOINT:-http://minio:9000}
128
+ - S3_ACCESS_KEY_ID=\${MINIO_ACCESS_KEY:-app}
129
+ - S3_SECRET_ACCESS_KEY=\${MINIO_SECRET_KEY:-app}
130
+ - S3_BUCKET=\${MINIO_BUCKET:-app}
131
+ - S3_REGION=\${S3_REGION:-eu-west-1}
132
+ - S3_FORCE_PATH_STYLE=true
133
+ `;
134
+ }
135
+
122
136
  yaml += ` healthcheck:
123
137
  test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
124
138
  interval: 30s
@@ -208,6 +222,32 @@ services:
208
222
  `;
209
223
  }
210
224
 
225
+ const minioImage = serviceMap.get('minio');
226
+ if (minioImage) {
227
+ yaml += `
228
+ minio:
229
+ image: ${minioImage}
230
+ container_name: minio
231
+ restart: unless-stopped
232
+ entrypoint: sh
233
+ command: -c 'mkdir -p /data/\${MINIO_BUCKET:-app} && /usr/bin/docker-entrypoint.sh server --console-address ":9001" /data'
234
+ environment:
235
+ MINIO_ROOT_USER: \${MINIO_ACCESS_KEY:-app}
236
+ MINIO_ROOT_PASSWORD: \${MINIO_SECRET_KEY:-app}
237
+ ports:
238
+ - "9001:9001" # Console UI
239
+ volumes:
240
+ - minio_data:/data
241
+ healthcheck:
242
+ test: ["CMD", "mc", "ready", "local"]
243
+ interval: 10s
244
+ timeout: 5s
245
+ retries: 5
246
+ networks:
247
+ - app-network
248
+ `;
249
+ }
250
+
211
251
  // Add volumes
212
252
  yaml += `
213
253
  volumes:
@@ -228,6 +268,11 @@ volumes:
228
268
  `;
229
269
  }
230
270
 
271
+ if (serviceMap.has('minio')) {
272
+ yaml += ` minio_data:
273
+ `;
274
+ }
275
+
231
276
  // Add networks
232
277
  yaml += `
233
278
  networks:
@@ -248,7 +293,9 @@ export function generateMinimalDockerCompose(
248
293
 
249
294
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
250
295
 
251
- return `version: '3.8'
296
+ return `# Use "gkm dev" or "gkm test" to start services.
297
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
298
+ version: '3.8'
252
299
 
253
300
  services:
254
301
  api:
@@ -301,13 +348,16 @@ export function generateWorkspaceCompose(
301
348
  const hasPostgres = services.db !== undefined && services.db !== false;
302
349
  const hasRedis = services.cache !== undefined && services.cache !== false;
303
350
  const hasMail = services.mail !== undefined && services.mail !== false;
351
+ const hasMinio = services.storage !== undefined && services.storage !== false;
304
352
 
305
353
  // Get image versions from config
306
354
  const postgresImage = getInfraServiceImage('postgres', services.db);
307
355
  const redisImage = getInfraServiceImage('redis', services.cache);
356
+ const minioImage = getInfraServiceImage('minio', services.storage);
308
357
 
309
358
  let yaml = `# Docker Compose for ${workspace.name} workspace
310
- # Generated by gkm - do not edit manually
359
+ # Use "gkm dev" or "gkm test" to start services.
360
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
311
361
 
312
362
  services:
313
363
  `;
@@ -318,6 +368,7 @@ services:
318
368
  registry,
319
369
  hasPostgres,
320
370
  hasRedis,
371
+ hasMinio,
321
372
  });
322
373
  }
323
374
 
@@ -376,6 +427,31 @@ services:
376
427
  `;
377
428
  }
378
429
 
430
+ if (hasMinio) {
431
+ yaml += `
432
+ minio:
433
+ image: ${minioImage}
434
+ container_name: ${workspace.name}-minio
435
+ restart: unless-stopped
436
+ entrypoint: sh
437
+ command: -c 'mkdir -p /data/\${MINIO_BUCKET:-app} && /usr/bin/docker-entrypoint.sh server --console-address ":9001" /data'
438
+ environment:
439
+ MINIO_ROOT_USER: \${MINIO_ACCESS_KEY:-app}
440
+ MINIO_ROOT_PASSWORD: \${MINIO_SECRET_KEY:-app}
441
+ ports:
442
+ - "9001:9001" # Console UI
443
+ volumes:
444
+ - minio_data:/data
445
+ healthcheck:
446
+ test: ["CMD", "mc", "ready", "local"]
447
+ interval: 10s
448
+ timeout: 5s
449
+ retries: 5
450
+ networks:
451
+ - workspace-network
452
+ `;
453
+ }
454
+
379
455
  // Add volumes section
380
456
  yaml += `
381
457
  volumes:
@@ -391,6 +467,11 @@ volumes:
391
467
  `;
392
468
  }
393
469
 
470
+ if (hasMinio) {
471
+ yaml += ` minio_data:
472
+ `;
473
+ }
474
+
394
475
  // Add networks section
395
476
  yaml += `
396
477
  networks:
@@ -405,12 +486,13 @@ networks:
405
486
  * Get infrastructure service image with version.
406
487
  */
407
488
  function getInfraServiceImage(
408
- serviceName: 'postgres' | 'redis',
489
+ serviceName: 'postgres' | 'redis' | 'minio',
409
490
  config: boolean | { version?: string; image?: string } | undefined,
410
491
  ): string {
411
- const defaults: Record<'postgres' | 'redis', string> = {
492
+ const defaults: Record<'postgres' | 'redis' | 'minio', string> = {
412
493
  postgres: 'postgres:18-alpine',
413
494
  redis: 'redis:7-alpine',
495
+ minio: 'minio/minio:latest',
414
496
  };
415
497
 
416
498
  if (!config || config === true) {
@@ -422,8 +504,12 @@ function getInfraServiceImage(
422
504
  return config.image;
423
505
  }
424
506
  if (config.version) {
425
- const baseImage = serviceName === 'postgres' ? 'postgres' : 'redis';
426
- return `${baseImage}:${config.version}`;
507
+ const baseImages: Record<'postgres' | 'redis' | 'minio', string> = {
508
+ postgres: 'postgres',
509
+ redis: 'redis',
510
+ minio: 'minio/minio',
511
+ };
512
+ return `${baseImages[serviceName]}:${config.version}`;
427
513
  }
428
514
  }
429
515
 
@@ -441,9 +527,10 @@ function generateAppService(
441
527
  registry?: string;
442
528
  hasPostgres: boolean;
443
529
  hasRedis: boolean;
530
+ hasMinio: boolean;
444
531
  },
445
532
  ): string {
446
- const { registry, hasPostgres, hasRedis } = options;
533
+ const { registry, hasPostgres, hasRedis, hasMinio } = options;
447
534
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
448
535
 
449
536
  // Health check path - frontends use /, backends use /health
@@ -485,6 +572,15 @@ function generateAppService(
485
572
  }
486
573
  if (hasRedis) {
487
574
  yaml += ` - REDIS_URL=\${REDIS_URL:-redis://redis:6379}
575
+ `;
576
+ }
577
+ if (hasMinio) {
578
+ yaml += ` - S3_ENDPOINT=\${S3_ENDPOINT:-http://minio:9000}
579
+ - S3_ACCESS_KEY_ID=\${MINIO_ACCESS_KEY:-app}
580
+ - S3_SECRET_ACCESS_KEY=\${MINIO_SECRET_KEY:-app}
581
+ - S3_BUCKET=\${MINIO_BUCKET:-app}
582
+ - S3_REGION=\${S3_REGION:-eu-west-1}
583
+ - S3_FORCE_PATH_STYLE=true
488
584
  `;
489
585
  }
490
586
  }
@@ -501,6 +597,7 @@ function generateAppService(
501
597
  if (app.type === 'backend') {
502
598
  if (hasPostgres) dependencies.push('postgres');
503
599
  if (hasRedis) dependencies.push('redis');
600
+ if (hasMinio) dependencies.push('minio');
504
601
  }
505
602
 
506
603
  if (dependencies.length > 0) {
@@ -161,7 +161,9 @@ export function generateDockerFiles(
161
161
  }
162
162
 
163
163
  // Build docker-compose.yml
164
- let dockerCompose = `services:
164
+ let dockerCompose = `# Use "gkm dev" or "gkm test" to start services.
165
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
166
+ services:
165
167
  ${services.join('\n\n')}
166
168
  `;
167
169
 
package/src/init/index.ts CHANGED
@@ -330,6 +330,7 @@ export async function initCommand(
330
330
  const secretServices: ComposeServiceName[] = [];
331
331
  if (services.db) secretServices.push('postgres');
332
332
  if (services.cache) secretServices.push('redis');
333
+ if (services.storage) secretServices.push('minio');
333
334
 
334
335
  const devSecrets = createStageSecrets('development', secretServices, {
335
336
  projectName: name,
@@ -45,7 +45,7 @@ export const GEEKMIDAS_VERSIONS = {
45
45
  '@geekmidas/storage': '~2.0.0',
46
46
  '@geekmidas/studio': '~1.0.0',
47
47
  '@geekmidas/telescope': '~1.0.0',
48
- '@geekmidas/testkit': '~1.0.2',
48
+ '@geekmidas/testkit': '~1.0.5',
49
49
  '@geekmidas/cli': CLI_VERSION,
50
50
  } as const;
51
51
 
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  createStageSecrets,
4
4
  generateConnectionUrls,
5
+ generateMinioEndpoint,
5
6
  generatePostgresUrl,
6
7
  generateRabbitmqUrl,
7
8
  generateRedisUrl,
@@ -65,6 +66,16 @@ describe('generateServiceCredentials', () => {
65
66
  expect(creds.vhost).toBe('/');
66
67
  expect(creds.password).toHaveLength(32);
67
68
  });
69
+
70
+ it('should generate minio credentials with defaults', () => {
71
+ const creds = generateServiceCredentials('minio');
72
+
73
+ expect(creds.host).toBe('localhost');
74
+ expect(creds.port).toBe(9000);
75
+ expect(creds.username).toBe('app');
76
+ expect(creds.bucket).toBe('app');
77
+ expect(creds.password).toHaveLength(32);
78
+ });
68
79
  });
69
80
 
70
81
  describe('generateServicesCredentials', () => {
@@ -142,6 +153,33 @@ describe('generateRedisUrl', () => {
142
153
  });
143
154
  });
144
155
 
156
+ describe('generateMinioEndpoint', () => {
157
+ it('should generate valid minio endpoint URL', () => {
158
+ const creds: ServiceCredentials = {
159
+ host: 'localhost',
160
+ port: 9000,
161
+ username: 'app',
162
+ password: 'secret123',
163
+ bucket: 'my-bucket',
164
+ };
165
+
166
+ const url = generateMinioEndpoint(creds);
167
+ expect(url).toBe('http://localhost:9000');
168
+ });
169
+
170
+ it('should use custom host and port', () => {
171
+ const creds: ServiceCredentials = {
172
+ host: 'minio.example.com',
173
+ port: 9090,
174
+ username: 'app',
175
+ password: 'secret',
176
+ };
177
+
178
+ const url = generateMinioEndpoint(creds);
179
+ expect(url).toBe('http://minio.example.com:9090');
180
+ });
181
+ });
182
+
145
183
  describe('generateRabbitmqUrl', () => {
146
184
  it('should generate valid rabbitmq URL with default vhost', () => {
147
185
  const creds: ServiceCredentials = {
@@ -209,11 +247,34 @@ describe('generateConnectionUrls', () => {
209
247
  password: 'rmq-pass',
210
248
  vhost: '/',
211
249
  },
250
+ minio: {
251
+ host: 'minio',
252
+ port: 9000,
253
+ username: 'app',
254
+ password: 'minio-pass',
255
+ bucket: 'app',
256
+ },
212
257
  });
213
258
 
214
259
  expect(urls.DATABASE_URL).toBeDefined();
215
260
  expect(urls.REDIS_URL).toBeDefined();
216
261
  expect(urls.RABBITMQ_URL).toBeDefined();
262
+ expect(urls.S3_ENDPOINT).toBe('http://minio:9000');
263
+ });
264
+
265
+ it('should generate S3_ENDPOINT for minio', () => {
266
+ const urls = generateConnectionUrls({
267
+ minio: {
268
+ host: 'localhost',
269
+ port: 9000,
270
+ username: 'app',
271
+ password: 'secret',
272
+ bucket: 'my-bucket',
273
+ },
274
+ });
275
+
276
+ expect(urls.S3_ENDPOINT).toBe('http://localhost:9000');
277
+ expect(urls.DATABASE_URL).toBeUndefined();
217
278
  });
218
279
  });
219
280
 
@@ -246,6 +307,13 @@ describe('createStageSecrets', () => {
246
307
  expect(secrets.urls.REDIS_URL).toBeDefined();
247
308
  expect(secrets.urls.RABBITMQ_URL).toBeDefined();
248
309
  });
310
+
311
+ it('should generate S3_ENDPOINT for minio', () => {
312
+ const secrets = createStageSecrets('production', ['minio']);
313
+
314
+ expect(secrets.services.minio).toBeDefined();
315
+ expect(secrets.urls.S3_ENDPOINT).toBe('http://localhost:9000');
316
+ });
249
317
  });
250
318
 
251
319
  describe('rotateServicePassword', () => {
@@ -338,6 +338,36 @@ describe('toEmbeddableSecrets', () => {
338
338
  expect(embeddable.RABBITMQ_VHOST).toBe('/myapp');
339
339
  });
340
340
 
341
+ it('should include minio service credentials', () => {
342
+ const secrets: StageSecrets = {
343
+ stage: 'production',
344
+ createdAt: new Date().toISOString(),
345
+ updatedAt: new Date().toISOString(),
346
+ services: {
347
+ minio: {
348
+ host: 'localhost',
349
+ port: 9000,
350
+ username: 'myaccesskey',
351
+ password: 'mysecretkey',
352
+ bucket: 'my-bucket',
353
+ },
354
+ },
355
+ urls: {
356
+ S3_ENDPOINT: 'http://localhost:9000',
357
+ },
358
+ custom: {},
359
+ };
360
+
361
+ const embeddable = toEmbeddableSecrets(secrets);
362
+
363
+ expect(embeddable.S3_ENDPOINT).toBe('http://localhost:9000');
364
+ expect(embeddable.S3_ACCESS_KEY_ID).toBe('myaccesskey');
365
+ expect(embeddable.S3_SECRET_ACCESS_KEY).toBe('mysecretkey');
366
+ expect(embeddable.S3_BUCKET).toBe('my-bucket');
367
+ expect(embeddable.S3_REGION).toBe('eu-west-1');
368
+ expect(embeddable.S3_FORCE_PATH_STYLE).toBe('true');
369
+ });
370
+
341
371
  it('should handle all services and custom secrets together', () => {
342
372
  const secrets: StageSecrets = {
343
373
  stage: 'production',
@@ -34,6 +34,12 @@ const SERVICE_DEFAULTS: Record<
34
34
  username: 'app',
35
35
  vhost: '/',
36
36
  },
37
+ minio: {
38
+ host: 'localhost',
39
+ port: 9000,
40
+ username: 'app',
41
+ bucket: 'app',
42
+ },
37
43
  };
38
44
 
39
45
  /**
@@ -89,6 +95,14 @@ export function generateRabbitmqUrl(creds: ServiceCredentials): string {
89
95
  return `amqp://${username}:${encodeURIComponent(password)}@${host}:${port}/${encodedVhost}`;
90
96
  }
91
97
 
98
+ /**
99
+ * Generate endpoint URL for MinIO (S3-compatible).
100
+ */
101
+ export function generateMinioEndpoint(creds: ServiceCredentials): string {
102
+ const { host, port } = creds;
103
+ return `http://${host}:${port}`;
104
+ }
105
+
92
106
  /**
93
107
  * Generate connection URLs from service credentials.
94
108
  */
@@ -109,6 +123,10 @@ export function generateConnectionUrls(
109
123
  urls.RABBITMQ_URL = generateRabbitmqUrl(services.rabbitmq);
110
124
  }
111
125
 
126
+ if (services.minio) {
127
+ urls.S3_ENDPOINT = generateMinioEndpoint(services.minio);
128
+ }
129
+
112
130
  return urls;
113
131
  }
114
132
 
@@ -129,6 +129,9 @@ export async function secretsInitCommand(
129
129
  if (secrets.urls.RABBITMQ_URL) {
130
130
  logger.log(` RABBITMQ_URL: ${maskUrl(secrets.urls.RABBITMQ_URL)}`);
131
131
  }
132
+ if (secrets.urls.S3_ENDPOINT) {
133
+ logger.log(` S3_ENDPOINT: ${secrets.urls.S3_ENDPOINT}`);
134
+ }
132
135
 
133
136
  if (Object.keys(secrets.custom).length > 0) {
134
137
  logger.log(`\n Custom secrets: ${Object.keys(secrets.custom).length}`);
@@ -234,6 +237,9 @@ export async function secretsShowCommand(
234
237
  if (creds.vhost) {
235
238
  logger.log(` vhost: ${creds.vhost}`);
236
239
  }
240
+ if (creds.bucket) {
241
+ logger.log(` bucket: ${creds.bucket}`);
242
+ }
237
243
  }
238
244
  }
239
245
 
@@ -254,6 +260,9 @@ export async function secretsShowCommand(
254
260
  ` RABBITMQ_URL: ${reveal ? secrets.urls.RABBITMQ_URL : maskUrl(secrets.urls.RABBITMQ_URL)}`,
255
261
  );
256
262
  }
263
+ if (secrets.urls.S3_ENDPOINT) {
264
+ logger.log(` S3_ENDPOINT: ${secrets.urls.S3_ENDPOINT}`);
265
+ }
257
266
 
258
267
  // Show custom secrets
259
268
  const customKeys = Object.keys(secrets.custom);
@@ -208,6 +208,13 @@ export function toEmbeddableSecrets(secrets: StageSecrets): EmbeddableSecrets {
208
208
  RABBITMQ_PORT: String(secrets.services.rabbitmq.port),
209
209
  RABBITMQ_VHOST: secrets.services.rabbitmq.vhost ?? '/',
210
210
  }),
211
+ ...(secrets.services.minio && {
212
+ S3_ACCESS_KEY_ID: secrets.services.minio.username,
213
+ S3_SECRET_ACCESS_KEY: secrets.services.minio.password,
214
+ S3_BUCKET: secrets.services.minio.bucket ?? 'app',
215
+ S3_REGION: 'eu-west-1',
216
+ S3_FORCE_PATH_STYLE: 'true',
217
+ }),
211
218
  };
212
219
  }
213
220
 
@@ -10,6 +10,8 @@ export interface ServiceCredentials {
10
10
  database?: string;
11
11
  /** Virtual host (for rabbitmq) */
12
12
  vhost?: string;
13
+ /** Bucket name (for minio) */
14
+ bucket?: string;
13
15
  }
14
16
 
15
17
  /** Stage secrets configuration */
@@ -25,12 +27,14 @@ export interface StageSecrets {
25
27
  postgres?: ServiceCredentials;
26
28
  redis?: ServiceCredentials;
27
29
  rabbitmq?: ServiceCredentials;
30
+ minio?: ServiceCredentials;
28
31
  };
29
32
  /** Generated connection URLs */
30
33
  urls: {
31
34
  DATABASE_URL?: string;
32
35
  REDIS_URL?: string;
33
36
  RABBITMQ_URL?: string;
37
+ S3_ENDPOINT?: string;
34
38
  };
35
39
  /** Custom user-defined secrets */
36
40
  custom: Record<string, string>;
@@ -194,6 +194,7 @@ async function generateFreshSecrets(
194
194
  const serviceNames: ComposeServiceName[] = [];
195
195
  if (workspace.services.db) serviceNames.push('postgres');
196
196
  if (workspace.services.cache) serviceNames.push('redis');
197
+ if (workspace.services.storage) serviceNames.push('minio');
197
198
 
198
199
  // Create base secrets with service credentials
199
200
  const secrets = createStageSecrets(stage, serviceNames, {
@@ -18,9 +18,11 @@ import {
18
18
  vi,
19
19
  } from 'vitest';
20
20
  import {
21
+ buildDockerComposeEnv,
21
22
  createCredentialsPreload,
22
23
  loadPortState,
23
24
  parseComposePortMappings,
25
+ resolveServicePorts,
24
26
  rewriteUrlsWithPorts,
25
27
  savePortState,
26
28
  } from '../../dev/index';
@@ -388,3 +390,116 @@ services:
388
390
  );
389
391
  });
390
392
  });
393
+
394
+ describe('test command Docker startup pipeline', () => {
395
+ let testDir: string;
396
+
397
+ beforeAll(() => {
398
+ vi.spyOn(console, 'log').mockImplementation(() => {});
399
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
400
+ });
401
+
402
+ afterAll(() => {
403
+ vi.restoreAllMocks();
404
+ });
405
+
406
+ beforeEach(() => {
407
+ testDir = join(tmpdir(), `gkm-test-docker-${Date.now()}`);
408
+ mkdirSync(testDir, { recursive: true });
409
+ });
410
+
411
+ afterEach(() => {
412
+ rmSync(testDir, { recursive: true, force: true });
413
+ });
414
+
415
+ it('should build docker compose env with secrets so credentials are interpolated', () => {
416
+ const secretsEnv = {
417
+ POSTGRES_USER: 'app',
418
+ POSTGRES_PASSWORD: 'supersecret',
419
+ POSTGRES_DB: 'myapp',
420
+ };
421
+ const portEnv = { POSTGRES_HOST_PORT: '5434' };
422
+
423
+ // This is the env that gets passed to `docker compose up -d`
424
+ const env = buildDockerComposeEnv(secretsEnv, portEnv);
425
+
426
+ // Secrets are available so ${POSTGRES_USER:-postgres} resolves correctly
427
+ expect(env.POSTGRES_USER).toBe('app');
428
+ expect(env.POSTGRES_PASSWORD).toBe('supersecret');
429
+ expect(env.POSTGRES_DB).toBe('myapp');
430
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
431
+ });
432
+
433
+ it('should resolve ports, rewrite URLs and hostnames, then append _test', async () => {
434
+ writeFileSync(
435
+ join(testDir, 'docker-compose.yml'),
436
+ `
437
+ services:
438
+ postgres:
439
+ image: postgres:18
440
+ ports:
441
+ - '\${POSTGRES_HOST_PORT:-5432}:5432'
442
+ redis:
443
+ image: redis:7
444
+ ports:
445
+ - '\${REDIS_HOST_PORT:-6379}:6379'
446
+ `,
447
+ );
448
+
449
+ // Simulate secrets loaded from encrypted store (Docker hostnames)
450
+ const secretsEnv: Record<string, string> = {
451
+ DATABASE_URL: 'postgresql://app:supersecret@postgres:5432/myapp',
452
+ REDIS_URL: 'redis://:redispass@redis:6379',
453
+ POSTGRES_USER: 'app',
454
+ POSTGRES_PASSWORD: 'supersecret',
455
+ POSTGRES_DB: 'myapp',
456
+ POSTGRES_HOST: 'postgres',
457
+ POSTGRES_PORT: '5432',
458
+ REDIS_PASSWORD: 'redispass',
459
+ REDIS_HOST: 'redis',
460
+ REDIS_PORT: '6379',
461
+ };
462
+
463
+ // Step 1: Resolve ports
464
+ const resolvedPorts = await resolveServicePorts(testDir);
465
+
466
+ // Step 2: Build env for docker compose (secrets + ports)
467
+ const dockerEnv = buildDockerComposeEnv(
468
+ secretsEnv,
469
+ resolvedPorts.dockerEnv,
470
+ );
471
+ expect(dockerEnv.POSTGRES_USER).toBe('app');
472
+ expect(dockerEnv.POSTGRES_PASSWORD).toBe('supersecret');
473
+
474
+ // Step 3: Rewrite URLs with ports and hostnames
475
+ const rewritten = rewriteUrlsWithPorts(secretsEnv, resolvedPorts);
476
+
477
+ // Step 4: Apply test suffix
478
+ const final = rewriteDatabaseUrlForTests(rewritten);
479
+
480
+ // Hostnames should be rewritten to localhost
481
+ expect(final.POSTGRES_HOST).toBe('localhost');
482
+ expect(final.REDIS_HOST).toBe('localhost');
483
+
484
+ // DATABASE_URL should have localhost and _test suffix
485
+ expect(final.DATABASE_URL).toContain('@localhost:');
486
+ expect(final.DATABASE_URL).toMatch(/\/myapp_test$/);
487
+
488
+ // REDIS_URL should have localhost
489
+ expect(final.REDIS_URL).toContain('@localhost:');
490
+ });
491
+
492
+ it('should not default to postgres:postgres when secrets are provided', () => {
493
+ const secretsEnv = {
494
+ POSTGRES_USER: 'myapp',
495
+ POSTGRES_PASSWORD: 'strongpass123',
496
+ };
497
+
498
+ const env = buildDockerComposeEnv(secretsEnv, {});
499
+
500
+ // The key assertion: secrets are in env so Docker Compose
501
+ // ${POSTGRES_USER:-postgres} resolves to 'myapp', not 'postgres'
502
+ expect(env.POSTGRES_USER).toBe('myapp');
503
+ expect(env.POSTGRES_PASSWORD).toBe('strongpass123');
504
+ });
505
+ });