@geekmidas/cli 1.10.30 → 1.10.32

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 (67) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/bin/gkm.mjs +1 -1
  3. package/dist/{config-jsRYHOHU.mjs → config-B62g483e.mjs} +2 -2
  4. package/dist/{config-jsRYHOHU.mjs.map → config-B62g483e.mjs.map} +1 -1
  5. package/dist/{config-D3ORuiUs.cjs → config-Cuo8vFsp.cjs} +2 -2
  6. package/dist/{config-D3ORuiUs.cjs.map → config-Cuo8vFsp.cjs.map} +1 -1
  7. package/dist/config.cjs +2 -2
  8. package/dist/config.d.cts +1 -1
  9. package/dist/config.d.mts +1 -1
  10. package/dist/config.mjs +2 -2
  11. package/dist/deploy/sniffer-worker.cjs +2 -0
  12. package/dist/deploy/sniffer-worker.cjs.map +1 -1
  13. package/dist/deploy/sniffer-worker.mjs +2 -0
  14. package/dist/deploy/sniffer-worker.mjs.map +1 -1
  15. package/dist/{index-BkibYzso.d.cts → index-C3t5VL4R.d.cts} +2 -32
  16. package/dist/index-C3t5VL4R.d.cts.map +1 -0
  17. package/dist/{index-CY-ieuRp.d.mts → index-Dt_dZ7K4.d.mts} +2 -32
  18. package/dist/index-Dt_dZ7K4.d.mts.map +1 -0
  19. package/dist/index.cjs +65 -26
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.mjs +65 -26
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/{openapi-ClA8lIhO.cjs → openapi-BQft1jm8.cjs} +5 -22
  24. package/dist/openapi-BQft1jm8.cjs.map +1 -0
  25. package/dist/{openapi-CTARkkkn.mjs → openapi-DE8b-vin.mjs} +6 -23
  26. package/dist/openapi-DE8b-vin.mjs.map +1 -0
  27. package/dist/openapi.cjs +3 -3
  28. package/dist/openapi.mjs +3 -3
  29. package/dist/workspace/index.cjs +1 -1
  30. package/dist/workspace/index.d.cts +2 -2
  31. package/dist/workspace/index.d.mts +2 -2
  32. package/dist/workspace/index.mjs +1 -1
  33. package/dist/{workspace-D4z4A4cq.mjs → workspace-Bi4X7Yzy.mjs} +1 -6
  34. package/dist/{workspace-D4z4A4cq.mjs.map → workspace-Bi4X7Yzy.mjs.map} +1 -1
  35. package/dist/{workspace-4SP3Gx4Y.cjs → workspace-CGYykWfn.cjs} +1 -6
  36. package/dist/{workspace-4SP3Gx4Y.cjs.map → workspace-CGYykWfn.cjs.map} +1 -1
  37. package/package.json +4 -4
  38. package/src/__tests__/openapi.spec.ts +0 -315
  39. package/src/build/__tests__/manifests.spec.ts +66 -0
  40. package/src/build/index.ts +12 -1
  41. package/src/build/types.ts +5 -0
  42. package/src/deploy/__tests__/__fixtures__/env-parsers/optional-env-parser.ts +21 -0
  43. package/src/deploy/__tests__/index.spec.ts +1 -3
  44. package/src/deploy/__tests__/sniffer.spec.ts +123 -0
  45. package/src/deploy/sniffer-worker.ts +2 -1
  46. package/src/deploy/sniffer.ts +86 -12
  47. package/src/docker/compose.ts +2 -0
  48. package/src/generators/CronGenerator.ts +3 -1
  49. package/src/generators/EndpointGenerator.ts +6 -2
  50. package/src/generators/FunctionGenerator.ts +3 -1
  51. package/src/generators/SubscriberGenerator.ts +3 -1
  52. package/src/index.ts +8 -0
  53. package/src/init/generators/docker.ts +2 -2
  54. package/src/init/generators/monorepo.ts +15 -1
  55. package/src/init/index.ts +2 -4
  56. package/src/init/versions.ts +2 -2
  57. package/src/openapi.ts +1 -35
  58. package/src/types.ts +7 -0
  59. package/src/workspace/__tests__/client-generator.spec.ts +1 -485
  60. package/src/workspace/client-generator.ts +1 -145
  61. package/src/workspace/index.ts +0 -1
  62. package/src/workspace/schema.ts +0 -9
  63. package/src/workspace/types.ts +0 -29
  64. package/dist/index-BkibYzso.d.cts.map +0 -1
  65. package/dist/index-CY-ieuRp.d.mts.map +0 -1
  66. package/dist/openapi-CTARkkkn.mjs.map +0 -1
  67. package/dist/openapi-ClA8lIhO.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.30",
