@geekmidas/cli 1.10.8 → 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 (68) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/{bundler-NpfYPBUo.cjs → bundler-Bm3Az_sv.cjs} +2 -2
  3. package/dist/{bundler-NpfYPBUo.cjs.map → bundler-Bm3Az_sv.cjs.map} +1 -1
  4. package/dist/{bundler-DQYjKFPm.mjs → bundler-kk_XJTRp.mjs} +2 -2
  5. package/dist/{bundler-DQYjKFPm.mjs.map → bundler-kk_XJTRp.mjs.map} +1 -1
  6. package/dist/config.d.cts +2 -2
  7. package/dist/config.d.mts +2 -2
  8. package/dist/{fullstack-secrets-ca0Kyrvt.mjs → fullstack-secrets-C2lbdbLZ.mjs} +15 -1
  9. package/dist/fullstack-secrets-C2lbdbLZ.mjs.map +1 -0
  10. package/dist/{fullstack-secrets-BctGaE4E.cjs → fullstack-secrets-CtWIYuI0.cjs} +15 -1
  11. package/dist/fullstack-secrets-CtWIYuI0.cjs.map +1 -0
  12. package/dist/{index-9tjTQjFt.d.mts → index-BdJZKXCJ.d.cts} +4 -2
  13. package/dist/index-BdJZKXCJ.d.cts.map +1 -0
  14. package/dist/{index-VOKKO-lm.d.cts → index-DB9VbcCD.d.mts} +4 -2
  15. package/dist/index-DB9VbcCD.d.mts.map +1 -0
  16. package/dist/index.cjs +126 -37
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +126 -37
  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-C5OyCA7V.mjs → reconcile-BnM6FA6g.mjs} +2 -2
  25. package/dist/{reconcile-C5OyCA7V.mjs.map → reconcile-BnM6FA6g.mjs.map} +1 -1
  26. package/dist/{reconcile-TEBsryVn.cjs → reconcile-D6u4HSg8.cjs} +2 -2
  27. package/dist/{reconcile-TEBsryVn.cjs.map → reconcile-D6u4HSg8.cjs.map} +1 -1
  28. package/dist/{storage-DmCbr6DI.mjs → storage-B7H2PPCS.mjs} +8 -1
  29. package/dist/{storage-DmCbr6DI.mjs.map → storage-B7H2PPCS.mjs.map} +1 -1
  30. package/dist/{storage-Dx_jZbq6.mjs → storage-C1FNm2EP.mjs} +1 -1
  31. package/dist/{storage-CoCNe0Pt.cjs → storage-Cs13jkJ9.cjs} +8 -1
  32. package/dist/{storage-CoCNe0Pt.cjs.map → storage-Cs13jkJ9.cjs.map} +1 -1
  33. package/dist/{storage-C7pmBq1u.cjs → storage-D6BGLgWf.cjs} +1 -1
  34. package/dist/{sync-6FoT41G3.mjs → sync-CyGe5f1I.mjs} +1 -1
  35. package/dist/{sync-CbeKrnQV.mjs → sync-CzXruMzP.mjs} +2 -2
  36. package/dist/{sync-CbeKrnQV.mjs.map → sync-CzXruMzP.mjs.map} +1 -1
  37. package/dist/sync-DLlwsrBs.cjs +4 -0
  38. package/dist/{sync-DdkKaHqP.cjs → sync-oCqELfeA.cjs} +2 -2
  39. package/dist/{sync-DdkKaHqP.cjs.map → sync-oCqELfeA.cjs.map} +1 -1
  40. package/dist/{types-C7QJJl9f.d.cts → types-D4MLWXSL.d.cts} +2 -2
  41. package/dist/{types-C7QJJl9f.d.cts.map → types-D4MLWXSL.d.cts.map} +1 -1
  42. package/dist/{types-Iqsq_FIG.d.mts → types-DwpLq_fp.d.mts} +2 -2
  43. package/dist/{types-Iqsq_FIG.d.mts.map → types-DwpLq_fp.d.mts.map} +1 -1
  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 +6 -6
  49. package/src/dev/__tests__/index.spec.ts +73 -0
  50. package/src/dev/index.ts +42 -26
  51. package/src/docker/__tests__/compose.spec.ts +145 -1
  52. package/src/docker/compose.ts +97 -5
  53. package/src/init/index.ts +1 -0
  54. package/src/init/versions.ts +1 -1
  55. package/src/secrets/__tests__/generator.spec.ts +68 -0
  56. package/src/secrets/__tests__/storage.spec.ts +30 -0
  57. package/src/secrets/generator.ts +18 -0
  58. package/src/secrets/index.ts +9 -0
  59. package/src/secrets/storage.ts +7 -0
  60. package/src/secrets/types.ts +4 -0
  61. package/src/setup/index.ts +1 -0
  62. package/src/types.ts +1 -1
  63. package/src/workspace/types.ts +2 -0
  64. package/dist/fullstack-secrets-BctGaE4E.cjs.map +0 -1
  65. package/dist/fullstack-secrets-ca0Kyrvt.mjs.map +0 -1
  66. package/dist/index-9tjTQjFt.d.mts.map +0 -1
  67. package/dist/index-VOKKO-lm.d.cts.map +0 -1
  68. package/dist/sync-RsnjXYwG.cjs +0 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.8",
