celery-env 0.1.2 → 0.1.3

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
@@ -44,7 +44,16 @@ bun add -d celery-env
44
44
 
45
45
  ## 60-Second Setup
46
46
 
47
- Create a schema:
47
+ Infer a schema from existing env files and source references:
48
+
49
+ ```sh
50
+ npx celery-env infer --schema env.schema.mjs
51
+ ```
52
+
53
+ This writes a starter schema and refuses overwrite unless you pass `--force`.
54
+ Review it before generating; inference cannot know every production-only rule.
55
+
56
+ Or create a schema manually:
48
57
 
49
58
  ```js
50
59
  // env.schema.mjs
@@ -85,8 +94,8 @@ export const env = loadEnv(process.env);
85
94
  Celery validates the env object you pass. Load `.env` with your platform,
86
95
  shell, or `dotenv` before calling `loadEnv`.
87
96
 
88
- That is the main path: write `env.schema.mjs`, generate `src/env.mjs`, and use
89
- the typed result everywhere else.
97
+ That is the main path: infer or write `env.schema.mjs`, generate `src/env.mjs`,
98
+ and use the typed result everywhere else.
90
99
 
91
100
  ## When To Use Celery
92
101
 
@@ -157,25 +166,25 @@ The benchmark corpus includes realistic API, web, worker, list-heavy, and
157
166
  JSON-heavy env schemas. Results are workload-specific; real `process.env` access
158
167
  narrows some gaps compared with frozen plain env objects.
159
168
 
160
- Current npm package metadata:
169
+ Package metadata snapshot. Celery is this branch's `npm pack --dry-run`;
170
+ competitors were checked with `npm view` on 2026-06-25.
161
171
 
162
172
  | Package | Version checked | Runtime deps | Unpacked npm size | Files |
163
173
  | --- | ---: | ---: | ---: | ---: |
164
- | `celery-env` | 0.1.2 | 0 | 95.7 kB | 20 |
174
+ | `celery-env` | 0.1.2 + infer | 0 | 117.7 kB | 26 |
165
175
  | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
166
176
  | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
167
177
  | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
168
178
  | `envsafe` | 2.0.3 | 0 | 91.4 kB | 27 |
169
179
  | `env-var` | 7.5.0 | 0 | 42.9 kB | 30 |
170
180
 
171
- Metadata checked with `npm view` on 2026-06-25.
172
-
173
181
  ## Documentation
174
182
 
175
183
  - [Getting Started](docs/GETTING_STARTED.md)
176
184
  - [Schema API](docs/SCHEMA.md)
177
185
  - [CLI](docs/CLI.md)
178
186
  - [TypeScript](docs/TYPESCRIPT.md)
187
+ - [Examples](docs/EXAMPLES.md)
179
188
  - [Runtime Mode](docs/RUNTIME.md)
180
189
  - [Comparison](docs/COMPARISON.md)
181
190
  - [Troubleshooting](docs/TROUBLESHOOTING.md)
@@ -68,12 +68,12 @@ Snapshot dependency versions:
68
68
 
69
69
  ## Package Metadata
70
70
 
71
- Package footprint is separate from runtime speed. Current npm metadata checked
72
- on 2026-06-25:
71
+ Package footprint is separate from runtime speed. Celery is this branch's
72
+ `npm pack --dry-run`; competitors were checked with `npm view` on 2026-06-25.
73
73
 
74
74
  | Package | Version Checked | Runtime Deps | Unpacked npm Size | Files |
75
75
  | --- | ---: | ---: | ---: | ---: |
76
- | `celery-env` | 0.1.2 | 0 | 95.7 kB | 20 |
76
+ | `celery-env` | 0.1.2 + infer | 0 | 117.7 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 |
@@ -89,6 +89,10 @@ The published package intentionally does not ship the local benchmark lab or
89
89
  competitor dependencies. Public docs keep the benchmark summary and claim rules
90
90
  so the npm package stays small and dependency-free.
91
91
 
92
+ The local benchmark lab lives outside the published package. A refresh should
93
+ rerun the benchmark corpus, then update only the summarized public tables and
94
+ metadata in this document and the README.
95
+
92
96
  When refreshing claims, record:
93
97
 
94
98
  - `celery-env` version or commit;
package/docs/CLI.md CHANGED
@@ -19,6 +19,32 @@ Targets:
19
19
  | `next` | Next.js projects. |
20
20
  | `vite` | Vite projects and edge-style environments. |
21
21
 
22
+ ## Infer A Schema
23
+
24
+ ```sh
25
+ npx celery-env infer --schema env.schema.mjs
26
+ ```
27
+
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`.
32
+
33
+ You can pass sources explicitly:
34
+
35
+ ```sh
36
+ npx celery-env infer \
37
+ --schema env.schema.mjs \
38
+ --env .env.example \
39
+ --scan src
40
+ ```
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
+ Review the result for project-specific constraints such as `requiredWhen`,
46
+ `min`, `max`, or stricter URL protocols.
47
+
22
48
  ## Generate
23
49
 
