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 +16 -7
- package/docs/BENCHMARKS.md +7 -3
- package/docs/CLI.md +37 -1
- package/docs/COMPARISON.md +25 -8
- package/docs/EXAMPLES.md +50 -0
- package/docs/GETTING_STARTED.md +21 -4
- package/docs/MIGRATION.md +14 -5
- package/docs/README.md +8 -6
- package/docs/RELEASE.md +60 -0
- package/examples/env.schema.mjs +9 -0
- package/examples/next/env.schema.mjs +20 -0
- package/examples/node-service/env.schema.mjs +19 -0
- package/package.json +8 -1
- package/src/cli.js +14 -1
- package/src/infer.js +400 -0
package/README.md
CHANGED
|
@@ -44,7 +44,16 @@ bun add -d celery-env
|
|
|
44
44
|
|
|
45
45
|
## 60-Second Setup
|
|
46
46
|
|
|
47
|
-
|
|
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`,
|
|
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
|
-
|
|
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 |
|
|
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)
|
package/docs/BENCHMARKS.md
CHANGED
|
@@ -68,12 +68,12 @@ Snapshot dependency versions:
|
|
|
68
68
|
|
|
69
69
|
## Package Metadata
|
|
70
70
|
|
|
71
|
-
Package footprint is separate from runtime speed.
|
|
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 |
|
|
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
|
| --- | --- |
|
package/docs/COMPARISON.md
CHANGED
|
@@ -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
|
|
36
|
-
|
|
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
|
|
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 |
|
|
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.
|
package/docs/EXAMPLES.md
ADDED
|
@@ -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.
|
package/docs/GETTING_STARTED.md
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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. [
|
|
13
|
-
7. [
|
|
14
|
-
8. [
|
|
15
|
-
9. [
|
|
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.
|
package/docs/RELEASE.md
ADDED
|
@@ -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.
|
|
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
|
+
}
|