@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.
@@ -0,0 +1,369 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { input, select } from "@inquirer/prompts";
4
+ import pc from "picocolors";
5
+ import { getSchemaTypes, findSchemaType, parseEnumChoices, getAvailableConstraints, } from "./schema.js";
6
+ import { parseEnvExample, serializeEnvExample, dedupeVariables, enrichVariablesForPolish, getGroup, inferDescription, stripMetaFromComment, buildCommentLine, getDefaultSchemaVersion, } from "./parse.js";
7
+ import { detectType, matchesSchemaType } from "./validate.js";
8
+ // ─── Summary card ────────────────────────────────────────────────────────────
9
+ function printVariableSummary(key, index, total, fields) {
10
+ const progress = `[${index}/${total}]`;
11
+ const W = 60;
12
+ const keyPart = `── ${key} `;
13
+ const progPart = ` ${progress} ──`;
14
+ const fill = Math.max(0, W - keyPart.length - progPart.length);
15
+ const header = keyPart + "─".repeat(fill) + progPart;
16
+ console.log("");
17
+ console.log(pc.cyan(pc.bold(header)));
18
+ const L = 16;
19
+ const pad = (s) => s.padEnd(L);
20
+ const row = (label, value, source) => {
21
+ const src = source ? " " + pc.dim(pc.yellow(source)) : "";
22
+ console.log(` ${pc.gray(pad(label))}${value}${src}`);
23
+ };
24
+ row("Group", fields.group ? pc.white(fields.group) : pc.dim("(none)"), "");
25
+ row("Description", pc.white(fields.description), fields.descSource);
26
+ if (fields.type && fields.schemaType) {
27
+ const desc = fields.schemaType.description;
28
+ const short = desc.length > 50 ? desc.substring(0, 47) + "..." : desc;
29
+ row("Type", pc.cyan(fields.type), fields.typeSource);
30
+ console.log(` ${" ".repeat(L)}${pc.dim(short)}`);
31
+ }
32
+ else if (fields.type) {
33
+ row("Type", pc.cyan(fields.type), fields.typeSource);
34
+ }
35
+ else {
36
+ row("Type", pc.dim("(none)"), "");
37
+ }
38
+ if (fields.schemaType?.examples && fields.schemaType.examples.length > 0) {
39
+ const exStr = fields.schemaType.examples.map((e) => String(e)).join(", ");
40
+ const short = exStr.length > 50 ? exStr.substring(0, 47) + "..." : exStr;
41
+ row("Examples", pc.dim(short), "");
42
+ }
43
+ if (fields.type === "structured/enum" && fields.constraints.pattern) {
44
+ const choices = parseEnumChoices(fields.constraints.pattern);
45
+ if (choices.length > 0) {
46
+ row("Choices", choices.join(pc.dim(" | ")), "");
47
+ }
48
+ }
49
+ const methodEntries = Object.entries(fields.constraints).filter(([k]) => {
50
+ if (k === "pattern" && fields.type !== "string")
51
+ return false;
52
+ if (k === "pattern" && fields.type === "structured/enum")
53
+ return false;
54
+ return true;
55
+ });
56
+ if (methodEntries.length > 0) {
57
+ const mStr = methodEntries.map(([k, v]) => `${k}=${v}`).join(", ");
58
+ row("Constraints", pc.white(mStr), "from [CONSTRAINTS]");
59
+ }
60
+ row("Required", fields.required ? pc.green("yes") : pc.dim("no"), fields.reqSource);
61
+ const autoGen = fields.schemaType?.auto_generate;
62
+ if (autoGen && !fields.defaultValue) {
63
+ row("Default", pc.magenta(`auto (${autoGen})`), "from schema type");
64
+ }
65
+ else if (fields.defaultValue) {
66
+ row("Default", pc.white(fields.defaultValue), "");
67
+ }
68
+ else {
69
+ row("Default", pc.dim("(empty)"), "");
70
+ }
71
+ console.log(pc.gray("─".repeat(W)));
72
+ }
73
+ // ─── Non-interactive polish ──────────────────────────────────────────────────
74
+ export function polishEnvExample(rootDir) {
75
+ const envExamplePath = path.join(rootDir, ".env.example");
76
+ if (!fs.existsSync(envExamplePath)) {
77
+ throw new Error(`.env.example not found at ${envExamplePath}`);
78
+ }
79
+ const { version, variables } = parseEnvExample(rootDir);
80
+ const deduped = dedupeVariables(variables);
81
+ const enriched = enrichVariablesForPolish(deduped);
82
+ const effectiveVersion = version ?? getDefaultSchemaVersion(rootDir);
83
+ const content = serializeEnvExample(effectiveVersion, enriched);
84
+ fs.writeFileSync(envExamplePath, content, "utf-8");
85
+ }
86
+ // ─── Interactive polish ──────────────────────────────────────────────────────
87
+ export async function polishEnvExampleInteractive(rootDir) {
88
+ const envExamplePath = path.join(rootDir, ".env.example");
89
+ if (!fs.existsSync(envExamplePath)) {
90
+ throw new Error(`.env.example not found at ${envExamplePath}`);
91
+ }
92
+ const { version, variables } = parseEnvExample(rootDir);
93
+ const deduped = dedupeVariables(variables);
94
+ const effectiveVersion = version ?? getDefaultSchemaVersion(rootDir);
95
+ const polished = [];
96
+ const total = deduped.length;
97
+ const knownGroups = new Set();
98
+ for (const v of deduped) {
99
+ const g = getGroup(v);
100
+ if (g)
101
+ knownGroups.add(g);
102
+ }
103
+ if (knownGroups.size > 0 && deduped.some((v) => !getGroup(v))) {
104
+ knownGroups.add("Other");
105
+ }
106
+ console.log(pc.cyan(pc.bold(" Interactive polish")) +
107
+ pc.dim(` — ${total} variables to review\n`));
108
+ for (let i = 0; i < deduped.length; i++) {
109
+ const v = deduped[i];
110
+ let group = getGroup(v) || (knownGroups.size > 0 ? "Other" : "");
111
+ let description = inferDescription(v);
112
+ let descSource = stripMetaFromComment(v.comment)
113
+ ? "from comment"
114
+ : "inferred from key";
115
+ let type = v.type || detectType(v.defaultValue, v.key);
116
+ let typeSource;
117
+ if (v.type)
118
+ typeSource = "from [TYPE] tag";
119
+ else if (type)
120
+ typeSource = "detected";
121
+ else
122
+ typeSource = "";
123
+ let schemaType = type ? findSchemaType(type) : undefined;
124
+ let required = v.required;
125
+ let reqSource = v.required ? "from [REQUIRED] tag" : "";
126
+ let constraints = v.constraints
127
+ ? { ...v.constraints }
128
+ : {};
129
+ let defaultValue = v.defaultValue;
130
+ let accepted = false;
131
+ while (!accepted) {
132
+ printVariableSummary(v.key, i + 1, total, {
133
+ description,
134
+ descSource,
135
+ type,
136
+ typeSource,
137
+ schemaType,
138
+ constraints,
139
+ required,
140
+ reqSource,
141
+ defaultValue,
142
+ group,
143
+ });
144
+ const availableConstraints = type ? getAvailableConstraints(type) : {};
145
+ const methodEntries = Object.entries(availableConstraints).filter(([mKey]) => !(type === "structured/enum" && mKey === "pattern"));
146
+ const actionChoices = [
147
+ { name: pc.green("Accept"), value: "accept" },
148
+ { name: "Edit description", value: "edit_desc" },
149
+ { name: "Edit type", value: "edit_type" },
150
+ { name: "Edit default", value: "edit_default" },
151
+ {
152
+ name: required ? "Mark as optional" : "Mark as required",
153
+ value: "edit_required",
154
+ },
155
+ { name: "Edit group", value: "edit_group" },
156
+ ];
157
+ for (const [mKey] of methodEntries) {
158
+ const current = constraints[mKey];
159
+ const label = current
160
+ ? `Set ${mKey} ${pc.dim(`(${current})`)}`
161
+ : `Set ${mKey}`;
162
+ actionChoices.push({ name: label, value: `set_method:${mKey}` });
163
+ }
164
+ const action = await select({
165
+ message: "Action",
166
+ choices: actionChoices,
167
+ default: "accept",
168
+ });
169
+ if (action === "accept") {
170
+ accepted = true;
171
+ }
172
+ else if (action === "edit_desc") {
173
+ description = await input({
174
+ message: "Description",
175
+ default: description,
176
+ });
177
+ descSource = "";
178
+ }
179
+ else if (action === "edit_type") {
180
+ const allTypes = getSchemaTypes();
181
+ const trimmedValue = defaultValue.trim();
182
+ const matchingTypes = trimmedValue
183
+ ? allTypes.filter((t) => matchesSchemaType(t, trimmedValue, v.key))
184
+ : [];
185
+ const matchingNames = new Set(matchingTypes.map((t) => t.name));
186
+ const otherTypes = allTypes.filter((t) => !matchingNames.has(t.name));
187
+ const formatType = (t) => ({
188
+ name: `${t.name}${pc.dim(" — " + t.description.substring(0, 50))}`,
189
+ value: t.name,
190
+ });
191
+ let newType;
192
+ if (matchingTypes.length > 0) {
193
+ const picked = await select({
194
+ message: "Type",
195
+ choices: [
196
+ { name: pc.dim("(none)"), value: "" },
197
+ ...matchingTypes.map(formatType),
198
+ { name: pc.cyan("Other..."), value: "__other__" },
199
+ ],
200
+ default: type ?? "",
201
+ });
202
+ if (picked === "__other__") {
203
+ newType = await select({
204
+ message: "Type",
205
+ choices: [
206
+ { name: pc.dim("(none)"), value: "" },
207
+ ...otherTypes.map(formatType),
208
+ ],
209
+ default: "",
210
+ });
211
+ }
212
+ else {
213
+ newType = picked;
214
+ }
215
+ }
216
+ else {
217
+ newType = await select({
218
+ message: "Type",
219
+ choices: [
220
+ { name: pc.dim("(none)"), value: "" },
221
+ ...allTypes.map(formatType),
222
+ ],
223
+ default: type ?? "",
224
+ });
225
+ }
226
+ if (newType === "structured/enum") {
227
+ const currentChoices = constraints.pattern
228
+ ? parseEnumChoices(constraints.pattern)
229
+ : [];
230
+ const choicesStr = await input({
231
+ message: "Enum values (pipe-separated, e.g. debug|info|warn|error)",
232
+ default: currentChoices.join("|"),
233
+ validate: (val) => val.trim().length > 0 || "At least one value is required",
234
+ });
235
+ const values = choicesStr
236
+ .split(/[|,]/)
237
+ .map((s) => s.trim())
238
+ .filter(Boolean);
239
+ constraints = { pattern: `^(${values.join("|")})$` };
240
+ }
241
+ else if (newType) {
242
+ const newAvailable = getAvailableConstraints(newType);
243
+ const cleaned = {};
244
+ for (const [k, val] of Object.entries(constraints)) {
245
+ if (k in newAvailable)
246
+ cleaned[k] = val;
247
+ }
248
+ constraints = cleaned;
249
+ }
250
+ else {
251
+ constraints = {};
252
+ }
253
+ type = newType || undefined;
254
+ schemaType = type ? findSchemaType(type) : undefined;
255
+ typeSource = "";
256
+ }
257
+ else if (action === "edit_default") {
258
+ const autoGen = schemaType?.auto_generate;
259
+ if (autoGen) {
260
+ const defaultChoices = [
261
+ { name: "Enter a static value", value: "static" },
262
+ {
263
+ name: pc.magenta(`auto:${autoGen}`) +
264
+ pc.dim(" — auto-generate when empty"),
265
+ value: "auto",
266
+ },
267
+ ];
268
+ const choice = await select({
269
+ message: "Default value",
270
+ choices: defaultChoices,
271
+ default: defaultValue ? "static" : "auto",
272
+ });
273
+ if (choice === "auto") {
274
+ defaultValue = "";
275
+ }
276
+ else {
277
+ defaultValue = await input({
278
+ message: "Value",
279
+ default: defaultValue,
280
+ });
281
+ }
282
+ }
283
+ else {
284
+ defaultValue = await input({
285
+ message: "Value",
286
+ default: defaultValue,
287
+ });
288
+ }
289
+ }
290
+ else if (action === "edit_required") {
291
+ required = !required;
292
+ reqSource = "";
293
+ }
294
+ else if (action === "edit_group") {
295
+ const groupChoices = [];
296
+ if (!knownGroups.has("Other")) {
297
+ groupChoices.push({ name: pc.dim("(none)"), value: "" });
298
+ }
299
+ groupChoices.push(...[...knownGroups].map((g) => ({ name: g, value: g })));
300
+ groupChoices.push({
301
+ name: pc.cyan("+ New group..."),
302
+ value: "__new__",
303
+ });
304
+ const picked = await select({
305
+ message: "Group",
306
+ choices: groupChoices,
307
+ default: group || (knownGroups.has("Other") ? "Other" : ""),
308
+ });
309
+ if (picked === "__new__") {
310
+ const newGroup = await input({
311
+ message: "Group name (e.g. Database, Auth, App)",
312
+ validate: (val) => val.trim().length > 0 || "Group name is required",
313
+ });
314
+ group = newGroup.trim();
315
+ knownGroups.add(group);
316
+ }
317
+ else {
318
+ group = picked;
319
+ }
320
+ }
321
+ else if (action.startsWith("set_method:")) {
322
+ const mKey = action.slice("set_method:".length);
323
+ if (mKey === "pattern") {
324
+ const patternStr = await input({
325
+ message: "Pattern (regex, e.g. ^[a-z0-9-]+$) [empty to clear]",
326
+ default: constraints.pattern || "",
327
+ });
328
+ if (patternStr.trim()) {
329
+ constraints.pattern = patternStr.trim();
330
+ }
331
+ else {
332
+ delete constraints.pattern;
333
+ }
334
+ }
335
+ else {
336
+ const current = constraints[mKey] || "";
337
+ const val = await input({
338
+ message: `${mKey} [empty to clear]`,
339
+ default: current,
340
+ });
341
+ if (val.trim()) {
342
+ constraints[mKey] = val.trim();
343
+ }
344
+ else {
345
+ delete constraints[mKey];
346
+ }
347
+ }
348
+ }
349
+ }
350
+ const commentLine = buildCommentLine({
351
+ description,
352
+ required,
353
+ type,
354
+ constraints: Object.keys(constraints).length > 0 ? constraints : undefined,
355
+ defaultValue,
356
+ });
357
+ polished.push({
358
+ ...v,
359
+ comment: commentLine,
360
+ defaultValue,
361
+ required,
362
+ type,
363
+ constraints: Object.keys(constraints).length > 0 ? constraints : undefined,
364
+ group: group || undefined,
365
+ });
366
+ }
367
+ const content = serializeEnvExample(effectiveVersion, polished);
368
+ fs.writeFileSync(envExamplePath, content, "utf-8");
369
+ }
@@ -0,0 +1,67 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ let _schema = null;
5
+ export function loadSchema(customPath) {
6
+ if (_schema && !customPath)
7
+ return _schema;
8
+ if (customPath) {
9
+ return JSON.parse(fs.readFileSync(customPath, "utf-8"));
10
+ }
11
+ const selfPath = fileURLToPath(import.meta.url);
12
+ const dir = path.dirname(selfPath);
13
+ for (const candidate of [
14
+ path.join(dir, "..", "schema.json"),
15
+ path.join(dir, "..", "..", "schema.json"),
16
+ path.join(dir, "schema.json"),
17
+ ]) {
18
+ if (fs.existsSync(candidate)) {
19
+ _schema = JSON.parse(fs.readFileSync(candidate, "utf-8"));
20
+ return _schema;
21
+ }
22
+ }
23
+ throw new Error("schema.json not found");
24
+ }
25
+ export function resetSchemaCache() {
26
+ _schema = null;
27
+ }
28
+ export function getSchemaTypes() {
29
+ return loadSchema().types;
30
+ }
31
+ export function findSchemaType(name) {
32
+ return getSchemaTypes().find((t) => t.name === name);
33
+ }
34
+ /** Parse ^(a|b|c)$ pattern into choice strings. */
35
+ export function parseEnumChoices(pattern) {
36
+ const m = pattern.match(/^\^?\(([^)]+)\)\$?$/);
37
+ if (m)
38
+ return m[1]
39
+ .split("|")
40
+ .map((s) => s.trim())
41
+ .filter(Boolean);
42
+ return [];
43
+ }
44
+ /**
45
+ * Return the available constraint descriptors for a type.
46
+ * If the type itself defines constraints, use those.
47
+ * Otherwise, fall back to the constraints of the corresponding primitive type.
48
+ */
49
+ export function getAvailableConstraints(typeName) {
50
+ const st = findSchemaType(typeName);
51
+ if (!st)
52
+ return {};
53
+ if (st.constraints)
54
+ return st.constraints;
55
+ const baseName = {
56
+ number: "float",
57
+ integer: "integer",
58
+ boolean: "boolean",
59
+ string: "string",
60
+ };
61
+ const base = baseName[st.type];
62
+ if (base) {
63
+ const bt = findSchemaType(base);
64
+ return bt?.constraints || {};
65
+ }
66
+ return {};
67
+ }
@@ -0,0 +1,255 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { getSchemaTypes, findSchemaType, parseEnumChoices, } from "./schema.js";
5
+ import { parseEnvExample, getExistingEnvVersion, getExistingEnvVariables, } from "./parse.js";
6
+ // ─── Type detection ──────────────────────────────────────────────────────────
7
+ export function detectType(value, key) {
8
+ const trimmed = value.trim();
9
+ if (!trimmed)
10
+ return undefined;
11
+ const types = getSchemaTypes();
12
+ const integerType = types.find((t) => t.name === "integer");
13
+ const floatType = types.find((t) => t.name === "float");
14
+ const booleanType = types.find((t) => t.name === "boolean");
15
+ const stringType = types.find((t) => t.name === "string");
16
+ const enumType = types.find((t) => t.name === "structured/enum");
17
+ const ordered = [
18
+ integerType,
19
+ floatType,
20
+ booleanType,
21
+ ...types.filter((t) => t.name !== "integer" &&
22
+ t.name !== "float" &&
23
+ t.name !== "boolean" &&
24
+ t.name !== "string" &&
25
+ t.name !== "structured/enum"),
26
+ stringType,
27
+ enumType,
28
+ ].filter(Boolean);
29
+ for (const t of ordered) {
30
+ if (matchesSchemaType(t, trimmed, key))
31
+ return t.name;
32
+ }
33
+ return undefined;
34
+ }
35
+ export function matchesSchemaType(t, value, key) {
36
+ if (t.pattern) {
37
+ if (t.name === "file/path" && !/[/\\]|^[.~]/.test(value))
38
+ return false;
39
+ if (t.name === "locale/langtag" &&
40
+ /^(true|false|yes|no|on|off|ok)$/i.test(value))
41
+ return false;
42
+ try {
43
+ if (!new RegExp(t.pattern).test(value))
44
+ return false;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ if (t.name === "structured/json") {
50
+ try {
51
+ JSON.parse(value);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ return true;
58
+ }
59
+ if (t.name === "credentials/secret" && t.minLength !== undefined) {
60
+ return (/SECRET|KEY|TOKEN|PASSWORD|SALT|BEARER|CREDENTIAL|AUTH/i.test(key) &&
61
+ value.length >= t.minLength);
62
+ }
63
+ if (t.name === "float" && t.type === "number") {
64
+ return /^-?\d*\.\d+$/.test(value) && !isNaN(parseFloat(value));
65
+ }
66
+ if (t.name === "integer" && t.type === "integer") {
67
+ return /^-?\d+$/.test(value) && !isNaN(parseInt(value, 10));
68
+ }
69
+ if (t.name === "boolean" && t.type === "boolean") {
70
+ return /^(true|false|1|0|yes|no)$/i.test(value);
71
+ }
72
+ if (t.name === "string" && t.type === "string" && !t.pattern) {
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+ // ─── Value validation ────────────────────────────────────────────────────────
78
+ export function validateValue(value, v) {
79
+ const trimmed = value.trim();
80
+ if (v.required && !trimmed)
81
+ return `${v.key} is required.`;
82
+ if (!trimmed && !v.required)
83
+ return null;
84
+ if (!v.type)
85
+ return null;
86
+ const st = findSchemaType(v.type);
87
+ if (!st)
88
+ return null;
89
+ if (v.type === "structured/enum" && v.constraints?.pattern) {
90
+ try {
91
+ if (!new RegExp(v.constraints.pattern).test(trimmed)) {
92
+ const choices = parseEnumChoices(v.constraints.pattern);
93
+ if (choices.length > 0) {
94
+ return `${v.key} must be one of: ${choices.join(", ")}`;
95
+ }
96
+ return `${v.key} must match pattern ${v.constraints.pattern}`;
97
+ }
98
+ }
99
+ catch {
100
+ return `${v.key} has an invalid enum pattern: ${v.constraints.pattern}`;
101
+ }
102
+ return null;
103
+ }
104
+ if (st.pattern) {
105
+ try {
106
+ if (!new RegExp(st.pattern).test(trimmed)) {
107
+ return `${v.key} must be a valid ${st.name} (${st.description}).`;
108
+ }
109
+ }
110
+ catch {
111
+ /* invalid pattern in schema, skip */
112
+ }
113
+ }
114
+ if (v.type === "structured/json") {
115
+ try {
116
+ JSON.parse(trimmed);
117
+ }
118
+ catch {
119
+ return `${v.key} must be valid JSON.`;
120
+ }
121
+ }
122
+ if (st.type === "number" || st.name === "float") {
123
+ const n = Number(trimmed);
124
+ if (isNaN(n))
125
+ return `${v.key} must be a number.`;
126
+ const m = v.constraints || {};
127
+ if (m.min !== undefined && n < Number(m.min))
128
+ return `${v.key} must be >= ${m.min}.`;
129
+ if (m.max !== undefined && n > Number(m.max))
130
+ return `${v.key} must be <= ${m.max}.`;
131
+ if (m.precision !== undefined) {
132
+ const prec = Number(m.precision);
133
+ const decPart = trimmed.split(".")[1];
134
+ if (decPart && decPart.length > prec) {
135
+ return `${v.key} must have at most ${prec} decimal places.`;
136
+ }
137
+ }
138
+ }
139
+ if (st.type === "integer" || st.name === "integer") {
140
+ const n = Number(trimmed);
141
+ if (isNaN(n) || Math.floor(n) !== n)
142
+ return `${v.key} must be an integer.`;
143
+ const m = v.constraints || {};
144
+ if (m.min !== undefined && n < Number(m.min))
145
+ return `${v.key} must be >= ${m.min}.`;
146
+ if (m.max !== undefined && n > Number(m.max))
147
+ return `${v.key} must be <= ${m.max}.`;
148
+ }
149
+ if (st.type === "boolean" || st.name === "boolean") {
150
+ if (!/^(true|false|1|0|yes|no)$/i.test(trimmed)) {
151
+ return `${v.key} must be a boolean (true/false/1/0/yes/no).`;
152
+ }
153
+ }
154
+ if (st.minLength !== undefined && trimmed.length < st.minLength) {
155
+ return `${v.key} must be at least ${st.minLength} characters.`;
156
+ }
157
+ if (st.type === "string") {
158
+ const m = v.constraints || {};
159
+ if (m.minLength !== undefined && trimmed.length < Number(m.minLength))
160
+ return `${v.key} must be at least ${m.minLength} characters.`;
161
+ if (m.maxLength !== undefined && trimmed.length > Number(m.maxLength))
162
+ return `${v.key} must be at most ${m.maxLength} characters.`;
163
+ if (m.pattern) {
164
+ try {
165
+ if (!new RegExp(m.pattern).test(trimmed))
166
+ return `${v.key} must match pattern ${m.pattern}`;
167
+ }
168
+ catch {
169
+ return `${v.key} has an invalid pattern: ${m.pattern}`;
170
+ }
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ export function validateEnv(rootDir, options = {}) {
176
+ const { version, variables } = parseEnvExample(rootDir);
177
+ const envFileName = options.envFile || ".env";
178
+ const envPath = path.join(rootDir, envFileName);
179
+ const existing = getExistingEnvVariables(envPath);
180
+ const errors = [];
181
+ const warnings = [];
182
+ if (fs.existsSync(envPath) && version) {
183
+ const schemaVersionInEnv = getExistingEnvVersion(fs.readFileSync(envPath, "utf-8"));
184
+ if (schemaVersionInEnv !== null && schemaVersionInEnv !== version) {
185
+ warnings.push(`ENV_SCHEMA_VERSION mismatch: ${envFileName} has "${schemaVersionInEnv}", .env.example has "${version}".`);
186
+ }
187
+ }
188
+ for (const v of variables) {
189
+ if (v.isCommentedOut)
190
+ continue;
191
+ const value = existing[v.key];
192
+ const valuePresent = value !== undefined && value !== null;
193
+ if (v.required && !valuePresent) {
194
+ errors.push(`Missing required variable: ${v.key}`);
195
+ continue;
196
+ }
197
+ if (!valuePresent)
198
+ continue;
199
+ const err = validateValue(value, v);
200
+ if (err)
201
+ errors.push(err);
202
+ }
203
+ return { valid: errors.length === 0, errors, warnings };
204
+ }
205
+ // ─── Coercion ────────────────────────────────────────────────────────────────
206
+ export function coerceToType(value, typeName) {
207
+ if (!typeName)
208
+ return value;
209
+ const st = findSchemaType(typeName);
210
+ if (!st)
211
+ return value;
212
+ const trimmed = value.trim();
213
+ if (st.type === "number" || st.name === "float") {
214
+ const n = Number(trimmed);
215
+ if (isNaN(n))
216
+ return value;
217
+ return String(n);
218
+ }
219
+ if (st.type === "integer" || st.name === "integer") {
220
+ const n = Number(trimmed);
221
+ if (isNaN(n))
222
+ return value;
223
+ return String(Math.floor(n));
224
+ }
225
+ if (st.type === "boolean" || st.name === "boolean") {
226
+ const lower = trimmed.toLowerCase();
227
+ if (["true", "1", "yes"].includes(lower))
228
+ return "true";
229
+ if (["false", "0", "no", ""].includes(lower))
230
+ return "false";
231
+ return value;
232
+ }
233
+ if (st.type === "string")
234
+ return trimmed;
235
+ return value;
236
+ }
237
+ // ─── Auto-generation ─────────────────────────────────────────────────────────
238
+ const AUTO_GENERATORS = {
239
+ rsa_private_key: () => {
240
+ const { privateKey } = crypto.generateKeyPairSync("rsa", {
241
+ modulusLength: 2048,
242
+ publicKeyEncoding: { type: "spki", format: "pem" },
243
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
244
+ });
245
+ return privateKey;
246
+ },
247
+ uuidv4: () => crypto.randomUUID(),
248
+ random_secret_32: () => crypto.randomBytes(32).toString("base64"),
249
+ };
250
+ export function generateAutoValue(kind) {
251
+ const gen = AUTO_GENERATORS[kind];
252
+ if (!gen)
253
+ return "";
254
+ return gen();
255
+ }