@geekmidas/envkit 0.5.0 → 0.7.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 (32) hide show
  1. package/dist/{EnvironmentParser-DlWHnhDY.cjs → EnvironmentParser-DJdW7vOL.cjs} +2 -2
  2. package/dist/{EnvironmentParser-DlWHnhDY.cjs.map → EnvironmentParser-DJdW7vOL.cjs.map} +1 -1
  3. package/dist/{EnvironmentParser-CBLsPUyQ.mjs → EnvironmentParser-zMblItla.mjs} +2 -2
  4. package/dist/{EnvironmentParser-CBLsPUyQ.mjs.map → EnvironmentParser-zMblItla.mjs.map} +1 -1
  5. package/dist/EnvironmentParser.cjs +2 -2
  6. package/dist/EnvironmentParser.mjs +2 -2
  7. package/dist/SnifferEnvironmentParser.cjs +58 -2
  8. package/dist/SnifferEnvironmentParser.cjs.map +1 -1
  9. package/dist/SnifferEnvironmentParser.d.cts +54 -1
  10. package/dist/SnifferEnvironmentParser.d.cts.map +1 -1
  11. package/dist/SnifferEnvironmentParser.d.mts +54 -1
  12. package/dist/SnifferEnvironmentParser.d.mts.map +1 -1
  13. package/dist/SnifferEnvironmentParser.mjs +58 -3
  14. package/dist/SnifferEnvironmentParser.mjs.map +1 -1
  15. package/dist/{formatter-fz8V7x6i.mjs → formatter-BRRrxQi3.mjs} +2 -4
  16. package/dist/formatter-BRRrxQi3.mjs.map +1 -0
  17. package/dist/formatter-Cox0NGxT.d.mts.map +1 -1
  18. package/dist/formatter-D85aIkpd.d.cts.map +1 -1
  19. package/dist/{formatter-w8Tsccw4.cjs → formatter-HxePpSy2.cjs} +2 -4
  20. package/dist/formatter-HxePpSy2.cjs.map +1 -0
  21. package/dist/formatter.cjs +1 -1
  22. package/dist/formatter.mjs +1 -1
  23. package/dist/index.cjs +2 -2
  24. package/dist/index.mjs +2 -2
  25. package/package.json +1 -1
  26. package/src/SnifferEnvironmentParser.ts +97 -0
  27. package/src/__tests__/SnifferEnvironmentParser.spec.ts +105 -1
  28. package/src/__tests__/formatter.spec.ts +17 -0
  29. package/src/formatter.ts +4 -5
  30. package/tsconfig.tsbuildinfo +1 -1
  31. package/dist/formatter-fz8V7x6i.mjs.map +0 -1
  32. package/dist/formatter-w8Tsccw4.cjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"formatter-D85aIkpd.d.cts","names":[],"sources":["../src/formatter.ts"],"sourcesContent":[],"mappings":";;;;;;AAKA;AAgDgB,UAhDC,aAAA,CAgDe;EAAA;EAAA,MACtB,CAAA,EAAA,OAAA;;AACkB;AAwF5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA1FgB,gBAAA,QACR,CAAA,CAAE,oBACA;;;;iBAwFM,aAAA,CAAA"}
1
+ {"version":3,"file":"formatter-D85aIkpd.d.cts","names":[],"sources":["../src/formatter.ts"],"sourcesContent":[],"mappings":";;;;;;AAKA;AAgDgB,UAhDC,aAAA,CAgDe;EAAA;EAAA,MACtB,CAAA,EAAA,OAAA;;AACkB;AAuF5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAzFgB,gBAAA,QACR,CAAA,CAAE,oBACA;;;;iBAuFM,aAAA,CAAA"}
@@ -1,5 +1,3 @@
1
- const require_chunk = require('./chunk-CUT6urMc.cjs');
2
- const zod_v4 = require_chunk.__toESM(require("zod/v4"));
3
1
 
4
2
  //#region src/formatter.ts
