@geekmidas/cli 1.3.0 → 1.5.0

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 (52) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{Route53Provider-xrWuBXih.cjs → Route53Provider-Bs7Arms9.cjs} +3 -2
  3. package/dist/Route53Provider-Bs7Arms9.cjs.map +1 -0
  4. package/dist/{Route53Provider-DOWmFnwN.mjs → Route53Provider-C8mS0zY6.mjs} +3 -2
  5. package/dist/Route53Provider-C8mS0zY6.mjs.map +1 -0
  6. package/dist/{config-C1bidhvG.mjs → config-DfCJ29PQ.mjs} +2 -2
  7. package/dist/{config-C1bidhvG.mjs.map → config-DfCJ29PQ.mjs.map} +1 -1
  8. package/dist/{config-C1dM7aZb.cjs → config-ZQM1vBoz.cjs} +2 -2
  9. package/dist/{config-C1dM7aZb.cjs.map → config-ZQM1vBoz.cjs.map} +1 -1
  10. package/dist/config.cjs +2 -2
  11. package/dist/config.d.cts +1 -1
  12. package/dist/config.d.mts +1 -1
  13. package/dist/config.mjs +2 -2
  14. package/dist/{index-DzmZ6SUW.d.cts → index-B58qjyBd.d.cts} +27 -1
  15. package/dist/index-B58qjyBd.d.cts.map +1 -0
  16. package/dist/{index-DvpWzLD7.d.mts → index-C0SpUT9Y.d.mts} +27 -1
  17. package/dist/index-C0SpUT9Y.d.mts.map +1 -0
  18. package/dist/index.cjs +117 -49
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.mjs +117 -49
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/{openapi-9k6a6VA4.mjs → openapi-BcSjLfWq.mjs} +2 -2
  23. package/dist/{openapi-9k6a6VA4.mjs.map → openapi-BcSjLfWq.mjs.map} +1 -1
  24. package/dist/{openapi-Dcja4e1C.cjs → openapi-D6Hcfov0.cjs} +2 -2
  25. package/dist/{openapi-Dcja4e1C.cjs.map → openapi-D6Hcfov0.cjs.map} +1 -1
  26. package/dist/openapi.cjs +3 -3
  27. package/dist/openapi.mjs +3 -3
  28. package/dist/workspace/index.cjs +1 -1
  29. package/dist/workspace/index.d.cts +1 -1
  30. package/dist/workspace/index.d.mts +1 -1
  31. package/dist/workspace/index.mjs +1 -1
  32. package/dist/{workspace-CeFgIDC-.cjs → workspace-2Do2YcGZ.cjs} +5 -1
  33. package/dist/{workspace-CeFgIDC-.cjs.map → workspace-2Do2YcGZ.cjs.map} +1 -1
  34. package/dist/{workspace-Cb_I7oCJ.mjs → workspace-BW2iU37P.mjs} +5 -1
  35. package/dist/{workspace-Cb_I7oCJ.mjs.map → workspace-BW2iU37P.mjs.map} +1 -1
  36. package/package.json +2 -2
  37. package/src/deploy/__tests__/Route53Provider.spec.ts +23 -0
  38. package/src/deploy/__tests__/env-resolver.spec.ts +384 -2
  39. package/src/deploy/__tests__/index.spec.ts +393 -5
  40. package/src/deploy/__tests__/sniffer.spec.ts +104 -93
  41. package/src/deploy/dns/Route53Provider.ts +4 -1
  42. package/src/deploy/env-resolver.ts +20 -0
  43. package/src/deploy/index.ts +83 -24
  44. package/src/deploy/sniffer.ts +39 -7
  45. package/src/init/generators/monorepo.ts +7 -1
  46. package/src/init/generators/web.ts +45 -2
  47. package/src/workspace/schema.ts +8 -0
  48. package/src/workspace/types.ts +23 -0
  49. package/dist/Route53Provider-DOWmFnwN.mjs.map +0 -1
  50. package/dist/Route53Provider-xrWuBXih.cjs.map +0 -1
  51. package/dist/index-DvpWzLD7.d.mts.map +0 -1
  52. package/dist/index-DzmZ6SUW.d.cts.map +0 -1