3
+ "version": "1.10.32",
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/constructs": "~3.0.7",
60
- "@geekmidas/envkit": "~1.0.4",
59
+ "@geekmidas/constructs": "~3.0.8",
60
+ "@geekmidas/envkit": "~1.0.5",
61
61
  "@geekmidas/errors": "~1.0.0",
62
- "@geekmidas/logger": "~1.0.0",
62
+ "@geekmidas/logger": "~1.0.1",
63
63
  "@geekmidas/schema": "~1.0.0"
64
64
  },
65
65
  "devDependencies": {
@@ -532,319 +532,4 @@ describe('openapiCommand - workspace mode', () => {
532
532
  expect.stringContaining('[api] Generated OpenAPI'),
533
533
  );
534
534
  });
535
-
536
- it('should copy OpenAPI to frontend app with client.output', async () => {
537
- // Create workspace structure
538
- const apiDir = join(tempDir, 'apps/api');
539
- const webDir = join(tempDir, 'apps/web');
540
- await mkdir(apiDir, { recursive: true });
541
- await mkdir(webDir, { recursive: true });
542
-
543
- // Create endpoint in backend app
544
- await createMockEndpointFile(
545
- apiDir,
546
- 'src/endpoints/users.ts',
547
- 'getUsers',
548
- '/users',
549
- 'GET',
550
- );
551
-
552
- // Create workspace config with frontend that depends on backend
553
- await createTestFile(
554
- tempDir,
555
- 'gkm.config.json',
556
- JSON.stringify({
557
- name: 'test-workspace',
558
- apps: {
559
- api: {
560
- type: 'backend',
561
- path: 'apps/api',
562
- port: 3000,
563
- routes: './src/endpoints/**/*.ts',
564
- openapi: { enabled: true },
565
- },
566
- web: {
567
- type: 'frontend',
568
- framework: 'nextjs',
569
- path: 'apps/web',
570
- port: 3001,
571
- dependencies: ['api'],
572
- client: {
573
- output: './src/api',
574
- },
575
- },
576
- },
577
- }),
578
- );
579
-
580
- process.chdir(tempDir);
581
- const consoleSpy = vi.spyOn(console, 'log');
582
-
583
- await openapiCommand({ cwd: tempDir });
584
-
585
- // Should generate OpenAPI in backend app
586
- const backendOutput = join(apiDir, OPENAPI_OUTPUT_PATH);
587
- expect(existsSync(backendOutput)).toBe(true);
588
-
589
- // Should copy to frontend app's client output path
590
- const frontendOutput = join(webDir, 'src/api/openapi.ts');
591
- expect(existsSync(frontendOutput)).toBe(true);
592
-
593
- // Content should be the same
594
- const backendContent = await readFile(backendOutput, 'utf-8');
595
- const frontendContent = await readFile(frontendOutput, 'utf-8');
596
- expect(frontendContent).toBe(backendContent);
597
-
598
- expect(consoleSpy).toHaveBeenCalledWith(
599
- expect.stringContaining('[web] ./src/api/openapi.ts'),
600
- );
601
- });
602
-
603
- it('should only copy to frontend apps that depend on the backend', async () => {
604
- // Create workspace structure with multiple apps
605
- const apiDir = join(tempDir, 'apps/api');
606
- const authDir = join(tempDir, 'apps/auth');
607
- const webDir = join(tempDir, 'apps/web');
608
- const adminDir = join(tempDir, 'apps/admin');
609
- await mkdir(apiDir, { recursive: true });
610
- await mkdir(authDir, { recursive: true });
611
- await mkdir(webDir, { recursive: true });
612
- await mkdir(adminDir, { recursive: true });
613
-
614
- // Create endpoints
615
- await createMockEndpointFile(
616
- apiDir,
617
- 'src/endpoints/users.ts',
618
- 'getUsers',
619
- '/users',
620
- 'GET',
621
- );
622
- await createMockEndpointFile(
623
- authDir,
624
- 'src/endpoints/login.ts',
625
- 'login',
626
- '/login',
627
- 'POST',
628
- );
629
-
630
- // Create workspace config
631
- await createTestFile(
632
- tempDir,
633
- 'gkm.config.json',
634
- JSON.stringify({
635
- name: 'test-workspace',
636
- apps: {
637
- api: {
638
- type: 'backend',
639
- path: 'apps/api',
640
- port: 3000,
641
- routes: './src/endpoints/**/*.ts',
642
- openapi: { enabled: true },
643
- },
644
- auth: {
645
- type: 'backend',
646
- path: 'apps/auth',
647
- port: 3001,
648
- routes: './src/endpoints/**/*.ts',
649
- openapi: { enabled: true },
650
- },
651
- web: {
652
- type: 'frontend',
653
- framework: 'nextjs',
654
- path: 'apps/web',
655
- port: 3002,
656
- dependencies: ['api'], // Only depends on api
657
- client: {
658
- output: './src/api',
659
- },
660
- },
661
- admin: {
662
- type: 'frontend',
663
- framework: 'nextjs',
664
- path: 'apps/admin',
665
- port: 3003,
666
- dependencies: ['auth'], // Only depends on auth
667
- client: {
668
- output: './src/client',
669
- },
670
- },
671
- },
672
- }),
673
- );
674
-
675
- process.chdir(tempDir);
676
-
677
- await openapiCommand({ cwd: tempDir });
678
-
679
- // Web should have api's OpenAPI (not auth's)
680
- const webApiOutput = join(webDir, 'src/api/openapi.ts');
681
- expect(existsSync(webApiOutput)).toBe(true);
682
- const webContent = await readFile(webApiOutput, 'utf-8');
683
- expect(webContent).toContain("'/users'");
684
- expect(webContent).not.toContain("'/login'");
685
-
686
- // Admin should have auth's OpenAPI (not api's)
687
- const adminClientOutput = join(adminDir, 'src/client/openapi.ts');
688
- expect(existsSync(adminClientOutput)).toBe(true);
689
- const adminContent = await readFile(adminClientOutput, 'utf-8');
690
- expect(adminContent).toContain("'/login'");
691
- expect(adminContent).not.toContain("'/users'");
692
- });
693
-
694
- it('should not copy to frontend with empty dependencies array', async () => {
695
- // Create workspace structure
696
- const apiDir = join(tempDir, 'apps/api');
697
- const webDir = join(tempDir, 'apps/web');
698
- await mkdir(apiDir, { recursive: true });
699
- await mkdir(webDir, { recursive: true });
700
-
701
- await createMockEndpointFile(
702
- apiDir,
703
- 'src/endpoints/users.ts',
704
- 'getUsers',
705
- '/users',
706
- 'GET',
707
- );
708
-
709
- // Frontend with empty dependencies array (depends on nothing)
710
- await createTestFile(
711
- tempDir,
712
- 'gkm.config.json',
713
- JSON.stringify({
714
- name: 'test-workspace',
715
- apps: {
716
- api: {
717
- type: 'backend',
718
- path: 'apps/api',
719
- port: 3000,
720
- routes: './src/endpoints/**/*.ts',
721
- openapi: { enabled: true },
722
- },
723
- web: {
724
- type: 'frontend',
725
- framework: 'nextjs',
726
- path: 'apps/web',
727
- port: 3001,
728
- dependencies: [], // Empty array means depends on nothing
729
- client: {
730
- output: './src/api',
731
- },
732
- },
733
- },
734
- }),
735
- );
736
-
737
- process.chdir(tempDir);
738
-
739
- await openapiCommand({ cwd: tempDir });
740
-
741
- // Should NOT copy to frontend since it has no dependencies
742
- const frontendOutput = join(webDir, 'src/api/openapi.ts');
743
- expect(existsSync(frontendOutput)).toBe(false);
744
- });
745
-
746
- it('should skip frontend apps without client.output', async () => {
747
- const apiDir = join(tempDir, 'apps/api');
748
- const webDir = join(tempDir, 'apps/web');
749
- await mkdir(apiDir, { recursive: true });
750
- await mkdir(webDir, { recursive: true });
751
-
752
- await createMockEndpointFile(
753
- apiDir,
754
- 'src/endpoints/users.ts',
755
- 'getUsers',
756
- '/users',
757
- 'GET',
758
- );
759
-
760
- // Frontend without client.output
761
- await createTestFile(
762
- tempDir,
763
- 'gkm.config.json',
764
- JSON.stringify({
765
- name: 'test-workspace',
766
- apps: {
767
- api: {
768
- type: 'backend',
769
- path: 'apps/api',
770
- port: 3000,
771
- routes: './src/endpoints/**/*.ts',
772
- openapi: { enabled: true },
773
- },
774
- web: {
775
- type: 'frontend',
776
- framework: 'nextjs',
777
- path: 'apps/web',
778
- port: 3001,
779
- dependencies: ['api'],
780
- // No client.output configured
781
- },
782
- },
783
- }),
784
- );
785
-
786
- process.chdir(tempDir);
787
-
788
- await openapiCommand({ cwd: tempDir });
789
-
790
- // Backend should still have OpenAPI generated
791
- expect(existsSync(join(apiDir, OPENAPI_OUTPUT_PATH))).toBe(true);
792
-
793
- // But no files should be created in frontend
794
- expect(existsSync(join(webDir, 'src/api/openapi.ts'))).toBe(false);
795
- });
796
-
797
- it('should handle nested client.output paths', async () => {
798
- const apiDir = join(tempDir, 'apps/api');
799
- const webDir = join(tempDir, 'apps/web');
800
- await mkdir(apiDir, { recursive: true });
801
- await mkdir(webDir, { recursive: true });
802
-
803
- await createMockEndpointFile(
804
- apiDir,
805
- 'src/endpoints/users.ts',
806
- 'getUsers',
807
- '/users',
808
- 'GET',
809
- );
810
-
811
- // Deeply nested client output path
812
- await createTestFile(
813
- tempDir,
814
- 'gkm.config.json',
815
- JSON.stringify({
816
- name: 'test-workspace',
817
- apps: {
818
- api: {
819
- type: 'backend',
820
- path: 'apps/api',
821
- port: 3000,
822
- routes: './src/endpoints/**/*.ts',
823
- openapi: { enabled: true },
824
- },
825
- web: {
826
- type: 'frontend',
827
- framework: 'nextjs',
828
- path: 'apps/web',
829
- port: 3001,
830
- dependencies: ['api'],
831
- client: {
832
- output: './src/lib/api/generated',
833
- },
834
- },
835
- },
836
- }),
837
- );
838
-
839
- process.chdir(tempDir);
840
-
841
- await openapiCommand({ cwd: tempDir });
842
-
843
- // Should create nested directories and file
844
- const frontendOutput = join(webDir, 'src/lib/api/generated/openapi.ts');
845
- expect(existsSync(frontendOutput)).toBe(true);
846
-
847
- const content = await readFile(frontendOutput, 'utf-8');
848
- expect(content).toContain('export interface paths');
849
- });
850
535
  });
