@geekmidas/cli 1.10.7 → 1.10.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.7",
3
+ "version": "1.10.8",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,10 +56,10 @@
56
56
  "prompts": "~2.4.2",
57
57
  "tsx": "~4.20.3",
58
58
  "yaml": "~2.8.2",
59
- "@geekmidas/errors": "~1.0.0",
59
+ "@geekmidas/constructs": "~3.0.2",
60
60
  "@geekmidas/envkit": "~1.0.3",
61
+ "@geekmidas/errors": "~1.0.0",
61
62
  "@geekmidas/logger": "~1.0.0",
62
- "@geekmidas/constructs": "~3.0.2",
63
63
  "@geekmidas/schema": "~1.0.0"
64
64
  },
65
65
  "devDependencies": {
@@ -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,
@@ -1753,3 +1754,71 @@ describe('generateServerEntryContent', () => {
1753
1754
  expect(content).toContain("await import('./custom-app.js')");
1754
1755
  });
1755
1756
  });
1757
+
1758
+ describe('buildDockerComposeEnv', () => {
1759
+ it('should include secrets in the env passed to docker compose', () => {
1760
+ const secretsEnv = {
1761
+ POSTGRES_USER: 'app',
1762
+ POSTGRES_PASSWORD: 'supersecret',
1763
+ POSTGRES_DB: 'myproject_dev',
1764
+ };
1765
+ const portEnv = { POSTGRES_HOST_PORT: '5434' };
1766
+
1767
+ const env = buildDockerComposeEnv(secretsEnv, portEnv);
1768
+
1769
+ expect(env.POSTGRES_USER).toBe('app');
1770
+ expect(env.POSTGRES_PASSWORD).toBe('supersecret');
1771
+ expect(env.POSTGRES_DB).toBe('myproject_dev');
1772
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
1773
+ });
1774
+
1775
+ it('should include multiple service secrets', () => {
1776
+ const secretsEnv = {
1777
+ POSTGRES_USER: 'app',
1778
+ POSTGRES_PASSWORD: 'dbpass',
1779
+ REDIS_PASSWORD: 'redispass',
1780
+ };
1781
+
1782
+ const env = buildDockerComposeEnv(secretsEnv, {});
1783
+
1784
+ expect(env.POSTGRES_USER).toBe('app');
1785
+ expect(env.POSTGRES_PASSWORD).toBe('dbpass');
1786
+ expect(env.REDIS_PASSWORD).toBe('redispass');
1787
+ });
1788
+
1789
+ it('should let port env override secrets env for same key', () => {
1790
+ const secretsEnv = { POSTGRES_HOST_PORT: '5432' };
1791
+ const portEnv = { POSTGRES_HOST_PORT: '5434' };
1792
+
1793
+ const env = buildDockerComposeEnv(secretsEnv, portEnv);
1794
+
1795
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
1796
+ });
1797
+
1798
+ it('should work without secrets env', () => {
1799
+ const env = buildDockerComposeEnv(undefined, {
1800
+ POSTGRES_HOST_PORT: '5434',
1801
+ });
1802
+
1803
+ expect(env.POSTGRES_HOST_PORT).toBe('5434');
1804
+ });
1805
+
1806
+ it('should work without any arguments', () => {
1807
+ const env = buildDockerComposeEnv();
1808
+
1809
+ // Should at least have process.env
1810
+ expect(env.PATH).toBeDefined();
1811
+ });
1812
+
1813
+ it('should include process.env as base', () => {
1814
+ const env = buildDockerComposeEnv(
1815
+ { POSTGRES_USER: 'app' },
1816
+ { POSTGRES_HOST_PORT: '5434' },
1817
+ );
1818
+
1819
+ // process.env values should be present
1820
+ expect(env.PATH).toBeDefined();
1821
+ // Custom values should override
1822
+ expect(env.POSTGRES_USER).toBe('app');
1823
+ });
1824
+ });
package/src/dev/index.ts CHANGED
@@ -1111,13 +1111,29 @@ export async function loadSecretsForApp(
1111
1111
  return mapped;
1112
1112
  }
1113
1113
 
1114
+ /**
1115
+ * Build the environment variables to pass to `docker compose up`.
1116
+ * Merges process.env, secrets, and port mappings so that Docker Compose
1117
+ * can interpolate variables like ${POSTGRES_USER} correctly.
1118
+ * @internal Exported for testing
1119
+ */
1120
+ export function buildDockerComposeEnv(
1121
+ secretsEnv?: Record<string, string>,
1122
+ portEnv?: Record<string, string>,
1123
+ ): Record<string, string | undefined> {
1124
+ return { ...process.env, ...secretsEnv, ...portEnv };
1125
+ }
1126
+
1114
1127
  /**
1115
1128
  * 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.
1116
1131
  * @internal Exported for testing
1117
1132
  */
