celery-env 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -171,7 +171,7 @@ competitors were checked with `npm view` on 2026-06-25.
171
171
 
172
172
  | Package | Version checked | Runtime deps | Unpacked npm size | Files |
173
173
  | --- | ---: | ---: | ---: | ---: |
174
- | `celery-env` | 0.1.2 + infer | 0 | 117.7 kB | 26 |
174
+ | `celery-env` | 0.1.4 | 0 | 119.2 kB | 26 |
175
175
  | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
176
176
  | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
177
177
  | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
@@ -73,7 +73,7 @@ Package footprint is separate from runtime speed. Celery is this branch's
73
73
 
74
74
  | Package | Version Checked | Runtime Deps | Unpacked npm Size | Files |
75
75
  | --- | ---: | ---: | ---: | ---: |
76
- | `celery-env` | 0.1.2 + infer | 0 | 117.7 kB | 26 |
76
+ | `celery-env` | 0.1.4 | 0 | 119.2 kB | 26 |
77
77
  | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
78
78
  | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
79
79
  | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
package/docs/CLI.md CHANGED
@@ -26,9 +26,9 @@ npx celery-env infer --schema env.schema.mjs
26
26
  ```
27
27
 
28
28
  Use `infer` when a project already has `.env` files or source code that reads
29
- env vars. It discovers `.env.example`, `.env`, `.env.local`, and common source
30
- directories by default. It writes a starter schema and refuses overwrite unless
31
- you pass `--force`.
29
+ env vars. It discovers `.env.example`, `.env`, `.env.local`, common source
30
+ directories, common config files, `scripts`, and `prisma` by default. It writes
31
+ a starter schema and refuses overwrite unless you pass `--force`.
32
32
 
33
33
  You can pass sources explicitly:
34
34
 
@@ -39,11 +39,17 @@ npx celery-env infer \
39
39
  --scan src
40
40
  ```
41
41
 
42
- Inference is conservative. Ambiguous values become `str({ min: 1 })`. Only
43
- example, sample, or template env files can emit `example` metadata; local env
44
- values and secret-looking values are not copied into the generated schema.
42
+ Inference is conservative starter-schema generation, not a replacement for
43
+ review. Ambiguous values become `str({ min: 1 })`. Safe example values can
44
+ infer enums and string-list item enums. A few common source defaults are also
45
+ detected, such as `process.env.PORT ?? "3000"`, `Number(process.env.X ?? 60)`,
46
+ and `process.env.DEBUG !== "false"`.
47
+
48
+ Only example, sample, or template env files can emit `example` metadata. Local
49
+ env values and secret-looking values are not copied into the generated schema.
45
50
  Review the result for project-specific constraints such as `requiredWhen`,
46
- `min`, `max`, or stricter URL protocols.
51
+ optional values, conditional requirements, `min`, `max`, or stricter URL
52
+ protocols.
47
53
 
48
54
  ## Generate
49
55
 
@@ -94,7 +94,7 @@ This table is package metadata, not benchmark speed. Celery is this branch's
94
94
 
95
95
  | Package | Version Checked | Runtime Deps | Unpacked npm Size | Files |
96
96
  | --- | ---: | ---: | ---: | ---: |
97
- | `celery-env` | 0.1.2 + infer | 0 | 117.7 kB | 26 |
97
+ | `celery-env` | 0.1.4 | 0 | 119.2 kB | 26 |
98
98
  | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
99
99
  | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
100
100
  | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "celery-env",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Type-safe process.env validation that generates zero-dependency standalone validators.",
5
5
  "type": "module",
6
6
  "types": "./src/index.d.ts",