24
50
  ```sh
@@ -31,7 +57,17 @@ npx celery-env generate \
31
57
 
32
58
  Use `generate` after editing `env.schema.mjs`.
33
59
 
34
- ## Flags
60
+ ## Infer Flags
61
+
62
+ | Flag | Meaning |
63
+ | --- | --- |
64
+ | `--schema <file>` | Schema file to write. |
65
+ | `--env <file>` | Env file to read for `infer`. Repeatable. |
66
+ | `--scan <path>` | File or directory to scan for `infer`. Repeatable. |
67
+ | `--force` | Overwrite an existing schema file. |
68
+ | `--version`, `-v` | Print the installed CLI version. |
69
+
70
+ ## Generate Flags
35
71
 
36
72
  | Flag | Meaning |
37
73
  | --- | --- |
@@ -16,10 +16,12 @@ tool for environment configuration.
16
16
  ## Celery vs Zod
17
17
 
18
18
  Zod is excellent for general TypeScript validation: forms, API payloads,
19
- domain objects, JSON data, and reusable schemas across an app.
19
+ domain objects, JSON data, and reusable schemas across an app. If your team
20
+ already uses Zod, keep it for those jobs.
20
21
 
21
22
  Celery is narrower. It validates `process.env`, then can generate a standalone
22
- validator and declaration file for production startup.
23
+ validator and declaration file for production startup. It is designed to sit
24
+ beside Zod, not replace it.
23
25
 
24
26
  | Question | Celery | Zod |
25
27
  | --- | --- | --- |
@@ -32,8 +34,24 @@ validator and declaration file for production startup.
32
34
  | Secret-safe env errors | Built in. | Build yourself. |
33
35
  | Ecosystem size | Small and focused. | Large and mature. |
34
36
 
35
- Use Zod when you need general validation. Use Celery when the thing being
36
- validated is application configuration.
37
+ Use both when that is the cleanest architecture: Zod for request bodies,
38
+ forms, and domain objects; Celery for process configuration that should be
39
+ validated once at startup.
40
+
41
+ ## Using Celery Alongside Zod
42
+
43
+ Keep the boundary simple:
44
+
45
+ ```js
46
+ // src/env.mjs is generated by celery-env.
47
+ import { loadEnv } from "./env.mjs";
48
+
49
+ export const env = loadEnv(process.env);
50
+ ```
51
+
52
+ Then pass `env` into the rest of the app. Zod schemas should not read
53
+ `process.env` directly; they should receive already-validated config when they
54
+ need it.
37
55
 
38
56
  ## Celery vs Envalid / Envsafe / env-var
39
57
 
@@ -71,19 +89,18 @@ Celery's main difference is generated mode:
71
89
 
72
90
  ## Package Footprint
73
91
 
74
- This table is npm package metadata, not benchmark speed:
92
+ This table is package metadata, not benchmark speed. Celery is this branch's
93
+ `npm pack --dry-run`; competitors were checked with `npm view` on 2026-06-25.
75
94
 
76
95
  | Package | Version Checked | Runtime Deps | Unpacked npm Size | Files |
77
96
  | --- | ---: | ---: | ---: | ---: |
78
- | `celery-env` | 0.1.2 | 0 | 95.7 kB | 20 |
97
+ | `celery-env` | 0.1.2 + infer | 0 | 117.7 kB | 26 |
79
98
  | `zod` | 4.4.3 | 0 | 4.56 MB | 718 |
80
99
  | `valibot` | 1.4.1 | 0 | 1.84 MB | 9 |
81
100
  | `envalid` | 8.2.0 | 1 | 88.8 kB | 39 |
82
101
  | `envsafe` | 2.0.3 | 0 | 91.4 kB | 27 |
83
102
  | `env-var` | 7.5.0 | 0 | 42.9 kB | 30 |
84
103
 
85
- Checked with `npm view` on 2026-06-25.
86
-
87
104
  ## What Celery Does Not Do
88
105
 
89
106
  - It does not load `.env` files. Use your platform, shell, or a dotenv loader.
@@ -0,0 +1,50 @@
1
+ # Examples
2
+
3
+ The examples directory contains small schema fixtures that mirror common app
4
+ setups. They are intentionally plain JavaScript so they work in TypeScript and
5
+ JavaScript projects.
6
+
7
+ ## Plain Node Service
8
+
9
+ Use [`examples/node-service/env.schema.mjs`](../examples/node-service/env.schema.mjs)
10
+ for API services, workers, and CLIs.
11
+
12
+ ```sh
13
+ npx celery-env generate \
14
+ --schema examples/node-service/env.schema.mjs \
15
+ --out src/env.mjs \
16
+ --types src/env.d.ts \
17
+ --example .env.example \
18
+ --minify
19
+ ```
20
+
21
+ Validate once at startup:
22
+
23
+ ```js
24
+ import { loadEnv } from "./env.mjs";
25
+
26
+ export const env = loadEnv(process.env);
27
+ ```
28
+
29
+ ## Next.js-Style Schema
30
+
31
+ Use [`examples/next/env.schema.mjs`](../examples/next/env.schema.mjs) when a
32
+ project has server-only variables and `NEXT_PUBLIC_` browser variables.
33
+
34
+ Generate the validator during development and import the generated output only
35
+ from server startup or server-only modules. Celery does not load `.env` files;
36
+ let Next.js, your platform, or a dotenv loader populate `process.env` first.
37
+
38
+ ## Runtime-Only Fallback
39
+
40
+ Use runtime mode when generation is not practical yet:
41
+
42
+ ```js
43
+ import schema from "./env.schema.mjs";
44
+ import { parseEnv } from "celery-env";
45
+
46
+ export const env = parseEnv(schema, process.env);
47
+ ```
48
+
49
+ Generated mode remains the recommended production path because it avoids loading
50
+ the schema library at startup.
@@ -17,9 +17,26 @@ bun add -d celery-env
17
17
  Generated validators do not need `celery-env` at runtime, so most apps install
18
18
  it as a dev dependency.
19
19
 
20
+ Check the CLI version when debugging local installs:
21
+
22
+ ```sh
23
+ npx celery-env --version
24
+ ```
25
+
20
26
  ## 2. Create A Schema
21
27
 
22
- Create `env.schema.mjs`:
28
+ If your project already has `.env` files or `process.env` references, infer a
29
+ starter schema:
30
+
31
+ ```sh
32
+ npx celery-env infer --schema env.schema.mjs
33
+ ```
34
+
35
+ This reads `.env.example`, `.env`, `.env.local`, and common source directories.
36
+ It writes a starter schema and refuses overwrite unless you pass `--force`.
37
+ Review it for project-specific rules such as production-only secrets, ranges,
38
+ or stricter URL protocols.
39
+ You can also create `env.schema.mjs` manually:
23
40
 
24
41
  ```js