@@ -346,6 +346,72 @@ describe('generateServerManifest', () => {
346
346
  });
347
347
  });
348
348
 
349
+ describe('generateAwsManifest (optional env vars)', () => {
350
+ itWithDir(
351
+ 'should preserve ? suffix on optional env vars in route environment',
352
+ async ({ dir }) => {
353
+ const routes: RouteInfo[] = [
354
+ {
355
+ path: '/users',
356
+ method: 'GET',
357
+ handler: '.gkm/aws/getUsers.handler',
358
+ authorizer: 'none',
359
+ environment: ['DATABASE_URL', 'PORT?', 'LOG_LEVEL?'],
360
+ },
361
+ ];
362
+
363
+ await generateAwsManifest(dir, routes, [], [], []);
364
+
365
+ const content = await readFile(join(dir, 'manifest', 'aws.ts'), 'utf-8');
366
+
367
+ expect(content).toContain('"DATABASE_URL"');
368
+ expect(content).toContain('"PORT?"');
369
+ expect(content).toContain('"LOG_LEVEL?"');
370
+ },
371
+ );
372
+
373
+ itWithDir(
374
+ 'should preserve ? suffix on optional env vars in function environment',
375
+ async ({ dir }) => {
376
+ const functions: FunctionInfo[] = [
377
+ {
378
+ name: 'processData',
379
+ handler: '.gkm/aws/processData.handler',
380
+ environment: ['DATABASE_URL', 'TIMEOUT?'],
381
+ },
382
+ ];
383
+
384
+ await generateAwsManifest(dir, [], functions, [], []);
385
+
386
+ const content = await readFile(join(dir, 'manifest', 'aws.ts'), 'utf-8');
387
+
388
+ expect(content).toContain('"DATABASE_URL"');
389
+ expect(content).toContain('"TIMEOUT?"');
390
+ },
391
+ );
392
+
393
+ itWithDir(
394
+ 'should preserve ? suffix on optional env vars in subscriber environment',
395
+ async ({ dir }) => {
396
+ const subscribers: SubscriberInfo[] = [
397
+ {
398
+ name: 'orderHandler',
399
+ handler: '.gkm/aws/orderHandler.handler',
400
+ subscribedEvents: ['order.created'],
401
+ environment: ['DATABASE_URL', 'RETRY_LIMIT?'],
402
+ },
403
+ ];
404
+
405
+ await generateAwsManifest(dir, [], [], [], subscribers);
406
+
407
+ const content = await readFile(join(dir, 'manifest', 'aws.ts'), 'utf-8');
408
+
409
+ expect(content).toContain('"DATABASE_URL"');
410
+ expect(content).toContain('"RETRY_LIMIT?"');
411
+ },
412
+ );
413
+ });
414
+
349
415
  describe('generateAwsManifest (partitioned)', () => {
350
416
  itWithDir(
351
417
  'should generate manifest with partitioned routes and flat functions',
@@ -26,6 +26,7 @@ import {
26
26
  type GeneratedConstruct,
27
27
  SubscriberGenerator,
28
28
  } from '../generators';
29
+ import { generateOpenApi, openapiCommand } from '../openapi.js';
29
30
  import {
30
31
  type BuildOptions,
31
32
  type BuildResult,
@@ -157,6 +158,7 @@ export async function buildCommand(
157
158
  hooks,
158
159
  production,
159
160
  dockerServices,
161
+ markOptional: options.markOptional ?? false,
160
162
  };
161
163
 
162
164
  // Initialize generators
@@ -219,6 +221,10 @@ export async function buildCommand(
219
221
  result = providerResult;
220
222
  }
221
223
  }
224
+
225
+ // Generate OpenAPI spec as part of the build
226
+ await generateOpenApi(config, { bustCache: true });
227
+
222
228
  return result;
223
229
  }
224
230
 
@@ -245,7 +251,9 @@ async function buildForProvider(
245
251
 
246
252
  logger.log(`\nGenerating handlers for provider: ${provider}`);
247
253
 
248
- // Build all constructs in parallel
254
+ // Build all constructs in parallel.
255
+ // context.markOptional is forwarded to each generator so that
256
+ // getEnvironment({ markOptional }) produces `VARNAME?` for optional vars.
249
257
  const [routes, functionInfos, cronInfos, subscriberInfos] = await Promise.all(
250
258
  [
251
259
  endpointGenerator.build(context, endpoints, outputDir, {
@@ -472,6 +480,9 @@ export async function workspaceBuildCommand(
472
480
 
473
481
  logger.log(`\n✅ Workspace build complete!`);
474
482
 
483
+ // Generate OpenAPI specs and copy to frontend apps
484
+ await openapiCommand({ cwd: workspace.root });
485
+
475
486
  // Summary
476
487
  logger.log(`\n📋 Build Summary:`);
477
488
  for (const result of results) {
@@ -90,6 +90,11 @@ export interface BuildContext {
90
90
  redis?: boolean;
91
91
  rabbitmq?: boolean;
92
92
  };
93
+ /**
94
+ * When true, optional env vars get a `?` suffix in each construct's
95
+ * environment array (e.g. `PORT?` instead of `PORT`).
96
+ */
97
+ markOptional?: boolean;
93
98
  }
94
99
 
95
100
  export interface ProviderBuildResult {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * An envParser fixture that mixes required and optional/default variables.
3
+ * Used to test the markOptional feature.
4
+ */
5
+ import type { SnifferEnvironmentParser } from '@geekmidas/envkit/sniffer';
6
+
7
+ export function envParser(parser: SnifferEnvironmentParser) {
8
+ return parser.create((get) => ({
9
+ // Required
10
+ databaseUrl: get('DATABASE_URL').string(),
11
+ apiKey: get('API_KEY').string(),
12
+ // Optional via .optional()
13
+ logLevel: get('LOG_LEVEL').string().optional(),
14
+ // Optional via .default()
15
+ port: get('PORT').string().default('3000'),
16
+ // Optional via .default() after transform
17
+ timeout: get('TIMEOUT').string().transform(Number).default(5000),
18
+ }));
19
+ }
20
+
21
+ export default envParser;
@@ -1,6 +1,7 @@
1
1
  import { HttpResponse, http } from 'msw';
2
2
  import { setupServer } from 'msw/node';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { getAppBuildOrder } from '../../workspace/index.js';
4
5
  import type { NormalizedWorkspace } from '../../workspace/types.js';
5
6
  import { DokployApi } from '../dokploy-api';
6
7
  import {
@@ -536,8 +537,6 @@ describe('workspaceDeployCommand', () => {
536
537
  },
537
538
  });
538
539
 
539
- // Import getAppBuildOrder to verify ordering
540
- const { getAppBuildOrder } = require('../../workspace/index.js');
541
540
  const order = getAppBuildOrder(workspace);
542
541
 
543
542
  // api and auth should come before web
@@ -577,7 +576,6 @@ describe('workspaceDeployCommand', () => {
577
576
  },
578
577
  });
579
578
 
580
- const { getAppBuildOrder } = require('../../workspace/index.js');
581
579
  const order = getAppBuildOrder(workspace);
582
580
 
583
581
  // db -> api -> web
@@ -193,6 +193,126 @@ describe('sniffAppEnvironment', () => {
193
193
  const result = await sniffAppEnvironment(app, 'api', workspacePath);
194
194
 
195
195
  expect(result.requiredEnvVars).toEqual([]);
196
+ expect(result.optionalEnvVars).toEqual([]);
197
+ });
198
+ });
199
+
200
+ describe('markOptional option', () => {
201
+ it('returns optionalEnvVars populated from envParser with optional/default vars', async () => {
202
+ const app = createApp({
203
+ path: envParserFixturesPath,
204
+ envParser: './optional-env-parser#envParser',
205
+ });
206
+
207
+ const result = await sniffAppEnvironment(
208
+ app,
209
+ 'api',
210
+ envParserFixturesPath,
211
+ {
212
+ logWarnings: false,
213
+ },
214
+ );
215
+
216
+ // All vars present regardless of markOptional
217
+ expect(result.requiredEnvVars).toContain('DATABASE_URL');
218
+ expect(result.requiredEnvVars).toContain('API_KEY');
219
+ expect(result.requiredEnvVars).toContain('LOG_LEVEL');
220
+ expect(result.requiredEnvVars).toContain('PORT');
221
+ expect(result.requiredEnvVars).toContain('TIMEOUT');
222
+
223
+ // Optional vars populated (no ? suffix since markOptional not set)
224
+ expect(result.optionalEnvVars).toContain('LOG_LEVEL');
225
+ expect(result.optionalEnvVars).toContain('PORT');
226
+ expect(result.optionalEnvVars).toContain('TIMEOUT');
227
+ expect(result.optionalEnvVars).not.toContain('DATABASE_URL');
228
+ expect(result.optionalEnvVars).not.toContain('API_KEY');
229
+ });
230
+
231
+ it('suffixes optional vars with ? in requiredEnvVars when markOptional is true', async () => {
232
+ const app = createApp({
233
+ path: envParserFixturesPath,
234
+ envParser: './optional-env-parser#envParser',
235
+ });
236
+
237
+ const result = await sniffAppEnvironment(
238
+ app,
239
+ 'api',
240
+ envParserFixturesPath,
241
+ {
242
+ logWarnings: false,
243
+ markOptional: true,
244
+ },
245
+ );
246
+
247
+ // Required vars unchanged
248
+ expect(result.requiredEnvVars).toContain('DATABASE_URL');
249
+ expect(result.requiredEnvVars).toContain('API_KEY');
250
+
251
+ // Optional vars suffixed with ?
252
+ expect(result.requiredEnvVars).toContain('LOG_LEVEL?');
253
+ expect(result.requiredEnvVars).toContain('PORT?');
254
+ expect(result.requiredEnvVars).toContain('TIMEOUT?');
255
+
256
+ // Plain names not present for optional vars
257
+ expect(result.requiredEnvVars).not.toContain('LOG_LEVEL');
258
+ expect(result.requiredEnvVars).not.toContain('PORT');
259
+ expect(result.requiredEnvVars).not.toContain('TIMEOUT');
260
+ });
261
+
262
+ it('does not add ? suffix when markOptional is false (default)', async () => {
263
+ const app = createApp({
264
+ path: envParserFixturesPath,
265
+ envParser: './optional-env-parser#envParser',
266
+ });
267
+
268
+ const result = await sniffAppEnvironment(
269
+ app,
270
+ 'api',
271
+ envParserFixturesPath,
272
+ {
273
+ logWarnings: false,
274
+ markOptional: false,
275
+ },
276
+ );
277
+
278
+ // No ? suffixes regardless of optionality
279
+ for (const v of result.requiredEnvVars) {
280
+ expect(v).not.toMatch(/\?$/);
281
+ }
282
+ });
283
+
284
+ it('returns empty optionalEnvVars for apps with no optional vars', async () => {
285
+ const app = createApp({
286
+ path: envParserFixturesPath,
287
+ envParser: './valid-env-parser#envParser',
288
+ });
289
+
290
+ const result = await sniffAppEnvironment(
291
+ app,
292
+ 'api',
293
+ envParserFixturesPath,
294
+ {
295
+ logWarnings: false,
296
+ markOptional: true,
297
+ },
298
+ );
299
+
300
+ expect(result.optionalEnvVars).toEqual([]);
301
+ // No ? suffixes when nothing is optional
302
+ for (const v of result.requiredEnvVars) {
303
+ expect(v).not.toMatch(/\?$/);
304
+ }
305
+ });
306
+
307
+ it('returns empty optionalEnvVars for frontend apps', async () => {
308
+ const app = createApp({
309
+ type: 'frontend',
310
+ dependencies: ['api'],
311
+ });
312
+
313
+ const result = await sniffAppEnvironment(app, 'web', workspacePath);
314
+
315
+ expect(result.optionalEnvVars).toEqual([]);
196
316
  });
197
317
  });
198
318
  });
@@ -234,16 +354,19 @@ describe('sniffAllApps', () => {
234
354
  expect(results.get('api')).toEqual({
235
355
  appName: 'api',
236
356
  requiredEnvVars: [],
357
+ optionalEnvVars: [],
237
358
  });
238
359
 
239
360
  expect(results.get('auth')).toEqual({
240
361
  appName: 'auth',
241
362
  requiredEnvVars: [],
363
+ optionalEnvVars: [],
242
364
  });
243
365
 
244
366
  expect(results.get('web')).toEqual({
245
367
  appName: 'web',
246
368
  requiredEnvVars: ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL'],
369
+ optionalEnvVars: [],
247
370
  });
248
371
  });
249
372
 
@@ -57,9 +57,10 @@ async function sniff(): Promise<void> {
57
57
  // Retrieve captured env vars from the global sniffer
58
58
  const sniffer = globalThis.__envSniffer;
59
59
  const envVars = sniffer ? sniffer.getEnvironmentVariables() : [];
60
+ const optionalEnvVars = sniffer ? sniffer.getOptionalVariables() : [];
60
61
 
61
62
  // Output result as JSON
62
- console.log(JSON.stringify({ envVars, error }));
63
+ console.log(JSON.stringify({ envVars, optionalEnvVars, error }));
63
64
  }
64
65
 
65
66
  // Handle unhandled rejections (fire-and-forget promises)