@geekmidas/cli 0.52.0 → 0.54.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "openapi-typescript": "^7.4.2",
50
50
  "pg": "~8.17.1",
51
51
  "prompts": "~2.4.2",
52
- "@geekmidas/constructs": "~0.8.0",
52
+ "@geekmidas/constructs": "~0.9.0",
53
53
  "@geekmidas/envkit": "~0.7.0",
54
54
  "@geekmidas/errors": "~0.1.0",
55
55
  "@geekmidas/schema": "~0.1.0",
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Test endpoint with multiple services.
3
+ * getEnvironment() should return ['AUTH_SECRET', 'AUTH_URL', 'DATABASE_URL', 'DB_POOL_SIZE'].
4
+ */
5
+ import { e } from '@geekmidas/constructs/endpoints';
6
+ import { z } from 'zod';
7
+ import { authService, databaseService } from '../services';
8
+
9
+ export const login = e
10
+ .services([databaseService, authService])
11
+ .post('/auth/login')
12
+ .body(z.object({ email: z.string(), password: z.string() }))
13
+ .output(z.object({ token: z.string() }))
14
+ .handle(async () => {
15
+ return { token: 'test-token' };
16
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Test endpoint without any services.
3
+ * getEnvironment() should return [].
4
+ */
5
+ import { e } from '@geekmidas/constructs/endpoints';
6
+ import { z } from 'zod';
7
+
8
+ export const healthCheck = e
9
+ .get('/health')
10
+ .output(z.object({ status: z.string() }))
11
+ .handle(async () => {
12
+ return { status: 'ok' };
13
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Test endpoint with database service.
3
+ * getEnvironment() should return ['DATABASE_URL', 'DB_POOL_SIZE'].
4
+ */
5
+ import { e } from '@geekmidas/constructs/endpoints';
6
+ import { z } from 'zod';
7
+ import { databaseService } from '../services';
8
+
9
+ export const getUsers = e
10
+ .services([databaseService])
11
+ .get('/users')
12
+ .output(z.array(z.object({ id: z.string(), name: z.string() })))
13
+ .handle(async () => {
14
+ return [{ id: '1', name: 'Test User' }];
15
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Test services for route-based app sniffing fixtures.
3
+ * These services access environment variables via envParser.create().
4
+ */
5
+ import type { Service } from '@geekmidas/services';
6
+
7
+ // Database service - requires DATABASE_URL
8
+ export const databaseService = {
9
+ serviceName: 'database' as const,
10
+ async register({ envParser }) {
11
+ const config = envParser
12
+ .create((get: any) => ({
13
+ url: get('DATABASE_URL').string(),
14
+ poolSize: get('DB_POOL_SIZE').string().transform(Number).optional(),
15
+ }))
16
+ .parse();
17
+ return { url: config.url };
18
+ },
19
+ } satisfies Service<'database', { url: string }>;
20
+
21
+ // Cache service - requires REDIS_URL
22
+ export const cacheService = {
23
+ serviceName: 'cache' as const,
24
+ async register({ envParser }) {
25
+ const config = envParser
26
+ .create((get: any) => ({
27
+ url: get('REDIS_URL').string(),
28
+ }))
29
+ .parse();
30
+ return { url: config.url };
31
+ },
32
+ } satisfies Service<'cache', { url: string }>;
33
+
34
+ // Auth service - requires AUTH_SECRET and AUTH_URL
35
+ export const authService = {
36
+ serviceName: 'auth' as const,
37
+ async register({ envParser }) {
38
+ const config = envParser
39
+ .create((get: any) => ({
40
+ secret: get('AUTH_SECRET').string(),
41
+ url: get('AUTH_URL').string(),
42
+ }))
43
+ .parse();
44
+ return { secret: config.secret, url: config.url };
45
+ },
46
+ } satisfies Service<'auth', { secret: string; url: string }>;
@@ -191,15 +191,17 @@ describe('resolveEnvVar', () => {
191
191
  expect(resolveEnvVar('PORT', context)).toBe('8080');
192
192
  });
193
193
 
194
- it('should resolve NODE_ENV based on stage', () => {
194
+ it('should resolve NODE_ENV to production for all stages (deployed apps)', () => {
195
+ // NODE_ENV is always 'production' for deployed apps
196
+ // gkm dev handles development mode separately
195
197
  expect(resolveEnvVar('NODE_ENV', createContext({ stage: 'production' }))).toBe(
196
198
  'production',
197
199
  );
198
200
  expect(resolveEnvVar('NODE_ENV', createContext({ stage: 'staging' }))).toBe(
199
- 'development',
201
+ 'production',
200
202
  );
201
203
  expect(resolveEnvVar('NODE_ENV', createContext({ stage: 'development' }))).toBe(
202
- 'development',
204
+ 'production',
203
205
  );
204
206
  });
205
207
 
@@ -5,6 +5,7 @@ import type { NormalizedAppConfig } from '../../workspace/types';
5
5
  import {
6
6
  _sniffEntryFile,
7
7
  _sniffEnvParser,
8
+ _sniffRouteFiles,
8
9
  sniffAllApps,
9
10
  sniffAppEnvironment,
10
11
  } from '../sniffer';
@@ -13,6 +14,7 @@ const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = dirname(__filename);
14
15
  const fixturesPath = resolve(__dirname, '__fixtures__/entry-apps');
15
16
  const envParserFixturesPath = resolve(__dirname, '__fixtures__/env-parsers');
17
+ const routeAppsFixturesPath = resolve(__dirname, '__fixtures__/route-apps');
16
18
 
17
19
  describe('sniffAppEnvironment', () => {
18
20
  const workspacePath = '/test/workspace';
@@ -544,3 +546,175 @@ describe('sniffAppEnvironment with envParser apps', () => {
544
546
  expect(result.requiredEnvVars).toEqual([]);
545
547
  });
546
548
  });
549
+
550
+ describe('route files sniffing via _sniffRouteFiles', () => {
551
+ // These tests verify the route-based sniffing for apps with routes config.
552
+ // Each test uses fixture files that export endpoints with services.
553
+
554
+ it('should sniff environment variables from endpoint with single service', async () => {
555
+ const result = await _sniffRouteFiles(
556
+ './endpoints/users.ts',
557
+ routeAppsFixturesPath,
558
+ routeAppsFixturesPath,
559
+ );
560
+
561
+ expect(result.envVars).toContain('DATABASE_URL');
562
+ // DB_POOL_SIZE is optional, may or may not be captured
563
+ expect(result.error).toBeUndefined();
564
+ });
565
+
566
+ it('should sniff environment variables from endpoint with multiple services', async () => {
567
+ const result = await _sniffRouteFiles(
568
+ './endpoints/auth.ts',
569
+ routeAppsFixturesPath,
570
+ routeAppsFixturesPath,
571
+ );
572
+
573
+ expect(result.envVars).toContain('DATABASE_URL');
574
+ expect(result.envVars).toContain('AUTH_SECRET');
575
+ expect(result.envVars).toContain('AUTH_URL');
576
+ expect(result.error).toBeUndefined();
577
+ });
578
+
579
+ it('should return empty for endpoint without services', async () => {
580
+ const result = await _sniffRouteFiles(
581
+ './endpoints/health.ts',
582
+ routeAppsFixturesPath,
583
+ routeAppsFixturesPath,
584
+ );
585
+
586
+ expect(result.envVars).toEqual([]);
587
+ expect(result.error).toBeUndefined();
588
+ });
589
+
590
+ it('should sniff all endpoints matching glob pattern', async () => {
591
+ const result = await _sniffRouteFiles(
592
+ './endpoints/**/*.ts',
593
+ routeAppsFixturesPath,
594
+ routeAppsFixturesPath,
595
+ );
596
+
597
+ // Should capture env vars from all endpoints
598
+ expect(result.envVars).toContain('DATABASE_URL');
599
+ expect(result.envVars).toContain('AUTH_SECRET');
600
+ expect(result.envVars).toContain('AUTH_URL');
601
+ expect(result.error).toBeUndefined();
602
+ });
603
+
604
+ it('should return empty for non-existent pattern', async () => {
605
+ const result = await _sniffRouteFiles(
606
+ './nonexistent/**/*.ts',
607
+ routeAppsFixturesPath,
608
+ routeAppsFixturesPath,
609
+ );
610
+
611
+ expect(result.envVars).toEqual([]);
612
+ expect(result.error).toBeUndefined();
613
+ });
614
+
615
+ it('should handle array of patterns', async () => {
616
+ const result = await _sniffRouteFiles(
617
+ ['./endpoints/users.ts', './endpoints/health.ts'],
618
+ routeAppsFixturesPath,
619
+ routeAppsFixturesPath,
620
+ );
621
+
622
+ expect(result.envVars).toContain('DATABASE_URL');
623
+ expect(result.error).toBeUndefined();
624
+ });
625
+
626
+ it('should deduplicate env vars from multiple endpoints using same service', async () => {
627
+ const result = await _sniffRouteFiles(
628
+ ['./endpoints/users.ts', './endpoints/auth.ts'],
629
+ routeAppsFixturesPath,
630
+ routeAppsFixturesPath,
631
+ );
632
+
633
+ // DATABASE_URL is used by both endpoints, should only appear once
634
+ const databaseUrlCount = result.envVars.filter(
635
+ (v) => v === 'DATABASE_URL',
636
+ ).length;
637
+ expect(databaseUrlCount).toBe(1);
638
+ });
639
+
640
+ it('should return sorted env vars', async () => {
641
+ const result = await _sniffRouteFiles(
642
+ './endpoints/**/*.ts',
643
+ routeAppsFixturesPath,
644
+ routeAppsFixturesPath,
645
+ );
646
+
647
+ const sorted = [...result.envVars].sort();
648
+ expect(result.envVars).toEqual(sorted);
649
+ });
650
+ });
651
+
652
+ describe('sniffAppEnvironment with route-based apps', () => {
653
+ // Integration tests for sniffAppEnvironment with route-based apps
654
+
655
+ it('should use route sniffing for apps with routes config', async () => {
656
+ const app: NormalizedAppConfig = {
657
+ type: 'backend',
658
+ path: routeAppsFixturesPath,
659
+ port: 3000,
660
+ dependencies: [],
661
+ resolvedDeployTarget: 'dokploy',
662
+ routes: './endpoints/**/*.ts',
663
+ envParser: './src/config/env#envParser', // Should be ignored when routes exist
664
+ };
665
+
666
+ const result = await sniffAppEnvironment(
667
+ app,
668
+ 'api',
669
+ routeAppsFixturesPath,
670
+ );
671
+
672
+ expect(result.appName).toBe('api');
673
+ expect(result.requiredEnvVars).toContain('DATABASE_URL');
674
+ expect(result.requiredEnvVars).toContain('AUTH_SECRET');
675
+ expect(result.requiredEnvVars).toContain('AUTH_URL');
676
+ });
677
+
678
+ it('should prefer requiredEnv over route sniffing', async () => {
679
+ const app: NormalizedAppConfig = {
680
+ type: 'backend',
681
+ path: routeAppsFixturesPath,
682
+ port: 3000,
683
+ dependencies: [],
684
+ resolvedDeployTarget: 'dokploy',
685
+ routes: './endpoints/**/*.ts',
686
+ requiredEnv: ['CUSTOM_VAR'], // Should use this instead
687
+ };
688
+
689
+ const result = await sniffAppEnvironment(
690
+ app,
691
+ 'api',
692
+ routeAppsFixturesPath,
693
+ );
694
+
695
+ expect(result.requiredEnvVars).toEqual(['CUSTOM_VAR']);
696
+ // Should NOT contain the sniffed vars
697
+ expect(result.requiredEnvVars).not.toContain('DATABASE_URL');
698
+ });
699
+
700
+ it('should handle route pattern that matches no files', async () => {
701
+ const app: NormalizedAppConfig = {
702
+ type: 'backend',
703
+ path: routeAppsFixturesPath,
704
+ port: 3000,
705
+ dependencies: [],
706
+ resolvedDeployTarget: 'dokploy',
707
+ routes: './nonexistent/**/*.ts',
708
+ };
709
+
710
+ const result = await sniffAppEnvironment(
711
+ app,
712
+ 'api',
713
+ routeAppsFixturesPath,
714
+ { logWarnings: false },
715
+ );
716
+
717
+ expect(result.appName).toBe('api');
718
+ expect(result.requiredEnvVars).toEqual([]);
719
+ });
720
+ });
@@ -68,6 +68,7 @@ export interface EnvResolutionResult {
68
68
  export const AUTO_SUPPORTED_VARS = [
69
69
  'PORT',
70
70
  'NODE_ENV',
71
+ 'STAGE',
71
72
  'DATABASE_URL',
72
73
  'REDIS_URL',
73
74
  'BETTER_AUTH_URL',
@@ -157,7 +158,11 @@ export function resolveEnvVar(
157
158
  return String(context.app.port);
158
159
 
159
160
  case 'NODE_ENV':
160
- return context.stage === 'production' ? 'production' : 'development';
161
+ // Always 'production' for deployed apps (gkm dev handles development mode)
162
+ return 'production';
163
+
164
+ case 'STAGE':
165
+ return context.stage;
161
166
 
162
167
  case 'DATABASE_URL':
163
168
  if (context.appCredentials && context.postgres) {
@@ -1395,8 +1395,12 @@ export async function workspaceDeployCommand(
1395
1395
  };
1396
1396
 
1397
1397
  // Resolve all required environment variables
1398
+ // Always include PORT, NODE_ENV, STAGE even if not explicitly required
1398
1399
  const appRequirements = sniffedApps.get(appName);
1399
- const requiredVars = appRequirements?.requiredEnvVars ?? [];
1400
+ const sniffedVars = appRequirements?.requiredEnvVars ?? [];
1401
+ const requiredVars = [
1402
+ ...new Set(['PORT', 'NODE_ENV', 'STAGE', ...sniffedVars]),
1403
+ ];
1400
1404
  const { valid, missing, resolved } = validateEnvVars(
1401
1405
  requiredVars,
1402
1406
  envContext,
@@ -1582,7 +1586,11 @@ export async function workspaceDeployCommand(
1582
1586
  });
1583
1587
 
1584
1588
  // Prepare environment variables - no secrets needed
1585
- const envVars: string[] = [`NODE_ENV=production`, `PORT=${app.port}`];
1589
+ const envVars: string[] = [
1590
+ `NODE_ENV=production`,
1591
+ `PORT=${app.port}`,
1592
+ `STAGE=${stage}`,
1593
+ ];
1586
1594
 
1587
1595
  // Configure and deploy application in Dokploy
1588
1596
  await api.saveDockerProvider(application.applicationId, imageRef, {
@@ -2,12 +2,30 @@ import { existsSync } from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import type { Construct } from '@geekmidas/constructs';
6
+ import { Endpoint } from '@geekmidas/constructs/endpoints';
7
+ import { Function as GkmFunction } from '@geekmidas/constructs/functions';
8
+ import { Cron } from '@geekmidas/constructs/crons';
9
+ import { Subscriber } from '@geekmidas/constructs/subscribers';
5
10
  import type { SniffResult } from '@geekmidas/envkit/sniffer';
11
+ import fg from 'fast-glob';
6
12
  import type { NormalizedAppConfig } from '../workspace/types.js';
7
13
 
8
14
  const __filename = fileURLToPath(import.meta.url);
9
15
  const __dirname = dirname(__filename);
10
16
 
17
+ /**
18
+ * Check if a value is a gkm construct (Endpoint, Function, Cron, or Subscriber).
19
+ */
20
+ function isConstruct(value: unknown): value is Construct {
21
+ return (
22
+ Endpoint.isEndpoint(value) ||
23
+ GkmFunction.isFunction(value) ||
24
+ Cron.isCron(value) ||
25
+ Subscriber.isSubscriber(value)
26
+ );
27
+ }
28
+
11
29
  /**
12
30
  * Resolve the path to a sniffer helper file.
13
31
  * Handles both dev (.ts with tsx) and production (.mjs from dist).
@@ -67,8 +85,9 @@ export interface SniffAppOptions {
67
85
  * 1. Frontend apps: Returns empty (no server secrets)
68
86
  * 2. Apps with `requiredEnv`: Uses explicit list from config
69
87
  * 3. Entry apps: Imports entry file in subprocess to capture config.parse() calls
70
- * 4. Apps with `envParser`: Runs SnifferEnvironmentParser to detect usage
71
- * 5. Apps with neither: Returns empty
88
+ * 4. Route-based apps: Loads route files and calls getEnvironment() on each construct
89
+ * 5. Apps with `envParser` (no routes): Runs SnifferEnvironmentParser to detect usage
90
+ * 6. Apps with neither: Returns empty
72
91
  *
73
92
  * This function handles "fire and forget" async operations gracefully,
74
93
  * capturing errors and unhandled rejections without failing the build.
@@ -110,7 +129,20 @@ export async function sniffAppEnvironment(
110
129
  return { appName, requiredEnvVars: result.envVars };
111
130
  }
112
131
 
113
- // 4. Apps with envParser - run sniffer to detect env var usage
132
+ // 4. Route-based apps - load routes and call getEnvironment() on each construct
133
+ if (app.routes) {
134
+ const result = await sniffRouteFiles(app.routes, app.path, workspacePath);
135
+
136
+ if (logWarnings && result.error) {
137
+ console.warn(
138
+ `[sniffer] ${appName}: Route sniffing threw error (env vars still captured): ${result.error.message}`,
139
+ );
140
+ }
141
+
142
+ return { appName, requiredEnvVars: result.envVars };
143
+ }
144
+
145
+ // 5. Apps with envParser but no routes - run sniffer to detect env var usage
114
146
  if (app.envParser) {
115
147
  const result = await sniffEnvParser(app.envParser, app.path, workspacePath);
116
148
 
@@ -232,6 +264,73 @@ async function sniffEntryFile(
232
264
  });
233
265
  }
234
266
 
267
+ /**
268
+ * Sniff route files by loading constructs and calling getEnvironment().
269
+ *
270
+ * Route-based apps have endpoints, functions, crons, and subscribers that
271
+ * use services. Each service's register() method accesses environment variables.
272
+ * This function mimics what the bundler does during build to capture those vars.
273
+ *
274
+ * @param routes - Glob pattern(s) for route files
275
+ * @param appPath - The app's path relative to workspace (e.g., 'apps/api')
276
+ * @param workspacePath - Absolute path to workspace root
277
+ * @returns EntrySniffResult with env vars and optional error
278
+ */
279
+ async function sniffRouteFiles(
280
+ routes: string | string[],
281
+ appPath: string,
282
+ workspacePath: string,
283
+ ): Promise<EntrySniffResult> {
284
+ const fullAppPath = resolve(workspacePath, appPath);
285
+ const patterns = Array.isArray(routes) ? routes : [routes];
286
+
287
+ const envVars = new Set<string>();
288
+ let error: Error | undefined;
289
+
290
+ try {
291
+ // Find all route files matching the patterns
292
+ const files = await fg(patterns, {
293
+ cwd: fullAppPath,
294
+ absolute: true,
295
+ });
296
+
297
+ // Import each file and find constructs
298
+ for (const file of files) {
299
+ try {
300
+ const module = await import(file);
301
+
302
+ // Check all exports for constructs
303
+ for (const [, exportValue] of Object.entries(module)) {
304
+ if (isConstruct(exportValue)) {
305
+ // Call getEnvironment() to capture env vars from services
306
+ try {
307
+ const constructEnvVars = await exportValue.getEnvironment();
308
+ constructEnvVars.forEach((v) => envVars.add(v));
309
+ } catch (e) {
310
+ // Individual construct may fail, continue with others
311
+ console.warn(
312
+ `[sniffer] Failed to get environment for construct in ${file}: ${e instanceof Error ? e.message : String(e)}`,
313
+ );
314
+ }
315
+ }
316
+ }
317
+ } catch (e) {
318
+ // Individual file import may fail, continue with others
319
+ console.warn(
320
+ `[sniffer] Failed to import ${file}: ${e instanceof Error ? e.message : String(e)}`,
321
+ );
322
+ }
323
+ }
324
+ } catch (e) {
325
+ error = e instanceof Error ? e : new Error(String(e));
326
+ }
327
+
328
+ return {
329
+ envVars: Array.from(envVars).sort(),
330
+ error,
331
+ };
332
+ }
333
+
235
334
  /**
236
335
  * Run the SnifferEnvironmentParser on an envParser module to detect
237
336
  * which environment variables it accesses.
@@ -333,4 +432,8 @@ export async function sniffAllApps(
333
432
  }
334
433
 
335
434
  // Export for testing
336
- export { sniffEnvParser as _sniffEnvParser, sniffEntryFile as _sniffEntryFile };
435
+ export {
436
+ sniffEnvParser as _sniffEnvParser,
437
+ sniffEntryFile as _sniffEntryFile,
438
+ sniffRouteFiles as _sniffRouteFiles,
439
+ };
@@ -293,7 +293,7 @@ WORKDIR /app
293
293
  COPY . .
294
294
 
295
295
  # Build production server using gkm
296
- RUN pnpm exec gkm build --provider server --production
296
+ RUN ${pm.exec} gkm build --provider server --production
297
297
 
298
298
  # Stage 3: Production
299
299
  FROM ${baseImage} AS runner
@@ -384,7 +384,7 @@ WORKDIR /app
384
384
  COPY --from=pruner /app/out/full/ ./
385
385
 
386
386
  # Build production server using gkm
387
- RUN pnpm exec gkm build --provider server --production
387
+ RUN ${pm.exec} gkm build --provider server --production
388
388
 
389
389
  # Stage 4: Production
390
390
  FROM ${baseImage} AS runner
@@ -756,7 +756,7 @@ RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
756
756
  fi
757
757
 
758
758
  # Build production server using gkm
759
- RUN cd ${appPath} && pnpm exec gkm build --provider server --production
759
+ RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
760
760
 
761
761
  # Stage 4: Production
762
762
  FROM ${baseImage} AS runner
@@ -811,7 +811,9 @@ export interface EntryDockerfileOptions {
811
811
  * This is used for apps that don't use gkm routes (e.g., Better Auth servers).
812
812
  * @internal Exported for testing
813
813
  */
814
- export function generateEntryDockerfile(options: EntryDockerfileOptions): string {
814
+ export function generateEntryDockerfile(
815
+ options: EntryDockerfileOptions,
816
+ ): string {
815
817
  const {
816
818
  baseImage,
817
819
  port,