5
3
  /**
@@ -58,7 +56,7 @@ function formatParseError(error, options = {}) {
58
56
  const invalidVars = [];
59
57
  for (const issue of error.issues) {
60
58
  let envName = "";
61
- if (issue.path.length > 0) envName = String(issue.path[0]);
59
+ if (issue.path.length > 0) envName = issue.path.map(String).join(".");
62
60
  else {
63
61
  const match = issue.message.match(/Environment variable "([^"]+)"/);
64
62
  if (match?.[1]) envName = match[1];
@@ -122,4 +120,4 @@ Object.defineProperty(exports, 'isDevelopment', {
122
120
  return isDevelopment;
123
121
  }
124
122
  });
125
- //# sourceMappingURL=formatter-w8Tsccw4.cjs.map
123
+ //# sourceMappingURL=formatter-HxePpSy2.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatter-HxePpSy2.cjs","names":["error: z.ZodError","options: FormatOptions","missingVars: Array<{ name: string; message: string }>","invalidVars: Array<{ name: string; value: unknown; message: string }>","lines: string[]","message: string"],"sources":["../src/formatter.ts"],"sourcesContent":["import type { z } from 'zod/v4';\n\n/**\n * Options for formatting parse errors.\n */\nexport interface FormatOptions {\n\t/** Whether to use colors in output. Defaults to auto-detect TTY. */\n\tcolors?: boolean;\n}\n\n/**\n * ANSI color codes for terminal output.\n */\nconst colors = {\n\treset: '\\x1b[0m',\n\tred: '\\x1b[31m',\n\tyellow: '\\x1b[33m',\n\tcyan: '\\x1b[36m',\n\tdim: '\\x1b[2m',\n\tbold: '\\x1b[1m',\n};\n\n/**\n * Formats a ZodError into a user-friendly string for development.\n *\n * @param error - The ZodError to format\n * @param options - Formatting options\n * @returns Formatted error message\n *\n * @example\n * ```typescript\n * try {\n * config.parse();\n * } catch (error) {\n * if (error instanceof ZodError) {\n * console.error(formatParseError(error));\n * }\n * }\n * ```\n *\n * Output:\n * ```\n * Environment Configuration Failed\n *\n * Missing Variables:\n * DATABASE_URL - Required\n * JWT_SECRET - Required\n *\n * Invalid Values:\n * NODE_ENV = \"invalid\"\n * Expected: \"development\" | \"staging\" | \"production\"\n * ```\n */\nexport function formatParseError(\n\terror: z.ZodError,\n\toptions: FormatOptions = {},\n): string {\n\tconst useColors =\n\t\toptions.colors ?? (process.stdout?.isTTY && process.env.NO_COLOR == null);\n\n\tconst c = useColors\n\t\t? colors\n\t\t: { reset: '', red: '', yellow: '', cyan: '', dim: '', bold: '' };\n\n\tconst missingVars: Array<{ name: string; message: string }> = [];\n\tconst invalidVars: Array<{ name: string; value: unknown; message: string }> =\n\t\t[];\n\n\tfor (const issue of error.issues) {\n\t\t// Extract environment variable name from path or message\n\t\tlet envName = '';\n\t\tif (issue.path.length > 0) {\n\t\t\t// Join the full path with '.' to show nested config keys and env var name\n\t\t\tenvName = issue.path.map(String).join('.');\n\t\t} else {\n\t\t\t// Try to extract from message like 'Environment variable \"NAME\": ...'\n\t\t\tconst match = issue.message.match(/Environment variable \"([^\"]+)\"/);\n\t\t\tif (match?.[1]) {\n\t\t\t\tenvName = match[1];\n\t\t\t}\n\t\t}\n\n\t\t// Determine if this is a missing or invalid value\n\t\t// Use type guard for received property\n\t\tconst received = 'received' in issue ? issue.received : undefined;\n\t\tconst isMissing =\n\t\t\tissue.code === 'invalid_type' &&\n\t\t\t(received === 'undefined' || received === 'null');\n\n\t\tif (isMissing) {\n\t\t\tmissingVars.push({\n\t\t\t\tname: envName || 'Unknown',\n\t\t\t\tmessage: cleanMessage(issue.message),\n\t\t\t});\n\t\t} else {\n\t\t\tinvalidVars.push({\n\t\t\t\tname: envName || 'Unknown',\n\t\t\t\tvalue: received,\n\t\t\t\tmessage: cleanMessage(issue.message),\n\t\t\t});\n\t\t}\n\t}\n\n\tconst lines: string[] = [];\n\n\tlines.push('');\n\tlines.push(`${c.red}${c.bold}Environment Configuration Failed${c.reset}`);\n\tlines.push('');\n\n\tif (missingVars.length > 0) {\n\t\tlines.push(`${c.yellow}Missing Variables:${c.reset}`);\n\t\tfor (const v of missingVars) {\n\t\t\tlines.push(` ${c.cyan}${v.name}${c.reset} ${c.dim}- Required${c.reset}`);\n\t\t}\n\t\tlines.push('');\n\t}\n\n\tif (invalidVars.length > 0) {\n\t\tlines.push(`${c.yellow}Invalid Values:${c.reset}`);\n\t\tfor (const v of invalidVars) {\n\t\t\tconst valueStr =\n\t\t\t\tv.value !== undefined ? ` = ${JSON.stringify(v.value)}` : '';\n\t\t\tlines.push(` ${c.cyan}${v.name}${c.reset}${valueStr}`);\n\t\t\tlines.push(` ${c.dim}${v.message}${c.reset}`);\n\t\t}\n\t\tlines.push('');\n\t}\n\n\treturn lines.join('\\n');\n}\n\n/**\n * Cleans up a Zod error message by removing redundant prefixes.\n */\nfunction cleanMessage(message: string): string {\n\t// Remove \"Environment variable \"NAME\": \" prefix if present\n\treturn message.replace(/^Environment variable \"[^\"]+\": /, '');\n}\n\n/**\n * Checks if the current environment is development.\n */\nexport function isDevelopment(): boolean {\n\tconst nodeEnv = process.env.NODE_ENV?.toLowerCase();\n\treturn nodeEnv == null || nodeEnv === 'development' || nodeEnv === 'dev';\n}\n"],"mappings":";;;;;AAaA,MAAM,SAAS;CACd,OAAO;CACP,KAAK;CACL,QAAQ;CACR,MAAM;CACN,KAAK;CACL,MAAM;AACN;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCD,SAAgB,iBACfA,OACAC,UAAyB,CAAE,GAClB;CACT,MAAM,YACL,QAAQ,WAAW,QAAQ,QAAQ,SAAS,QAAQ,IAAI,YAAY;CAErE,MAAM,IAAI,YACP,SACA;EAAE,OAAO;EAAI,KAAK;EAAI,QAAQ;EAAI,MAAM;EAAI,KAAK;EAAI,MAAM;CAAI;CAElE,MAAMC,cAAwD,CAAE;CAChE,MAAMC,cACL,CAAE;AAEH,MAAK,MAAM,SAAS,MAAM,QAAQ;EAEjC,IAAI,UAAU;AACd,MAAI,MAAM,KAAK,SAAS,EAEvB,WAAU,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,IAAI;OACpC;GAEN,MAAM,QAAQ,MAAM,QAAQ,MAAM,iCAAiC;AACnE,OAAI,QAAQ,GACX,WAAU,MAAM;EAEjB;EAID,MAAM,WAAW,cAAc,QAAQ,MAAM;EAC7C,MAAM,YACL,MAAM,SAAS,mBACd,aAAa,eAAe,aAAa;AAE3C,MAAI,UACH,aAAY,KAAK;GAChB,MAAM,WAAW;GACjB,SAAS,aAAa,MAAM,QAAQ;EACpC,EAAC;MAEF,aAAY,KAAK;GAChB,MAAM,WAAW;GACjB,OAAO;GACP,SAAS,aAAa,MAAM,QAAQ;EACpC,EAAC;CAEH;CAED,MAAMC,QAAkB,CAAE;AAE1B,OAAM,KAAK,GAAG;AACd,OAAM,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,KAAK,kCAAkC,EAAE,MAAM,EAAE;AACzE,OAAM,KAAK,GAAG;AAEd,KAAI,YAAY,SAAS,GAAG;AAC3B,QAAM,MAAM,EAAE,EAAE,OAAO,oBAAoB,EAAE,MAAM,EAAE;AACrD,OAAK,MAAM,KAAK,YACf,OAAM,MAAM,IAAI,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,GAAG,EAAE,IAAI,YAAY,EAAE,MAAM,EAAE;AAE1E,QAAM,KAAK,GAAG;CACd;AAED,KAAI,YAAY,SAAS,GAAG;AAC3B,QAAM,MAAM,EAAE,EAAE,OAAO,iBAAiB,EAAE,MAAM,EAAE;AAClD,OAAK,MAAM,KAAK,aAAa;GAC5B,MAAM,WACL,EAAE,oBAAuB,KAAK,KAAK,UAAU,EAAE,MAAM,CAAC,IAAI;AAC3D,SAAM,MAAM,IAAI,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE;AACvD,SAAM,MAAM,MAAM,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE;EAChD;AACD,QAAM,KAAK,GAAG;CACd;AAED,QAAO,MAAM,KAAK,KAAK;AACvB;;;;AAKD,SAAS,aAAaC,SAAyB;AAE9C,QAAO,QAAQ,QAAQ,mCAAmC,GAAG;AAC7D;;;;AAKD,SAAgB,gBAAyB;CACxC,MAAM,UAAU,QAAQ,IAAI,UAAU,aAAa;AACnD,QAAO,WAAW,QAAQ,YAAY,iBAAiB,YAAY;AACnE"}
@@ -1,4 +1,4 @@
1
- const require_formatter = require('./formatter-w8Tsccw4.cjs');
1
+ const require_formatter = require('./formatter-HxePpSy2.cjs');
2
2
 
3
3
  exports.formatParseError = require_formatter.formatParseError;
4
4
  exports.isDevelopment = require_formatter.isDevelopment;
@@ -1,3 +1,3 @@
1
- import { formatParseError, isDevelopment } from "./formatter-fz8V7x6i.mjs";
1
+ import { formatParseError, isDevelopment } from "./formatter-BRRrxQi3.mjs";
2
2
 
3
3
  export { formatParseError, isDevelopment };
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  const require_EnvironmentBuilder = require('./EnvironmentBuilder-Djr1VsWM.cjs');
2
- const require_formatter = require('./formatter-w8Tsccw4.cjs');
3
- const require_EnvironmentParser = require('./EnvironmentParser-DlWHnhDY.cjs');
2
+ const require_formatter = require('./formatter-HxePpSy2.cjs');
3
+ const require_EnvironmentParser = require('./EnvironmentParser-DJdW7vOL.cjs');
4
4
 
5
5
  exports.ConfigParser = require_EnvironmentParser.ConfigParser;
6
6
  exports.EnvironmentBuilder = require_EnvironmentBuilder.EnvironmentBuilder;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EnvironmentBuilder, environmentCase } from "./EnvironmentBuilder-BSuHZm0y.mjs";
2
- import { formatParseError, isDevelopment } from "./formatter-fz8V7x6i.mjs";
3
- import { ConfigParser, EnvironmentParser } from "./EnvironmentParser-CBLsPUyQ.mjs";
2
+ import { formatParseError, isDevelopment } from "./formatter-BRRrxQi3.mjs";
3
+ import { ConfigParser, EnvironmentParser } from "./EnvironmentParser-zMblItla.mjs";
4
4
 
5
5
  export { ConfigParser, EnvironmentBuilder, EnvironmentParser, environmentCase, formatParseError, isDevelopment };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/envkit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -207,3 +207,100 @@ class SnifferConfigParser<
207
207
  return '';
208
208
  }