3
+ "version": "1.10.9",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,11 +56,11 @@
56
56
  "prompts": "~2.4.2",
57
57
  "tsx": "~4.20.3",
58
58
  "yaml": "~2.8.2",
59
- "@geekmidas/constructs": "~3.0.2",
60
- "@geekmidas/envkit": "~1.0.3",
61
- "@geekmidas/errors": "~1.0.0",
62
59
  "@geekmidas/logger": "~1.0.0",
63
- "@geekmidas/schema": "~1.0.0"
60
+ "@geekmidas/envkit": "~1.0.3",
61
+ "@geekmidas/constructs": "~3.0.2",
62
+ "@geekmidas/schema": "~1.0.0",
63
+ "@geekmidas/errors": "~1.0.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/lodash.kebabcase": "^4.1.9",
@@ -70,7 +70,7 @@
70
70
  "typescript": "^5.8.2",
71
71
  "vitest": "^3.2.4",
72
72
  "zod": "~4.1.13",
73
- "@geekmidas/testkit": "1.0.2"
73
+ "@geekmidas/testkit": "1.0.5"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "@geekmidas/telescope": "~1.0.0"
@@ -22,6 +22,7 @@ import {
22
22
  normalizeStudioConfig,
23
23
  normalizeTelescopeConfig,
24
24
  parseComposePortMappings,
25
+ parseComposeServiceNames,
25
26
  replacePortInUrl,
26
27
  rewriteUrlsWithPorts,
27
28
  savePortState,
@@ -1707,6 +1708,78 @@ services:
1707
1708
  });
1708
1709
  });
1709
1710
 