1118
1133
  export async function startWorkspaceServices(
1119
1134
  workspace: NormalizedWorkspace,
1120
1135
  portEnv?: Record<string, string>,
1136
+ secretsEnv?: Record<string, string>,
1121
1137
  ): Promise<void> {
1122
1138
  const services = workspace.services;
1123
1139
  if (!services.db && !services.cache && !services.mail) {
@@ -1152,11 +1168,12 @@ export async function startWorkspaceServices(
1152
1168
  return;
1153
1169
  }
1154
1170
 
1155
- // Start services with docker-compose
1171
+ // Start services with docker-compose, passing secrets so that
1172
+ // POSTGRES_USER, POSTGRES_PASSWORD, etc. are interpolated correctly
1156
1173
  execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
1157
1174
  cwd: workspace.root,
1158
1175
  stdio: 'inherit',
1159
- env: { ...process.env, ...portEnv },
1176
+ env: buildDockerComposeEnv(secretsEnv, portEnv),
1160
1177
  });
1161
1178
 
1162
1179
  logger.log('✅ Services started');
@@ -1246,14 +1263,15 @@ async function workspaceDevCommand(
1246
1263
  // Resolve dynamic service ports from docker-compose.yml
1247
1264
  const resolvedPorts = await resolveServicePorts(workspace.root);
1248
1265
 
1249
- // Start docker-compose services with resolved ports
1250
- await startWorkspaceServices(workspace, resolvedPorts.dockerEnv);
1266
+ // Load secrets BEFORE starting Docker so POSTGRES_USER, POSTGRES_PASSWORD,
1267
+ // etc. are available for docker-compose variable interpolation
1268
+ const rawSecrets = await loadDevSecrets(workspace);
1251
1269
 
1252
- // Load secrets if enabled, then rewrite URLs with resolved ports
1253
- const secretsEnv = rewriteUrlsWithPorts(
1254
- await loadDevSecrets(workspace),
1255
- resolvedPorts,
1256
- );
1270
+ // Start docker-compose services with resolved ports AND secrets
1271
+ await startWorkspaceServices(workspace, resolvedPorts.dockerEnv, rawSecrets);
1272
+
1273
+ // Rewrite URLs with resolved ports (hostnames and port numbers)
1274
+ const secretsEnv = rewriteUrlsWithPorts(rawSecrets, resolvedPorts);
1257
1275
  if (Object.keys(secretsEnv).length > 0) {
1258
1276
  logger.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
1259
1277
  }
@@ -577,7 +577,12 @@ describe('generateWorkspaceCompose', () => {
577
577
  const workspace = createWorkspace();
578
578
  const yaml = generateWorkspaceCompose(workspace);
579
579
 
580
- expect(yaml).toContain('# Generated by gkm - do not edit manually');
580
+ expect(yaml).toContain(
581
+ '# Use "gkm dev" or "gkm test" to start services.',
582
+ );
583
+ expect(yaml).toContain(
584
+ '# Running "docker compose up" directly will not inject secrets or resolve ports.',
585
+ );
581
586
  });
582
587
 
583
588
  it('should include services section', () => {
@@ -87,7 +87,9 @@ export function generateDockerCompose(options: ComposeOptions): string {
87
87
 
88
88
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
89
89
 
90
- let yaml = `version: '3.8'
90
+ let yaml = `# Use "gkm dev" or "gkm test" to start services.
91
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
92
+ version: '3.8'
91
93
 
92
94
  services:
93
95
  api:
@@ -248,7 +250,9 @@ export function generateMinimalDockerCompose(
248
250
 
249
251
  const imageRef = registry ? `\${REGISTRY:-${registry}}/` : '';
250
252
 
251
- return `version: '3.8'
253
+ return `# Use "gkm dev" or "gkm test" to start services.
254
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
255
+ version: '3.8'
252
256
 
253
257
  services:
254
258
  api:
@@ -307,7 +311,8 @@ export function generateWorkspaceCompose(
307
311
  const redisImage = getInfraServiceImage('redis', services.cache);
308
312
 
309
313
  let yaml = `# Docker Compose for ${workspace.name} workspace
310
- # Generated by gkm - do not edit manually
314
+ # Use "gkm dev" or "gkm test" to start services.
315
+ # Running "docker compose up" directly will not inject secrets or resolve ports.
311
316
 
312
317
  services:
313
318
  `;
@@ -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
 
@@ -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
+ });
package/src/test/index.ts CHANGED
@@ -8,7 +8,9 @@ import {
8
8
  loadEnvFiles,
9
9
  loadPortState,
10
10
  parseComposePortMappings,
11
+ resolveServicePorts,
11
12
  rewriteUrlsWithPorts,
13
+ startWorkspaceServices,
12
14
  } from '../dev/index';
13
15
  import { readStageSecrets, toEmbeddableSecrets } from '../secrets/storage';
14
16
  import { getDependencyEnvVars } from '../workspace/index';
@@ -64,28 +66,28 @@ export async function testCommand(options: TestOptions = {}): Promise<void> {
64
66
  }
65
67
  }
66
68
 
67
- // 3. Rewrite URLs with resolved Docker ports (from gkm dev)
68
- const composePath = join(cwd, 'docker-compose.yml');
69
- const mappings = parseComposePortMappings(composePath);
70
- if (mappings.length > 0) {
71
- const ports = await loadPortState(cwd);
72
- if (Object.keys(ports).length > 0) {
73
- secretsEnv = rewriteUrlsWithPorts(secretsEnv, {
74
- dockerEnv: {},
75
- ports,
76
- mappings,
77
- });
78
- console.log(` 🔌 Applied ${Object.keys(ports).length} port mapping(s)`);
79
- }
80
- }
81
-
82
- // 4. Use a separate test database (append _test suffix)
83
- secretsEnv = rewriteDatabaseUrlForTests(secretsEnv);
84
-
85
- // 5. Load workspace config + dependency URLs + sniff env vars
69
+ // 3. Load workspace config + start Docker services with secrets
86
70
  let dependencyEnv: Record<string, string> = {};
87
71
  try {
88
72
  const appInfo = await loadWorkspaceAppInfo(cwd);
73
+
74
+ // Resolve ports and start Docker services with secrets so that
75
+ // POSTGRES_USER, POSTGRES_PASSWORD, etc. are interpolated correctly
76
+ const resolvedPorts = await resolveServicePorts(appInfo.workspaceRoot);
77
+ await startWorkspaceServices(
78
+ appInfo.workspace,
79
+ resolvedPorts.dockerEnv,
80
+ secretsEnv,
81
+ );
82
+
83
+ // Rewrite URLs with resolved Docker ports and hostnames
84
+ if (resolvedPorts.mappings.length > 0) {
85
+ secretsEnv = rewriteUrlsWithPorts(secretsEnv, resolvedPorts);
86
+ console.log(
87
+ ` 🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`,
88
+ );
89
+ }
90
+
89
91
  dependencyEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
90
92
 
91
93
  if (Object.keys(dependencyEnv).length > 0) {
@@ -94,7 +96,7 @@ export async function testCommand(options: TestOptions = {}): Promise<void> {
94
96
  );
95
97
  }
96
98
 
97
- // 6. Sniff to detect which env vars the app needs
99
+ // Sniff to detect which env vars the app needs
98
100
  const sniffed = await sniffAppEnvironment(
99
101
  appInfo.app,
100
102
  appInfo.appName,
@@ -119,9 +121,27 @@ export async function testCommand(options: TestOptions = {}): Promise<void> {
119
121
  );
120
122
  }
121
123
  } catch {
122
- // Not in a workspace — continue with just secrets
124
+ // Not in a workspace — fall back to port state file
125
+ const composePath = join(cwd, 'docker-compose.yml');
126
+ const mappings = parseComposePortMappings(composePath);
127
+ if (mappings.length > 0) {
128
+ const ports = await loadPortState(cwd);
129
+ if (Object.keys(ports).length > 0) {
130
+ secretsEnv = rewriteUrlsWithPorts(secretsEnv, {
131
+ dockerEnv: {},
132
+ ports,
133
+ mappings,
134
+ });
135
+ console.log(
136
+ ` 🔌 Applied ${Object.keys(ports).length} port mapping(s)`,
137
+ );
138
+ }
139
+ }
123
140
  }
124
141
 
142
+ // 4. Use a separate test database (append _test suffix)
143
+ secretsEnv = rewriteDatabaseUrlForTests(secretsEnv);
144
+
125
145
  console.log('');
126
146
 
127
147
  // Write combined secrets to JSON and create credentials preload