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 +13 -8
- package/package.json +1 -1
- package/src/cli.js +7 -2
- package/src/infer.js +112 -37
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`,
|
|
30
|
-
directories
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
51
|
+
optional values, conditional requirements, `min`, `max`, or stricter URL
|
|
52
|
+
protocols.
|
|
48
53
|
|
|
49
54
|
## Generate
|
|
50
55
|
|
package/package.json
CHANGED
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 {
|
|
67
|
+
const { inferSchema } = await import("./infer.js");
|
|
68
68
|
await mkdir(dirname(schemaPath), { recursive: true });
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
39
|
-
record(entries, key, {
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
70
|
-
|
|
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 [...
|
|
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(
|
|
131
|
-
for (const match of source.matchAll(pattern))
|
|
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: [],
|
|
270
|
+
entry = { key, values: [], defaults: [] };
|
|
228
271
|
entries.set(key, entry);
|
|
229
272
|
}
|
|
230
|
-
if (source.
|
|
231
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|