209
209
  }
210
+
211
+ /**
212
+ * Result of sniffing with fire-and-forget handling.
213
+ */
214
+ export interface SniffResult {
215
+ /** Environment variables that were accessed during sniffing */
216
+ envVars: string[];
217
+ /** Error thrown during sniffing (env vars may still be captured) */
218
+ error?: Error;
219
+ /** Unhandled promise rejections captured during sniffing */
220
+ unhandledRejections: Error[];
221
+ }
222
+
223
+ /**
224
+ * Options for sniffing with fire-and-forget handling.
225
+ */
226
+ export interface SniffOptions {
227
+ /**
228
+ * Time in milliseconds to wait for fire-and-forget promises to settle.
229
+ * Some libraries like better-auth create async operations that may reject
230
+ * after the initial event loop tick.
231
+ * @default 100
232
+ */
233
+ settleTimeMs?: number;
234
+ }
235
+
236
+ /**
237
+ * Executes a sniffing operation with fire-and-forget handling.
238
+ *
239
+ * This function:
240
+ * 1. Captures unhandled promise rejections during the operation
241
+ * 2. Waits for async operations to settle before returning
242
+ * 3. Gracefully handles errors without throwing
243
+ *
244
+ * Use this when sniffing environment variables from code that may:
245
+ * - Throw synchronous errors
246
+ * - Create fire-and-forget promises that reject
247
+ * - Have async initialization that may fail
248
+ *
249
+ * @param sniffer - The SnifferEnvironmentParser instance to use
250
+ * @param operation - The async operation to execute (e.g., service.register)
251
+ * @param options - Optional configuration
252
+ * @returns SniffResult with env vars and any errors encountered
253
+ *
254
+ * @example
255
+ * ```typescript
256
+ * const sniffer = new SnifferEnvironmentParser();
257
+ * const result = await sniffWithFireAndForget(sniffer, async () => {
258
+ * await service.register({ envParser: sniffer });
259
+ * });
260
+ * console.log('Env vars:', result.envVars);
261
+ * console.log('Error:', result.error);
262
+ * console.log('Unhandled rejections:', result.unhandledRejections);
263
+ * ```
264
+ */
265
+ export async function sniffWithFireAndForget(
266
+ sniffer: SnifferEnvironmentParser,
267
+ operation: () => unknown | Promise<unknown>,
268
+ options: SniffOptions = {},
269
+ ): Promise<SniffResult> {
270
+ const { settleTimeMs = 100 } = options;
271
+ const unhandledRejections: Error[] = [];
272
+
273
+ // Capture unhandled rejections during sniffing (fire-and-forget promises)
274
+ // Libraries like better-auth create async operations that may reject after
275
+ // the initial event loop tick
276
+ const captureRejection = (reason: unknown) => {
277
+ const err = reason instanceof Error ? reason : new Error(String(reason));
278
+ unhandledRejections.push(err);
279
+ };
280
+ process.on('unhandledRejection', captureRejection);
281
+
282
+ let error: Error | undefined;
283
+
284
+ try {
285
+ const result = operation();
286
+
287
+ // Handle async result
288
+ if (result && typeof result === 'object' && 'then' in result) {
289
+ await Promise.resolve(result).catch((e) => {
290
+ error = e instanceof Error ? e : new Error(String(e));
291
+ });
292
+ }
293
+ } catch (e) {
294
+ error = e instanceof Error ? e : new Error(String(e));
295
+ } finally {
296
+ // Wait for fire-and-forget promises to settle
297
+ await new Promise((resolve) => setTimeout(resolve, settleTimeMs));
298
+ process.off('unhandledRejection', captureRejection);
299
+ }
300
+
301
+ return {
302
+ envVars: sniffer.getEnvironmentVariables(),
303
+ error,
304
+ unhandledRejections,
305
+ };
306
+ }
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { z } from 'zod/v4';
3
- import { SnifferEnvironmentParser } from '../SnifferEnvironmentParser';
3
+ import {
4
+ SnifferEnvironmentParser,
5
+ sniffWithFireAndForget,
6
+ } from '../SnifferEnvironmentParser';
4
7
 
