@xlameiro/env-typegen 0.1.5 → 0.1.7

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/dist/index.d.cts CHANGED
@@ -43,6 +43,21 @@ type ParsedEnvVar = {
43
43
  */
44
44
  isSecret?: boolean;
45
45
  };
46
+ /**
47
+ * A non-fatal issue detected during parsing (e.g. a duplicate key).
48
+ * Collected in {@link ParsedEnvFile.warnings} when the parser encounters
49
+ * degenerate-but-valid input.
50
+ */
51
+ type ParsedEnvWarning = {
52
+ /** Machine-readable code for programmatic handling. */
53
+ code: "ENV_DUPLICATE_KEY";
54
+ /** Human-readable description of the issue. */
55
+ message: string;
56
+ /** 1-based line number of the earlier (discarded) occurrence. */
57
+ line: number;
58
+ /** The duplicated variable name. */
59
+ key: string;
60
+ };
46
61
  /**
47
62
  * The complete result of parsing a single .env.example file.
48
63
  * Passed to generators to produce TypeScript / Zod / t3-env output.
@@ -54,6 +69,11 @@ type ParsedEnvFile = {
54
69
  vars: ParsedEnvVar[];
55
70
  /** Unique group names found in the file, in order of appearance */
56
71
  groups: string[];
72
+ /**
73
+ * Non-fatal issues detected during parsing (e.g. duplicate keys).
74
+ * Undefined when no issues were found so the field is omitted in clean parses.
75
+ */
76
+ warnings?: ParsedEnvWarning[];
57
77
  };
58
78
 
59
79
  /**
@@ -461,9 +481,12 @@ type EnvContract$1 = {
461
481
 
462
482
  /**
463
483
  * Contract file names searched in order when calling {@link loadContract}.
464
- * Mirrors the search strategy used by `loadConfig` in `config.ts`.
484
+ * BUG-01: .mjs and .js are searched before .ts — TypeScript files cannot be
485
+ * loaded by Node.js import() at runtime without a loader like tsx or ts-node.
486
+ * Discovering a .mjs file first prevents a confusing ERR_UNKNOWN_FILE_EXTENSION
487
+ * failure when both .ts and .mjs files coexist in the same directory.
465
488
  */
466
- declare const CONTRACT_FILE_NAMES: readonly ["env.contract.ts", "env.contract.mjs", "env.contract.js"];
489
+ declare const CONTRACT_FILE_NAMES: readonly ["env.contract.mjs", "env.contract.js", "env.contract.ts"];
467
490
  /**
468
491
  * Type-safe contract factory. Returns the contract object unchanged — exists
469
492
  * purely for IDE autocompletion and compile-time validation of the contract shape.
package/dist/index.d.ts CHANGED
@@ -43,6 +43,21 @@ type ParsedEnvVar = {
43
43
  */
44
44
  isSecret?: boolean;
45
45
  };
46
+ /**
47
+ * A non-fatal issue detected during parsing (e.g. a duplicate key).
48
+ * Collected in {@link ParsedEnvFile.warnings} when the parser encounters
49
+ * degenerate-but-valid input.
50
+ */
51
+ type ParsedEnvWarning = {
52
+ /** Machine-readable code for programmatic handling. */
53
+ code: "ENV_DUPLICATE_KEY";
54
+ /** Human-readable description of the issue. */
55
+ message: string;
56
+ /** 1-based line number of the earlier (discarded) occurrence. */
57
+ line: number;
58
+ /** The duplicated variable name. */
59
+ key: string;
60
+ };
46
61
  /**
47
62
  * The complete result of parsing a single .env.example file.
48
63
  * Passed to generators to produce TypeScript / Zod / t3-env output.
@@ -54,6 +69,11 @@ type ParsedEnvFile = {
54
69
  vars: ParsedEnvVar[];
55
70
  /** Unique group names found in the file, in order of appearance */
56
71
  groups: string[];
72
+ /**
73
+ * Non-fatal issues detected during parsing (e.g. duplicate keys).
74
+ * Undefined when no issues were found so the field is omitted in clean parses.
75
+ */
76
+ warnings?: ParsedEnvWarning[];
57
77
  };
58
78
 
59
79
  /**
@@ -461,9 +481,12 @@ type EnvContract$1 = {
461
481
 
462
482
  /**
463
483
  * Contract file names searched in order when calling {@link loadContract}.
464
- * Mirrors the search strategy used by `loadConfig` in `config.ts`.
484
+ * BUG-01: .mjs and .js are searched before .ts — TypeScript files cannot be
485
+ * loaded by Node.js import() at runtime without a loader like tsx or ts-node.
486
+ * Discovering a .mjs file first prevents a confusing ERR_UNKNOWN_FILE_EXTENSION
487
+ * failure when both .ts and .mjs files coexist in the same directory.
465
488
  */
