@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.7",
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/errors": "~1.0.0",
60
- "@geekmidas/envkit": "~1.0.3",
61
59
  "@geekmidas/logger": "~1.0.0",
60
+ "@geekmidas/envkit": "~1.0.3",
62
61
  "@geekmidas/constructs": "~3.0.2",
63
- "@geekmidas/schema": "~1.0.0"
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"
@@ -9,6 +9,7 @@ import type {
9
9
  NormalizedWorkspace,
10
10
  } from '../../workspace/index.js';
11
11
  import {
12
+ buildDockerComposeEnv,
12
13
  checkPortConflicts,
13
14
  findAvailablePort,
14
15
  generateAllDependencyEnvVars,
@@ -21,6 +22,7 @@ import {
21
22
  normalizeStudioConfig,
22
23
  normalizeTelescopeConfig,
23
24
  parseComposePortMappings,
25
+ parseComposeServiceNames,
24
26
  replacePortInUrl,
25
27
  rewriteUrlsWithPorts,
26
28
  savePortState,
@@ -1706,6 +1708,78 @@ services:
1706
1708
  });
1707
1709
  });
1708
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
+
1709
1783
  describe('generateServerEntryContent', () => {
1710
1784
  it('should use dynamic import for createApp when secrets are provided', () => {
1711
1785
  const content = generateServerEntryContent({
@@ -1753,3 +1827,71 @@ describe('generateServerEntryContent', () => {
1753
1827
  expect(content).toContain("await import('./custom-app.js')");
1754
1828
  });
1755
1829
  });
1830
+
1831
+ describe('buildDockerComposeEnv', () => {
1832
+ it('should include secrets in the env passed to docker compose', () => {
1833
+ const secretsEnv = {
1834
+ POSTGRES_USER: 'app',
1835
+ POSTGRES_PASSWORD: 'supersecret',
1836
+ POSTGRES_DB: 'myproject_dev',
1837
+ };
1838
+ const portEnv = { POSTGRES_HOST_PORT: '5434' };
1839
+
1840
+ const env = buildDockerComposeEnv(secretsEnv, portEnv);
1841
+
1842
+ expect(env.POSTGRES_USER).toBe('app');
1843
+ expect(env.POSTGRES_PASSWORD).toBe('supersecret');
1844
+ expect(env.POSTGRES_DB).toBe('myproject_dev');
1845
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
1846
+ });
1847
+
1848
+ it('should include multiple service secrets', () => {
1849
+ const secretsEnv = {
1850
+ POSTGRES_USER: 'app',
1851
+ POSTGRES_PASSWORD: 'dbpass',
1852
+ REDIS_PASSWORD: 'redispass',
1853
+ };
1854
+
1855
+ const env = buildDockerComposeEnv(secretsEnv, {});
1856
+
1857
+ expect(env.POSTGRES_USER).toBe('app');
1858
+ expect(env.POSTGRES_PASSWORD).toBe('dbpass');
1859
+ expect(env.REDIS_PASSWORD).toBe('redispass');
1860
+ });
1861
+
1862
+ it('should let port env override secrets env for same key', () => {
1863
+ const secretsEnv = { POSTGRES_HOST_PORT: '5432' };
1864
+ const portEnv = { POSTGRES_HOST_PORT: '5434' };
1865
+
1866
+ const env = buildDockerComposeEnv(secretsEnv, portEnv);
1867
+
1868
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
1869
+ });
1870
+
1871
+ it('should work without secrets env', () => {
1872
+ const env = buildDockerComposeEnv(undefined, {
1873
+ POSTGRES_HOST_PORT: '5434',
1874
+ });
1875
+
1876
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
1877
+ });
1878
+
1879
+ it('should work without any arguments', () => {
1880
+ const env = buildDockerComposeEnv();
1881
+
1882
+ // Should at least have process.env
1883
+ expect(env.PATH).toBeDefined();
1884
+ });
1885
+
1886
+ it('should include process.env as base', () => {
1887
+ const env = buildDockerComposeEnv(
1888
+ { POSTGRES_USER: 'app' },
1889
+ { POSTGRES_HOST_PORT: '5434' },
1890
+ );
1891
+
1892
+ // process.env values should be present
1893
+ expect(env.PATH).toBeDefined();
1894
+ // Custom values should override
1895
+ expect(env.POSTGRES_USER).toBe('app');
1896
+ });
1897
+ });
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(
@@ -1111,30 +1123,59 @@ export async function loadSecretsForApp(
1111
1123
  return mapped;
1112
1124
  }
1113
1125
 
1126
+ /**
1127
+ * Build the environment variables to pass to `docker compose up`.
1128
+ * Merges process.env, secrets, and port mappings so that Docker Compose
1129
+ * can interpolate variables like ${POSTGRES_USER} correctly.
1130
+ * @internal Exported for testing
1131
+ */
1132
+ export function buildDockerComposeEnv(
1133
+ secretsEnv?: Record<string, string>,
1134
+ portEnv?: Record<string, string>,
1135
+ ): Record<string, string | undefined> {
1136
+ return { ...process.env, ...secretsEnv, ...portEnv };
1137
+ }
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
+
1114
1156
  /**
1115
1157
  * Start docker-compose services for the workspace.
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.
1116
1161
  * @internal Exported for testing
1117
1162
  */
1118
1163
  export async function startWorkspaceServices(
1119
1164
  workspace: NormalizedWorkspace,
1120
1165
  portEnv?: Record<string, string>,
1166
+ secretsEnv?: Record<string, string>,
1121
1167
  ): Promise<void> {
1122
- const services = workspace.services;
1123
- if (!services.db && !services.cache && !services.mail) {
1168
+ const composeFile = join(workspace.root, 'docker-compose.yml');
1169
+ if (!existsSync(composeFile)) {
1124
1170
  return;
1125
1171
  }
1126
1172
 
1127
- const servicesToStart: string[] = [];
1173
+ // Discover all services from docker-compose.yml
1174
+ const allServices = parseComposeServiceNames(composeFile);
1128
1175
 
1129
- if (services.db) {
1130
- servicesToStart.push('postgres');
1131
- }
1132
- if (services.cache) {
1133
- servicesToStart.push('redis');
1134
- }
1135
- if (services.mail) {
1136
- servicesToStart.push('mailpit');
1137
- }
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));
1138
1179
 
1139
1180
  if (servicesToStart.length === 0) {
1140
1181
  return;
@@ -1143,20 +1184,12 @@ export async function startWorkspaceServices(
1143
1184
  logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
1144
1185
 
1145
1186
  try {
1146
- // Check if docker-compose.yml exists
1147
- const composeFile = join(workspace.root, 'docker-compose.yml');
1148
- if (!existsSync(composeFile)) {
1149
- logger.warn(
1150
- '⚠️ No docker-compose.yml found. Services will not be started.',
1151
- );
1152
- return;
1153
- }
1154
-
1155
- // Start services with docker-compose
1187
+ // Start services with docker-compose, passing secrets so that
1188
+ // POSTGRES_USER, POSTGRES_PASSWORD, etc. are interpolated correctly
1156
1189
  execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
1157
1190
  cwd: workspace.root,
1158
1191
  stdio: 'inherit',
1159
- env: { ...process.env, ...portEnv },
1192
+ env: buildDockerComposeEnv(secretsEnv, portEnv),
1160
1193
  });
1161
1194
 
1162
1195
  logger.log('✅ Services started');
@@ -1246,14 +1279,15 @@ async function workspaceDevCommand(
1246
1279
  // Resolve dynamic service ports from docker-compose.yml
1247
1280
  const resolvedPorts = await resolveServicePorts(workspace.root);
1248
1281
 
1249
- // Start docker-compose services with resolved ports
1250
- await startWorkspaceServices(workspace, resolvedPorts.dockerEnv);
1282
+ // Load secrets BEFORE starting Docker so POSTGRES_USER, POSTGRES_PASSWORD,
1283
+ // etc. are available for docker-compose variable interpolation
1284
+ const rawSecrets = await loadDevSecrets(workspace);
1251
1285
 
1252
- // Load secrets if enabled, then rewrite URLs with resolved ports
1253
- const secretsEnv = rewriteUrlsWithPorts(
1254
- await loadDevSecrets(workspace),
1255
- resolvedPorts,
1256
- );
1286
+ // Start docker-compose services with resolved ports AND secrets
1287
+ await startWorkspaceServices(workspace, resolvedPorts.dockerEnv, rawSecrets);
1288
+
1289
+ // Rewrite URLs with resolved ports (hostnames and port numbers)
1290
+ const secretsEnv = rewriteUrlsWithPorts(rawSecrets, resolvedPorts);
1257
1291
  if (Object.keys(secretsEnv).length > 0) {
1258
1292
  logger.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
1259
1293
  }
@@ -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: {},
@@ -577,7 +664,12 @@ describe('generateWorkspaceCompose', () => {
577
664
  const workspace = createWorkspace();
578
665
  const yaml = generateWorkspaceCompose(workspace);
579
666
 
580
- expect(yaml).toContain('# Generated by gkm - do not edit manually');
667
+ expect(yaml).toContain(
668
+ '# Use "gkm dev" or "gkm test" to start services.',
669
+ );
670
+ expect(yaml).toContain(
671
+ '# Running "docker compose up" directly will not inject secrets or resolve ports.',
672
+ );
581
673
  });
582
674
 
583
675
  it('should include services section', () => {
@@ -680,12 +772,14 @@ describe('generateWorkspaceCompose', () => {
680
772
  path: 'apps/api',
681
773
  port: 3000,
682
774
  dependencies: [],
775
+ resolvedDeployTarget: 'dokploy',
683
776
  },
684
777
  auth: {
685
778
  type: 'backend',
686
779
  path: 'apps/auth',
687
780
  port: 3002,
688
781
  dependencies: [],
782
+ resolvedDeployTarget: 'dokploy',
689
783
  },
690
784
  web: {
691
785
  type: 'frontend',
@@ -693,6 +787,7 @@ describe('generateWorkspaceCompose', () => {
693
787
  port: 3001,
694
788
  dependencies: ['api', 'auth'],
695
789
  framework: 'nextjs',
790
+ resolvedDeployTarget: 'dokploy',
696
791
  },
697
792
  },
698
793
  });
@@ -710,6 +805,7 @@ describe('generateWorkspaceCompose', () => {
710
805
  path: 'apps/api',
711
806
  port: 3000,
712
807
  dependencies: [],
808
+ resolvedDeployTarget: 'dokploy',
713
809
  },
714
810
  },
715
811
  });
@@ -729,6 +825,7 @@ describe('generateWorkspaceCompose', () => {
729
825
  path: 'apps/api',
730
826
  port: 3000,
731
827
  dependencies: [],
828
+ resolvedDeployTarget: 'dokploy',
732
829
  },
733
830
  },
734
831
  });
@@ -746,6 +843,7 @@ describe('generateWorkspaceCompose', () => {
746
843
  port: 3001,
747
844
  dependencies: [],
748
845
  framework: 'nextjs',
846
+ resolvedDeployTarget: 'dokploy',
749
847
  },
750
848
  },
751
849
  });
@@ -773,6 +871,7 @@ describe('generateWorkspaceCompose', () => {
773
871
  path: 'apps/api',
774
872
  port: 3000,
775
873
  dependencies: [],
874
+ resolvedDeployTarget: 'dokploy',
776
875
  },
777
876
  },
778
877
  });
@@ -889,6 +988,7 @@ describe('generateWorkspaceCompose', () => {
889
988
  port: 3001,
890
989
  dependencies: [],
891
990
  framework: 'nextjs',
991
+ resolvedDeployTarget: 'dokploy',
892
992
  },
893
993
  },
894
994
  services: { db: true },
@@ -926,6 +1026,55 @@ describe('generateWorkspaceCompose', () => {
926
1026
 
927
1027
  expect(yaml).toContain('image: redis:6-alpine');
928
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
+ });
929
1078
  });
930
1079
 
931
1080
  describe('registry configuration', () => {