@wchen.ai/env-from-example 1.0.0
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 +240 -0
- package/dist/env-from-example.js +416 -0
- package/dist/setup-env.js +473 -0
- package/dist/src/parse.js +395 -0
- package/dist/src/polish.js +369 -0
- package/dist/src/schema.js +67 -0
- package/dist/src/validate.js +255 -0
- package/dist/src/version.js +35 -0
- package/dist/test/integration/cli.test.js +451 -0
- package/dist/test/unit/env-from-example.test.js +846 -0
- package/dist/test/unit/setup-env.test.js +236 -0
- package/dist/vitest.config.js +15 -0
- package/package.json +65 -0
- package/schema.json +263 -0
package/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# env-from-example
|
|
2
|
+
|
|
3
|
+
Interactive and non-interactive CLI to set up `.env` from `.env.example`.
|
|
4
|
+
|
|
5
|
+
Walks you through each variable, validates types, auto-generates secrets, and prints a summary of what was configured. All type detection and validation are driven by a bundled [`schema.json`](schema.json).
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Run without installing (npx fetches and runs)
|
|
11
|
+
npx @wchen.ai/env-from-example
|
|
12
|
+
|
|
13
|
+
# After installing in your project, use the short form:
|
|
14
|
+
npx env-from-example
|
|
15
|
+
|
|
16
|
+
# Don't have a .env.example yet? Create one:
|
|
17
|
+
npx env-from-example --init # starter template, add env vars, then polish
|
|
18
|
+
npx env-from-example --init .env # from your existing .env
|
|
19
|
+
|
|
20
|
+
# Don't have a .env? Generate one from .env.example
|
|
21
|
+
npx env-from-example # Generate .env
|
|
22
|
+
npx env-from-example -e staging # Generate .env.staging
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Install in your project so you can run `npx env-from-example` from the project root:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @wchen.ai/env-from-example
|
|
31
|
+
# or
|
|
32
|
+
pnpm add @wchen.ai/env-from-example
|
|
33
|
+
# or as a dev dependency (recommended for CLI tools)
|
|
34
|
+
npm install -D @wchen.ai/env-from-example
|
|
35
|
+
pnpm add -D @wchen.ai/env-from-example
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then run from your project root: **`npx env-from-example`** (or add it to your `package.json` scripts).
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
Add a `.env.example` in your project root (or run `env-from-example --init` to create one).
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx env-from-example --init # starter template
|
|
46
|
+
npx env-from-example --init .env # from your existing .env
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Before and after (first run)
|
|
50
|
+
|
|
51
|
+
If you start with only a `.env.example` and no `.env` (or an empty one), running the tool fills in `.env` from your answers and defaults.
|
|
52
|
+
|
|
53
|
+
**Before** — you have `.env.example` and no `.env` (or `.env` is empty):
|
|
54
|
+
|
|
55
|
+
```env
|
|
56
|
+
# .env.example (excerpt)
|
|
57
|
+
DATABASE_URL=postgres://localhost:5432/myapp
|
|
58
|
+
API_KEY=
|
|
59
|
+
NODE_ENV=development
|
|
60
|
+
SESSION_SECRET=
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```env
|
|
64
|
+
# .env — missing or empty
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**After** — run `env-from-example` (or `env-from-example -y` to accept defaults). The CLI prompts for required values, can auto-generate secrets (e.g. `SESSION_SECRET`), and writes:
|
|
68
|
+
|
|
69
|
+
```env
|
|
70
|
+
# .env — created/updated by env-from-example
|
|
71
|
+
DATABASE_URL=postgres://localhost:5432/myapp
|
|
72
|
+
API_KEY=your-api-key-here
|
|
73
|
+
NODE_ENV=development
|
|
74
|
+
SESSION_SECRET=a1b2c3d4e5f6...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use `env-from-example -y --dry-run` to preview the result without writing files.
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Run from your project root (where `.env.example` lives):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
env-from-example
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Options:**
|
|
88
|
+
|
|
89
|
+
| Flag | Description |
|
|
90
|
+
| ---------------------- | ----------------------------------------------------------------------------------- |
|
|
91
|
+
| `-y, --yes` | Non-interactive: accept existing values or defaults without prompting |
|
|
92
|
+
| `-f, --force` | Force re-run even if `.env` is already up-to-date |
|
|
93
|
+
| `-e, --env <name>` | Target environment (e.g., `local`, `test`, `production`) |
|
|
94
|
+
| `--cwd <path>` | Project root directory (default: current working directory) |
|
|
95
|
+
| `--init [source]` | Create `.env.example` from an existing env file or from scratch |
|
|
96
|
+
| `--polish` | Polish `.env.example`: add descriptions, types, defaults (`-y` for non-interactive) |
|
|
97
|
+
| `--version [bump]` | Bump or set `ENV_SCHEMA_VERSION` (`patch`, `minor`, `major`, or exact semver) |
|
|
98
|
+
| `--sync-package` | With `--version`: also update `package.json` version |
|
|
99
|
+
| `--validate [envFile]` | Validate `.env` against `.env.example` schema (exit 1 if invalid) |
|
|
100
|
+
| `--dry-run` | Preview what would be written without creating/modifying files |
|
|
101
|
+
|
|
102
|
+
**Examples:**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Interactive setup (default .env)
|
|
106
|
+
env-from-example
|
|
107
|
+
|
|
108
|
+
# Non-interactive: create/update .env with defaults or existing values
|
|
109
|
+
env-from-example -y
|
|
110
|
+
|
|
111
|
+
# Preview what would be generated (no files written)
|
|
112
|
+
env-from-example -y --dry-run
|
|
113
|
+
|
|
114
|
+
# Create .env.local with prompts
|
|
115
|
+
env-from-example -e local
|
|
116
|
+
|
|
117
|
+
# Create .env.test without prompts (e.g. CI)
|
|
118
|
+
env-from-example -y -e test
|
|
119
|
+
|
|
120
|
+
# Force re-run even if .env is up-to-date
|
|
121
|
+
env-from-example -f
|
|
122
|
+
|
|
123
|
+
# Run from another directory (e.g. monorepo package)
|
|
124
|
+
env-from-example --cwd ./apps/api
|
|
125
|
+
|
|
126
|
+
# Override a variable via CLI
|
|
127
|
+
env-from-example --database-url "postgres://prod:5432/db" -y
|
|
128
|
+
|
|
129
|
+
# Bump ENV_SCHEMA_VERSION
|
|
130
|
+
env-from-example --version patch
|
|
131
|
+
env-from-example --version minor --sync-package
|
|
132
|
+
|
|
133
|
+
# Validate .env against .env.example schema
|
|
134
|
+
env-from-example --validate
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Annotations
|
|
138
|
+
|
|
139
|
+
Each variable in `.env.example` can be annotated with structured tags in the comment line above it:
|
|
140
|
+
|
|
141
|
+
```env
|
|
142
|
+
# <description> [REQUIRED] [TYPE: <schema-type>] [CONSTRAINTS: key=value,...] Default: <value>
|
|
143
|
+
VARIABLE_NAME=default_value
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Annotation | Syntax | Purpose |
|
|
147
|
+
| ----------- | ------------------------------ | ---------------------------------------------------- |
|
|
148
|
+
| Description | Free text at start of comment | Human-readable explanation |
|
|
149
|
+
| Required | `[REQUIRED]` | Variable must be non-empty |
|
|
150
|
+
| Type | `[TYPE: <schema-type>]` | Type from `schema.json` for validation and detection |
|
|
151
|
+
| Constraints | `[CONSTRAINTS: key=value,...]` | Constraints (min, max, pattern, etc.) |
|
|
152
|
+
| Default | `Default: <value>` | Documented default (informational) |
|
|
153
|
+
|
|
154
|
+
All annotations are optional. The `--polish` command auto-detects and adds them.
|
|
155
|
+
|
|
156
|
+
### Polish: before and after
|
|
157
|
+
|
|
158
|
+
`env-from-example --polish` (or `--polish -y` for non-interactive) updates your `.env.example` with inferred descriptions, types, and default annotations.
|
|
159
|
+
|
|
160
|
+
**Before** — minimal `.env.example`:
|
|
161
|
+
|
|
162
|
+
```env
|
|
163
|
+
DATABASE_URL=postgres://localhost:5432/myapp
|
|
164
|
+
PORT=3000
|
|
165
|
+
NODE_ENV=development
|
|
166
|
+
API_KEY=
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**After** — run `env-from-example --polish -y`:
|
|
170
|
+
|
|
171
|
+
```env
|
|
172
|
+
# env-from-example (https://www.npmjs.com/package/@wchen.ai/env-from-example)
|
|
173
|
+
|
|
174
|
+
# ENV_SCHEMA_VERSION="1.0.0"
|
|
175
|
+
|
|
176
|
+
# ========================================
|
|
177
|
+
# Database
|
|
178
|
+
# ========================================
|
|
179
|
+
|
|
180
|
+
# Database Url
|
|
181
|
+
# [REQUIRED] [TYPE: network/uri] Default: postgres://localhost:5432/myapp
|
|
182
|
+
DATABASE_URL=postgres://localhost:5432/myapp
|
|
183
|
+
|
|
184
|
+
# ========================================
|
|
185
|
+
# App
|
|
186
|
+
# ========================================
|
|
187
|
+
|
|
188
|
+
# Application port
|
|
189
|
+
# [REQUIRED] [TYPE: integer] [CONSTRAINTS: min=3000,max=10000] Default: 3000
|
|
190
|
+
PORT=3000
|
|
191
|
+
|
|
192
|
+
# Node Env
|
|
193
|
+
# [TYPE: structured/enum] [CONSTRAINTS: pattern=^(development|test|staging|production|ci)$] Default: development
|
|
194
|
+
NODE_ENV=development
|
|
195
|
+
|
|
196
|
+
# ========================================
|
|
197
|
+
# Other
|
|
198
|
+
# ========================================
|
|
199
|
+
|
|
200
|
+
# OPEN AI API KEY
|
|
201
|
+
# [REQUIRED] [TYPE: credentials/secret] Default: (empty)
|
|
202
|
+
API_KEY=
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Use `env-from-example --polish --dry-run` to preview changes without writing the file.
|
|
206
|
+
|
|
207
|
+
## CI / CD
|
|
208
|
+
|
|
209
|
+
**GitHub Actions** -- generate `.env.test` before running tests:
|
|
210
|
+
|
|
211
|
+
```yaml
|
|
212
|
+
# .github/workflows/test.yml
|
|
213
|
+
jobs:
|
|
214
|
+
test:
|
|
215
|
+
runs-on: ubuntu-latest
|
|
216
|
+
steps:
|
|
217
|
+
- uses: actions/checkout@v4
|
|
218
|
+
- uses: actions/setup-node@v4
|
|
219
|
+
with:
|
|
220
|
+
node-version: 20
|
|
221
|
+
- run: npm ci
|
|
222
|
+
- run: npx env-from-example -y -e test
|
|
223
|
+
- run: npm test
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Pre-commit hook** -- validate `.env` stays in sync:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
# .husky/pre-commit
|
|
230
|
+
npx env-from-example --validate
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Requirements
|
|
234
|
+
|
|
235
|
+
- A `.env.example` file in the project root (or the path given by `--cwd`). Run `env-from-example --init` to create one.
|
|
236
|
+
- `schema.json` is bundled with the package and used automatically.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
ISC
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { input, confirm, select } from "@inquirer/prompts";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import { findSchemaType, parseEnumChoices, } from "./src/schema.js";
|
|
9
|
+
import { parseEnvExample, getExistingEnvVersion, getExistingEnvVariables, groupVariablesBySection, getGroup, inferDescription, stripMetaFromComment, initEnvExample, } from "./src/parse.js";
|
|
10
|
+
import { validateValue, validateEnv, coerceToType, generateAutoValue, } from "./src/validate.js";
|
|
11
|
+
import { polishEnvExample, polishEnvExampleInteractive } from "./src/polish.js";
|
|
12
|
+
import { bumpSemver, updateEnvSchemaVersion } from "./src/version.js";
|
|
13
|
+
// ─── Re-exports (public API consumed by tests and external users) ────────────
|
|
14
|
+
export { loadSchema, resetSchemaCache, getSchemaTypes, findSchemaType, parseEnumChoices, getAvailableConstraints, } from "./src/schema.js";
|
|
15
|
+
export { parseEnvExample, getExistingEnvVersion, getExistingEnvVariables, serializeEnvExample, inferDescription, initEnvExample, } from "./src/parse.js";
|
|
16
|
+
export { detectType, matchesSchemaType, validateValue, validateEnv, coerceToType, generateAutoValue, } from "./src/validate.js";
|
|
17
|
+
export { polishEnvExample, polishEnvExampleInteractive } from "./src/polish.js";
|
|
18
|
+
export { bumpSemver, updateEnvSchemaVersion } from "./src/version.js";
|
|
19
|
+
// ─── CLI helpers ─────────────────────────────────────────────────────────────
|
|
20
|
+
export function getRootDirFromArgv() {
|
|
21
|
+
const argv = process.argv;
|
|
22
|
+
const cwdIdx = argv.indexOf("--cwd");
|
|
23
|
+
if (cwdIdx !== -1 && argv[cwdIdx + 1]) {
|
|
24
|
+
return path.resolve(argv[cwdIdx + 1]);
|
|
25
|
+
}
|
|
26
|
+
return process.cwd();
|
|
27
|
+
}
|
|
28
|
+
function renderGroupBanner(groupName) {
|
|
29
|
+
const W = 40;
|
|
30
|
+
const bar = "# " + "=".repeat(W);
|
|
31
|
+
const padLen = Math.max(1, Math.floor((W - groupName.length) / 2));
|
|
32
|
+
const center = "#" + " ".repeat(padLen) + groupName;
|
|
33
|
+
return [bar, center, bar];
|
|
34
|
+
}
|
|
35
|
+
function buildEnvContent(schemaVersion, variables, finalValues) {
|
|
36
|
+
let content = `# ==============================================\n`;
|
|
37
|
+
content += `# Environment Variables\n`;
|
|
38
|
+
content += `# ==============================================\n`;
|
|
39
|
+
if (schemaVersion) {
|
|
40
|
+
content += `# ENV_SCHEMA_VERSION="${schemaVersion}"\n`;
|
|
41
|
+
}
|
|
42
|
+
content += `# Generated on ${new Date().toISOString()}\n`;
|
|
43
|
+
content += `# Generated by env-from-example (https://www.npmjs.com/package/env-from-example)\n`;
|
|
44
|
+
content += `# ==============================================\n\n`;
|
|
45
|
+
const grouped = groupVariablesBySection(variables);
|
|
46
|
+
let lastGroup = "";
|
|
47
|
+
for (const v of grouped) {
|
|
48
|
+
const group = getGroup(v);
|
|
49
|
+
if (group && group !== lastGroup) {
|
|
50
|
+
content += "\n" + renderGroupBanner(group).join("\n") + "\n\n";
|
|
51
|
+
lastGroup = group;
|
|
52
|
+
}
|
|
53
|
+
const desc = inferDescription(v);
|
|
54
|
+
if (desc) {
|
|
55
|
+
content += `# ${desc}\n`;
|
|
56
|
+
}
|
|
57
|
+
if (v.key in finalValues) {
|
|
58
|
+
const val = finalValues[v.key];
|
|
59
|
+
const needsQuotes = /[\s#"']/.test(val) || val === "";
|
|
60
|
+
const safeValue = needsQuotes && val !== "" ? `"${val.replace(/"/g, '\\"')}"` : val;
|
|
61
|
+
content += `${v.key}=${safeValue}\n`;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
content += `# ${v.key}=\n`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return content;
|
|
68
|
+
}
|
|
69
|
+
function printSummary(summary, envFileName, schemaVersion) {
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log(pc.green(pc.bold(`✅ ${envFileName} successfully created/updated!`)));
|
|
72
|
+
if (schemaVersion) {
|
|
73
|
+
console.log(pc.gray(` Schema version: ${schemaVersion}`));
|
|
74
|
+
}
|
|
75
|
+
console.log("");
|
|
76
|
+
const total = summary.fromExisting.length +
|
|
77
|
+
summary.fromDefault.length +
|
|
78
|
+
summary.autoGenerated.length +
|
|
79
|
+
summary.fromCli.length +
|
|
80
|
+
summary.skippedCommented.length;
|
|
81
|
+
console.log(pc.bold(` ${total} variables configured:`));
|
|
82
|
+
if (summary.fromCli.length > 0) {
|
|
83
|
+
console.log(pc.cyan(` ⮑ ${summary.fromCli.length} from CLI flags: `) +
|
|
84
|
+
pc.dim(summary.fromCli.join(", ")));
|
|
85
|
+
}
|
|
86
|
+
if (summary.fromExisting.length > 0) {
|
|
87
|
+
console.log(pc.green(` ⮑ ${summary.fromExisting.length} from existing ${envFileName}: `) + pc.dim(summary.fromExisting.join(", ")));
|
|
88
|
+
}
|
|
89
|
+
if (summary.fromDefault.length > 0) {
|
|
90
|
+
console.log(pc.blue(` ⮑ ${summary.fromDefault.length} from defaults: `) +
|
|
91
|
+
pc.dim(summary.fromDefault.join(", ")));
|
|
92
|
+
}
|
|
93
|
+
if (summary.autoGenerated.length > 0) {
|
|
94
|
+
console.log(pc.magenta(` ⮑ ${summary.autoGenerated.length} auto-generated: `) +
|
|
95
|
+
pc.dim(summary.autoGenerated.join(", ")));
|
|
96
|
+
}
|
|
97
|
+
if (summary.skippedCommented.length > 0) {
|
|
98
|
+
console.log(pc.gray(` ⮑ ${summary.skippedCommented.length} commented-out (kept as-is): `) + pc.dim(summary.skippedCommented.join(", ")));
|
|
99
|
+
}
|
|
100
|
+
if (summary.requiredMissing.length > 0) {
|
|
101
|
+
console.log(pc.yellow(` ⚠ ${summary.requiredMissing.length} required but empty: `) + pc.dim(summary.requiredMissing.join(", ")));
|
|
102
|
+
}
|
|
103
|
+
console.log("");
|
|
104
|
+
}
|
|
105
|
+
// ─── Main CLI ────────────────────────────────────────────────────────────────
|
|
106
|
+
async function run() {
|
|
107
|
+
const program = new Command();
|
|
108
|
+
program
|
|
109
|
+
.name("env-from-example")
|
|
110
|
+
.description("Interactive and non-interactive CLI to set up .env from .env.example")
|
|
111
|
+
.option("-y, --yes", "Non-interactive: accept existing values or defaults without prompting")
|
|
112
|
+
.option("-f, --force", "Force re-run even if .env is already up-to-date")
|
|
113
|
+
.option("-e, --env <environment>", "Target environment (e.g., local, test, production)")
|
|
114
|
+
.option("--cwd <path>", "Project root directory (default: current working directory)")
|
|
115
|
+
.option("--init [source]", "Create .env.example from an existing env file (default: .env) or from scratch")
|
|
116
|
+
.option("--polish", "Polish .env.example: add descriptions, types, defaults (use -y for non-interactive)")
|
|
117
|
+
.option("--version [bump]", "Bump or set ENV_SCHEMA_VERSION (patch|minor|major or exact semver)")
|
|
118
|
+
.option("--sync-package", "With --version: also update package.json version")
|
|
119
|
+
.option("--validate [envFile]", "Validate .env against .env.example schema (exit 1 if invalid)")
|
|
120
|
+
.option("--dry-run", "Preview what would be written without creating/modifying files");
|
|
121
|
+
const earlyRoot = getRootDirFromArgv();
|
|
122
|
+
try {
|
|
123
|
+
const { variables: earlyVars } = parseEnvExample(earlyRoot);
|
|
124
|
+
earlyVars.forEach((v) => {
|
|
125
|
+
const optName = `--${v.key.toLowerCase().replace(/_/g, "-")}`;
|
|
126
|
+
const desc = stripMetaFromComment(v.comment) || `Set ${v.key}`;
|
|
127
|
+
program.option(`${optName} <value>`, desc);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* .env.example may not exist yet */
|
|
132
|
+
}
|
|
133
|
+
program.parse();
|
|
134
|
+
const options = program.opts();
|
|
135
|
+
const ROOT_DIR = path.resolve(options.cwd || process.cwd());
|
|
136
|
+
if (options.init !== undefined) {
|
|
137
|
+
try {
|
|
138
|
+
const source = typeof options.init === "string" ? options.init : undefined;
|
|
139
|
+
initEnvExample(ROOT_DIR, { from: source });
|
|
140
|
+
console.log(pc.green(pc.bold("✅ .env.example created.")));
|
|
141
|
+
if (!source) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (options.yes) {
|
|
145
|
+
polishEnvExample(ROOT_DIR);
|
|
146
|
+
console.log(pc.green(pc.bold("✅ .env.example polished (non-interactive).")));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
await polishEnvExampleInteractive(ROOT_DIR);
|
|
150
|
+
console.log(pc.green(pc.bold("✅ .env.example polished.")));
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
console.error(pc.red(e instanceof Error ? e.message : String(e)));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (options.polish) {
|
|
160
|
+
try {
|
|
161
|
+
if (options.yes) {
|
|
162
|
+
polishEnvExample(ROOT_DIR);
|
|
163
|
+
console.log(pc.green(pc.bold("✅ .env.example polished (non-interactive).")));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.log(pc.cyan(pc.bold("Interactive polish: conform .env.example to convention (description, default, type, etc.)\n")));
|
|
167
|
+
await polishEnvExampleInteractive(ROOT_DIR);
|
|
168
|
+
console.log(pc.green(pc.bold("✅ .env.example polished.")));
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
console.error(pc.red(e instanceof Error ? e.message : String(e)));
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (options.validate !== undefined) {
|
|
178
|
+
const envFile = options.validate === true ? ".env" : `.env.${options.validate}`;
|
|
179
|
+
try {
|
|
180
|
+
const result = validateEnv(ROOT_DIR, { envFile });
|
|
181
|
+
if (result.warnings.length > 0) {
|
|
182
|
+
result.warnings.forEach((w) => console.warn(pc.yellow("Warning:"), w));
|
|
183
|
+
}
|
|
184
|
+
if (result.valid) {
|
|
185
|
+
console.log(pc.green(pc.bold(`✅ ${envFile} is valid against .env.example schema.`)));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
result.errors.forEach((e) => console.error(pc.red("Error:"), e));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
console.error(pc.red(e instanceof Error ? e.message : String(e)));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (options.version !== undefined) {
|
|
197
|
+
try {
|
|
198
|
+
const { version } = parseEnvExample(ROOT_DIR);
|
|
199
|
+
const current = version || "1.0.0";
|
|
200
|
+
const bump = options.version === true ? undefined : options.version;
|
|
201
|
+
let newVersion;
|
|
202
|
+
if (bump === "patch" || bump === "minor" || bump === "major") {
|
|
203
|
+
newVersion = bumpSemver(current, bump);
|
|
204
|
+
}
|
|
205
|
+
else if (bump && typeof bump === "string") {
|
|
206
|
+
newVersion = bump;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
newVersion = bumpSemver(current, "patch");
|
|
210
|
+
}
|
|
211
|
+
updateEnvSchemaVersion(ROOT_DIR, newVersion, {
|
|
212
|
+
syncPackage: options.syncPackage,
|
|
213
|
+
});
|
|
214
|
+
console.log(pc.green(pc.bold(`✅ ENV_SCHEMA_VERSION set to ${newVersion}`)));
|
|
215
|
+
if (options.syncPackage) {
|
|
216
|
+
console.log(pc.gray(" package.json version updated."));
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
console.error(pc.red(e instanceof Error ? e.message : String(e)));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// ─── Main: generate .env ─────────────────────────────────────────────────
|
|
226
|
+
let schemaVersion;
|
|
227
|
+
let variables;
|
|
228
|
+
try {
|
|
229
|
+
const result = parseEnvExample(ROOT_DIR);
|
|
230
|
+
schemaVersion = result.version;
|
|
231
|
+
variables = result.variables;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
const examplePath = path.join(ROOT_DIR, ".env.example");
|
|
235
|
+
console.error(pc.red(`No .env.example found at ${examplePath}`));
|
|
236
|
+
console.error("");
|
|
237
|
+
console.error(pc.bold("To get started:"));
|
|
238
|
+
console.error(pc.cyan(" env-from-example --init") +
|
|
239
|
+
pc.gray(" Create a starter .env.example"));
|
|
240
|
+
console.error(pc.cyan(" env-from-example --init .env") +
|
|
241
|
+
pc.gray(" Create .env.example from existing .env"));
|
|
242
|
+
console.error("");
|
|
243
|
+
console.error(pc.gray("Or create .env.example manually — see https://www.npmjs.com/package/env-from-example"));
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
const activeVars = variables.filter((v) => !v.isCommentedOut);
|
|
247
|
+
const totalPromptable = activeVars.length;
|
|
248
|
+
console.log("");
|
|
249
|
+
console.log(pc.cyan(pc.bold(" env-from-example")) +
|
|
250
|
+
pc.gray(` — ${totalPromptable} variables from .env.example`));
|
|
251
|
+
console.log("");
|
|
252
|
+
let targetEnv = options.env;
|
|
253
|
+
if (!targetEnv && !options.yes) {
|
|
254
|
+
const envChoice = await select({
|
|
255
|
+
message: "Select target environment to generate:",
|
|
256
|
+
choices: [
|
|
257
|
+
{ name: "default (.env)", value: "default" },
|
|
258
|
+
{ name: "local (.env.local)", value: "local" },
|
|
259
|
+
{ name: "test (.env.test)", value: "test" },
|
|
260
|
+
{ name: "staging (.env.stage)", value: "stage" },
|
|
261
|
+
{ name: "production (.env.production)", value: "production" },
|
|
262
|
+
{ name: "custom", value: "custom" },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
if (envChoice === "custom") {
|
|
266
|
+
targetEnv = await input({
|
|
267
|
+
message: "Enter custom environment name (e.g., ci, demo):",
|
|
268
|
+
validate: (val) => val.trim().length > 0 || "Environment name is required",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
targetEnv = envChoice === "default" ? "" : envChoice;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else if (!targetEnv) {
|
|
276
|
+
targetEnv = "";
|
|
277
|
+
}
|
|
278
|
+
const envFileName = targetEnv ? `.env.${targetEnv}` : ".env";
|
|
279
|
+
const envPath = path.join(ROOT_DIR, envFileName);
|
|
280
|
+
const existingEnvExists = fs.existsSync(envPath);
|
|
281
|
+
const existingVars = getExistingEnvVariables(envPath);
|
|
282
|
+
let existingVersion = null;
|
|
283
|
+
if (existingEnvExists) {
|
|
284
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
285
|
+
existingVersion = getExistingEnvVersion(content);
|
|
286
|
+
}
|
|
287
|
+
if (existingEnvExists &&
|
|
288
|
+
existingVersion === schemaVersion &&
|
|
289
|
+
!options.force &&
|
|
290
|
+
!options.yes) {
|
|
291
|
+
const proceed = await confirm({
|
|
292
|
+
message: pc.green(`${envFileName} is already up-to-date (v${schemaVersion}). Re-run setup?`),
|
|
293
|
+
default: false,
|
|
294
|
+
});
|
|
295
|
+
if (!proceed) {
|
|
296
|
+
console.log(pc.gray("Nothing changed."));
|
|
297
|
+
process.exit(0);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const finalValues = {};
|
|
301
|
+
const summary = {
|
|
302
|
+
fromExisting: [],
|
|
303
|
+
fromDefault: [],
|
|
304
|
+
autoGenerated: [],
|
|
305
|
+
fromCli: [],
|
|
306
|
+
skippedCommented: [],
|
|
307
|
+
requiredMissing: [],
|
|
308
|
+
};
|
|
309
|
+
let promptIndex = 0;
|
|
310
|
+
for (const v of variables) {
|
|
311
|
+
const camelKey = v.key
|
|
312
|
+
.toLowerCase()
|
|
313
|
+
.replace(/_([a-z0-9])/gi, (_, c) => c.toUpperCase());
|
|
314
|
+
const valFromCli = options[camelKey];
|
|
315
|
+
if (valFromCli !== undefined &&
|
|
316
|
+
valFromCli !== null &&
|
|
317
|
+
typeof valFromCli === "string") {
|
|
318
|
+
finalValues[v.key] = valFromCli;
|
|
319
|
+
summary.fromCli.push(v.key);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const hasExisting = v.key in existingVars;
|
|
323
|
+
let currentDefault = existingVars[v.key] ?? v.defaultValue;
|
|
324
|
+
const schemaType = v.type ? findSchemaType(v.type) : undefined;
|
|
325
|
+
const autoGen = schemaType?.auto_generate;
|
|
326
|
+
let wasAutoGenerated = false;
|
|
327
|
+
if (autoGen && !currentDefault) {
|
|
328
|
+
currentDefault = generateAutoValue(autoGen);
|
|
329
|
+
wasAutoGenerated = true;
|
|
330
|
+
}
|
|
331
|
+
if (v.isCommentedOut) {
|
|
332
|
+
finalValues[v.key] = currentDefault;
|
|
333
|
+
summary.skippedCommented.push(v.key);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (options.yes) {
|
|
337
|
+
if (v.required && !currentDefault) {
|
|
338
|
+
console.warn(pc.yellow(` ⚠ [REQUIRED] ${v.key} has no value — set it manually in ${envFileName}`));
|
|
339
|
+
summary.requiredMissing.push(v.key);
|
|
340
|
+
}
|
|
341
|
+
finalValues[v.key] = currentDefault;
|
|
342
|
+
if (wasAutoGenerated)
|
|
343
|
+
summary.autoGenerated.push(v.key);
|
|
344
|
+
else if (hasExisting)
|
|
345
|
+
summary.fromExisting.push(v.key);
|
|
346
|
+
else
|
|
347
|
+
summary.fromDefault.push(v.key);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
promptIndex++;
|
|
351
|
+
const progress = pc.dim(`[${promptIndex}/${totalPromptable}]`);
|
|
352
|
+
const desc = stripMetaFromComment(v.comment);
|
|
353
|
+
if (desc) {
|
|
354
|
+
console.log(pc.gray(` ${desc}`));
|
|
355
|
+
}
|
|
356
|
+
let answer;
|
|
357
|
+
const isEnum = v.type === "structured/enum" && v.constraints?.pattern;
|
|
358
|
+
const enumChoices = isEnum ? parseEnumChoices(v.constraints.pattern) : [];
|
|
359
|
+
if (enumChoices.length > 0) {
|
|
360
|
+
const choiceValue = currentDefault && enumChoices.includes(currentDefault)
|
|
361
|
+
? currentDefault
|
|
362
|
+
: enumChoices[0];
|
|
363
|
+
answer = await select({
|
|
364
|
+
message: `${progress} ${v.key}` +
|
|
365
|
+
(v.required ? pc.bold(pc.yellow(" REQUIRED")) : ""),
|
|
366
|
+
choices: enumChoices.map((c) => ({ name: c, value: c })),
|
|
367
|
+
default: choiceValue,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const hint = wasAutoGenerated ? pc.dim(" (auto-generated)") : "";
|
|
372
|
+
answer = await input({
|
|
373
|
+
message: `${progress} ${v.key}` +
|
|
374
|
+
(v.required ? pc.bold(pc.yellow(" REQUIRED")) : "") +
|
|
375
|
+
hint,
|
|
376
|
+
default: currentDefault,
|
|
377
|
+
validate: (val) => validateValue(val, v) ?? true,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const coerced = coerceToType(answer, v.type);
|
|
381
|
+
finalValues[v.key] = coerced;
|
|
382
|
+
if (wasAutoGenerated && coerced === currentDefault)
|
|
383
|
+
summary.autoGenerated.push(v.key);
|
|
384
|
+
else if (hasExisting && coerced === existingVars[v.key])
|
|
385
|
+
summary.fromExisting.push(v.key);
|
|
386
|
+
else
|
|
387
|
+
summary.fromDefault.push(v.key);
|
|
388
|
+
}
|
|
389
|
+
const newEnvContent = buildEnvContent(schemaVersion, variables, finalValues);
|
|
390
|
+
if (options.dryRun) {
|
|
391
|
+
console.log("");
|
|
392
|
+
console.log(pc.bold(pc.cyan(`--- Dry run: ${envFileName} (not written) ---`)));
|
|
393
|
+
console.log(pc.dim(newEnvContent));
|
|
394
|
+
console.log(pc.bold(pc.cyan("--- End dry run ---")));
|
|
395
|
+
printSummary(summary, envFileName, schemaVersion);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
fs.writeFileSync(envPath, newEnvContent, "utf-8");
|
|
399
|
+
printSummary(summary, envFileName, schemaVersion);
|
|
400
|
+
}
|
|
401
|
+
// ─── Entry ───────────────────────────────────────────────────────────────────
|
|
402
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
403
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
|
|
404
|
+
if (isMain) {
|
|
405
|
+
run().catch((err) => {
|
|
406
|
+
if (err &&
|
|
407
|
+
typeof err === "object" &&
|
|
408
|
+
"name" in err &&
|
|
409
|
+
err.name === "ExitPromptError") {
|
|
410
|
+
console.log(pc.gray("\nCancelled."));
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
console.error(pc.red("Error:"), err);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
});
|
|
416
|
+
}
|