25
42
  import { bool, defineEnv, int, oneOf, str, url } from "celery-env";
@@ -75,7 +92,8 @@ Add a script so generation is repeatable:
75
92
  ```json
76
93
  {
77
94
  "scripts": {
78
- "env:generate": "celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify --force"
95
+ "env:generate": "celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify --force",
96
+ "env:check": "npm run env:generate && git diff --exit-code src/env.mjs src/env.d.ts .env.example"
79
97
  }
80
98
  }
81
99
  ```
@@ -138,8 +156,7 @@ npm run env:generate
138
156
  In CI, verify generated files are current:
139
157
 
140
158
  ```sh
141
- npm run env:generate
142
- git diff --exit-code src/env.mjs src/env.d.ts .env.example
159
+ npm run env:check
143
160
  ```
144
161
 
145
162
  ## Next Steps
package/docs/MIGRATION.md CHANGED
@@ -2,7 +2,16 @@
2
2
 
3
3
  Use this guide when adding Celery to an existing app.
4
4
 
5
- ## Step 1. Find Env Reads
5
+ ## Step 1. Infer A Starter Schema
6
+
7
+ ```sh
8
+ npx celery-env infer --schema env.schema.mjs
9
+ ```
10
+
11
+ This reads existing env files and static source references. Review the generated
12
+ schema before using it in production.
13
+
14
+ ## Step 2. Find Env Reads
6
15
 
7
16
  Search for direct env access:
8
17
 
@@ -12,7 +21,7 @@ rg "process\\.env|env\\.[A-Z_]+"
12
21
 
13
22
  The goal is one central module that reads env and exports validated config.
14
23
 
15
- ## Step 2. Write A Flat Schema
24
+ ## Step 3. Tighten The Flat Schema
16
25
 
17
26
  Keep schema keys close to actual env var names:
18
27
 
@@ -26,13 +35,13 @@ export default defineEnv({
26
35
  });
27
36
  ```
28
37
 
29
- ## Step 3. Generate
38
+ ## Step 4. Generate
30
39
 
31
40
  ```sh
32
41
  npx celery-env generate --schema env.schema.mjs --out src/env.mjs --types src/env.d.ts --example .env.example --minify
33
42
  ```
34
43
 
35
- ## Step 4. Keep Your Existing Shape
44
+ ## Step 5. Keep Your Existing Shape
36
45
 
37
46
  If your app already expects nested config, adapt once:
38
47
 
@@ -49,6 +58,6 @@ export function loadConfig(source = process.env) {
49
58
  }
50
59
  ```
51
60
 
52
- ## Step 5. Remove Direct Env Reads
61
+ ## Step 6. Remove Direct Env Reads
53
62
 
54
63
  Use the validated config everywhere else.
package/docs/README.md CHANGED
@@ -4,12 +4,14 @@ Start here if you are new to Celery or new to typed env validation.
4
4
 
5
5
  ## Learning Path
6
6
 
7
- 1. [Getting Started](GETTING_STARTED.md): install, write a schema, generate a validator.
7
+ 1. [Getting Started](GETTING_STARTED.md): install, infer or write a schema, generate a validator.
8
8
  2. [Schema API](SCHEMA.md): every validator and option.
9
- 3. [CLI](CLI.md): `init`, `generate`, and generation flags.
9
+ 3. [CLI](CLI.md): `init`, `infer`, `generate`, and command flags.
10
10
  4. [TypeScript](TYPESCRIPT.md): generated types and `InferEnv`.
11
11
  5. [Runtime Mode](RUNTIME.md): use Celery without generation.