466
- declare const CONTRACT_FILE_NAMES: readonly ["env.contract.ts", "env.contract.mjs", "env.contract.js"];
489
+ declare const CONTRACT_FILE_NAMES: readonly ["env.contract.mjs", "env.contract.js", "env.contract.ts"];
467
490
  /**
468
491
  * Type-safe contract factory. Returns the contract object unchanged — exists
469
492
  * purely for IDE autocompletion and compile-time validation of the contract shape.
package/dist/index.js CHANGED
@@ -157,7 +157,9 @@ var inferenceRules = [
157
157
  {
158
158
  id: "P10_url_scheme",
159
159
  priority: 10,
160
- match: (_key, value) => /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value),
160
+ // BUG-02: comma-separated URL lists (e.g. ALLOWED_ORIGINS) must NOT be inferred as
161
+ // a single URL — they don't pass z.string().url() or new URL() validation at runtime.
162
+ match: (_key, value) => !value.includes(",") && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value),
161
163
  type: "url"
162
164
  },
163
165
  {
@@ -243,6 +245,27 @@ function buildParsedVar(params, commentBlock, options) {
243
245
  }
244
246
  return parsedVar;
245
247
  }
248
+ function deduplicateVars(vars) {
249
+ const seenKeys = /* @__PURE__ */ new Set();
250
+ const deduped = [];
251
+ const warnings = [];
252
+ for (let i = vars.length - 1; i >= 0; i--) {
253
+ const variable = vars[i];
254
+ if (variable === void 0) continue;
255
+ if (seenKeys.has(variable.key)) {
256
+ warnings.push({
257
+ code: "ENV_DUPLICATE_KEY",
258
+ message: `Duplicate key "${variable.key}" at line ${variable.lineNumber} \u2014 last occurrence wins.`,
259
+ line: variable.lineNumber,
260
+ key: variable.key
261
+ });
262
+ } else {
263
+ seenKeys.add(variable.key);
264
+ deduped.unshift(variable);
265
+ }
266
+ }
267
+ return { deduped, warnings };
268
+ }
246
269
  function parseEnvFileContent(content, filePath, options) {
247
270
  const lines = content.split("\n");
248
271
  const vars = [];
@@ -285,7 +308,13 @@ function parseEnvFileContent(content, filePath, options) {
285
308
  );
286
309
  commentBlock = [];
287
310
  }
288
- return { filePath, vars, groups };
311
+ const { deduped, warnings } = deduplicateVars(vars);
312
+ return {
313
+ filePath,
314
+ vars: deduped,
315
+ groups,
316
+ ...warnings.length > 0 && { warnings }
317
+ };
289
318
  }