5
8
  describe('SnifferEnvironmentParser', () => {
6
9
  describe('Environment variable tracking', () => {
@@ -384,3 +387,104 @@ describe('SnifferEnvironmentParser', () => {
384
387
  });
385
388
  });
386
389
  });
390
+
391
+ describe('sniffWithFireAndForget', () => {
392
+ it('should capture environment variables from synchronous operations', async () => {
393
+ const sniffer = new SnifferEnvironmentParser();
394
+
395
+ const result = await sniffWithFireAndForget(sniffer, () => {
396
+ sniffer.create((get) => ({
397
+ dbUrl: get('DATABASE_URL').string(),
398
+ apiKey: get('API_KEY').string(),
399
+ }));
400
+ });
401
+
402
+ expect(result.envVars).toEqual(['API_KEY', 'DATABASE_URL']);
403
+ expect(result.error).toBeUndefined();
404
+ expect(result.unhandledRejections).toEqual([]);
405
+ });
406
+
407
+ it('should capture environment variables from async operations', async () => {
408
+ const sniffer = new SnifferEnvironmentParser();
409
+
410
+ const result = await sniffWithFireAndForget(sniffer, async () => {
411
+ await Promise.resolve();
412
+ sniffer.create((get) => ({
413
+ redisUrl: get('REDIS_URL').string(),
414
+ }));
415
+ });
416
+
417
+ expect(result.envVars).toEqual(['REDIS_URL']);
418
+ expect(result.error).toBeUndefined();
419
+ });
420
+
421
+ it('should capture error when operation throws synchronously', async () => {
422
+ const sniffer = new SnifferEnvironmentParser();
423
+
424
+ const result = await sniffWithFireAndForget(sniffer, () => {
425
+ sniffer.create((get) => ({
426
+ value: get('CAPTURED_BEFORE_ERROR').string(),
427
+ }));
428
+ throw new Error('Sync error');
429
+ });
430
+
431
+ // Should still capture env vars accessed before the error
432
+ expect(result.envVars).toEqual(['CAPTURED_BEFORE_ERROR']);
433
+ expect(result.error).toBeInstanceOf(Error);
434
+ expect(result.error?.message).toBe('Sync error');
435
+ });
436
+
437
+ it('should capture error when async operation rejects', async () => {
438
+ const sniffer = new SnifferEnvironmentParser();
439
+
440
+ const result = await sniffWithFireAndForget(sniffer, async () => {
441
+ sniffer.create((get) => ({
442
+ value: get('CAPTURED_BEFORE_REJECT').string(),
443
+ }));
444
+ throw new Error('Async rejection');
445
+ });
446
+
447
+ expect(result.envVars).toEqual(['CAPTURED_BEFORE_REJECT']);
448
+ expect(result.error).toBeInstanceOf(Error);
449
+ expect(result.error?.message).toBe('Async rejection');
450
+ });
451
+
452
+ it('should convert non-Error throws to Error objects', async () => {
453
+ const sniffer = new SnifferEnvironmentParser();
454
+
455
+ const result = await sniffWithFireAndForget(sniffer, () => {
456
+ throw 'string error';
457
+ });
458
+
459
+ expect(result.error).toBeInstanceOf(Error);
460
+ expect(result.error?.message).toBe('string error');
461
+ });
462
+
463
+ it('should use custom settle time', async () => {
464
+ const sniffer = new SnifferEnvironmentParser();
465
+ const startTime = Date.now();
466
+
467
+ await sniffWithFireAndForget(
468
+ sniffer,
469
+ () => {},
470
+ { settleTimeMs: 50 },
471
+ );
472
+
473
+ const elapsed = Date.now() - startTime;
474
+ // Should wait at least 50ms but not much longer
475
+ expect(elapsed).toBeGreaterThanOrEqual(50);
476
+ expect(elapsed).toBeLessThan(200);
477
+ });
478
+
479
+ it('should return empty arrays when no vars accessed and no errors', async () => {
480
+ const sniffer = new SnifferEnvironmentParser();
481
+
482
+ const result = await sniffWithFireAndForget(sniffer, () => {
483
+ // Do nothing
484
+ });
485
+
486
+ expect(result.envVars).toEqual([]);
487
+ expect(result.error).toBeUndefined();
488
+ expect(result.unhandledRejections).toEqual([]);
489
+ });
490
+ });
@@ -102,6 +102,23 @@ describe('formatParseError', () => {
102
102
  expect(formatted).toContain('Expected number, received string');
103
103
  });
