@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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import { getSchemaTypes, findSchemaType } from "./schema.js";
|
|
5
|
+
import { detectType } from "./validate.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse [TYPE: full/name] and [CONSTRAINTS: k=v,k=v] from comment text.
|
|
8
|
+
*/
|
|
9
|
+
function parseSchemaMeta(comment, _key) {
|
|
10
|
+
const full = comment.replace(/\s+/g, " ");
|
|
11
|
+
const out = {};
|
|
12
|
+
const validNames = new Set(getSchemaTypes().map((t) => t.name));
|
|
13
|
+
const typeMatch = full.match(/\[TYPE:\s*([^\]]+)\]/i);
|
|
14
|
+
if (typeMatch) {
|
|
15
|
+
const t = typeMatch[1].trim();
|
|
16
|
+
if (validNames.has(t))
|
|
17
|
+
out.type = t;
|
|
18
|
+
}
|
|
19
|
+
const parseConstraints = (raw) => {
|
|
20
|
+
const constraints = {};
|
|
21
|
+
const pairs = raw.split(/,(?=[a-zA-Z_]+=)/);
|
|
22
|
+
for (const pair of pairs) {
|
|
23
|
+
const eqIdx = pair.indexOf("=");
|
|
24
|
+
if (eqIdx > 0) {
|
|
25
|
+
let k = pair.substring(0, eqIdx).trim();
|
|
26
|
+
const v = pair.substring(eqIdx + 1).trim();
|
|
27
|
+
if (k === "minimum")
|
|
28
|
+
k = "min";
|
|
29
|
+
if (k === "maximum")
|
|
30
|
+
k = "max";
|
|
31
|
+
constraints[k] = v;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return constraints;
|
|
35
|
+
};
|
|
36
|
+
const constraintsMatch = full.match(/\[CONSTRAINTS:\s*([^\]]+)\]/i);
|
|
37
|
+
if (constraintsMatch) {
|
|
38
|
+
const c = parseConstraints(constraintsMatch[1].trim());
|
|
39
|
+
if (Object.keys(c).length > 0)
|
|
40
|
+
out.constraints = c;
|
|
41
|
+
}
|
|
42
|
+
const methodsMatch = full.match(/\[METHODS:\s*([^\]]+)\]/i);
|
|
43
|
+
if (methodsMatch) {
|
|
44
|
+
const c = parseConstraints(methodsMatch[1].trim());
|
|
45
|
+
if (Object.keys(c).length > 0)
|
|
46
|
+
out.constraints = { ...out.constraints, ...c };
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// ─── File parsing ────────────────────────────────────────────────────────────
|
|
51
|
+
export function parseEnvExample(rootDir) {
|
|
52
|
+
const envExamplePath = path.join(rootDir, ".env.example");
|
|
53
|
+
if (!fs.existsSync(envExamplePath)) {
|
|
54
|
+
throw new Error(`.env.example not found at ${envExamplePath}`);
|
|
55
|
+
}
|
|
56
|
+
const raw = fs.readFileSync(envExamplePath, "utf-8");
|
|
57
|
+
const content = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
58
|
+
const lines = content.split("\n");
|
|
59
|
+
const variables = [];
|
|
60
|
+
let currentComments = [];
|
|
61
|
+
let currentGroup = "";
|
|
62
|
+
let version = null;
|
|
63
|
+
let inBannerBlock = false;
|
|
64
|
+
let bannerGroupName = "";
|
|
65
|
+
let bannerEverClosed = false;
|
|
66
|
+
const buildComment = (comments) => comments.join("\n");
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (trimmed.startsWith("# ENV_SCHEMA_VERSION=")) {
|
|
70
|
+
const match = trimmed.match(/# ENV_SCHEMA_VERSION="?([^"]+)"?/);
|
|
71
|
+
if (match)
|
|
72
|
+
version = match[1];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (/^#\s*={5,}\s*$/.test(trimmed)) {
|
|
76
|
+
if (!inBannerBlock && !bannerEverClosed) {
|
|
77
|
+
inBannerBlock = true;
|
|
78
|
+
bannerGroupName = "";
|
|
79
|
+
}
|
|
80
|
+
else if (inBannerBlock) {
|
|
81
|
+
if (bannerGroupName) {
|
|
82
|
+
currentGroup = bannerGroupName;
|
|
83
|
+
}
|
|
84
|
+
currentComments = [];
|
|
85
|
+
inBannerBlock = false;
|
|
86
|
+
bannerEverClosed = true;
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (inBannerBlock) {
|
|
91
|
+
bannerGroupName = trimmed.replace(/^#\s*/, "").trim();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const sectionDashesMatch = trimmed.match(/^#\s*-+\s*(.+?)\s*-+\s*$/);
|
|
95
|
+
if (sectionDashesMatch) {
|
|
96
|
+
currentGroup = sectionDashesMatch[1].trim();
|
|
97
|
+
currentComments = [];
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (trimmed.startsWith("#")) {
|
|
101
|
+
const maybeVarMatch = !trimmed.startsWith("# ENV_SCHEMA_VERSION=")
|
|
102
|
+
? trimmed.match(/^#\s*([A-Z0-9_]+)=(.*)$/)
|
|
103
|
+
: null;
|
|
104
|
+
if (maybeVarMatch) {
|
|
105
|
+
let val = maybeVarMatch[2].trim();
|
|
106
|
+
val = val.split(" #")[0].trim();
|
|
107
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
108
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
109
|
+
val = val.slice(1, -1);
|
|
110
|
+
}
|
|
111
|
+
const commentStr = buildComment(currentComments);
|
|
112
|
+
const meta = parseSchemaMeta(commentStr, maybeVarMatch[1]);
|
|
113
|
+
variables.push({
|
|
114
|
+
key: maybeVarMatch[1],
|
|
115
|
+
defaultValue: val,
|
|
116
|
+
comment: commentStr,
|
|
117
|
+
required: false,
|
|
118
|
+
isCommentedOut: true,
|
|
119
|
+
group: currentGroup || undefined,
|
|
120
|
+
...meta,
|
|
121
|
+
});
|
|
122
|
+
currentComments = [];
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
currentComments.push(trimmed.replace(/^#\s*/, ""));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!trimmed) {
|
|
129
|
+
currentComments = [];
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const match = trimmed.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
133
|
+
if (match) {
|
|
134
|
+
let val = match[2].trim();
|
|
135
|
+
val = val.split(" #")[0].trim();
|
|
136
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
137
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
138
|
+
val = val.slice(1, -1);
|
|
139
|
+
}
|
|
140
|
+
const fullComment = currentComments.join(" ");
|
|
141
|
+
const required = fullComment.toUpperCase().includes("[REQUIRED]");
|
|
142
|
+
const commentStr = buildComment(currentComments);
|
|
143
|
+
const meta = parseSchemaMeta(commentStr, match[1]);
|
|
144
|
+
variables.push({
|
|
145
|
+
key: match[1],
|
|
146
|
+
defaultValue: val,
|
|
147
|
+
comment: commentStr,
|
|
148
|
+
required,
|
|
149
|
+
isCommentedOut: false,
|
|
150
|
+
group: currentGroup || undefined,
|
|
151
|
+
...meta,
|
|
152
|
+
});
|
|
153
|
+
currentComments = [];
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
currentComments = [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (variables.some((v) => v.group)) {
|
|
160
|
+
variables.forEach((v) => {
|
|
161
|
+
if (v.group === undefined || v.group === "")
|
|
162
|
+
v.group = "Other";
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { version, variables };
|
|
166
|
+
}
|
|
167
|
+
export function getExistingEnvVersion(content) {
|
|
168
|
+
const match = content.match(/# ENV_SCHEMA_VERSION="?([^"\n]+)"?/);
|
|
169
|
+
return match ? match[1] : null;
|
|
170
|
+
}
|
|
171
|
+
export function getExistingEnvVariables(envPath) {
|
|
172
|
+
if (fs.existsSync(envPath)) {
|
|
173
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
174
|
+
return dotenv.parse(content);
|
|
175
|
+
}
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
// ─── Grouping / serialization ────────────────────────────────────────────────
|
|
179
|
+
export function getGroup(v) {
|
|
180
|
+
return v.group || "";
|
|
181
|
+
}
|
|
182
|
+
function renderGroupBanner(groupName) {
|
|
183
|
+
const W = 40;
|
|
184
|
+
const bar = "# " + "=".repeat(W);
|
|
185
|
+
const padLen = Math.max(1, Math.floor((W - groupName.length) / 2));
|
|
186
|
+
const center = "#" + " ".repeat(padLen) + groupName;
|
|
187
|
+
return [bar, center, bar];
|
|
188
|
+
}
|
|
189
|
+
/** Reorder variables so same-group variables are contiguous. Ungrouped vars become "Other" when any group is used. */
|
|
190
|
+
export function groupVariablesBySection(variables) {
|
|
191
|
+
const hasAnyGroup = variables.some((v) => getGroup(v) !== "");
|
|
192
|
+
const groups = new Map();
|
|
193
|
+
const groupOrder = [];
|
|
194
|
+
for (const v of variables) {
|
|
195
|
+
const group = getGroup(v) || (hasAnyGroup ? "Other" : "");
|
|
196
|
+
if (!groups.has(group)) {
|
|
197
|
+
groups.set(group, []);
|
|
198
|
+
groupOrder.push(group);
|
|
199
|
+
}
|
|
200
|
+
groups.get(group).push(v);
|
|
201
|
+
}
|
|
202
|
+
const result = [];
|
|
203
|
+
for (const g of groupOrder) {
|
|
204
|
+
result.push(...groups.get(g));
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
export function dedupeVariables(variables) {
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
return variables.filter((v) => {
|
|
211
|
+
if (seen.has(v.key))
|
|
212
|
+
return false;
|
|
213
|
+
seen.add(v.key);
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const ENV_EXAMPLE_HEADER = [
|
|
218
|
+
"# ==============================================",
|
|
219
|
+
"# Environment Variables",
|
|
220
|
+
"# ==============================================",
|
|
221
|
+
"# env-from-example (https://www.npmjs.com/package/env-from-example)",
|
|
222
|
+
"# ==============================================",
|
|
223
|
+
"",
|
|
224
|
+
];
|
|
225
|
+
export function serializeEnvExample(version, variables) {
|
|
226
|
+
const lines = [...ENV_EXAMPLE_HEADER];
|
|
227
|
+
if (version !== null && version !== undefined) {
|
|
228
|
+
lines.push(`# ENV_SCHEMA_VERSION="${version}"`);
|
|
229
|
+
lines.push("");
|
|
230
|
+
}
|
|
231
|
+
const grouped = groupVariablesBySection(variables);
|
|
232
|
+
let lastGroup = "";
|
|
233
|
+
for (const v of grouped) {
|
|
234
|
+
const group = getGroup(v);
|
|
235
|
+
if (group && group !== lastGroup) {
|
|
236
|
+
if (lines.length > 0 && lines[lines.length - 1] !== "")
|
|
237
|
+
lines.push("");
|
|
238
|
+
lines.push(...renderGroupBanner(group));
|
|
239
|
+
lines.push("");
|
|
240
|
+
lastGroup = group;
|
|
241
|
+
}
|
|
242
|
+
const commentLines = v.comment.split("\n").filter(Boolean);
|
|
243
|
+
for (const c of commentLines) {
|
|
244
|
+
lines.push("# " + c.replace(/^#\s*/, ""));
|
|
245
|
+
}
|
|
246
|
+
const needsQuotes = /[\s#"']/.test(v.defaultValue) || v.defaultValue === "";
|
|
247
|
+
const value = needsQuotes && v.defaultValue !== ""
|
|
248
|
+
? `"${v.defaultValue.replace(/"/g, '\\"')}"`
|
|
249
|
+
: v.defaultValue;
|
|
250
|
+
if (v.isCommentedOut) {
|
|
251
|
+
lines.push(`# ${v.key}=${value}`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
lines.push(`${v.key}=${value}`);
|
|
255
|
+
}
|
|
256
|
+
lines.push("");
|
|
257
|
+
}
|
|
258
|
+
return (lines
|
|
259
|
+
.join("\n")
|
|
260
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
261
|
+
.trimEnd() + "\n");
|
|
262
|
+
}
|
|
263
|
+
// ─── Description / comment helpers ───────────────────────────────────────────
|
|
264
|
+
export function humanizeEnvKey(key) {
|
|
265
|
+
return key
|
|
266
|
+
.split("_")
|
|
267
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
268
|
+
.join(" ");
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Strip [REQUIRED], [TYPE: ...], [CONSTRAINTS: ...], Default: ... from comment
|
|
272
|
+
* to get the plain description text.
|
|
273
|
+
*/
|
|
274
|
+
export function stripMetaFromComment(comment) {
|
|
275
|
+
return comment
|
|
276
|
+
.replace(/\s*\[REQUIRED\]\s*/gi, " ")
|
|
277
|
+
.replace(/\s*\[TYPE:\s*[^\]]+\]\s*/gi, " ")
|
|
278
|
+
.replace(/\s*\[CONSTRAINTS:\s*[^\]]+\]\s*/gi, " ")
|
|
279
|
+
.replace(/\s*Default:\s*[^\n]+/gi, " ")
|
|
280
|
+
.replace(/\s+/g, " ")
|
|
281
|
+
.trim();
|
|
282
|
+
}
|
|
283
|
+
export function inferDescription(v) {
|
|
284
|
+
const plain = stripMetaFromComment(v.comment);
|
|
285
|
+
if (plain)
|
|
286
|
+
return plain;
|
|
287
|
+
return humanizeEnvKey(v.key);
|
|
288
|
+
}
|
|
289
|
+
export function buildCommentLine(parts) {
|
|
290
|
+
const meta = [];
|
|
291
|
+
if (parts.required)
|
|
292
|
+
meta.push("[REQUIRED]");
|
|
293
|
+
if (parts.type)
|
|
294
|
+
meta.push(`[TYPE: ${parts.type}]`);
|
|
295
|
+
if (parts.constraints && Object.keys(parts.constraints).length > 0) {
|
|
296
|
+
const constraintsStr = Object.entries(parts.constraints)
|
|
297
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
298
|
+
.join(",");
|
|
299
|
+
meta.push(`[CONSTRAINTS: ${constraintsStr}]`);
|
|
300
|
+
}
|
|
301
|
+
meta.push(parts.defaultValue === ""
|
|
302
|
+
? "Default: (empty)"
|
|
303
|
+
: `Default: ${parts.defaultValue}`);
|
|
304
|
+
return parts.description + "\n" + meta.join(" ");
|
|
305
|
+
}
|
|
306
|
+
export function enrichVariablesForPolish(variables) {
|
|
307
|
+
return variables.map((v) => {
|
|
308
|
+
const commentLines = v.comment.split("\n").filter(Boolean);
|
|
309
|
+
let description = commentLines
|
|
310
|
+
.find((l) => !l.toUpperCase().includes("[REQUIRED]"))
|
|
311
|
+
?.trim() || "";
|
|
312
|
+
if (!description)
|
|
313
|
+
description = humanizeEnvKey(v.key);
|
|
314
|
+
const type = v.type || detectType(v.defaultValue, v.key);
|
|
315
|
+
const line = buildCommentLine({
|
|
316
|
+
description,
|
|
317
|
+
required: v.required,
|
|
318
|
+
type,
|
|
319
|
+
constraints: v.constraints,
|
|
320
|
+
defaultValue: v.defaultValue,
|
|
321
|
+
});
|
|
322
|
+
return { ...v, comment: line, type, group: getGroup(v) || v.group };
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
326
|
+
export function initEnvExample(rootDir, options = {}) {
|
|
327
|
+
const envExamplePath = path.join(rootDir, ".env.example");
|
|
328
|
+
if (fs.existsSync(envExamplePath)) {
|
|
329
|
+
throw new Error(`.env.example already exists at ${envExamplePath}. Use --polish to update it.`);
|
|
330
|
+
}
|
|
331
|
+
let variables = [];
|
|
332
|
+
const sourceFile = options.from || ".env";
|
|
333
|
+
const sourcePath = path.join(rootDir, sourceFile);
|
|
334
|
+
if (fs.existsSync(sourcePath)) {
|
|
335
|
+
const existingVars = dotenv.parse(fs.readFileSync(sourcePath, "utf-8"));
|
|
336
|
+
for (const [key, value] of Object.entries(existingVars)) {
|
|
337
|
+
const detectedType = detectType(value, key);
|
|
338
|
+
const matchedSchema = detectedType
|
|
339
|
+
? findSchemaType(detectedType)
|
|
340
|
+
: undefined;
|
|
341
|
+
const schema = {
|
|
342
|
+
key,
|
|
343
|
+
defaultValue: value,
|
|
344
|
+
comment: humanizeEnvKey(key),
|
|
345
|
+
required: false,
|
|
346
|
+
isCommentedOut: false,
|
|
347
|
+
type: detectedType,
|
|
348
|
+
};
|
|
349
|
+
if (matchedSchema?.auto_generate && !value) {
|
|
350
|
+
schema.defaultValue = "";
|
|
351
|
+
}
|
|
352
|
+
variables.push(schema);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
variables = [
|
|
357
|
+
{
|
|
358
|
+
key: "NODE_ENV",
|
|
359
|
+
defaultValue: "development",
|
|
360
|
+
comment: "Node environment",
|
|
361
|
+
type: "structured/enum",
|
|
362
|
+
constraints: { pattern: "^development|test|staging|production$" },
|
|
363
|
+
required: false,
|
|
364
|
+
isCommentedOut: false,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
key: "PORT",
|
|
368
|
+
defaultValue: "3000",
|
|
369
|
+
comment: "Server port",
|
|
370
|
+
required: true,
|
|
371
|
+
isCommentedOut: false,
|
|
372
|
+
type: "integer",
|
|
373
|
+
constraints: { min: "1", max: "65535" },
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
const version = getDefaultSchemaVersion(rootDir);
|
|
378
|
+
const enriched = enrichVariablesForPolish(variables);
|
|
379
|
+
const content = serializeEnvExample(version, enriched);
|
|
380
|
+
fs.writeFileSync(envExamplePath, content, "utf-8");
|
|
381
|
+
}
|
|
382
|
+
export function getDefaultSchemaVersion(rootDir) {
|
|
383
|
+
const pkgPath = path.join(rootDir, "package.json");
|
|
384
|
+
if (fs.existsSync(pkgPath)) {
|
|
385
|
+
try {
|
|
386
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
387
|
+
if (pkg.version && typeof pkg.version === "string")
|
|
388
|
+
return pkg.version;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
/* ignore */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return "1.0.0";
|
|
395
|
+
}
|