290
319
  function parseEnvFile(filePath) {
291
320
  const content = readFileSync(filePath, "utf8");
@@ -486,9 +515,9 @@ function generateT3Env(parsed) {
486
515
  return lines.join("\n") + "\n";
487
516
  }
488
517
  var CONTRACT_FILE_NAMES = [
489
- "env.contract.ts",
490
518
  "env.contract.mjs",
491
- "env.contract.js"
519
+ "env.contract.js",
520
+ "env.contract.ts"
492
521
  ];
493
522
  function defineContract(contract) {
494
523
  return contract;
@@ -497,6 +526,12 @@ async function loadContract(cwd = process.cwd()) {
497
526
  for (const name of CONTRACT_FILE_NAMES) {
498
527
  const filePath = path6.resolve(cwd, name);
499
528
  if (existsSync(filePath)) {
529
+ if (filePath.endsWith(".ts")) {
530
+ throw new Error(
531
+ `Contract file ${filePath} is a TypeScript file and cannot be loaded at runtime by Node.js.
532
+ Rename it to ${filePath.replace(/\.ts$/, ".mjs")} and use ESM syntax (export default ...), or run via \`tsx\` / \`ts-node\` as a loader.`
533
+ );
534
+ }
500
535
  const fileUrl = pathToFileURL(filePath).href;
501
536
  const mod = await import(fileUrl);
502
537
  return mod.default;
@@ -542,7 +577,8 @@ async function readEnvFile(filePath) {
542
577
  return await readFile(resolved, "utf8");
543
578
  } catch (err) {
544
579
  if (err instanceof Error && err.code === "ENOENT") {
545
- throw new Error(`File not found: ${filePath}`);
580
+ const displayPath = path6.isAbsolute(filePath) ? filePath : `${filePath} (resolved: ${resolved})`;
581
+ throw new Error(`File not found: ${displayPath}`);
546
582
  }
547
583
  throw err;
548
584
  }
@@ -579,7 +615,14 @@ function success(message) {
579
615
 
580
616
  // src/pipeline.ts
581
617
  function deriveOutputPath(base, generator, isSingle) {
582
- if (isSingle) return base;
618
+ if (isSingle) {
619
+ if (generator === "declaration" && !base.endsWith(".d.ts")) {
620
+ const ext2 = path6.extname(base);
621
+ const noExt2 = ext2.length > 0 ? base.slice(0, -ext2.length) : base;
622
+ return `${noExt2}.d.ts`;
623
+ }
624
+ return base;
625
+ }
583
626
  const ext = path6.extname(base);
584
627
  const noExt = ext.length > 0 ? base.slice(0, -ext.length) : base;
585
628
  const baseExt = ext.length > 0 ? ext : ".ts";
@@ -589,7 +632,8 @@ function deriveOutputPath(base, generator, isSingle) {
589
632
  function deriveOutputBaseForInput(output, inputPath) {
590
633
  const dir = path6.dirname(output);
591
634
  const ext = path6.extname(output);
592
- const stem = path6.basename(inputPath, path6.extname(inputPath));
635
+ const rawBasename = path6.basename(inputPath);
636
+ const stem = rawBasename.startsWith(".") ? rawBasename.slice(1).replaceAll(".", "-") : path6.basename(inputPath, path6.extname(inputPath));
593
637
  return path6.join(dir, `${stem}${ext}`);
594
638
  }
595
639
  function buildOutput(generator, parsed) {
@@ -626,6 +670,12 @@ async function persistOutput(params) {
626
670
  success(`Generated ${outputPath}`);
627
671
  }
628
672
  }
673
+ function emitParserWarnings(parsed) {
674
+ if (parsed.warnings === void 0) return;
675
+ for (const w of parsed.warnings) {
676
+ warn(`[${w.code}] ${w.message}`);
677
+ }
678
+ }
629
679
  async function runGenerate(options) {
630
680
  const {
631
681
  input,
@@ -648,6 +698,7 @@ async function runGenerate(options) {
648
698
  inputPath,
649
699
  inferenceRules2 === void 0 ? void 0 : { inferenceRules: inferenceRules2 }
650
700
  );
701
+ emitParserWarnings(parsed);
651
702
  for (const generator of generators) {
652
703
  let generated = buildOutput(generator, parsed);
653
704
  if (shouldFormat && !dryRun) {
@@ -724,7 +775,8 @@ function checkInvalidType(variable, entry, environment) {
724
775
  break;
725
776
  }
726
777
  case "boolean": {
727
- if (value !== "true" && value !== "false") {
778
+ const lower = value.toLowerCase();
779
+ if (!["true", "false", "1", "0", "yes", "no"].includes(lower)) {
728
780
  return {
729
781
  code: "ENV_INVALID_TYPE",
730
782
  key: variable.key,
@@ -847,7 +899,8 @@ function validateContract(parsed, contract, opts = {}) {
847
899
  const parsedByKey = new Map(parsed.vars.map((v) => [v.key, v]));
848
900
  for (const entry of contract.vars) {
849
901
  if (!entry.required) continue;
850
- if (parsedByKey.has(entry.name)) continue;
902
+ const existing = parsedByKey.get(entry.name);
903
+ if (existing !== void 0 && existing.rawValue !== "") continue;
851
904
  issues.push({
852
905
  code: "ENV_MISSING",
853
906
  key: entry.name,
@@ -949,6 +1002,11 @@ function outputHuman(result) {
949
1002
  async function runCheck(opts) {
950
1003
  const contract = await resolveContract(opts.contract, opts.cwd);
951
1004
  const parsed = parseEnvFile(opts.input);
1005
+ if (parsed.warnings !== void 0) {
1006
+ for (const w of parsed.warnings) {
1007
+ warn(`[${w.code}] ${w.message}`);
1008
+ }
1009
+ }
952
1010
  const validatorOpts = {};
953
1011
  if (opts.environment !== void 0) validatorOpts.environment = opts.environment;
954
1012
  if (opts.strict !== void 0) validatorOpts.strict = opts.strict;
@@ -1033,7 +1091,8 @@ async function loadCloudSource(options) {
1033
1091
  raw = await readFile(resolvedPath, "utf8");
1034
1092
  } catch (err) {
1035
1093
  if (err instanceof Error && err.code === "ENOENT") {
1036
- throw new Error(`File not found: ${options.filePath}`);
1094
+ const displayPath = path6.isAbsolute(options.filePath) ? options.filePath : `${options.filePath} (resolved: ${resolvedPath})`;
1095
+ throw new Error(`File not found: ${displayPath}`);
1037
1096
  }
1038
1097
  throw err;
1039
1098
  }
@@ -1041,7 +1100,7 @@ async function loadCloudSource(options) {
1041
1100
  return parseProviderPayload(options.provider, parsed);
1042
1101
  }
1043
1102
  var SECRET_KEY_RE = /(SECRET|TOKEN|PASSWORD|PRIVATE|API_KEY|ACCESS_KEY|CLIENT_SECRET)/i;
1044
- var CONTRACT_FILE_NAMES2 = ["env.contract.ts", "env.contract.mjs", "env.contract.js"];
1103
+ var CONTRACT_FILE_NAMES2 = ["env.contract.mjs", "env.contract.js", "env.contract.ts"];
1045
1104
  function isRecord2(value) {
1046
1105
  return typeof value === "object" && value !== null && !Array.isArray(value);
1047
1106
  }
@@ -1165,6 +1224,12 @@ async function loadValidationContract(options) {
1165
1224
  const discoveredContractPath = findDefaultContractPath(cwd);
1166
1225
  const resolvedContractPath = contractPath === void 0 ? discoveredContractPath : path6.resolve(cwd, contractPath);
1167
1226
  if (resolvedContractPath !== void 0 && existsSync(resolvedContractPath)) {
1227
+ if (resolvedContractPath.endsWith(".ts")) {
1228
+ throw new Error(
1229
+ `Contract file ${resolvedContractPath} is a TypeScript file and cannot be loaded at runtime by Node.js.
1230
+ Rename it to ${resolvedContractPath.replace(/\.ts$/, ".mjs")} and use ESM syntax (export default ...), or run via \`tsx\` / \`ts-node\` as a loader.`
1231
+ );
1232
+ }
1168
1233
  const moduleUrl = pathToFileURL(resolvedContractPath).href;
1169
1234
  const moduleValue = await import(moduleUrl);
1170
1235
  const candidate = moduleValue.default ?? moduleValue.contract;
@@ -1418,7 +1483,7 @@ function isClientSecret(variable, key) {
1418
1483
  function checkContractVariable(key, variable, context) {
1419
1484
  const { options, issues } = context;
1420
1485
  const value = options.values[key];
1421
- const hasValue = value !== void 0;
1486
+ const hasValue = value !== void 0 && (value !== "" || !variable.required);
1422
1487
  if (variable.required && !hasValue) {
1423
1488
  issues.push(
1424
1489
  createIssue({
@@ -1520,7 +1585,11 @@ function diffTypeConflicts(key, present, context) {
1520
1585
  for (const entry of present) {
1521
1586
  typeBySource.set(entry.sourceName, detectReceivedType(entry.value ?? ""));
1522
1587
  }
1523
- if (new Set(typeBySource.values()).size <= 1) return;
1588
+ const knownTypes = /* @__PURE__ */ new Set();
1589
+ for (const t of typeBySource.values()) {
1590
+ if (t !== "unknown") knownTypes.add(t);
1591
+ }
1592
+ if (knownTypes.size <= 1) return;
1524
1593
  for (const [sourceName, detectedType] of typeBySource.entries()) {
1525
1594
  issues.push(
1526
1595
  createIssue({
@@ -1687,6 +1756,7 @@ async function loadEnvSource(options) {
1687
1756
  return parseEnvSourceContent(content);
1688
1757
  } catch (error_) {
1689
1758
  if (options.allowMissing === true && error_ instanceof Error && "code" in error_ && error_.code === "ENOENT") {
1759
+ warn(`Target file not found: ${options.filePath} \u2014 treating as empty`);
1690
1760
  return {};
1691
1761
  }
1692
1762
  throw error_;
@@ -1854,7 +1924,8 @@ async function loadCommandConfig(configPath) {
1854
1924
  }
1855
1925
  const resolvedPath = path6.resolve(configPath);
1856
1926
  if (!existsSync(resolvedPath)) {
1857
- throw new Error(`Config file not found: ${configPath}`);
1927
+ const displayPath = path6.isAbsolute(configPath) ? configPath : `${configPath} (resolved: ${resolvedPath})`;
1928
+ throw new Error(`Config file not found: ${displayPath}`);
1858
1929
  }
1859
1930
  const configDir = path6.dirname(resolvedPath);
1860
1931
  const moduleValue = await import(pathToFileURL(resolvedPath).href);