package/src/cli.js CHANGED
@@ -64,9 +64,14 @@ async function init(args) {
64
64
  async function infer(args) {
65
65
  if (!args.schema) usage(1);
66
66
  const schemaPath = resolve(args.schema);
67
- const { inferSchemaSource } = await import("./infer.js");
67
+ const { inferSchema } = await import("./infer.js");
68
68
  await mkdir(dirname(schemaPath), { recursive: true });
69
- await writeOutput(schemaPath, await inferSchemaSource({ envFiles: args.envFiles, scanPaths: args.scanPaths }), args.force);
69
+ const result = await inferSchema({ envFiles: args.envFiles, scanPaths: args.scanPaths });
70
+ await writeOutput(schemaPath, result.source, args.force);
71
+ console.log(`Wrote ${schemaPath}
72
+ Scanned ${result.envFileCount} env file(s) and ${result.sourceFileCount} source file(s).
73
+ Found ${result.keyCount} environment variable(s).
74
+ Next: review the schema, then run celery-env generate --schema ${schemaPath} --out src/env.mjs --types src/env.d.ts`);
70
75
  }
71
76
 
72
77
  function parseArgs(argv) {
package/src/infer.js CHANGED
@@ -6,10 +6,20 @@ const JS_IDENT = /^[$A-Z_a-z][$\w]*$/;
6
6
  const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".svelte", ".ts", ".tsx", ".vue"]);
7
7
  const SKIP_DIRS = new Set([".git", ".next", ".nuxt", ".output", ".tmp", "build", "coverage", "dist", "node_modules"]);
8
8
  const DEFAULT_ENV_FILES = [".env.example", ".env", ".env.local"];
9
- const DEFAULT_SCAN_PATHS = ["src", "app", "pages", "lib", "server"];
10
- const BOOL_VALUES = new Set(["true", "yes", "on", "false", "no", "off"]);
9
+ const DEFAULT_SCAN_PATHS = ["src", "app", "pages", "lib", "server", "scripts", "prisma", ...["next", "vite", "astro"].flatMap((name) => configNames(name)), ...["server", "index"].flatMap((name) => configNames(name, ""))];
10
+ const BOOL_VALUE = /^(?:true|yes|on|false|no|off)$/;
11
+ const BOOLISH_KEY = /^(?:DEBUG|VERBOSE|(?:ENABLE|DISABLE|ENABLED|DISABLED|IS|HAS|USE|ALLOW|REGISTER)_.*|.*_(?:ENABLED|DISABLED|FLAG|FLAGS|ACTIVE))$/;
11
12
  const SECRET_KEY = /(?:SECRET|TOKEN|PASSWORD|PASS|PRIVATE|CREDENTIAL|AUTH|API_KEY|ACCESS_KEY)/i;
12
13
  const SECRET_VALUE = /(?:^sk_|^pk_|^gh[pousr]_|^xox[baprs]-|^eyJ|-----BEGIN |:\/\/[^/\s:@]+:[^/\s:@]+@|[A-Za-z0-9+/=_-]{32,})/;
14
+ const ENUM_VALUE = /^[A-Za-z][A-Za-z0-9_.:-]{0,31}$/;
15
+ const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"];
16
+ const NAMED_ENUMS = {
17
+ COMMAND_SCOPE: ["global", "guild"],
18
+ LOGGER_LEVEL: LOG_LEVELS,
19
+ LOG_LEVEL: LOG_LEVELS,
20
+ NODE_ENV: ["development", "test", "production"],
21
+ VERCEL_ENV: ["development", "preview", "production"]
22
+ };
13
23
  const MAX_ENV_FILE_BYTES = 256 * 1024;
14
24
  const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
15
25
  const MAX_SOURCE_FILES = 2000;
@@ -17,6 +27,10 @@ const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
17
27
  const MAX_SCAN_DEPTH = 32;
18
28
 
19
29
  export async function inferSchemaSource(options = {}) {
30
+ return (await inferSchema(options)).source;
31
+ }
32
+
33
+ export async function inferSchema(options = {}) {
20
34
  const cwd = resolve(options.cwd || process.cwd());
21
35
  const explicitEnvFiles = options.envFiles?.length;
22
36
  const explicitScanPaths = options.scanPaths?.length;
@@ -32,10 +46,11 @@ export async function inferSchemaSource(options = {}) {
32
46
  }
33
47
  }