12
- 6. [Comparison](COMPARISON.md): when to choose Celery, Zod, Envalid, Envsafe, or runtime-only env validators.
13
- 7. [Troubleshooting](TROUBLESHOOTING.md): common setup and generation issues.
14
- 8. [Migration Guide](MIGRATION.md): add Celery to an existing project.
15
- 9. [Benchmarks](BENCHMARKS.md): what is measured and what claims are safe.
12
+ 6. [Examples](EXAMPLES.md): Node service and Next.js-style schemas.
13
+ 7. [Comparison](COMPARISON.md): when to choose Celery, Zod, Envalid, Envsafe, or runtime-only env validators.
14
+ 8. [Troubleshooting](TROUBLESHOOTING.md): common setup and generation issues.
15
+ 9. [Migration Guide](MIGRATION.md): add Celery to an existing project.
16
+ 10. [Benchmarks](BENCHMARKS.md): what is measured and what claims are safe.
17
+ 11. [Release Checklist](RELEASE.md): maintainer publishing steps.
@@ -0,0 +1,60 @@
1
+ # Release Checklist
2
+
3
+ Use this when publishing a new npm version.
4
+
5
+ ## Before Publishing
6
+
7
+ 1. Update `package.json`.
8
+ 2. Add a matching `CHANGELOG.md` section.
9
+ 3. Refresh package metadata tables if packed size or file count changed.
10
+ 4. Run:
11
+
12
+ ```sh
13
+ npm run release:check
14
+ npm run ci
15
+ ```
16
+
17
+ `release:check` verifies the changelog entry, checks that the version is not
18
+ already on npm, and dry-runs the package contents.
19
+
20
+ ## Publish
21
+
22
+ ```sh
23
+ npm publish --access public
24
+ ```
25
+
26
+ If npm asks for browser or one-time-password authentication, finish that prompt
27
+ in the browser and rerun the same command if needed.
28
+
29
+ If npm reports a cache ownership or permission error, do not change package
30
+ files to work around it. Use a temporary cache for the publish command:
31
+
32
+ ```sh
33
+ npm --cache /tmp/celery-npm-cache publish --access public
34
+ ```
35
+
36
+ ## After Publishing
37
+
38
+ 1. Verify npm:
39
+
40
+ ```sh
41
+ npm view celery-env version dist.unpackedSize dist.fileCount
42
+ ```
43
+
44
+ 2. Smoke-test a fresh install:
45
+
46
+ ```sh
47
+ npm install celery-env@latest --prefix /tmp/celery-smoke
48
+ ```
49
+
50
+ 3. Tag the release:
51
+
52
+ ```sh
53
+ git tag vX.Y.Z
54
+ git push origin vX.Y.Z
55
+ ```
56
+
57
+ 4. Create a GitHub Release from the tag with the changelog bullets.
58
+
59
+ Keep publishing manual until npm provenance and release permissions are designed
60
+ explicitly.
@@ -0,0 +1,9 @@
1
+ import { bool, defineEnv, int, oneOf, str, url } from "celery-env";
2
+
3
+ export default defineEnv({
4
+ NODE_ENV: oneOf(["development", "test", "production"], { default: "development" }),
5
+ DATABASE_URL: url({ protocols: ["postgres", "postgresql"] }),
6
+ PORT: int({ default: 3000, min: 1, max: 65535 }),
7
+ DEBUG: bool({ default: false }),
8
+ API_KEY: str({ min: 8 })
9
+ });
@@ -0,0 +1,20 @@
1
+ import { bool, defineEnv, oneOf, str, url } from "celery-env";
2
+
3
+ export default defineEnv({
4
+ NODE_ENV: oneOf(["development", "test", "production"], { default: "development" }),
5
+ DATABASE_URL: url({
6
+ protocols: ["postgres", "postgresql"],
7
+ desc: "Server-only database connection string."
8
+ }),
9
+ NEXT_PUBLIC_APP_URL: url({
10
+ protocols: ["https"],
11
+ desc: "Browser-visible application origin.",
12
+ example: "https://app.example.com"
13
+ }),
14
+ NEXT_PUBLIC_ANALYTICS: bool({ default: false }),
15
+ SESSION_SECRET: str({
16
+ optional: true,
17
+ min: 32,
18
+ requiredWhen: (env) => env.NODE_ENV === "production"
19
+ })
20
+ });
@@ -0,0 +1,19 @@
1
+ import { bool, defineEnv, int, oneOf, str, url } from "celery-env";
2
+
3
+ export default defineEnv({
4
+ NODE_ENV: oneOf(["development", "test", "production"], { default: "development" }),
5
+ DATABASE_URL: url({
6
+ protocols: ["postgres", "postgresql"],
7
+ desc: "Primary database connection string.",
8
+ example: "postgres://user:pass@localhost:5432/app"
9
+ }),
10
+ PORT: int({ default: 3000, min: 1, max: 65535 }),
11
+ LOG_LEVEL: oneOf(["debug", "info", "warn", "error"], { default: "info" }),
12
+ QUEUE_ENABLED: bool({ default: false }),
13
+ SESSION_SECRET: str({
14
+ optional: true,
15
+ min: 32,
16
+ requiredWhen: (env) => env.NODE_ENV === "production",
17
+ desc: "Required for production sessions."
18
+ })
19
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "celery-env",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "src/index.js",
33
33
  "src/compiler.js",
34
+ "src/infer.js",
34
35
  "src/index.d.ts",
35
36
  "src/compiler.d.ts",
36
37
  "src/cli.js",
@@ -38,13 +39,18 @@
38
39
  "docs/BENCHMARKS.md",
39
40
  "docs/CLI.md",
40
41
  "docs/COMPARISON.md",
42
+ "docs/EXAMPLES.md",
41
43
  "docs/GETTING_STARTED.md",
42
44
  "docs/MIGRATION.md",
43
45
  "docs/README.md",
46
+ "docs/RELEASE.md",
44
47
  "docs/RUNTIME.md",
45
48
  "docs/SCHEMA.md",
46
49
  "docs/TROUBLESHOOTING.md",
47
50
  "docs/TYPESCRIPT.md",
51
+ "examples/env.schema.mjs",
52
+ "examples/node-service/env.schema.mjs",
53
+ "examples/next/env.schema.mjs",
48
54
  "SECURITY.md",
49
55
  "README.md",
50
56
  "LICENSE"
@@ -56,6 +62,7 @@
56
62
  "security:scan": "node scripts/security-scan.mjs && npm run prepublishOnly",
57
63
  "validate:publish": "node scripts/validate-publish.mjs",
58
64
  "smoke:pack": "node scripts/smoke-pack.mjs",
65
+ "release:check": "node scripts/release-check.mjs",
59
66
  "prepublishOnly": "npm test && npm run size && npm run validate:publish && npm run smoke:pack",
60
67
  "demo:generate": "node src/cli.js --schema examples/env.schema.mjs --out .tmp/generated/env.mjs --types .tmp/generated/env.d.ts"
61
68
  },
package/src/cli.js CHANGED
@@ -16,6 +16,8 @@ if (args.version) {
16
16
 
17
17
  if (args.command === "init") {
18
18
  await init(args);
19
+ } else if (args.command === "infer") {
20
+ await infer(args);
19
21
  } else {
20
22
  await generate(args);
21
23
  }
@@ -59,14 +61,24 @@ async function init(args) {
59
61
  await writeFile(schemaPath, source, { encoding: "utf8", flag: "wx" });
60
62
  }
61
63
 
64
+ async function infer(args) {
65
+ if (!args.schema) usage(1);
66
+ const schemaPath = resolve(args.schema);
67
+ const { inferSchemaSource } = await import("./infer.js");
68
+ await mkdir(dirname(schemaPath), { recursive: true });
69
+ await writeOutput(schemaPath, await inferSchemaSource({ envFiles: args.envFiles, scanPaths: args.scanPaths }), args.force);
70
+ }
71
+
62
72
  function parseArgs(argv) {
63
73
  const out = { command: "generate", functionName: "loadEnv" };
64
- if (argv[0] === "generate" || argv[0] === "init") out.command = argv.shift();
74
+ if (argv[0] === "generate" || argv[0] === "init" || argv[0] === "infer") out.command = argv.shift();
65
75
  for (let i = 0; i < argv.length; i += 1) {
66
76
  const arg = argv[i];
67
77
  if (arg === "--help" || arg === "-h") out.help = true;
68
78
  else if (arg === "--version" || arg === "-v") out.version = true;
69
79
  else if (arg === "--schema") out.schema = argv[++i];
80
+ else if (arg === "--env") (out.envFiles ||= []).push(argv[++i]);
81
+ else if (arg === "--scan") (out.scanPaths ||= []).push(argv[++i]);
70
82
  else if (arg === "--target") out.target = argv[++i];
71
83
  else if (arg === "--out") out.out = argv[++i];
72
84
  else if (arg === "--types") out.types = argv[++i];
@@ -138,6 +150,7 @@ function usage(code) {
138
150
  console.log(`Usage:
139
151
  celery-env --schema env.schema.mjs --out src/env.mjs [--types src/env.d.ts]
140
152
  celery-env generate --schema env.schema.mjs --out src/env.mjs [--types src/env.d.ts] [--example .env.example] [--force] [--optimize speed]
153
+ celery-env infer --schema env.schema.mjs [--env .env.example] [--scan src] [--force]
141
154
  celery-env init --target node|next|vite --schema env.schema.mjs
142
155
  celery-env --version`);
143
156
  process.exit(code);
package/src/infer.js ADDED
@@ -0,0 +1,400 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { basename, extname, join, resolve } from "node:path";
3
+
4
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
+ const JS_IDENT = /^[$A-Z_a-z][$\w]*$/;
6
+ const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".svelte", ".ts", ".tsx", ".vue"]);
7
+ const SKIP_DIRS = new Set([".git", ".next", ".nuxt", ".output", ".tmp", "build", "coverage", "dist", "node_modules"]);
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"]);
11
+ const SECRET_KEY = /(?:SECRET|TOKEN|PASSWORD|PASS|PRIVATE|CREDENTIAL|AUTH|API_KEY|ACCESS_KEY)/i;
12
+ const SECRET_VALUE = /(?:^sk_|^pk_|^gh[pousr]_|^xox[baprs]-|^eyJ|-----BEGIN |:\/\/[^/\s:@]+:[^/\s:@]+@|[A-Za-z0-9+/=_-]{32,})/;
13
+ const MAX_ENV_FILE_BYTES = 256 * 1024;
14
+ const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
15
+ const MAX_SOURCE_FILES = 2000;
16
+ const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
17
+ const MAX_SCAN_DEPTH = 32;
18
+
19
+ export async function inferSchemaSource(options = {}) {
20
+ const cwd = resolve(options.cwd || process.cwd());
21
+ const explicitEnvFiles = options.envFiles?.length;
22
+ const explicitScanPaths = options.scanPaths?.length;
23
+ const envFiles = explicitEnvFiles ? resolveAll(cwd, options.envFiles) : await discoverExisting(cwd, DEFAULT_ENV_FILES);
24
+ const scanPaths = explicitScanPaths ? resolveAll(cwd, options.scanPaths) : await discoverExisting(cwd, DEFAULT_SCAN_PATHS);
25
+ const entries = new Map();
26
+
27
+ for (const file of envFiles) {
28
+ const source = await readEnvFile(file);
29
+ const safeExamples = isExampleEnvFile(file);
30
+ for (const item of parseEnvSource(source)) {
31
+ record(entries, item.key, { value: item.value, safeExamples });
32
+ }
33
+ }
34
+
35
+ for (const file of await sourceFiles(scanPaths, { rejectRootSymlinks: Boolean(explicitScanPaths) })) {
36
+ const source = await readFile(file, "utf8");
37
+ for (const key of scanEnvKeys(source)) {
38
+ record(entries, key, { codeOnly: true });
39
+ }
40
+ }
41
+
42
+ if (!entries.size) {
43
+ throw new Error("No environment variables found; pass --env or --scan");
44
+ }
45
+
46
+ return generateSchemaModule(entries);
47
+ }
48
+
49
+ export function parseEnvSource(source) {
50
+ const out = [];
51
+ for (const raw of source.replace(/^\uFEFF/, "").split(/\r?\n/)) {
52
+ const parsed = parseEnvLine(raw);
53
+ if (parsed) out.push(parsed);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ 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);
67
+ }
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);
70
+ }
71
+
72
+ return [...keys].sort();
73
+ }
74
+
75
+ function parseEnvLine(line) {
76
+ let text = line.trim();
77
+ if (!text || text.startsWith("#")) return;
78
+ if (text.startsWith("export ")) text = text.slice(7).trimStart();
79
+
80
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(text);
81
+ if (!match) return;
82
+
83
+ const key = match[1];
84
+ let value = match[2] ?? "";
85
+ if (!ENV_NAME.test(key)) return;
86
+ value = parseEnvValue(value);
87
+ return { key, value };
88
+ }
89
+
90
+ function parseEnvValue(raw) {
91
+ const value = raw.trim();
92
+ if (!value) return "";
93
+ const quote = value[0];
94
+ if (quote === "'" || quote === "\"" || quote === "`") {
95
+ let end = 1;
96
+ let escaped = false;
97
+ for (; end < value.length; end++) {
98
+ const char = value[end];
99
+ if (escaped) {
100
+ escaped = false;
101
+ } else if (quote !== "'" && char === "\\") {
102
+ escaped = true;
103
+ } else if (char === quote) {
104
+ break;
105
+ }
106
+ }
107
+ const inner = value.slice(1, end);
108
+ return quote === "'" ? inner : unescapeQuoted(inner);
109
+ }
110
+ return stripInlineComment(value).trim();
111
+ }
112
+
113
+ function unescapeQuoted(value) {
114
+ return value.replace(/\\([nrt"\\`])/g, (_, char) => {
115
+ if (char === "n") return "\n";
116
+ if (char === "r") return "\r";
117
+ if (char === "t") return "\t";
118
+ return char;
119
+ });
120
+ }
121
+
122
+ function stripInlineComment(value) {
123
+ for (let i = 0; i < value.length; i++) {
124
+ if (value[i] === "#" && (i === 0 || /\s/.test(value[i - 1]))) return value.slice(0, i);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function collectMatches(keys, source, pattern, group) {
130
+ for (const match of source.matchAll(pattern)) keys.add(match[group]);
131
+ }
132
+
133
+ function destructuredKeys(source) {
134
+ const keys = [];
135
+ for (const raw of source.split(",")) {
136
+ const item = raw.trim();
137
+ if (!item || item.startsWith("...")) continue;
138
+ const key = item.split(/[:=]/, 1)[0].trim();
139
+ if (ENV_NAME.test(key)) keys.push(key);
140
+ }
141
+ return keys;
142
+ }
143
+
144
+ async function discoverExisting(cwd, names) {
145
+ const out = [];
146
+ for (const name of names) {
147
+ const path = resolve(cwd, name);
148
+ if (await exists(path)) out.push(path);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function resolveAll(cwd, paths) {
154
+ return paths.map((path) => resolve(cwd, path));
155
+ }
156
+
157
+ async function exists(path) {
158
+ try {
159
+ await lstat(path);
160
+ return true;
161
+ } catch (error) {
162
+ if (error.code === "ENOENT") return false;
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ async function readEnvFile(path) {
168
+ let info;
169
+ try {
170
+ info = await lstat(path);
171
+ } catch (error) {
172
+ if (error.code === "ENOENT") throw new Error(`${path} does not exist`);
173
+ throw error;
174
+ }
175
+ if (info.isSymbolicLink()) throw new Error(`${path} is a symlink; refusing to read`);
176
+ if (!info.isFile()) throw new Error(`${path} is not a file`);
177
+ if (info.size > MAX_ENV_FILE_BYTES) throw new Error(`${path} is too large for env inference: ${info.size} > ${MAX_ENV_FILE_BYTES} bytes`);
178
+ return readFile(path, "utf8");
179
+ }
180
+
181
+ async function sourceFiles(paths, options = {}) {
182
+ const out = [];
183
+ const state = { files: 0, bytes: 0 };
184
+ for (const path of paths) {
185
+ await collectSourceFiles(out, path, state, 0, options.rejectRootSymlinks);
186
+ }
187
+ return out.sort();
188
+ }
189
+
190
+ async function collectSourceFiles(out, path, state, depth, rejectSymlink) {
191
+ if (depth > MAX_SCAN_DEPTH) throw new Error(`${path} exceeds scan depth limit: ${depth} > ${MAX_SCAN_DEPTH}`);
192
+
193
+ let info;
194
+ try {
195
+ info = await lstat(path);
196
+ } catch (error) {
197
+ if (error.code === "ENOENT") return;
198
+ throw error;
199
+ }
200
+
201
+ if (info.isSymbolicLink()) {
202
+ if (rejectSymlink) throw new Error(`${path} is a symlink; refusing to scan`);
203
+ return;
204
+ }
205
+
206
+ if (info.isDirectory()) {
207
+ if (SKIP_DIRS.has(basename(path))) return;
208
+ for (const entry of await readdir(path)) {
209
+ await collectSourceFiles(out, join(path, entry), state, depth + 1, false);
210
+ }
211
+ return;
212
+ }
213
+
214
+ if (!info.isFile() || !SOURCE_EXTENSIONS.has(extname(path))) return;
215
+ if (info.size > MAX_SOURCE_FILE_BYTES) throw new Error(`${path} is too large for source inference: ${info.size} > ${MAX_SOURCE_FILE_BYTES} bytes`);
216
+ state.files += 1;
217
+ state.bytes += info.size;
218
+ if (state.files > MAX_SOURCE_FILES) throw new Error(`source scan found too many files: ${state.files} > ${MAX_SOURCE_FILES}`);
219
+ if (state.bytes > MAX_SOURCE_BYTES) throw new Error(`source scan is too large: ${state.bytes} > ${MAX_SOURCE_BYTES} bytes`);
220
+ out.push(path);
221
+ }
222
+
223
+ function record(entries, key, source) {
224
+ let entry = entries.get(key);
225
+ if (!entry) {
226
+ entry = { key, values: [], code: false };
227
+ entries.set(key, entry);
228
+ }
229
+ if (source.codeOnly) entry.code = true;
230
+ else entry.values.push({ value: source.value, safeExamples: source.safeExamples });
231
+ }
232
+
233
+ function generateSchemaModule(entries) {
234
+ const keys = [...entries.keys()].sort();
235
+ const rules = keys.map((key) => [key, inferRule(entries.get(key))]);
236
+ const imports = new Set(["defineEnv"]);
237
+ for (const [, rule] of rules) collectRuleImports(imports, rule);
238
+
239
+ const lines = [
240
+ `import { ${[...imports].sort(importSort).join(", ")} } from "celery-env";`,
241
+ "",
242
+ "export default defineEnv({"
243
+ ];
244
+
245
+ for (let i = 0; i < rules.length; i++) {
246
+ const [key, rule] = rules[i];
247
+ lines.push(` ${schemaKey(key)}: ${ruleSource(rule)}${i === rules.length - 1 ? "" : ","}`);
248
+ }
249
+
250
+ lines.push("});", "");
251
+ return lines.join("\n");
252
+ }
253
+
254
+ function inferRule(entry) {
255
+ if (entry.key === "NODE_ENV") {
256
+ return { kind: "oneOf", values: ["development", "test", "production"], options: { default: "development" } };
257
+ }
258
+
259
+ const samples = entry.values.map((item) => item.value).filter((value) => value !== "");
260
+ if (!samples.length) return { kind: "str", options: { min: 1 } };
261
+
262
+ const kinds = samples.map(inferValue);
263
+ if (kinds.some((kind) => kind.kind === "str")) return stringRule(entry);
264
+
265
+ const first = kinds[0].kind;
266
+ if (!kinds.every((kind) => kind.kind === first)) return stringRule(entry);
267
+
268
+ const rule = mergeRules(first, kinds);
269
+ const example = safeExample(entry, rule);
270
+ if (example !== undefined) rule.options = { ...rule.options, example };
271
+ return rule;
272
+ }
273
+
274
+ function inferValue(value) {
275
+ if (BOOL_VALUES.has(value)) return { kind: "bool" };
276
+ if (strictInt(value)) return { kind: "int", options: { strict: true } };
277
+ if (strictNumber(value)) return { kind: "num", options: { strict: true } };
278
+ const jsonRule = inferJson(value);
279
+ if (jsonRule) return jsonRule;
280
+ const listRule = inferList(value);
281
+ if (listRule) return listRule;
282
+ const urlRule = inferUrl(value);
283
+ if (urlRule) return urlRule;
284
+ return { kind: "str", options: { min: 1 } };
285
+ }
286
+
287
+ function inferJson(value) {
288
+ if (!/^\s*[\[{]/.test(value)) return;
289
+ try {
290
+ const parsed = JSON.parse(value);
291
+ if (parsed && typeof parsed === "object") return { kind: "json" };
292
+ } catch {}
293
+ }
294
+
295
+ function inferList(value) {
296
+ if (!value.includes(",") || /^\s*[\[{]/.test(value)) return;
297
+ const parts = value.split(",").map((part) => part.trim());
298
+ if (parts.length < 2 || parts.some((part) => part === "")) return;
299
+ const items = parts.map((part) => {
300
+ if (BOOL_VALUES.has(part)) return { kind: "bool" };
301
+ if (strictInt(part)) return { kind: "int", options: { strict: true } };
302
+ if (strictNumber(part)) return { kind: "num", options: { strict: true } };
303
+ const urlRule = inferUrl(part);
304
+ return urlRule || { kind: "str" };
305
+ });
306
+ const first = items[0].kind;
307
+ if (first === "str" || !items.every((item) => item.kind === first)) return;
308
+ return { kind: "list", item: mergeRules(first, items) };
309
+ }
310
+
311
+ function inferUrl(value) {
312
+ try {
313
+ const url = new URL(value);
314
+ if (!url.protocol) return;
315
+ return { kind: "url", options: { protocols: [url.protocol.slice(0, -1)] } };
316
+ } catch {}
317
+ }
318
+
319
+ function strictInt(value) {
320
+ return /^[+-]?\d+$/.test(value) && Number.isSafeInteger(Number(value));
321
+ }
322
+
323
+ function strictNumber(value) {
324
+ if (!/^[+-]?(?:\d+\.\d*|\.\d+|\d+)$/.test(value)) return false;
325
+ return Number.isFinite(Number(value));
326
+ }
327
+
328
+ function mergeRules(kind, rules) {
329
+ if (kind === "url") {
330
+ return {
331
+ kind: "url",
332
+ options: { protocols: [...new Set(rules.flatMap((rule) => rule.options?.protocols || []))].sort() }
333
+ };
334
+ }
335
+ if (kind === "list") {
336
+ const itemKind = rules[0].item.kind;
337
+ return { kind: "list", item: mergeRules(itemKind, rules.map((rule) => rule.item)) };
338
+ }
339
+ if (kind === "int" || kind === "num") return { kind, options: { strict: true } };
340
+ return { kind };
341
+ }
342
+
343
+ function stringRule(entry) {
344
+ const rule = { kind: "str", options: { min: 1 } };
345
+ const example = safeExample(entry, rule);
346
+ if (example !== undefined) rule.options.example = example;
347
+ return rule;
348
+ }
349
+
350
+ function safeExample(entry, rule) {
351
+ const sample = entry.values.find((item) => item.safeExamples && item.value !== "");
352
+ if (!sample || SECRET_KEY.test(entry.key) || SECRET_VALUE.test(sample.value)) return;
353
+ return exampleValue(sample.value, rule);
354
+ }
355
+
356
+ function exampleValue(value, rule) {
357
+ if (rule.kind === "bool") return value === "true" || value === "1" || value === "yes" || value === "on";
358
+ if (rule.kind === "int" || rule.kind === "num") return Number(value);
359
+ if (rule.kind === "json") {
360
+ try {
361
+ return JSON.parse(value);
362
+ } catch {
363
+ return;
364
+ }
365
+ }
366
+ if (rule.kind === "list") return value.split(",").map((part) => exampleValue(part.trim(), rule.item));
367
+ return value;
368
+ }
369
+
370
+ function collectRuleImports(imports, rule) {
371
+ imports.add(rule.kind);
372
+ if (rule.kind === "list") collectRuleImports(imports, rule.item);
373
+ }
374
+
375
+ function ruleSource(rule) {
376
+ if (rule.kind === "oneOf") return `oneOf(${literal(rule.values)}, ${literal(rule.options)})`;
377
+ if (rule.kind === "list") {
378
+ const item = ruleSource(rule.item);
379
+ return rule.options && Object.keys(rule.options).length ? `list(${item}, ${literal(rule.options)})` : `list(${item})`;
380
+ }
381
+ if (rule.options && Object.keys(rule.options).length) return `${rule.kind}(${literal(rule.options)})`;
382
+ return `${rule.kind}()`;
383
+ }
384
+
385
+ function schemaKey(key) {
386
+ return JS_IDENT.test(key) && key !== "__proto__" ? key : JSON.stringify(key);
387
+ }
388
+
389
+ function literal(value) {
390
+ return JSON.stringify(value);
391
+ }
392
+
393
+ function importSort(a, b) {
394
+ return a.localeCompare(b);
395
+ }
396
+
397
+ function isExampleEnvFile(path) {
398
+ const name = basename(path).toLowerCase();
399
+ return name.includes("example") || name.includes("sample") || name.includes("template");
400
+ }