celery-env 0.1.4 → 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/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,12 +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.
45
- Safe example values can infer enums and string-list item enums.
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.
46
50
  Review the result for project-specific constraints such as `requiredWhen`,
47
- `min`, `max`, or stricter URL protocols.
51
+ optional values, conditional requirements, `min`, `max`, or stricter URL
52
+ protocols.
48
53
 
49
54
  ## Generate
50
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "celery-env",
3
- "version": "0.1.4",
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,11 +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,})/;
13
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
+ };
14
23
  const MAX_ENV_FILE_BYTES = 256 * 1024;
15
24
  const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
16
25
  const MAX_SOURCE_FILES = 2000;
@@ -18,6 +27,10 @@ const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
18
27
  const MAX_SCAN_DEPTH = 32;
19
28
 
20
29
  export async function inferSchemaSource(options = {}) {
30
+ return (await inferSchema(options)).source;
31
+ }
32
+
33
+ export async function inferSchema(options = {}) {
21
34
  const cwd = resolve(options.cwd || process.cwd());
22
35
  const explicitEnvFiles = options.envFiles?.length;
23
36
  const explicitScanPaths = options.scanPaths?.length;
@@ -33,10 +46,11 @@ export async function inferSchemaSource(options = {}) {
33
46
  }
34
47
  }
35
48
 
36
- 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) {
37
51
  const source = await readFile(file, "utf8");
38
- for (const key of scanEnvKeys(source)) {
39
- record(entries, key, { codeOnly: true });
52
+ for (const hint of scanEnvHints(source)) {
53
+ record(entries, hint.key, { defaults: hint.defaults });
40
54
  }
41
55
  }
42
56
 
@@ -44,7 +58,12 @@ export async function inferSchemaSource(options = {}) {
44
58
  throw new Error("No environment variables found; pass --env or --scan");
45
59
  }
46
60
 
47
- return generateSchemaModule(entries);
61
+ return {
62
+ source: generateSchemaModule(entries),
63
+ envFileCount: envFiles.length,
64
+ sourceFileCount: scannedSources.length,
65
+ keyCount: entries.size
66
+ };
48
67
  }
49
68
 
50
69
  export function parseEnvSource(source) {
@@ -57,20 +76,33 @@ export function parseEnvSource(source) {
57
76
  }
58
77
 
59
78
  export function scanEnvKeys(source) {
60
- const keys = new Set();
61
- collectMatches(keys, source, /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)\b/g, 1);
62
- collectMatches(keys, source, /\bimport\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)\b/g, 1);
63
- collectMatches(keys, source, /\bprocess\.env\[\s*(["'`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\]/g, 2);
64
- collectMatches(keys, source, /\bimport\.meta\.env\[\s*(["'`])([A-Za-z_][A-Za-z0-9_]*)\1\s*\]/g, 2);
65
-
66
- for (const match of source.matchAll(/\{([^}]+)\}\s*=\s*process\.env\b/g)) {
67
- 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);
89
+ }
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]));
68
94
  }
69
- for (const match of source.matchAll(/\{([^}]+)\}\s*=\s*import\.meta\.env\b/g)) {
70
- for (const key of destructuredKeys(match[1])) keys.add(key);
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");
71
103
  }
72
104
 
73
- return [...keys].sort();
105
+ return [...hints.values()].sort((a, b) => a.key.localeCompare(b.key));
74
106
  }
75
107
 