34
48
 
35
- for (const file of await sourceFiles(scanPaths, { rejectRootSymlinks: Boolean(explicitScanPaths) })) {
49
+ const scannedSources = await sourceFiles(scanPaths, { rejectRootSymlinks: Boolean(explicitScanPaths) });
50
+ for (const file of scannedSources) {
36
51
  const source = await readFile(file, "utf8");
37
- for (const key of scanEnvKeys(source)) {
38
- record(entries, key, { codeOnly: true });
52
+ for (const hint of scanEnvHints(source)) {
53
+ record(entries, hint.key, { defaults: hint.defaults });
39
54
  }
40
55
  }
41
56
 
@@ -43,7 +58,12 @@ export async function inferSchemaSource(options = {}) {
43
58
  throw new Error("No environment variables found; pass --env or --scan");
44
59
  }
45
60
 
46
- return generateSchemaModule(entries);
61
+ return {
62
+ source: generateSchemaModule(entries),
63
+ envFileCount: envFiles.length,
64
+ sourceFileCount: scannedSources.length,
65
+ keyCount: entries.size
66
+ };
47
67
  }
48
68
 
49
69
  export function parseEnvSource(source) {
@@ -56,20 +76,33 @@ export function parseEnvSource(source) {
56
76
  }
57
77
 
58
78
  export function scanEnvKeys(source) {
59
- const keys = new Set();
60
- collectMatches(keys, source, /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)\b/g, 1);
61
- collectMatches(keys, source, /\bimport\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)\b/g, 1);
62
- collectMatches(keys, source, /\bprocess\.env\[\s*(["'`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\]/g, 2);
63
- collectMatches(keys, source, /\bimport\.meta\.env\[\s*(["'`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\]/g, 2);
64
-
65
- for (const match of source.matchAll(/\{([^}]+)\}\s*=\s*process\.env\b/g)) {
66
- for (const key of destructuredKeys(match[1])) keys.add(key);
79
+ return scanEnvHints(source).map((hint) => hint.key);
80
+ }
81
+
82
+ function scanEnvHints(source) {
83
+ const hints = new Map();
84
+ collectMatches(hints, source, /\b(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)\b/g, 1);
85
+ collectMatches(hints, source, /\b(?:process\.env|import\.meta\.env)\[\s*(["'`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\]/g, 2);
86
+
87
+ for (const match of source.matchAll(/\{([^}]+)\}\s*=\s*(?:process\.env|import\.meta\.env)\b/g)) {
88
+ for (const key of destructuredKeys(match[1])) addHint(hints, key);
67
89
  }
68
- for (const match of source.matchAll(/\{([^}]+)\}\s*=\s*import\.meta\.env\b/g)) {
69
- for (const key of destructuredKeys(match[1])) keys.add(key);
90
+
91
+ const ref = String.raw`\b(?:process\.env|import\.meta\.env)\.([A-Za-z_][A-Za-z0-9_]*)\s*`;
92
+ for (const match of source.matchAll(new RegExp(`${ref}\\?\\?\\s*(["'\`])([^"'\`\\\\]*(?:\\\\.[^"'\`\\\\]*)*)\\2`, "g"))) {
93
+ addHint(hints, match[1], unescapeQuoted(match[3]));
94
+ }
95
+ for (const match of source.matchAll(new RegExp(`${ref}\\?\\?\\s*([+-]?(?:\\d+\\.\\d*|\\.\\d+|\\d+)|true|false)`, "g"))) {
96
+ addHint(hints, match[1], match[2]);
97
+ }
98
+ for (const match of source.matchAll(new RegExp(`${ref}!==\\s*(["'])(?:false|0)\\2`, "g"))) {
99
+ addHint(hints, match[1], "true");
100
+ }
101
+ for (const match of source.matchAll(new RegExp(`${ref}===\\s*(["'])(?:true|1)\\2`, "g"))) {
102
+ addHint(hints, match[1], "false");
70
103
  }
71
104
 
72
- return [...keys].sort();
105
+ return [...hints.values()].sort((a, b) => a.key.localeCompare(b.key));
73
106
  }
74
107
 
75
108
  function parseEnvLine(line) {
@@ -126,8 +159,19 @@ function stripInlineComment(value) {
126
159
  return value;
127
160
  }
128
161
 
129
- function collectMatches(keys, source, pattern, group) {
130
- for (const match of source.matchAll(pattern)) keys.add(match[group]);
162
+ function collectMatches(hints, source, pattern, group) {
163
+ for (const match of source.matchAll(pattern)) addHint(hints, match[group]);
164
+ }
165
+
166
+ function addHint(hints, key, defaultValue) {
167
+ if (!ENV_NAME.test(key)) return;
168
+ const hint = hints.get(key) || { key, defaults: [] };
169
+ if (defaultValue !== undefined) hint.defaults.push(defaultValue);
170
+ hints.set(key, hint);
171
+ }
172
+
173
+ function configNames(name, infix = ".config") {
174
+ return ["js", "mjs", "ts"].map((ext) => `${name}${infix}.${ext}`);
131
175
  }
132
176
 
133
177
  function destructuredKeys(source) {
@@ -223,11 +267,11 @@ async function collectSourceFiles(out, path, state, depth, rejectSymlink) {
223
267
  function record(entries, key, source) {
224
268
  let entry = entries.get(key);
225
269
  if (!entry) {
226
- entry = { key, values: [], code: false };
270
+ entry = { key, values: [], defaults: [] };
227
271
  entries.set(key, entry);
228
272
  }
229
- if (source.codeOnly) entry.code = true;
230
- else entry.values.push({ value: source.value, safeExamples: source.safeExamples });
273
+ if (source.defaults) entry.defaults.push(...source.defaults.filter((value) => !unsafe(key, value)));
274
+ if (source.value !== undefined) entry.values.push({ value: source.value, safeExamples: source.safeExamples });
231
275
  }
232
276
 
233
277
  function generateSchemaModule(entries) {
@@ -252,38 +296,44 @@ function generateSchemaModule(entries) {
252
296
  }
253
297
 
254
298
  function inferRule(entry) {
255
- if (entry.key === "NODE_ENV") {
256
- return { kind: "oneOf", values: ["development", "test", "production"], options: { default: "development" } };
257
- }
299
+ const samples = entry.values.filter((item) => item.value !== "");
300
+ const defaults = cleanDefaults(entry);
301
+ const knownEnum = namedEnumRule(entry, samples, defaults);
302
+ if (knownEnum) return withExample(entry, withDefault(defaults, knownEnum));
258
303
 
259
- const samples = entry.values.map((item) => item.value).filter((value) => value !== "");
260
- if (!samples.length) return { kind: "str", options: { min: 1 } };
304
+ const observations = samples.concat(defaults.map((value) => ({ value, safeExamples: false })));
305
+ if (!observations.length) return { kind: "str", options: { min: 1 } };
261
306
 
262
- const kinds = samples.map(inferValue);
307
+ const enumRule = sampleEnumRule(entry, samples);
308
+ if (enumRule) return withExample(entry, withDefault(defaults, enumRule));
309
+
310
+ const kinds = observations.map((item) => inferValue(entry.key, item.value, enumSafe(entry, item)));
263
311
  if (kinds.some((kind) => kind.kind === "str")) return stringRule(entry);
264
312
 
265
313
  const first = kinds[0].kind;
266
314
  if (!kinds.every((kind) => kind.kind === first)) return stringRule(entry);
267
315
 
268
316
  const rule = mergeRules(first, kinds);
269
- const example = safeExample(entry, rule);
270
- if (example !== undefined) rule.options = { ...rule.options, example };
271
- return rule;
317
+ return withExample(entry, withDefault(defaults, rule));
272
318
  }
273
319
 
274
- function inferValue(value) {
275
- if (BOOL_VALUES.has(value)) return { kind: "bool" };
320
+ function inferValue(key, value, allowEnum) {
321
+ if (isBoolValue(key, value)) return { kind: "bool" };
276
322
  if (strictInt(value)) return { kind: "int", options: { strict: true } };
277
323
  if (strictNumber(value)) return { kind: "num", options: { strict: true } };
278
324
  const jsonRule = inferJson(value);
279
325
  if (jsonRule) return jsonRule;
280
- const listRule = inferList(value);
326
+ const listRule = inferList(key, value, allowEnum);
281
327
  if (listRule) return listRule;
282
328
  const urlRule = inferUrl(value);
283
329
  if (urlRule) return urlRule;
284
330
  return { kind: "str", options: { min: 1 } };
285
331
  }
286
332
 
333
+ function isBoolValue(key, value) {
334
+ return BOOL_VALUE.test(value) || ((value === "1" || value === "0") && BOOLISH_KEY.test(key));
335
+ }
336
+
287
337
  function inferJson(value) {
288
338
  if (!/^\s*[\[{]/.test(value)) return;
289
339
  try {
@@ -292,19 +342,23 @@ function inferJson(value) {
292
342
  } catch {}
293
343
  }
294
344
 
295
- function inferList(value) {
345
+ function inferList(key, value, allowEnum) {
296
346
  if (!value.includes(",") || /^\s*[\[{]/.test(value)) return;
297
347
  const parts = value.split(",").map((part) => part.trim());
298
348
  if (parts.length < 2 || parts.some((part) => part === "")) return;
299
349
  const items = parts.map((part) => {
300
- if (BOOL_VALUES.has(part)) return { kind: "bool" };
350
+ if (isBoolValue(key, part)) return { kind: "bool" };
301
351
  if (strictInt(part)) return { kind: "int", options: { strict: true } };
302
352
  if (strictNumber(part)) return { kind: "num", options: { strict: true } };
303
353
  const urlRule = inferUrl(part);
304
354
  return urlRule || { kind: "str" };
305
355
  });
306
356
  const first = items[0].kind;
307
- if (first === "str" || !items.every((item) => item.kind === first)) return;
357
+ if (first === "str") {
358
+ const values = allowEnum && enumValues(parts);
359
+ return { kind: "list", item: values ? { kind: "oneOf", values } : { kind: "str", options: { min: 1 } } };
360
+ }
361
+ if (!items.every((item) => item.kind === first)) return;
308
362
  return { kind: "list", item: mergeRules(first, items) };
309
363
  }
310
364
 
@@ -334,25 +388,72 @@ function mergeRules(kind, rules) {
334
388
  }
335
389
  if (kind === "list") {
336
390
  const itemKind = rules[0].item.kind;
391
+ if (!rules.every((rule) => rule.item.kind === itemKind)) return { kind: "list", item: { kind: "str", options: { min: 1 } } };
337
392
  return { kind: "list", item: mergeRules(itemKind, rules.map((rule) => rule.item)) };
338
393
  }
394
+ if (kind === "oneOf") return { kind, values: enumValues(rules.flatMap((rule) => rule.values)) };
339
395
  if (kind === "int" || kind === "num") return { kind, options: { strict: true } };
396
+ if (kind === "str" && rules.some((rule) => rule.options)) return { kind, options: { min: 1 } };
340
397
  return { kind };
341
398
  }
342
399
 
343
400
  function stringRule(entry) {
344
401
  const rule = { kind: "str", options: { min: 1 } };
402
+ withDefault(cleanDefaults(entry), rule);
345
403
  const example = safeExample(entry, rule);
346
- if (example !== undefined) rule.options.example = example;
404
+ if (example !== undefined && rule.options.default !== example) rule.options.example = example;
347
405
  return rule;
348
406
  }
349
407
 
350
408
  function safeExample(entry, rule) {
351
409
  const sample = entry.values.find((item) => item.safeExamples && item.value !== "");
352
- if (!sample || SECRET_KEY.test(entry.key) || SECRET_VALUE.test(sample.value)) return;
410
+ if (!sample || unsafe(entry.key, sample.value)) return;
353
411
  return exampleValue(sample.value, rule);
354
412
  }
355
413
 
414
+ function withExample(entry, rule) {
415
+ const example = safeExample(entry, rule);
416
+ if (example !== undefined && rule.options?.default !== example) rule.options = { ...rule.options, example };
417
+ return rule;
418
+ }
419
+
420
+ function enumSafe(entry, item) {
421
+ return item.safeExamples && !unsafe(entry.key, item.value);
422
+ }
423
+
424
+ function sampleEnumRule(entry, samples) {
425
+ const values = samples.every((item) => enumSafe(entry, item)) && enumValues(samples.map((item) => item.value));
426
+ if (values && values.length > 1 && values.length <= 8) return { kind: "oneOf", values };
427
+ }
428
+
429
+ function namedEnumRule(entry, samples, defaults) {
430
+ const values = NAMED_ENUMS[entry.key];
431
+ if (!values) return;
432
+ const seen = samples.map((item) => item.value).concat(defaults);
433
+ if (seen.some((value) => !values.includes(value))) return;
434
+ if (entry.key !== "NODE_ENV" && !seen.length) return;
435
+ const options = entry.key === "NODE_ENV" ? { default: "development" } : undefined;
436
+ return { kind: "oneOf", values, options };
437
+ }
438
+
439
+ function cleanDefaults(entry) {
440
+ const values = [...new Set(entry.defaults.filter(Boolean))];
441
+ return values[1] ? [] : values;
442
+ }
443
+
444
+ function withDefault(defaults, rule) {
445
+ if (!defaults.length) return rule;
446
+ const value = exampleValue(defaults[0], rule);
447
+ if (value === undefined) return rule;
448
+ if (rule.kind === "oneOf" && !rule.values.includes(defaults[0])) return rule;
449
+ rule.options = { ...rule.options, default: value };
450
+ return rule;
451
+ }
452
+
453
+ function unsafe(key, value) {
454
+ return SECRET_KEY.test(key) || SECRET_VALUE.test(value);
455
+ }
456
+
356
457
  function exampleValue(value, rule) {
357
458
  if (rule.kind === "bool") return value === "true" || value === "1" || value === "yes" || value === "on";
358
459
  if (rule.kind === "int" || rule.kind === "num") return Number(value);
@@ -373,7 +474,7 @@ function collectRuleImports(imports, rule) {
373
474
  }
374
475
 
375
476
  function ruleSource(rule) {
376
- if (rule.kind === "oneOf") return `oneOf(${literal(rule.values)}, ${literal(rule.options)})`;
477
+ if (rule.kind === "oneOf") return rule.options && Object.keys(rule.options).length ? `oneOf(${literal(rule.values)}, ${literal(rule.options)})` : `oneOf(${literal(rule.values)})`;
377
478
  if (rule.kind === "list") {
378
479
  const item = ruleSource(rule.item);
379
480
  return rule.options && Object.keys(rule.options).length ? `list(${item}, ${literal(rule.options)})` : `list(${item})`;
@@ -387,9 +488,16 @@ function schemaKey(key) {
387
488
  }
388
489
 
389
490
  function literal(value) {
491
+ if (Array.isArray(value)) return `[${value.map(literal).join(", ")}]`;
492
+ if (value && typeof value === "object") return `{ ${Object.entries(value).map(([key, item]) => `${schemaKey(key)}: ${literal(item)}`).join(", ")} }`;
390
493
  return JSON.stringify(value);
391
494
  }
392
495
 
496
+ function enumValues(values) {
497
+ if (!values.every((value) => ENUM_VALUE.test(value))) return;
498
+ return [...new Set(values)].sort();
499
+ }
500
+
393
501
  function importSort(a, b) {
394
502
  return a.localeCompare(b);
395
503
  }