@@ -31,8 +31,8 @@ describe('sniffAppEnvironment', () => {
31
31
  });
32
32
 
33
33
  describe('frontend apps', () => {
34
- it('should return empty env vars for frontend apps', async () => {
35
- const app = createApp({ type: 'frontend' });
34
+ it('should return empty env vars for frontend apps with no dependencies', async () => {
35
+ const app = createApp({ type: 'frontend', dependencies: [] });
36
36
 
37
37
  const result = await sniffAppEnvironment(app, 'web', workspacePath);
38
38
 
@@ -40,51 +40,117 @@ describe('sniffAppEnvironment', () => {
40
40
  expect(result.requiredEnvVars).toEqual([]);
41
41
  });
42
42
 
43
- it('should ignore requiredEnv for frontend apps', async () => {
43
+ it('should return NEXT_PUBLIC_{DEP}_URL for frontend dependencies', async () => {
44
44
  const app = createApp({
45
45
  type: 'frontend',
46
- requiredEnv: ['API_KEY', 'SECRET'], // Should be ignored
46
+ dependencies: ['api', 'auth'],
47
47
  });
48
48
 
49
49
  const result = await sniffAppEnvironment(app, 'web', workspacePath);
50
50
 
51
- expect(result.requiredEnvVars).toEqual([]);
51
+ expect(result.appName).toBe('web');
52
+ expect(result.requiredEnvVars).toContain('NEXT_PUBLIC_API_URL');
53
+ expect(result.requiredEnvVars).toContain('NEXT_PUBLIC_AUTH_URL');
54
+ expect(result.requiredEnvVars).toHaveLength(2);
52
55
  });
53
- });
54
56
 
55
- describe('entry-based apps with requiredEnv', () => {
56
- it('should return requiredEnv list for entry-based apps', async () => {
57
+ it('should generate uppercase dep names in NEXT_PUBLIC_{DEP}_URL', async () => {
57
58
  const app = createApp({
58
- entry: './src/index.ts',
59
- requiredEnv: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
59
+ type: 'frontend',
60
+ dependencies: ['payments-service', 'notification_api'],
60
61
  });
61
62
 
62
- const result = await sniffAppEnvironment(app, 'auth', workspacePath);
63
+ const result = await sniffAppEnvironment(app, 'web', workspacePath);
63
64
 
64
- expect(result.appName).toBe('auth');
65
- expect(result.requiredEnvVars).toEqual([
66
- 'DATABASE_URL',
67
- 'BETTER_AUTH_SECRET',
68
- ]);
65
+ expect(result.requiredEnvVars).toContain(
66
+ 'NEXT_PUBLIC_PAYMENTS-SERVICE_URL',
67
+ );
68
+ expect(result.requiredEnvVars).toContain(
69
+ 'NEXT_PUBLIC_NOTIFICATION_API_URL',
70
+ );
69
71
  });
70
72
 
71
- it('should return copy of requiredEnv (not reference)', async () => {
72
- const requiredEnv = ['DATABASE_URL'];
73
- const app = createApp({ requiredEnv });
74
-
75
- const result = await sniffAppEnvironment(app, 'api', workspacePath);
76
-
77
- // Modify the result and verify original is unchanged
78
- result.requiredEnvVars.push('MODIFIED');
79
- expect(requiredEnv).toEqual(['DATABASE_URL']);
80
- });
73
+ describe('config sniffing', () => {
74
+ it('should sniff env vars from config.client path', async () => {
75
+ const app = createApp({
76
+ type: 'frontend',
77
+ path: fixturesPath,
78
+ dependencies: [],
79
+ config: {
80
+ client: './simple-entry.ts',
81
+ },
82
+ });
83
+
84
+ const result = await sniffAppEnvironment(app, 'web', fixturesPath);
85
+
86
+ expect(result.requiredEnvVars).toContain('PORT');
87
+ expect(result.requiredEnvVars).toContain('DATABASE_URL');
88
+ expect(result.requiredEnvVars).toContain('REDIS_URL');
89
+ });
81
90
 
82
- it('should return empty when requiredEnv is empty array', async () => {
83
- const app = createApp({ requiredEnv: [] });
91
+ it('should sniff env vars from config.server path', async () => {
92
+ const app = createApp({
93
+ type: 'frontend',
94
+ path: fixturesPath,
95
+ dependencies: [],
96
+ config: {
97
+ server: './nested-config-entry.ts',
98
+ },
99
+ });
100
+
101
+ const result = await sniffAppEnvironment(app, 'web', fixturesPath);
102
+
103
+ expect(result.requiredEnvVars).toContain('PORT');
104
+ expect(result.requiredEnvVars).toContain('HOST');
105
+ expect(result.requiredEnvVars).toContain('DATABASE_URL');
106
+ });
84
107
 
85
- const result = await sniffAppEnvironment(app, 'api', workspacePath);
108
+ it('should combine vars from both config.client and config.server', async () => {
109
+ const app = createApp({
110
+ type: 'frontend',
111
+ path: fixturesPath,
112
+ dependencies: ['api'],
113
+ config: {
114
+ client: './simple-entry.ts',
115
+ server: './nested-config-entry.ts',
116
+ },
117
+ });
118
+
119
+ const result = await sniffAppEnvironment(app, 'web', fixturesPath);
120
+
121
+ // Dependency var
122
+ expect(result.requiredEnvVars).toContain('NEXT_PUBLIC_API_URL');
123
+ // From simple-entry.ts
124
+ expect(result.requiredEnvVars).toContain('REDIS_URL');
125
+ // From nested-config-entry.ts
126
+ expect(result.requiredEnvVars).toContain('HOST');
127
+ expect(result.requiredEnvVars).toContain('BETTER_AUTH_SECRET');
128
+ });
86
129
 
87
- expect(result.requiredEnvVars).toEqual([]);
130
+ it('should deduplicate vars from both config files', async () => {
131
+ const app = createApp({
132
+ type: 'frontend',
133
+ path: fixturesPath,
134
+ dependencies: [],
135
+ config: {
136
+ client: './simple-entry.ts',
137
+ server: './nested-config-entry.ts',
138
+ },
139
+ });
140
+
141
+ const result = await sniffAppEnvironment(app, 'web', fixturesPath);
142
+
143
+ // Both files have PORT and DATABASE_URL, should only appear once
144
+ const portCount = result.requiredEnvVars.filter(
145
+ (v) => v === 'PORT',
146
+ ).length;
147
+ const dbUrlCount = result.requiredEnvVars.filter(
148
+ (v) => v === 'DATABASE_URL',
149
+ ).length;
150
+
151
+ expect(portCount).toBe(1);
152
+ expect(dbUrlCount).toBe(1);
153
+ });
88
154
  });
89
155
  });
90
156
 
@@ -119,9 +185,9 @@ describe('sniffAppEnvironment', () => {
119
185
  });
120
186
 
121
187
  describe('apps without env detection', () => {
122
- it('should return empty when no envParser or requiredEnv', async () => {
188
+ it('should return empty when no envParser, entry, or routes', async () => {
123
189
  const app = createApp({
124
- // No envParser or requiredEnv
190
+ // No envParser, entry, or routes
125
191
  });
126
192
 
127
193
  const result = await sniffAppEnvironment(app, 'api', workspacePath);
@@ -142,7 +208,7 @@ describe('sniffAllApps', () => {
142
208
  port: 3000,
143
209
  dependencies: [],
144
210
  resolvedDeployTarget: 'dokploy',
145
- requiredEnv: ['DATABASE_URL', 'REDIS_URL'],
211
+ // No entry, routes, or envParser - will return empty
146
212
  },
147
213
  auth: {
148
214
  type: 'backend',
@@ -150,7 +216,7 @@ describe('sniffAllApps', () => {
150
216
  port: 3002,
151
217
  dependencies: [],
152
218
  resolvedDeployTarget: 'dokploy',
153
- requiredEnv: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
219
+ // No entry, routes, or envParser - will return empty
154
220
  },
155
221
  web: {
156
222
  type: 'frontend',
@@ -167,17 +233,17 @@ describe('sniffAllApps', () => {
167
233
 
168
234
  expect(results.get('api')).toEqual({
169
235
  appName: 'api',
170
- requiredEnvVars: ['DATABASE_URL', 'REDIS_URL'],
236
+ requiredEnvVars: [],
171
237
  });
172
238
 
173
239
  expect(results.get('auth')).toEqual({
174
240
  appName: 'auth',
175
- requiredEnvVars: ['DATABASE_URL', 'BETTER_AUTH_SECRET'],
241
+ requiredEnvVars: [],
176
242
  });
177
243
 
178
244
  expect(results.get('web')).toEqual({
179
245
  appName: 'web',
180
- requiredEnvVars: [], // Frontend - no secrets
246
+ requiredEnvVars: ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL'],
181
247
  });
182
248
  });
183
249
 
@@ -324,7 +390,7 @@ describe('entry app sniffing via subprocess', () => {
324
390
  describe('sniffAppEnvironment with entry apps', () => {
325
391
  // Integration tests for sniffAppEnvironment with entry-based apps
326
392
 
327
- it('should use subprocess sniffing for entry apps without requiredEnv', async () => {
393
+ it('should use subprocess sniffing for entry apps', async () => {
328
394
  const app: NormalizedAppConfig = {
329
395
  type: 'backend',
330
396
  path: fixturesPath,
@@ -342,25 +408,6 @@ describe('sniffAppEnvironment with entry apps', () => {
342
408
  expect(result.requiredEnvVars).toContain('REDIS_URL');
343
409
  });
344
410
 
345
- it('should prefer requiredEnv over sniffing for entry apps', async () => {
346
- const app: NormalizedAppConfig = {
347
- type: 'backend',
348
- path: fixturesPath,
349
- port: 3000,
350
- dependencies: [],
351
- resolvedDeployTarget: 'dokploy',
352
- entry: './simple-entry.ts',
353
- requiredEnv: ['CUSTOM_VAR', 'ANOTHER_VAR'], // Should use this instead of sniffing
354
- };
355
-
356
- const result = await sniffAppEnvironment(app, 'api', fixturesPath);
357
-
358
- expect(result.requiredEnvVars).toEqual(['CUSTOM_VAR', 'ANOTHER_VAR']);
359
- // Should NOT contain the sniffed vars since requiredEnv takes precedence
360
- expect(result.requiredEnvVars).not.toContain('PORT');
361
- expect(result.requiredEnvVars).not.toContain('DATABASE_URL');
362
- });
363
-
364
411
  it('should handle entry app that throws and still return captured env vars', async () => {
365
412
  const app: NormalizedAppConfig = {
366
413
  type: 'backend',
@@ -499,24 +546,6 @@ describe('sniffAppEnvironment with envParser apps', () => {
499
546
  expect(result.requiredEnvVars).toContain('DB_POOL_SIZE');
500
547
  });
501
548
 
502
- it('should prefer requiredEnv over envParser sniffing', async () => {
503
- const app: NormalizedAppConfig = {
504
- type: 'backend',
505
- path: envParserFixturesPath,
506
- port: 3000,
507
- dependencies: [],
508
- resolvedDeployTarget: 'dokploy',
509
- envParser: './valid-env-parser.ts#envParser',
510
- requiredEnv: ['CUSTOM_VAR'], // Should use this instead
511
- };
512
-
513
- const result = await sniffAppEnvironment(app, 'api', envParserFixturesPath);
514
-
515
- expect(result.requiredEnvVars).toEqual(['CUSTOM_VAR']);
516
- // Should NOT contain the sniffed vars
517
- expect(result.requiredEnvVars).not.toContain('PORT');
518
- });
519
-
520
549
  it('should handle envParser that exports non-function gracefully', async () => {
521
550
  const app: NormalizedAppConfig = {
522
551
  type: 'backend',
@@ -663,24 +692,6 @@ describe('sniffAppEnvironment with route-based apps', () => {
663
692
  expect(result.requiredEnvVars).toContain('AUTH_URL');
664
693
  });
665
694
 
666
- it('should prefer requiredEnv over route sniffing', async () => {
667
- const app: NormalizedAppConfig = {
668
- type: 'backend',
669
- path: routeAppsFixturesPath,
670
- port: 3000,
671
- dependencies: [],
672
- resolvedDeployTarget: 'dokploy',
673
- routes: './endpoints/**/*.ts',
674
- requiredEnv: ['CUSTOM_VAR'], // Should use this instead
675
- };
676
-
677
- const result = await sniffAppEnvironment(app, 'api', routeAppsFixturesPath);
678
-
679
- expect(result.requiredEnvVars).toEqual(['CUSTOM_VAR']);
680
- // Should NOT contain the sniffed vars
681
- expect(result.requiredEnvVars).not.toContain('DATABASE_URL');
682
- });
683
-
684
695
  it('should handle route pattern that matches no files', async () => {
685
696
  const app: NormalizedAppConfig = {
686
697
  type: 'backend',
@@ -45,8 +45,11 @@ export class Route53Provider implements DnsProvider {
45
45
  private hostedZoneCache: Map<string, string> = new Map();
46
46
 
47
47
  constructor(options: Route53ProviderOptions = {}) {
48
+ // Route53 is a global service, default to us-east-1 if no region specified
49
+ const region = options.region ?? process.env.AWS_REGION ?? 'us-east-1';
50
+
48
51
  this.client = new Route53Client({
49
- ...(options.region && { region: options.region }),
52
+ region,
50
53
  ...(options.endpoint && { endpoint: options.endpoint }),
51
54
  ...(options.profile && {
52
55
  credentials: fromIni({ profile: options.profile }),
@@ -50,6 +50,8 @@ export interface EnvResolverContext {
50
50
  userSecrets?: StageSecrets;
51
51
  /** Master key for runtime decryption (optional) */
52
52
  masterKey?: string;
53
+ /** URLs of deployed dependency apps (e.g., { auth: 'https://auth.example.com' }) */
54
+ dependencyUrls?: Record<string, string>;
53
55
  }
54
56
 
55
57
  /**
@@ -205,6 +207,24 @@ export function resolveEnvVar(
205
207
  break;
206
208
  }
207
209
 
210
+ // Check dependency URLs (e.g., AUTH_URL -> dependencyUrls.auth)
211
+ // Also supports NEXT_PUBLIC_ prefix for frontend apps (NEXT_PUBLIC_AUTH_URL -> dependencyUrls.auth)
212
+ if (context.dependencyUrls && varName.endsWith('_URL')) {
213
+ let depName: string;
214
+
215
+ if (varName.startsWith('NEXT_PUBLIC_')) {
216
+ // NEXT_PUBLIC_AUTH_URL -> auth
217
+ depName = varName.slice(12, -4).toLowerCase();
218
+ } else {
219
+ // AUTH_URL -> auth
220
+ depName = varName.slice(0, -4).toLowerCase();
221
+ }
222
+
223
+ if (context.dependencyUrls[depName]) {
224
+ return context.dependencyUrls[depName];
225
+ }
226
+ }
227
+
208
228
  // Check user-provided secrets
209
229
  if (context.userSecrets) {
210
230
  // Check custom secrets first
@@ -73,12 +73,7 @@ import {
73
73
  type DokployPostgres,
74
74
  type DokployRedis,
75
75
  } from './dokploy-api';
76
- import {
77
- generatePublicUrlBuildArgs,
78
- getPublicUrlArgNames,
79
- isMainFrontendApp,
80
- resolveHost,
81
- } from './domain.js';
76
+ import { isMainFrontendApp, resolveHost } from './domain.js';
82
77
  import {
83
78
  type EnvResolverContext,
84
79
  formatMissingVarsError,
@@ -1375,6 +1370,16 @@ export async function workspaceDeployCommand(
1375
1370
  false, // Backend apps are not main frontend
1376
1371
  );
1377
1372
 
1373
+ // Build dependency URLs from already-deployed apps
1374
+ const dependencyUrls: Record<string, string> = {};
1375
+ if (app.dependencies) {
1376
+ for (const dep of app.dependencies) {
1377
+ if (publicUrls[dep]) {
1378
+ dependencyUrls[dep] = publicUrls[dep];
1379
+ }
1380
+ }
1381
+ }
1382
+
1378
1383
  // Build env resolver context
1379
1384
  const envContext: EnvResolverContext = {
1380
1385
  app,
@@ -1400,6 +1405,7 @@ export async function workspaceDeployCommand(
1400
1405
  frontendUrls,
1401
1406
  userSecrets: stageSecrets ?? undefined,
1402
1407
  masterKey: appSecrets?.masterKey,
1408
+ dependencyUrls,
1403
1409
  };
1404
1410
 
1405
1411
  // Resolve all required environment variables
@@ -1565,13 +1571,71 @@ export async function workspaceDeployCommand(
1565
1571
  // Store application ID in state
1566
1572
  setApplicationId(state, appName, application.applicationId);
1567
1573
 
1568
- // Generate public URL build args from dependencies
1569
- const buildArgs = generatePublicUrlBuildArgs(app, publicUrls);
1574
+ // Build dependency URLs for frontend (same pattern as backend)
1575
+ const dependencyUrls: Record<string, string> = {};
1576
+ if (app.dependencies) {
1577
+ for (const dep of app.dependencies) {
1578
+ if (publicUrls[dep]) {
1579
+ dependencyUrls[dep] = publicUrls[dep];
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ // Compute hostname for this frontend app
1585
+ const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
1586
+ const frontendHost = resolveHost(
1587
+ appName,
1588
+ app,
1589
+ stage,
1590
+ dokployConfig,
1591
+ isMainFrontend,
1592
+ );
1593
+
1594
+ // Build env context for frontend
1595
+ const envContext: EnvResolverContext = {
1596
+ app,
1597
+ appName,
1598
+ stage,
1599
+ state,
1600
+ appHostname: frontendHost,
1601
+ frontendUrls: [],
1602
+ userSecrets: stageSecrets ?? undefined,
1603
+ dependencyUrls,
1604
+ };
1605
+
1606
+ // Resolve all env vars BEFORE Docker build (NEXT_PUBLIC_* must be present at build time)
1607
+ const sniffedVars = sniffedApps.get(appName)?.requiredEnvVars ?? [];
1608
+ const { valid, missing, resolved } = validateEnvVars(
1609
+ sniffedVars,
1610
+ envContext,
1611
+ );
1612
+
1613
+ if (!valid) {
1614
+ throw new Error(formatMissingVarsError(appName, missing, stage));
1615
+ }
1616
+
1617
+ if (Object.keys(resolved).length > 0) {
1618
+ logger.log(
1619
+ ` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(', ')}`,
1620
+ );
1621
+ }
1622
+
1623
+ // Build args: all NEXT_PUBLIC_* vars must be present at Next.js build time
1624
+ const buildArgs: string[] = [];
1625
+ const publicUrlArgNames: string[] = [];
1626
+
1627
+ for (const [key, value] of Object.entries(resolved)) {
1628
+ if (key.startsWith('NEXT_PUBLIC_')) {
1629
+ buildArgs.push(`${key}=${value}`);
1630
+ publicUrlArgNames.push(key);
1631
+ }
1632
+ }
1633
+
1570
1634
  if (buildArgs.length > 0) {
1571
- logger.log(` Public URLs: ${buildArgs.join(', ')}`);
1635
+ logger.log(` Build args: ${publicUrlArgNames.join(', ')}`);
1572
1636
  }
1573
1637
 
1574
- // Build Docker image with public URLs
1638
+ // Build Docker image with NEXT_PUBLIC_* vars as build args
1575
1639
  const imageName = `${workspace.name}-${appName}`;
1576
1640
  const imageRef = registry
1577
1641
  ? `${registry}/${imageName}:${imageTag}`
@@ -1589,17 +1653,22 @@ export async function workspaceDeployCommand(
1589
1653
  appName,
1590
1654
  },
1591
1655
  buildArgs,
1592
- // Pass public URL arg names for Dockerfile generation
1593
- publicUrlArgs: getPublicUrlArgNames(app),
1656
+ // Pass arg names for Dockerfile ARG generation
1657
+ publicUrlArgs: publicUrlArgNames,
1594
1658
  });
1595
1659
 
1596
- // Prepare environment variables - no secrets needed
1660
+ // Prepare runtime environment variables
1597
1661
  const envVars: string[] = [
1598
1662
  `NODE_ENV=production`,
1599
1663
  `PORT=${app.port}`,
1600
1664
  `STAGE=${stage}`,
1601
1665
  ];
1602
1666
 
1667
+ // Add all resolved vars as runtime env (for SSR and server components)
1668
+ for (const [key, value] of Object.entries(resolved)) {
1669
+ envVars.push(`${key}=${value}`);
1670
+ }
1671
+
1603
1672
  // Configure and deploy application in Dokploy
1604
1673
  await api.saveDockerProvider(application.applicationId, imageRef, {
1605
1674
  registryId,
@@ -1613,17 +1682,7 @@ export async function workspaceDeployCommand(
1613
1682
  logger.log(` Deploying to Dokploy...`);
1614
1683
  await api.deployApplication(application.applicationId);
1615
1684
 
1616
- // Create or find domain for this app
1617
- const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
1618
- const frontendHost = resolveHost(
1619
- appName,
1620
- app,
1621
- stage,
1622
- dokployConfig,
1623
- isMainFrontend,
1624
- );
1625
-
1626
- // Check if domain already exists
1685
+ // Check if domain already exists (frontendHost computed earlier for env context)
1627
1686
  const existingFrontendDomains = await api.getDomainsByApplicationId(
1628
1687
  application.applicationId,
1629
1688
  );
@@ -98,17 +98,49 @@ export async function sniffAppEnvironment(
98
98
  ): Promise<SniffedEnvironment> {
99
99
  const { logWarnings = true } = options;
100
100
 
101
- // 1. Frontend apps don't have server-side secrets
101
+ // 1. Frontend apps - handle dependencies and config sniffing
102
102
  if (app.type === 'frontend') {
103
- return { appName, requiredEnvVars: [] };
104
- }
103
+ // Auto-generate NEXT_PUBLIC_{DEP}_URL from dependencies
104
+ const depVars = (app.dependencies ?? []).map(
105
+ (dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`,
106
+ );
107
+
108
+ // If config specified, sniff by importing the file(s)
109
+ // The file calls .parse() at module load, which triggers sniffer to capture vars
110
+ if (app.config) {
111
+ const sniffedVars: string[] = [];
112
+
113
+ // Collect config paths to sniff
114
+ const configPaths: string[] = [];
115
+ if (app.config.client) configPaths.push(app.config.client);
116
+ if (app.config.server) configPaths.push(app.config.server);
117
+
118
+ // Sniff each config file
119
+ for (const configPath of configPaths) {
120
+ const result = await sniffEntryFile(
121
+ configPath,
122
+ app.path,
123
+ workspacePath,
124
+ );
125
+
126
+ if (logWarnings && result.error) {
127
+ console.warn(
128
+ `[sniffer] ${appName}: Config file "${configPath}" threw error during sniffing (env vars still captured): ${result.error.message}`,
129
+ );
130
+ }
131
+
132
+ sniffedVars.push(...result.envVars);
133
+ }
134
+
135
+ // Combine: dependency vars + sniffed vars (deduplicated)
136
+ const allVars = [...new Set([...depVars, ...sniffedVars])];
137
+ return { appName, requiredEnvVars: allVars };
138
+ }
105
139
 
106
- // 2. Entry-based apps with explicit env list
107
- if (app.requiredEnv && app.requiredEnv.length > 0) {
108
- return { appName, requiredEnvVars: [...app.requiredEnv] };
140
+ return { appName, requiredEnvVars: depVars };
109
141
  }
110
142
 
111
- // 3. Entry apps - import entry file in subprocess to trigger config.parse()
143
+ // 2. Entry apps - import entry file in subprocess to trigger config.parse()
112
144
  if (app.entry) {
113
145
  const result = await sniffEntryFile(app.entry, app.path, workspacePath);
114
146
 
@@ -48,6 +48,7 @@ export function generateMonorepoFiles(
48
48
  '@biomejs/biome': '~2.3.0',
49
49
  '@geekmidas/cli': GEEKMIDAS_VERSIONS['@geekmidas/cli'],
50
50
  esbuild: '~0.27.0',
51
+ tsx: '~4.20.0',
51
52
  turbo: '~2.3.0',
52
53
  typescript: '~5.8.2',
53
54
  vitest: '~4.0.0',
@@ -342,7 +343,8 @@ export default defineWorkspace({
342
343
  port: 3000,
343
344
  routes: '${getRoutesGlob()}',
344
345
  envParser: './src/config/env#envParser',
345
- logger: './src/config/logger#logger',`;
346
+ logger: './src/config/logger#logger',
347
+ dependencies: ['auth'],`;
346
348
 
347
349
  if (telescope) {
348
350
  config += `
@@ -371,6 +373,10 @@ export default defineWorkspace({
371
373
  path: 'apps/web',
372
374
  port: 3001,
373
375
  dependencies: ['api', 'auth'],
376
+ config: {
377
+ client: './src/config/client.ts',
378
+ server: './src/config/server.ts',
379
+ },
374
380
  client: {
375
381
  output: './src/api',
376
382
  },
@@ -133,12 +133,46 @@ export function getQueryClient() {
133
133
  }
134
134
  `;
135
135
 
136
+ // Client config - NEXT_PUBLIC_* vars (available in browser)
137
+ const clientConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
138
+
139
+ // Client config - only NEXT_PUBLIC_* vars (available in browser)
140
+ // These values are inlined at build time by Next.js
141
+ const envParser = new EnvironmentParser({
142
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
143
+ NEXT_PUBLIC_AUTH_URL: process.env.NEXT_PUBLIC_AUTH_URL,
144
+ });
145
+
146
+ export const clientConfig = envParser
147
+ .create((get) => ({
148
+ apiUrl: get('NEXT_PUBLIC_API_URL').string(),
149
+ authUrl: get('NEXT_PUBLIC_AUTH_URL').string(),
150
+ }))
151
+ .parse();
152
+ `;
153
+
154
+ // Server config - server-only vars (not available in browser)
155
+ const serverConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
156
+
157
+ // Server config - all env vars (server-side only, not exposed to browser)
158
+ // Access these only in Server Components, Route Handlers, or Server Actions
159
+ const envParser = new EnvironmentParser({ ...process.env });
160
+
161
+ export const serverConfig = envParser
162
+ .create((get) => ({
163
+ // Add server-only secrets here
164
+ // Example: stripeSecretKey: get('STRIPE_SECRET_KEY').string(),
165
+ }))
166
+ .parse();
167
+ `;
168
+
136
169
  // Auth client for better-auth
137
170
  const authClientTs = `import { createAuthClient } from 'better-auth/react';
138
171
  import { magicLinkClient } from 'better-auth/client/plugins';
172
+ import { clientConfig } from '~/config/client';
139
173
 
140
174
  export const authClient = createAuthClient({
141
- baseURL: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3002',
175
+ baseURL: clientConfig.authUrl,
142
176
  plugins: [magicLinkClient()],
143
177
  });
144
178
 
@@ -163,9 +197,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
163
197
  // API client setup - uses createApi with shared QueryClient
164
198
  const apiIndexTs = `import { createApi } from './openapi';
165
199
  import { getQueryClient } from '~/lib/query-client';
200
+ import { clientConfig } from '~/config/client';
166
201
 
167
202
  export const api = createApi({
168
- baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
203
+ baseURL: clientConfig.apiUrl,
169
204
  queryClient: getQueryClient(),
170
205
  });
171
206
  `;
@@ -295,6 +330,14 @@ node_modules/
295
330
  path: 'apps/web/src/app/page.tsx',
296
331
  content: pageTsx,
297
332
  },
333
+ {
334
+ path: 'apps/web/src/config/client.ts',
335
+ content: clientConfigTs,
336
+ },
337
+ {
338
+ path: 'apps/web/src/config/server.ts',
339
+ content: serverConfigTs,
340
+ },
298
341
  {
299
342
  path: 'apps/web/src/lib/query-client.ts',
300
343
  content: queryClientTs,
@@ -543,6 +543,14 @@ const AppConfigSchema = z
543
543
  framework: FrameworkSchema.optional(),
544
544
  client: ClientConfigSchema.optional(),
545
545
 
546
+ // Frontend-specific: config file paths for env sniffing (calls .parse() at import)
547
+ config: z
548
+ .object({
549
+ client: z.string().optional(),
550
+ server: z.string().optional(),
551
+ })
552
+ .optional(),
553
+
546
554
  // Auth-specific
547
555
  provider: AuthProviderSchema.optional(),
548
556
  })