76
108
  function parseEnvLine(line) {
@@ -127,8 +159,19 @@ function stripInlineComment(value) {
127
159
  return value;
128
160
  }
129
161
 
130
- function collectMatches(keys, source, pattern, group) {
131
- 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}`);
132
175
  }
133
176
 
134
177
  function destructuredKeys(source) {
@@ -224,11 +267,11 @@ async function collectSourceFiles(out, path, state, depth, rejectSymlink) {
224
267
  function record(entries, key, source) {
225
268
  let entry = entries.get(key);
226
269
  if (!entry) {
227
- entry = { key, values: [], code: false };
270
+ entry = { key, values: [], defaults: [] };
228
271
  entries.set(key, entry);
229
272
  }
230
- if (source.codeOnly) entry.code = true;
231
- 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 });
232
275
  }
233
276
 
234
277
  function generateSchemaModule(entries) {
@@ -253,39 +296,44 @@ function generateSchemaModule(entries) {
253
296
  }
254
297
 
255
298
  function inferRule(entry) {
256
- if (entry.key === "NODE_ENV") {
257
- return { kind: "oneOf", values: ["development", "test", "production"], options: { default: "development" } };
258
- }
259
-
260
299
  const samples = entry.values.filter((item) => item.value !== "");
261
- if (!samples.length) return { kind: "str", options: { min: 1 } };
300
+ const defaults = cleanDefaults(entry);
301
+ const knownEnum = namedEnumRule(entry, samples, defaults);
302
+ if (knownEnum) return withExample(entry, withDefault(defaults, knownEnum));
303
+
304
+ const observations = samples.concat(defaults.map((value) => ({ value, safeExamples: false })));
305
+ if (!observations.length) return { kind: "str", options: { min: 1 } };
262
306
 
263
307
  const enumRule = sampleEnumRule(entry, samples);
264
- if (enumRule) return withExample(entry, enumRule);
308
+ if (enumRule) return withExample(entry, withDefault(defaults, enumRule));
265
309
 
266
- const kinds = samples.map((item) => inferValue(item.value, enumSafe(entry, item)));
310
+ const kinds = observations.map((item) => inferValue(entry.key, item.value, enumSafe(entry, item)));
267
311
  if (kinds.some((kind) => kind.kind === "str")) return stringRule(entry);
268
312
 
269
313
  const first = kinds[0].kind;
270
314
  if (!kinds.every((kind) => kind.kind === first)) return stringRule(entry);
271
315
 
272
316
  const rule = mergeRules(first, kinds);
273
- return withExample(entry, rule);
317
+ return withExample(entry, withDefault(defaults, rule));
274
318
  }
275
319
 
276
- function inferValue(value, allowEnum) {
277
- if (BOOL_VALUES.has(value)) return { kind: "bool" };
320
+ function inferValue(key, value, allowEnum) {
321
+ if (isBoolValue(key, value)) return { kind: "bool" };
278
322
  if (strictInt(value)) return { kind: "int", options: { strict: true } };
279
323
  if (strictNumber(value)) return { kind: "num", options: { strict: true } };
280
324
  const jsonRule = inferJson(value);
281
325
  if (jsonRule) return jsonRule;
282
- const listRule = inferList(value, allowEnum);
326
+ const listRule = inferList(key, value, allowEnum);
283
327
  if (listRule) return listRule;
284
328
  const urlRule = inferUrl(value);
285
329
  if (urlRule) return urlRule;
286
330
  return { kind: "str", options: { min: 1 } };
287
331
  }
288
332
 
333
+ function isBoolValue(key, value) {
334
+ return BOOL_VALUE.test(value) || ((value === "1" || value === "0") && BOOLISH_KEY.test(key));
335
+ }
336
+
289
337
  function inferJson(value) {
290
338
  if (!/^\s*[\[{]/.test(value)) return;
291
339
  try {
@@ -294,12 +342,12 @@ function inferJson(value) {
294
342
  } catch {}
295
343
  }
296
344
 
297
- function inferList(value, allowEnum) {
345
+ function inferList(key, value, allowEnum) {
298
346
  if (!value.includes(",") || /^\s*[\[{]/.test(value)) return;
299
347
  const parts = value.split(",").map((part) => part.trim());
300
348
  if (parts.length < 2 || parts.some((part) => part === "")) return;
301
349
  const items = parts.map((part) => {
302
- if (BOOL_VALUES.has(part)) return { kind: "bool" };
350
+ if (isBoolValue(key, part)) return { kind: "bool" };
303
351
  if (strictInt(part)) return { kind: "int", options: { strict: true } };
304
352
  if (strictNumber(part)) return { kind: "num", options: { strict: true } };
305
353
  const urlRule = inferUrl(part);
@@ -351,8 +399,9 @@ function mergeRules(kind, rules) {
351
399
 
352
400
  function stringRule(entry) {
353
401
  const rule = { kind: "str", options: { min: 1 } };
402
+ withDefault(cleanDefaults(entry), rule);
354
403
  const example = safeExample(entry, rule);
355
- if (example !== undefined) rule.options.example = example;
404
+ if (example !== undefined && rule.options.default !== example) rule.options.example = example;
356
405
  return rule;
357
406
  }
358
407
 
@@ -364,7 +413,7 @@ function safeExample(entry, rule) {
364
413
 
365
414
  function withExample(entry, rule) {
366
415
  const example = safeExample(entry, rule);
367
- if (example !== undefined) rule.options = { ...rule.options, example };
416
+ if (example !== undefined && rule.options?.default !== example) rule.options = { ...rule.options, example };
368
417
  return rule;
369
418
  }
370
419
 
@@ -377,6 +426,30 @@ function sampleEnumRule(entry, samples) {
377
426
  if (values && values.length > 1 && values.length <= 8) return { kind: "oneOf", values };
378
427
  }
379
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
+
380
453
  function unsafe(key, value) {
381
454
  return SECRET_KEY.test(key) || SECRET_VALUE.test(value);
382
455
  }
@@ -415,6 +488,8 @@ function schemaKey(key) {
415
488
  }
416
489
 
417
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(", ")} }`;
418
493
  return JSON.stringify(value);
419
494
  }
420
495