104
104
 
105
+ it('should join nested paths with dot', () => {
106
+ const error = new z.ZodError([
107
+ createIssue({
108
+ code: 'invalid_type',
109
+ expected: 'string',
110
+ received: 'undefined',
111
+ path: ['databaseUrl', 'DATABASE_URL'],
112
+ message: 'Environment variable "DATABASE_URL": Required',
113
+ }),
114
+ ]);
115
+
116
+ const formatted = formatParseError(error, { colors: false });
117
+
118
+ // Should show the full path joined with '.'
119
+ expect(formatted).toContain('databaseUrl.DATABASE_URL');
120
+ });
121
+
105
122
  it('should extract env name from message when path is empty', () => {
106
123
  const error = new z.ZodError([
107
124
  createIssue({
package/src/formatter.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { z } from 'zod/v4';
1
+ import type { z } from 'zod/v4';
2
2
 
3
3
  /**
4
4
  * Options for formatting parse errors.
@@ -70,7 +70,8 @@ export function formatParseError(
70
70
  // Extract environment variable name from path or message
71
71
  let envName = '';
72
72
  if (issue.path.length > 0) {
73
- envName = String(issue.path[0]);
73
+ // Join the full path with '.' to show nested config keys and env var name
74
+ envName = issue.path.map(String).join('.');
74
75
  } else {
75
76
  // Try to extract from message like 'Environment variable "NAME": ...'
76
77
  const match = issue.message.match(/Environment variable "([^"]+)"/);
@@ -103,9 +104,7 @@ export function formatParseError(
103
104
  const lines: string[] = [];
104
105
 
105
106
  lines.push('');
106
- lines.push(
107
- `${c.red}${c.bold}Environment Configuration Failed${c.reset}`,
108
- );
107
+ lines.push(`${c.red}${c.bold}Environment Configuration Failed${c.reset}`);
109
108
  lines.push('');
110
109
 
111
110
  if (missingVars.length > 0) {