1711
+ describe('parseComposeServiceNames', () => {
1712
+ let testDir: string;
1713
+
1714
+ beforeEach(() => {
1715
+ testDir = join(tmpdir(), `gkm-test-service-names-${Date.now()}`);
1716
+ mkdirSync(testDir, { recursive: true });
1717
+ });
1718
+
1719
+ afterEach(() => {
1720
+ rmSync(testDir, { recursive: true, force: true });
1721
+ });
1722
+
1723
+ it('should return all service names from docker-compose.yml', () => {
1724
+ const composePath = join(testDir, 'docker-compose.yml');
1725
+ writeFileSync(
1726
+ composePath,
1727
+ `services:
1728
+ postgres:
1729
+ image: postgres:18-alpine
1730
+ redis:
1731
+ image: redis:7-alpine
1732
+ minio:
1733
+ image: minio/minio:latest
1734
+ `,
1735
+ );
1736
+
1737
+ const names = parseComposeServiceNames(composePath);
1738
+ expect(names).toEqual(['postgres', 'redis', 'minio']);
1739
+ });
1740
+
1741
+ it('should return empty array when file does not exist', () => {
1742
+ const names = parseComposeServiceNames(join(testDir, 'missing.yml'));
1743
+ expect(names).toEqual([]);
1744
+ });
1745
+
1746
+ it('should return empty array when no services defined', () => {
1747
+ const composePath = join(testDir, 'docker-compose.yml');
1748
+ writeFileSync(composePath, 'version: "3.8"\n');
1749
+
1750
+ const names = parseComposeServiceNames(composePath);
1751
+ expect(names).toEqual([]);
1752
+ });
1753
+
1754
+ it('should include app and infrastructure services', () => {
1755
+ const composePath = join(testDir, 'docker-compose.yml');
1756
+ writeFileSync(
1757
+ composePath,
1758
+ `services:
1759
+ api:
1760
+ build: .
1761
+ web:
1762
+ build: .
1763
+ postgres:
1764
+ image: postgres:18-alpine
1765
+ redis:
1766
+ image: redis:7-alpine
1767
+ custom-service:
1768
+ image: custom:latest
1769
+ `,
1770
+ );
1771
+
1772
+ const names = parseComposeServiceNames(composePath);
1773
+ expect(names).toEqual([
1774
+ 'api',
1775
+ 'web',
1776
+ 'postgres',
1777
+ 'redis',
1778
+ 'custom-service',
1779
+ ]);
1780
+ });
1781
+ });
1782
+
1710
1783
  describe('generateServerEntryContent', () => {
1711
1784
  it('should use dynamic import for createApp when secrets are provided', () => {
1712
1785
  const content = generateServerEntryContent({
package/src/dev/index.ts CHANGED
@@ -274,6 +274,8 @@ export async function resolveServicePorts(
274
274
  const savedState = await loadPortState(workspaceRoot);
275
275
  const dockerEnv: Record<string, string> = {};
276
276
  const ports: PortState = {};
277
+ // Track ports assigned in this cycle to avoid duplicates
278
+ const assignedPorts = new Set<number>();
277
279
 
278
280
  logger.log('\n🔌 Resolving service ports...');
279
281
 
@@ -287,6 +289,7 @@ export async function resolveServicePorts(
287
289
  if (containerPort !== null) {
288
290
  ports[mapping.envVar] = containerPort;
289
291
  dockerEnv[mapping.envVar] = String(containerPort);
292
+ assignedPorts.add(containerPort);
290
293
  logger.log(
291
294
  ` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`,
292
295
  );
@@ -295,19 +298,28 @@ export async function resolveServicePorts(
295
298
 
296
299
  // 2. Check saved port state
297
300
  const savedPort = savedState[mapping.envVar];
298
- if (savedPort && (await isPortAvailable(savedPort))) {
301
+ if (
302
+ savedPort &&
303
+ !assignedPorts.has(savedPort) &&
304
+ (await isPortAvailable(savedPort))
305
+ ) {
299
306
  ports[mapping.envVar] = savedPort;
300
307
  dockerEnv[mapping.envVar] = String(savedPort);
308
+ assignedPorts.add(savedPort);
301
309
  logger.log(
302
310
  ` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`,
303
311
  );
304
312
  continue;
305
313
  }
306
314
 
307
- // 3. Find available port
308
- const resolvedPort = await findAvailablePort(mapping.defaultPort);
315
+ // 3. Find available port (skipping ports already assigned this cycle)
316
+ let resolvedPort = await findAvailablePort(mapping.defaultPort);
317
+ while (assignedPorts.has(resolvedPort)) {
318
+ resolvedPort = await findAvailablePort(resolvedPort + 1);
319
+ }
309
320
  ports[mapping.envVar] = resolvedPort;
310
321
  dockerEnv[mapping.envVar] = String(resolvedPort);
322
+ assignedPorts.add(resolvedPort);
311
323
 
312
324
  if (resolvedPort !== mapping.defaultPort) {
313
325
  logger.log(
@@ -1124,10 +1136,28 @@ export function buildDockerComposeEnv(
1124
1136
  return { ...process.env, ...secretsEnv, ...portEnv };
1125
1137
  }
1126
1138
 
1139
+ /**
1140
+ * Parse all service names from a docker-compose.yml file.
1141
+ * @internal Exported for testing
1142
+ */
1143
+ export function parseComposeServiceNames(composePath: string): string[] {
1144
+ if (!existsSync(composePath)) {
1145
+ return [];
1146
+ }
1147
+
1148
+ const content = readFileSync(composePath, 'utf-8');
1149
+ const compose = parseYaml(content) as {
1150
+ services?: Record<string, unknown>;
1151
+ };
1152
+
1153
+ return Object.keys(compose?.services ?? {});
1154
+ }
1155
+
1127
1156
  /**
1128
1157
  * Start docker-compose services for the workspace.
1129
- * Passes both port mappings and secrets to docker-compose so that
1130
- * variables like POSTGRES_USER/POSTGRES_PASSWORD are available.
1158
+ * Parses the docker-compose.yml to discover all services and starts
1159
+ * everything except app services (which are managed by turbo).
1160
+ * This ensures manually added services are always started.
1131
1161
  * @internal Exported for testing
1132
1162
  */
1133
1163
  export async function startWorkspaceServices(
@@ -1135,22 +1165,17 @@ export async function startWorkspaceServices(
1135
1165
  portEnv?: Record<string, string>,
1136
1166
  secretsEnv?: Record<string, string>,
1137
1167
  ): Promise<void> {
1138
- const services = workspace.services;
1139
- if (!services.db && !services.cache && !services.mail) {
1168
+ const composeFile = join(workspace.root, 'docker-compose.yml');
1169
+ if (!existsSync(composeFile)) {
1140
1170
  return;
1141
1171
  }
1142
1172
 
1143
- const servicesToStart: string[] = [];
1173
+ // Discover all services from docker-compose.yml
1174
+ const allServices = parseComposeServiceNames(composeFile);
1144
1175
 
1145
- if (services.db) {
1146
- servicesToStart.push('postgres');
1147
- }
1148
- if (services.cache) {
1149
- servicesToStart.push('redis');
1150
- }
1151
- if (services.mail) {
1152
- servicesToStart.push('mailpit');
1153
- }
1176
+ // Exclude app services (managed by turbo, not docker)
1177
+ const appNames = new Set(Object.keys(workspace.apps));
1178
+ const servicesToStart = allServices.filter((name) => !appNames.has(name));
1154
1179
 
1155
1180
  if (servicesToStart.length === 0) {
1156
1181
  return;
@@ -1159,15 +1184,6 @@ export async function startWorkspaceServices(
1159
1184
  logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
1160
1185
 
1161
1186
  try {
1162
- // Check if docker-compose.yml exists
1163
- const composeFile = join(workspace.root, 'docker-compose.yml');
1164
- if (!existsSync(composeFile)) {
1165
- logger.warn(
1166
- '⚠️ No docker-compose.yml found. Services will not be started.',
1167
- );
1168
- return;
1169
- }
1170
-
1171
1187
  // Start services with docker-compose, passing secrets so that
1172
1188
  // POSTGRES_USER, POSTGRES_PASSWORD, etc. are interpolated correctly
1173
1189
  execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import type { ComposeServiceName } from '../../types';
2
3
  import type { NormalizedWorkspace } from '../../workspace/types.js';
3
4
  import {
4
5
  type ComposeOptions,
@@ -10,7 +11,7 @@ import {
10
11
  } from '../compose';
11
12
 
12
13
  /** Helper to get full default image reference */
13
- function getDefaultImage(service: 'postgres' | 'redis' | 'rabbitmq'): string {
14
+ function getDefaultImage(service: ComposeServiceName): string {
14
15
  return `${DEFAULT_SERVICE_IMAGES[service]}:${DEFAULT_SERVICE_VERSIONS[service]}`;
15
16
  }
16
17
 
@@ -344,6 +345,90 @@ describe('generateDockerCompose', () => {
344
345
  });
345
346
  });
346
347
 
348
+ describe('minio service', () => {
349
+ it('should add S3 environment variables', () => {
350
+ const yaml = generateDockerCompose({
351
+ ...baseOptions,
352
+ services: { minio: true },
353
+ });
354
+
355
+ expect(yaml).toContain('- S3_ENDPOINT=${S3_ENDPOINT:-http://minio:9000}');
356
+ expect(yaml).toContain('- S3_ACCESS_KEY_ID=${MINIO_ACCESS_KEY:-app}');
357
+ expect(yaml).toContain('- S3_SECRET_ACCESS_KEY=${MINIO_SECRET_KEY:-app}');
358
+ expect(yaml).toContain('- S3_BUCKET=${MINIO_BUCKET:-app}');
359
+ expect(yaml).toContain('- S3_REGION=${S3_REGION:-eu-west-1}');
360
+ expect(yaml).toContain('- S3_FORCE_PATH_STYLE=true');
361
+ });
362
+
363
+ it('should add minio service definition with default image', () => {
364
+ const yaml = generateDockerCompose({
365
+ ...baseOptions,
366
+ services: { minio: true },
367
+ });
368
+
369
+ expect(yaml).toContain('minio:');
370
+ expect(yaml).toContain(`image: ${getDefaultImage('minio')}`);
371
+ expect(yaml).toContain('container_name: minio');
372
+ });
373
+
374
+ it('should use custom minio version', () => {
375
+ const yaml = generateDockerCompose({
376
+ ...baseOptions,
377
+ services: { minio: { version: 'RELEASE.2024-01-01' } },
378
+ });
379
+
380
+ expect(yaml).toContain('image: minio/minio:RELEASE.2024-01-01');
381
+ });
382
+
383
+ it('should configure minio credentials', () => {
384
+ const yaml = generateDockerCompose({
385
+ ...baseOptions,
386
+ services: { minio: true },
387
+ });
388
+
389
+ expect(yaml).toContain('MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-app}');
390
+ expect(yaml).toContain('MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-app}');
391
+ });
392
+
393
+ it('should expose console UI port', () => {
394
+ const yaml = generateDockerCompose({
395
+ ...baseOptions,
396
+ services: { minio: true },
397
+ });
398
+
399
+ expect(yaml).toContain('- "9001:9001"');
400
+ });
401
+
402
+ it('should add minio volume', () => {
403
+ const yaml = generateDockerCompose({
404
+ ...baseOptions,
405
+ services: { minio: true },
406
+ });
407
+
408
+ expect(yaml).toContain('- minio_data:/data');
409
+ expect(yaml).toContain('minio_data:');
410
+ });
411
+
412
+ it('should include minio healthcheck', () => {
413
+ const yaml = generateDockerCompose({
414
+ ...baseOptions,
415
+ services: { minio: true },
416
+ });
417
+
418
+ expect(yaml).toContain('test: ["CMD", "mc", "ready", "local"]');
419
+ });
420
+
421
+ it('should add depends_on for minio', () => {
422
+ const yaml = generateDockerCompose({
423
+ ...baseOptions,
424
+ services: { minio: true },
425
+ });
426
+
427
+ expect(yaml).toContain('depends_on:');
428
+ expect(yaml).toContain('condition: service_healthy');
429
+ });
430
+ });
431
+
347
432
  describe('multiple services', () => {
348
433
  it('should include all services when all specified', () => {
349
434
  const yaml = generateDockerCompose({
@@ -548,6 +633,7 @@ describe('generateWorkspaceCompose', () => {
548
633
  path: 'apps/api',
549
634
  port: 3000,
550
635
  dependencies: [],
636
+ resolvedDeployTarget: 'dokploy',
551
637
  },
552
638
  web: {
553
639
  type: 'frontend',
@@ -555,6 +641,7 @@ describe('generateWorkspaceCompose', () => {
555
641
  port: 3001,
556
642
  dependencies: ['api'],
557
643
  framework: 'nextjs',
644
+ resolvedDeployTarget: 'dokploy',
558
645
  },
559
646
  },
560
647
  services: {},
@@ -685,12 +772,14 @@ describe('generateWorkspaceCompose', () => {
685
772
  path: 'apps/api',
686
773
  port: 3000,
687
774
  dependencies: [],
775
+ resolvedDeployTarget: 'dokploy',
688
776
  },
689
777
  auth: {
690
778
  type: 'backend',
691
779
  path: 'apps/auth',
692
780
  port: 3002,
693
781
  dependencies: [],
782
+ resolvedDeployTarget: 'dokploy',
694
783
  },
695
784
  web: {
696
785
  type: 'frontend',
@@ -698,6 +787,7 @@ describe('generateWorkspaceCompose', () => {
698
787
  port: 3001,
699
788
  dependencies: ['api', 'auth'],
700
789
  framework: 'nextjs',
790
+ resolvedDeployTarget: 'dokploy',
701
791
  },
702
792
  },
703
793
  });
@@ -715,6 +805,7 @@ describe('generateWorkspaceCompose', () => {
715
805
  path: 'apps/api',
716
806
  port: 3000,
717
807
  dependencies: [],
808
+ resolvedDeployTarget: 'dokploy',
718
809
  },
719
810
  },
720
811
  });
@@ -734,6 +825,7 @@ describe('generateWorkspaceCompose', () => {
734
825
  path: 'apps/api',
735
826
  port: 3000,
736
827
  dependencies: [],
828
+ resolvedDeployTarget: 'dokploy',
737
829
  },
738
830
  },
739
831
  });
@@ -751,6 +843,7 @@ describe('generateWorkspaceCompose', () => {
751
843
  port: 3001,
752
844
  dependencies: [],
753
845
  framework: 'nextjs',
846
+ resolvedDeployTarget: 'dokploy',
754
847
  },
755
848
  },
756
849
  });
@@ -778,6 +871,7 @@ describe('generateWorkspaceCompose', () => {
778
871
  path: 'apps/api',
779
872
  port: 3000,
780
873
  dependencies: [],
874
+ resolvedDeployTarget: 'dokploy',
781
875
  },
782
876
  },
783
877
  });
@@ -894,6 +988,7 @@ describe('generateWorkspaceCompose', () => {
894
988
  port: 3001,
895
989
  dependencies: [],
896
990
  framework: 'nextjs',
991
+ resolvedDeployTarget: 'dokploy',
897
992
  },
898
993
  },
899
994
  services: { db: true },
@@ -931,6 +1026,55 @@ describe('generateWorkspaceCompose', () => {
931
1026
 
932
1027
  expect(yaml).toContain('image: redis:6-alpine');
933
1028
  });
1029
+
1030
+ it('should add minio service when storage is configured', () => {
1031
+ const workspace = createWorkspace({
1032
+ services: { storage: true },
1033
+ });
1034
+ const yaml = generateWorkspaceCompose(workspace);
1035
+
1036
+ expect(yaml).toContain('minio:');
1037
+ expect(yaml).toContain('image: minio/minio:latest');
1038
+ expect(yaml).toContain('container_name: test-workspace-minio');
1039
+ });
1040
+
1041
+ it('should add S3 env vars for backend apps when minio is enabled', () => {
1042
+ const workspace = createWorkspace({
1043
+ services: { storage: true },
1044
+ });
1045
+ const yaml = generateWorkspaceCompose(workspace);
1046
+
1047
+ expect(yaml).toContain('S3_ENDPOINT=${S3_ENDPOINT:-http://minio:9000}');
1048
+ expect(yaml).toContain('S3_FORCE_PATH_STYLE=true');
1049
+ });
1050
+
1051
+ it('should add minio_data volume when minio is enabled', () => {
1052
+ const workspace = createWorkspace({
1053
+ services: { storage: true },
1054
+ });
1055
+ const yaml = generateWorkspaceCompose(workspace);
1056
+
1057
+ expect(yaml).toContain('minio_data:');
1058
+ expect(yaml).toContain('minio_data:/data');
1059
+ });
1060
+
1061
+ it('should add depends_on minio for backend apps', () => {
1062
+ const workspace = createWorkspace({
1063
+ services: { storage: true },
1064
+ });
1065
+ const yaml = generateWorkspaceCompose(workspace);
1066
+
1067
+ expect(yaml).toMatch(/minio:\s+condition: service_healthy/);
1068
+ });
1069
+
1070
+ it('should support custom minio version', () => {
1071
+ const workspace = createWorkspace({
1072
+ services: { storage: { version: 'RELEASE.2024-01-01' } },
1073
+ });
1074
+ const yaml = generateWorkspaceCompose(workspace);
1075
+
1076
+ expect(yaml).toContain('image: minio/minio:RELEASE.2024-01-01');
1077
+ });
934
1078
  });
935
1079
 
936
1080
  describe('registry configuration', () => {
@@ -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 {
@@ -121,6 +123,16 @@ services:
121
123
  `;
122
124
  }
123
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
+
124
136
  yaml += ` healthcheck:
125
137
  test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
126
138
  interval: 30s
@@ -210,6 +222,32 @@ services:
210
222
  `;
211
223
  }
212
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
+
213
251
  // Add volumes
214
252
  yaml += `
215
253
  volumes:
@@ -230,6 +268,11 @@ volumes:
230
268
  `;
231
269
  }
232
270
 
271
+ if (serviceMap.has('minio')) {
272
+ yaml += ` minio_data:
273
+ `;
274
+ }
275
+
233
276
  // Add networks
234
277
  yaml += `
235
278
  networks:
@@ -305,10 +348,12 @@ export function generateWorkspaceCompose(
305
348
  const hasPostgres = services.db !== undefined && services.db !== false;
306
349
  const hasRedis = services.cache !== undefined && services.cache !== false;
307
350
  const hasMail = services.mail !== undefined && services.mail !== false;
351
+ const hasMinio = services.storage !== undefined && services.storage !== false;
308
352
 
309
353
  // Get image versions from config
310
354
  const postgresImage = getInfraServiceImage('postgres', services.db);
311
355
  const redisImage = getInfraServiceImage('redis', services.cache);
356
+ const minioImage = getInfraServiceImage('minio', services.storage);
312
357
 
313
358
  let yaml = `# Docker Compose for ${workspace.name} workspace
314
359
  # Use "gkm dev" or "gkm test" to start services.
@@ -323,6 +368,7 @@ services:
323
368
  registry,
324
369
  hasPostgres,
325
370
  hasRedis,
371
+ hasMinio,
326
372
  });
327
373
  }
328
374
 
@@ -381,6 +427,31 @@ services:
381
427
  `;
382
428
  }
383
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
+
384
455
  // Add volumes section
385
456
  yaml += `
386
457
  volumes:
@@ -396,6 +467,11 @@ volumes:
396
467
  `;
397
468
  }
398
469
 
470
+ if (hasMinio) {
471
+ yaml += ` minio_data:
472
+ `;
473
+ }
474
+
399
475
  // Add networks section
400
476
  yaml += `
401
477
  networks:
@@ -410,12 +486,13 @@ networks:
410
486
  * Get infrastructure service image with version.
411
487
  */
412
488
  function getInfraServiceImage(
413
- serviceName: 'postgres' | 'redis',
489
+ serviceName: 'postgres' | 'redis' | 'minio',
414
490
  config: boolean | { version?: string; image?: string } | undefined,
415
491
  ): string {
416
- const defaults: Record<'postgres' | 'redis', string> = {
492
+ const defaults: Record<'postgres' | 'redis' | 'minio', string> = {
417
493
  postgres: 'postgres:18-alpine',
418
494
  redis: 'redis:7-alpine',
495
+ minio: 'minio/minio:latest',
419
496
  };
420
497
 
421
498
  if (!config || config === true) {
@@ -427,8 +504,12 @@ function getInfraServiceImage(
427
504
  return config.image;
428
505
  }
429
506
  if (config.version) {
430
- const baseImage = serviceName === 'postgres' ? 'postgres' : 'redis';
431
- 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}`;
432
513
  }
433
514
  }
434
515
 
@@ -446,9 +527,10 @@ function generateAppService(
446
527
  registry?: string;
447
528
  hasPostgres: boolean;
448
529
  hasRedis: boolean;
530
+ hasMinio: boolean;
449
531
  },
450
532
  ): string {
451
- const { registry, hasPostgres, hasRedis } = options;
533
+ const { registry, hasPostgres, hasRedis, hasMinio } = options;
452
534
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
453
535
 
454
536
  // Health check path - frontends use /, backends use /health
@@ -490,6 +572,15 @@ function generateAppService(
490
572
  }
491
573
  if (hasRedis) {
492
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
493
584
  `;
494
585
  }
495
586
  }
@@ -506,6 +597,7 @@ function generateAppService(
506
597
  if (app.type === 'backend') {
507
598
  if (hasPostgres) dependencies.push('postgres');
508
599
  if (hasRedis) dependencies.push('redis');
600
+ if (hasMinio) dependencies.push('minio');
509
601
  }
510
602
 
511
603
  if (dependencies.length > 0) {
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