@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,846 @@
1
+ import { describe, it, expect, afterEach, beforeAll } from "vitest";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { getRootDirFromArgv, parseEnvExample, getExistingEnvVersion, getExistingEnvVariables, serializeEnvExample, polishEnvExample, bumpSemver, updateEnvSchemaVersion, generateAutoValue, validateValue, coerceToType, validateEnv, initEnvExample, detectType, parseEnumChoices, findSchemaType, getAvailableConstraints, inferDescription, } from "../../env-from-example.js";
5
+ const PROJECT_ROOT = path.resolve(__dirname, "../..");
6
+ const FIXTURES_DIR = path.join(PROJECT_ROOT, "test", "fixtures");
7
+ const FULL_FIXTURE_DIR = path.join(FIXTURES_DIR, "full");
8
+ const FULL_FIXTURE_ENV_EXAMPLE = path.join(FULL_FIXTURE_DIR, ".env.example");
9
+ const FULL_FIXTURE_CONTENT = `# ==============================================
10
+ # Environment Variables
11
+ # ==============================================
12
+ # env-from-example (https://www.npmjs.com/package/env-from-example)
13
+ # ==============================================
14
+
15
+ # ENV_SCHEMA_VERSION="1.0"
16
+
17
+ # ------ Database ------
18
+ # [REQUIRED] Postgres connection string
19
+ DATABASE_URL=postgres://localhost:5432/myapp
20
+
21
+ # Pool size (number); default is fine for dev
22
+ DATABASE_POOL_SIZE=10
23
+
24
+ # ------ API ------
25
+ # Default: (empty)
26
+ API_KEY=
27
+
28
+ # Secret for signing; no default
29
+ API_SECRET=
30
+
31
+ # Base URL, can contain spaces or special chars when quoted
32
+ API_BASE_URL=https://api.example.com/v1
33
+
34
+ # ------ App ------
35
+ # [TYPE: structured/enum]
36
+ NODE_ENV=development
37
+
38
+ # Session secret: auto-generated if left empty (64-byte base64)
39
+ SESSION_SECRET=
40
+
41
+ # Optional feature flag (commented out = included with default, not prompted)
42
+ # FEATURE_BETA=false
43
+
44
+ # Optional port; comment line = variable still in schema with default
45
+ # PORT=3000
46
+ `;
47
+ describe("getRootDirFromArgv", () => {
48
+ const originalArgv = process.argv;
49
+ afterEach(() => {
50
+ process.argv = originalArgv;
51
+ });
52
+ it("returns process.cwd() when --cwd is not present", () => {
53
+ process.argv = ["node", "env-from-example.ts"];
54
+ expect(getRootDirFromArgv()).toBe(process.cwd());
55
+ });
56
+ it("returns resolved path when --cwd is present with value", () => {
57
+ process.argv = ["node", "env-from-example.ts", "--cwd", "/some/project"];
58
+ expect(getRootDirFromArgv()).toBe(path.resolve("/some/project"));
59
+ });
60
+ it("returns process.cwd() when --cwd is last (no value)", () => {
61
+ process.argv = ["node", "env-from-example.ts", "--yes", "--cwd"];
62
+ expect(getRootDirFromArgv()).toBe(process.cwd());
63
+ });
64
+ it("returns resolved path for relative --cwd", () => {
65
+ process.argv = ["node", "env-from-example.ts", "--cwd", "./fixtures/full"];
66
+ const result = getRootDirFromArgv();
67
+ expect(path.isAbsolute(result)).toBe(true);
68
+ expect(result).toMatch(/fixtures[\\/]full$/);
69
+ });
70
+ });
71
+ describe("parseEnvExample", () => {
72
+ beforeAll(() => {
73
+ fs.mkdirSync(FULL_FIXTURE_DIR, { recursive: true });
74
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
75
+ });
76
+ it("throws when .env.example does not exist", () => {
77
+ expect(() => parseEnvExample("/nonexistent/dir")).toThrow(/.env.example not found at/);
78
+ });
79
+ it("parses full fixture: version, sections, required, commented-out", () => {
80
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
81
+ const rootDir = path.join(FIXTURES_DIR, "full");
82
+ const { version, variables } = parseEnvExample(rootDir);
83
+ expect(version).toBe("1.0");
84
+ const keys = variables.map((v) => v.key);
85
+ expect(keys).toContain("DATABASE_URL");
86
+ expect(keys).toContain("DATABASE_POOL_SIZE");
87
+ expect(keys).toContain("API_KEY");
88
+ expect(keys).toContain("API_SECRET");
89
+ expect(keys).toContain("API_BASE_URL");
90
+ expect(keys).toContain("NODE_ENV");
91
+ expect(keys).toContain("SESSION_SECRET");
92
+ expect(keys).toContain("FEATURE_BETA");
93
+ expect(keys).toContain("PORT");
94
+ const databaseUrl = variables.find((v) => v.key === "DATABASE_URL");
95
+ expect(databaseUrl.defaultValue).toBe("postgres://localhost:5432/myapp");
96
+ expect(databaseUrl.required).toBe(true);
97
+ expect(databaseUrl.isCommentedOut).toBe(false);
98
+ expect(databaseUrl.comment).toMatch(/Postgres|REQUIRED/);
99
+ const apiBaseUrl = variables.find((v) => v.key === "API_BASE_URL");
100
+ expect(apiBaseUrl.defaultValue).toBe("https://api.example.com/v1");
101
+ const featureBeta = variables.find((v) => v.key === "FEATURE_BETA");
102
+ expect(featureBeta.isCommentedOut).toBe(true);
103
+ expect(featureBeta.defaultValue).toBe("false");
104
+ const port = variables.find((v) => v.key === "PORT");
105
+ expect(port.isCommentedOut).toBe(true);
106
+ expect(port.defaultValue).toBe("3000");
107
+ });
108
+ it("parses minimal fixture with version", () => {
109
+ const rootDir = path.join(FIXTURES_DIR, "minimal");
110
+ const { version, variables } = parseEnvExample(rootDir);
111
+ expect(version).toBe("2.0");
112
+ expect(variables).toHaveLength(2);
113
+ const nodeEnv = variables.find((v) => v.key === "NODE_ENV");
114
+ expect(nodeEnv.defaultValue).toBe("development");
115
+ expect(nodeEnv.required).toBe(false);
116
+ const someKey = variables.find((v) => v.key === "SOME_KEY");
117
+ expect(someKey.defaultValue).toBe("default_value");
118
+ });
119
+ it("parses required-only fixture", () => {
120
+ const rootDir = path.join(FIXTURES_DIR, "required-only");
121
+ const { version, variables } = parseEnvExample(rootDir);
122
+ expect(version).toBe("1.0");
123
+ expect(variables).toHaveLength(1);
124
+ expect(variables[0].key).toBe("REQUIRED_VAR");
125
+ expect(variables[0].required).toBe(true);
126
+ expect(variables[0].defaultValue).toBe("");
127
+ });
128
+ it("parses no-version fixture: version is null", () => {
129
+ const rootDir = path.join(FIXTURES_DIR, "no-version");
130
+ const { version, variables } = parseEnvExample(rootDir);
131
+ expect(version).toBeNull();
132
+ expect(variables).toHaveLength(2);
133
+ expect(variables.find((v) => v.key === "FOO")?.defaultValue).toBe("bar");
134
+ expect(variables.find((v) => v.key === "BAZ")?.defaultValue).toBe("qux");
135
+ });
136
+ it("strips inline comments from values", () => {
137
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
138
+ const rootDir = path.join(FIXTURES_DIR, "full");
139
+ const { variables } = parseEnvExample(rootDir);
140
+ const apiBase = variables.find((v) => v.key === "API_BASE_URL");
141
+ expect(apiBase.defaultValue).toBe("https://api.example.com/v1");
142
+ });
143
+ it("preserves section group from banner", () => {
144
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
145
+ const rootDir = path.join(FIXTURES_DIR, "full");
146
+ const { variables } = parseEnvExample(rootDir);
147
+ const dbUrl = variables.find((v) => v.key === "DATABASE_URL");
148
+ expect(dbUrl.group).toBeDefined();
149
+ expect(typeof dbUrl.group).toBe("string");
150
+ expect(dbUrl.group.length).toBeGreaterThan(0);
151
+ });
152
+ });
153
+ describe("getExistingEnvVersion", () => {
154
+ it("returns null for content without version", () => {
155
+ expect(getExistingEnvVersion("FOO=bar")).toBeNull();
156
+ expect(getExistingEnvVersion("")).toBeNull();
157
+ });
158
+ it("returns version from quoted ENV_SCHEMA_VERSION", () => {
159
+ const content = '# ENV_SCHEMA_VERSION="1.0"\nFOO=bar';
160
+ expect(getExistingEnvVersion(content)).toBe("1.0");
161
+ });
162
+ it("returns version from unquoted ENV_SCHEMA_VERSION", () => {
163
+ const content = "# ENV_SCHEMA_VERSION=2.0\n";
164
+ expect(getExistingEnvVersion(content)).toBe("2.0");
165
+ });
166
+ it("returns first match when multiple version-like lines exist", () => {
167
+ const content = '# ENV_SCHEMA_VERSION="1.0"\n# ENV_SCHEMA_VERSION="2.0"';
168
+ expect(getExistingEnvVersion(content)).toBe("1.0");
169
+ });
170
+ });
171
+ describe("getExistingEnvVariables", () => {
172
+ it("returns empty object when file does not exist", () => {
173
+ const result = getExistingEnvVariables(path.join(FIXTURES_DIR, "nonexistent.env"));
174
+ expect(result).toEqual({});
175
+ });
176
+ it("parses existing .env file", () => {
177
+ const envPath = path.join(FIXTURES_DIR, "full", ".env.example");
178
+ const result = getExistingEnvVariables(envPath);
179
+ expect(result).toBeDefined();
180
+ expect(typeof result).toBe("object");
181
+ expect(result.DATABASE_URL).toBe("postgres://localhost:5432/myapp");
182
+ expect(result.NODE_ENV).toBe("development");
183
+ });
184
+ it("returns empty object for empty or comment-only file", () => {
185
+ const tmpDir = path.join(FIXTURES_DIR, "full");
186
+ const commentOnlyPath = path.join(tmpDir, ".env.comment-only");
187
+ fs.writeFileSync(commentOnlyPath, "# only comments\n\n", "utf-8");
188
+ try {
189
+ const result = getExistingEnvVariables(commentOnlyPath);
190
+ expect(result).toEqual({});
191
+ }
192
+ finally {
193
+ try {
194
+ fs.unlinkSync(commentOnlyPath);
195
+ }
196
+ catch {
197
+ /* ignore */
198
+ }
199
+ }
200
+ });
201
+ });
202
+ describe("serializeEnvExample", () => {
203
+ it("outputs version line and variables with sections", () => {
204
+ const variables = [
205
+ {
206
+ key: "FOO",
207
+ defaultValue: "bar",
208
+ comment: "Description",
209
+ required: false,
210
+ isCommentedOut: false,
211
+ group: "Section",
212
+ },
213
+ {
214
+ key: "BAZ",
215
+ defaultValue: "qux",
216
+ comment: "",
217
+ required: false,
218
+ isCommentedOut: true,
219
+ },
220
+ ];
221
+ const out = serializeEnvExample("1.0", variables);
222
+ expect(out).toMatch(/# ENV_SCHEMA_VERSION="1.0"/);
223
+ expect(out).toMatch(/# ={5,}/);
224
+ expect(out).toMatch(/#\s+Section/);
225
+ expect(out).toMatch(/FOO=bar/);
226
+ expect(out).toMatch(/# BAZ=qux/);
227
+ });
228
+ it("outputs no version line when version is null", () => {
229
+ const variables = [
230
+ {
231
+ key: "X",
232
+ defaultValue: "y",
233
+ comment: "",
234
+ required: false,
235
+ isCommentedOut: false,
236
+ },
237
+ ];
238
+ const out = serializeEnvExample(null, variables);
239
+ expect(out).not.toMatch(/ENV_SCHEMA_VERSION/);
240
+ expect(out).toMatch(/env-from-example/);
241
+ expect(out).toMatch(/\nX=y\n/);
242
+ });
243
+ });
244
+ describe("bumpSemver", () => {
245
+ it("bumps patch", () => {
246
+ expect(bumpSemver("1.0.0", "patch")).toBe("1.0.1");
247
+ expect(bumpSemver("1.0", "patch")).toBe("1.0.1");
248
+ });
249
+ it("bumps minor", () => {
250
+ expect(bumpSemver("1.0.0", "minor")).toBe("1.1.0");
251
+ expect(bumpSemver("2.1", "minor")).toBe("2.2.0");
252
+ });
253
+ it("bumps major", () => {
254
+ expect(bumpSemver("1.0.0", "major")).toBe("2.0.0");
255
+ expect(bumpSemver("3.2.1", "major")).toBe("4.0.0");
256
+ });
257
+ });
258
+ describe("polishEnvExample", () => {
259
+ it("overwrites .env.example with normalized content and dedupes keys", () => {
260
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
261
+ const fixtureDir = path.join(FIXTURES_DIR, "full");
262
+ const envPath = path.join(fixtureDir, ".env.example");
263
+ const before = fs.readFileSync(envPath, "utf-8");
264
+ polishEnvExample(fixtureDir);
265
+ const after = fs.readFileSync(envPath, "utf-8");
266
+ expect(after).toMatch(/# ENV_SCHEMA_VERSION="1.0"/);
267
+ expect(after).toMatch(/DATABASE_URL=postgres:\/\/localhost:5432\/myapp/);
268
+ expect(after).toMatch(/#\s+Database/);
269
+ fs.writeFileSync(envPath, before, "utf-8");
270
+ });
271
+ it("adds ENV_SCHEMA_VERSION when missing", () => {
272
+ const fixtureDir = path.join(FIXTURES_DIR, "no-version");
273
+ const envPath = path.join(fixtureDir, ".env.example");
274
+ const before = fs.readFileSync(envPath, "utf-8");
275
+ polishEnvExample(fixtureDir);
276
+ const after = fs.readFileSync(envPath, "utf-8");
277
+ expect(after).toMatch(/# ENV_SCHEMA_VERSION="/);
278
+ expect(after).toMatch(/FOO=bar/);
279
+ expect(after).toMatch(/BAZ=qux/);
280
+ fs.writeFileSync(envPath, before, "utf-8");
281
+ });
282
+ it("enriches comments with Default: and description", () => {
283
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
284
+ const fixtureDir = path.join(FIXTURES_DIR, "full");
285
+ const envPath = path.join(fixtureDir, ".env.example");
286
+ const before = fs.readFileSync(envPath, "utf-8");
287
+ polishEnvExample(fixtureDir);
288
+ const after = fs.readFileSync(envPath, "utf-8");
289
+ expect(after).toMatch(/Default: postgres:\/\/localhost:5432\/myapp/);
290
+ expect(after).toMatch(/\[REQUIRED\]/);
291
+ expect(after).toMatch(/Default: \(empty\)/);
292
+ fs.writeFileSync(envPath, before, "utf-8");
293
+ });
294
+ it("dedupes duplicate keys (keeps first occurrence)", () => {
295
+ const tmpDir = path.join(FIXTURES_DIR, "..", "fixtures-polish-dedup");
296
+ const envPath = path.join(tmpDir, ".env.example");
297
+ fs.mkdirSync(tmpDir, { recursive: true });
298
+ fs.writeFileSync(envPath, "FOO=first\nBAR=ok\nFOO=second\n", "utf-8");
299
+ try {
300
+ polishEnvExample(tmpDir);
301
+ const after = fs.readFileSync(envPath, "utf-8");
302
+ const fooMatches = after.match(/^FOO=/gm);
303
+ expect(fooMatches).toHaveLength(1);
304
+ expect(after).toMatch(/FOO=first/);
305
+ expect(after).not.toMatch(/FOO=second/);
306
+ expect(after).toMatch(/BAR=ok/);
307
+ }
308
+ finally {
309
+ try {
310
+ fs.unlinkSync(envPath);
311
+ fs.rmdirSync(tmpDir);
312
+ }
313
+ catch {
314
+ /* ignore */
315
+ }
316
+ }
317
+ });
318
+ it("throws when .env.example does not exist", () => {
319
+ expect(() => polishEnvExample("/nonexistent/dir")).toThrow(/.env.example not found/);
320
+ });
321
+ });
322
+ describe("updateEnvSchemaVersion", () => {
323
+ it("updates ENV_SCHEMA_VERSION in .env.example", () => {
324
+ const fixtureDir = path.join(FIXTURES_DIR, "minimal");
325
+ const envPath = path.join(fixtureDir, ".env.example");
326
+ const before = fs.readFileSync(envPath, "utf-8");
327
+ try {
328
+ updateEnvSchemaVersion(fixtureDir, "3.0.0");
329
+ const after = fs.readFileSync(envPath, "utf-8");
330
+ expect(after).toMatch(/# ENV_SCHEMA_VERSION="3.0.0"/);
331
+ expect(after).toMatch(/NODE_ENV=development/);
332
+ }
333
+ finally {
334
+ fs.writeFileSync(envPath, before, "utf-8");
335
+ }
336
+ });
337
+ it("throws when .env.example does not exist", () => {
338
+ expect(() => updateEnvSchemaVersion("/nonexistent", "1.0.0")).toThrow(/.env.example not found/);
339
+ });
340
+ });
341
+ describe("parseEnvExample schema meta (type, constraints)", () => {
342
+ it("parses [TYPE], [CONSTRAINTS] from comments", () => {
343
+ const rootDir = path.join(FIXTURES_DIR, "schema-meta");
344
+ const { variables } = parseEnvExample(rootDir);
345
+ const logLevel = variables.find((v) => v.key === "LOG_LEVEL");
346
+ expect(logLevel.type).toBe("structured/enum");
347
+ expect(logLevel.constraints).toEqual({
348
+ pattern: "^(debug|info|warn|error)$",
349
+ });
350
+ expect(logLevel.defaultValue).toBe("info");
351
+ const port = variables.find((v) => v.key === "PORT");
352
+ expect(port.type).toBe("integer");
353
+ expect(port.constraints).toEqual({ min: "1", max: "65535" });
354
+ const featureX = variables.find((v) => v.key === "FEATURE_X");
355
+ expect(featureX.type).toBe("boolean");
356
+ const apiBase = variables.find((v) => v.key === "API_BASE");
357
+ expect(apiBase.type).toBe("network/url");
358
+ const mySecret = variables.find((v) => v.key === "MY_SECRET");
359
+ expect(mySecret.type).toBe("credentials/secret");
360
+ });
361
+ it("parses [TYPE: visual/hex_color] from comments", () => {
362
+ const rootDir = path.join(FIXTURES_DIR, "schema-meta");
363
+ const { variables } = parseEnvExample(rootDir);
364
+ const brandColor = variables.find((v) => v.key === "BRAND_COLOR");
365
+ expect(brandColor.type).toBe("visual/hex_color");
366
+ expect(brandColor.defaultValue).toBe("#3b82f6");
367
+ });
368
+ it("parses [TYPE: version/semver] from comments", () => {
369
+ const rootDir = path.join(FIXTURES_DIR, "schema-meta");
370
+ const { variables } = parseEnvExample(rootDir);
371
+ const appVersion = variables.find((v) => v.key === "APP_VERSION");
372
+ expect(appVersion.type).toBe("version/semver");
373
+ expect(appVersion.defaultValue).toBe("1.0.0");
374
+ });
375
+ });
376
+ describe("detectType", () => {
377
+ it("detects HTTPS URLs", () => {
378
+ expect(detectType("https://api.example.com", "API_URL")).toBe("network/https_url");
379
+ });
380
+ it("detects HTTP URLs", () => {
381
+ expect(detectType("http://localhost:3000", "API_URL")).toBe("network/url");
382
+ });
383
+ it("detects non-HTTP URIs", () => {
384
+ expect(detectType("postgres://user:pass@db:5432/mydb", "DATABASE_URL")).toBe("network/uri");
385
+ expect(detectType("redis://:password@redis:6379/0", "REDIS_URL")).toBe("network/uri");
386
+ });
387
+ it("detects UUIDs", () => {
388
+ expect(detectType("3fa85f64-5717-4562-b3fc-2c963f66afa6", "REQUEST_ID")).toBe("id/uuid");
389
+ });
390
+ it("detects semver", () => {
391
+ expect(detectType("1.2.3", "APP_VERSION")).toBe("version/semver");
392
+ });
393
+ it("detects hex colors", () => {
394
+ expect(detectType("#3b82f6", "BRAND_COLOR")).toBe("visual/hex_color");
395
+ expect(detectType("#fff", "COLOR")).toBe("visual/hex_color");
396
+ });
397
+ it("detects JSON", () => {
398
+ expect(detectType('{"key":"value"}', "CONFIG")).toBe("structured/json");
399
+ });
400
+ it("detects integers", () => {
401
+ expect(detectType("42", "PORT")).toBe("integer");
402
+ expect(detectType("0", "COUNT")).toBe("integer");
403
+ });
404
+ it("detects floats", () => {
405
+ expect(detectType("3.14", "RATE")).toBe("float");
406
+ });
407
+ it("detects booleans", () => {
408
+ expect(detectType("true", "ENABLED")).toBe("boolean");
409
+ expect(detectType("false", "FLAG")).toBe("boolean");
410
+ expect(detectType("yes", "FLAG")).toBe("boolean");
411
+ expect(detectType("no", "FLAG")).toBe("boolean");
412
+ });
413
+ it("falls back to string for generic text", () => {
414
+ expect(detectType("hello world", "GREETING")).toBe("string");
415
+ expect(detectType("development", "NODE_ENV")).toBe("string");
416
+ });
417
+ it("returns undefined for empty string", () => {
418
+ expect(detectType("", "KEY")).toBeUndefined();
419
+ expect(detectType(" ", "KEY")).toBeUndefined();
420
+ });
421
+ it("detects credentials/secret by key name + length", () => {
422
+ const longSecret = "sk_live_4f3b2a1cabcdef12";
423
+ expect(detectType(longSecret, "API_SECRET")).toBe("credentials/secret");
424
+ expect(detectType(longSecret, "API_KEY")).toBe("credentials/secret");
425
+ expect(detectType(longSecret, "AUTH_TOKEN")).toBe("credentials/secret");
426
+ });
427
+ it("does not detect credentials/secret for short values", () => {
428
+ expect(detectType("short", "API_SECRET")).toBe("string");
429
+ });
430
+ it("does not detect credentials/secret without matching key", () => {
431
+ expect(detectType("some_long_value_here_yes", "NODE_ENV")).toBe("string");
432
+ });
433
+ it("detects file paths", () => {
434
+ expect(detectType("./config.yml", "CONFIG_PATH")).toBe("file/path");
435
+ expect(detectType("/var/app/secrets.json", "FILE")).toBe("file/path");
436
+ });
437
+ it("does not detect numbers as file paths", () => {
438
+ expect(detectType("3000", "PORT")).toBe("integer");
439
+ expect(detectType("true", "FLAG")).toBe("boolean");
440
+ });
441
+ it("detects durations", () => {
442
+ expect(detectType("30s", "TIMEOUT")).toBe("temporal/duration");
443
+ expect(detectType("7d", "TTL")).toBe("temporal/duration");
444
+ });
445
+ it("detects IPv4", () => {
446
+ expect(detectType("127.0.0.1", "HOST")).toBe("network/ip");
447
+ });
448
+ it("detects domains", () => {
449
+ expect(detectType("example.com", "DOMAIN")).toBe("network/domain");
450
+ });
451
+ it("detects cron expressions", () => {
452
+ expect(detectType("0 3 1 * *", "CRON")).toBe("temporal/cron");
453
+ });
454
+ it("detects CSV values", () => {
455
+ expect(detectType("a,b,c", "TAGS")).toBe("structured/csv");
456
+ });
457
+ });
458
+ describe("parseEnumChoices", () => {
459
+ it("parses ^(a|b|c)$ format", () => {
460
+ expect(parseEnumChoices("^(debug|info|warn|error)$")).toEqual([
461
+ "debug",
462
+ "info",
463
+ "warn",
464
+ "error",
465
+ ]);
466
+ });
467
+ it("parses (a|b|c) without anchors", () => {
468
+ expect(parseEnumChoices("(a|b|c)")).toEqual(["a", "b", "c"]);
469
+ });
470
+ it("returns empty for non-enum patterns", () => {
471
+ expect(parseEnumChoices("^[a-z]+$")).toEqual([]);
472
+ expect(parseEnumChoices("")).toEqual([]);
473
+ });
474
+ });
475
+ describe("findSchemaType", () => {
476
+ it("finds known types", () => {
477
+ expect(findSchemaType("integer")).toBeDefined();
478
+ expect(findSchemaType("integer").name).toBe("integer");
479
+ expect(findSchemaType("network/url")).toBeDefined();
480
+ expect(findSchemaType("boolean")).toBeDefined();
481
+ });
482
+ it("returns undefined for unknown types", () => {
483
+ expect(findSchemaType("nonexistent")).toBeUndefined();
484
+ });
485
+ });
486
+ describe("getAvailableConstraints", () => {
487
+ it("returns constraints for integer type", () => {
488
+ const constraints = getAvailableConstraints("integer");
489
+ expect(constraints).toHaveProperty("min");
490
+ expect(constraints).toHaveProperty("max");
491
+ });
492
+ it("returns constraints for float type", () => {
493
+ const constraints = getAvailableConstraints("float");
494
+ expect(constraints).toHaveProperty("min");
495
+ expect(constraints).toHaveProperty("max");
496
+ expect(constraints).toHaveProperty("precision");
497
+ });
498
+ it("returns string constraints for string type", () => {
499
+ const constraints = getAvailableConstraints("string");
500
+ expect(constraints).toHaveProperty("minLength");
501
+ expect(constraints).toHaveProperty("maxLength");
502
+ expect(constraints).toHaveProperty("pattern");
503
+ });
504
+ it("inherits string constraints for string sub-types", () => {
505
+ const constraints = getAvailableConstraints("network/url");
506
+ expect(constraints).toHaveProperty("minLength");
507
+ expect(constraints).toHaveProperty("maxLength");
508
+ expect(constraints).toHaveProperty("pattern");
509
+ });
510
+ it("returns empty for boolean type", () => {
511
+ const constraints = getAvailableConstraints("boolean");
512
+ expect(Object.keys(constraints)).toHaveLength(0);
513
+ });
514
+ it("returns own constraints for structured/enum", () => {
515
+ const constraints = getAvailableConstraints("structured/enum");
516
+ expect(constraints).toHaveProperty("pattern");
517
+ });
518
+ });
519
+ describe("generateAutoValue", () => {
520
+ it("returns UUID v4 for uuidv4", () => {
521
+ const v = generateAutoValue("uuidv4");
522
+ expect(v).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
523
+ });
524
+ it("returns base64 for random_secret_32", () => {
525
+ const v = generateAutoValue("random_secret_32");
526
+ expect(v.length).toBeGreaterThan(0);
527
+ expect(Buffer.from(v, "base64").length).toBe(32);
528
+ });
529
+ it("returns PEM key for rsa_private_key", () => {
530
+ const v = generateAutoValue("rsa_private_key");
531
+ expect(v).toMatch(/-----BEGIN PRIVATE KEY-----/);
532
+ expect(v).toMatch(/-----END PRIVATE KEY-----/);
533
+ });
534
+ it("returns empty string for unknown kind", () => {
535
+ expect(generateAutoValue("nonexistent")).toBe("");
536
+ });
537
+ });
538
+ describe("validateValue", () => {
539
+ it("returns error when required and empty", () => {
540
+ const v = {
541
+ key: "X",
542
+ defaultValue: "",
543
+ comment: "",
544
+ required: true,
545
+ isCommentedOut: false,
546
+ };
547
+ expect(validateValue("", v)).toMatch(/required/);
548
+ expect(validateValue(" ", v)).toMatch(/required/);
549
+ });
550
+ it("returns null when optional and empty", () => {
551
+ const v = {
552
+ key: "X",
553
+ defaultValue: "",
554
+ comment: "",
555
+ required: false,
556
+ isCommentedOut: false,
557
+ };
558
+ expect(validateValue("", v)).toBeNull();
559
+ });
560
+ it("validates structured/enum against constraints pattern", () => {
561
+ const v = {
562
+ key: "L",
563
+ defaultValue: "info",
564
+ comment: "",
565
+ required: false,
566
+ isCommentedOut: false,
567
+ type: "structured/enum",
568
+ constraints: { pattern: "^(debug|info|warn|error)$" },
569
+ };
570
+ expect(validateValue("info", v)).toBeNull();
571
+ expect(validateValue("debug", v)).toBeNull();
572
+ expect(validateValue("verbose", v)).toMatch(/one of/);
573
+ });
574
+ it("validates integer type", () => {
575
+ const v = {
576
+ key: "N",
577
+ defaultValue: "0",
578
+ comment: "",
579
+ required: false,
580
+ isCommentedOut: false,
581
+ type: "integer",
582
+ };
583
+ expect(validateValue("42", v)).toBeNull();
584
+ expect(validateValue("3.14", v)).toMatch(/integer/);
585
+ expect(validateValue("abc", v)).toMatch(/integer/);
586
+ });
587
+ it("validates integer with constraints constraints", () => {
588
+ const v = {
589
+ key: "P",
590
+ defaultValue: "3000",
591
+ comment: "",
592
+ required: false,
593
+ isCommentedOut: false,
594
+ type: "integer",
595
+ constraints: { min: "1", max: "65535" },
596
+ };
597
+ expect(validateValue("8080", v)).toBeNull();
598
+ expect(validateValue("0", v)).toMatch(/>= 1/);
599
+ expect(validateValue("99999", v)).toMatch(/<= 65535/);
600
+ });
601
+ it("validates float type with precision", () => {
602
+ const v = {
603
+ key: "R",
604
+ defaultValue: "0.0",
605
+ comment: "",
606
+ required: false,
607
+ isCommentedOut: false,
608
+ type: "float",
609
+ constraints: { precision: "2" },
610
+ };
611
+ expect(validateValue("3.14", v)).toBeNull();
612
+ expect(validateValue("3.141", v)).toMatch(/decimal places/);
613
+ });
614
+ it("validates boolean type", () => {
615
+ const v = {
616
+ key: "B",
617
+ defaultValue: "false",
618
+ comment: "",
619
+ required: false,
620
+ isCommentedOut: false,
621
+ type: "boolean",
622
+ };
623
+ expect(validateValue("true", v)).toBeNull();
624
+ expect(validateValue("false", v)).toBeNull();
625
+ expect(validateValue("yes", v)).toBeNull();
626
+ expect(validateValue("no", v)).toBeNull();
627
+ expect(validateValue("maybe", v)).toMatch(/boolean/);
628
+ });
629
+ it("validates network/url against schema pattern", () => {
630
+ const v = {
631
+ key: "U",
632
+ defaultValue: "",
633
+ comment: "",
634
+ required: false,
635
+ isCommentedOut: false,
636
+ type: "network/url",
637
+ };
638
+ expect(validateValue("https://example.com", v)).toBeNull();
639
+ expect(validateValue("http://localhost:3000", v)).toBeNull();
640
+ expect(validateValue("not-a-url", v)).toMatch(/valid network\/url/);
641
+ });
642
+ it("validates network/https_url rejects http", () => {
643
+ const v = {
644
+ key: "U",
645
+ defaultValue: "",
646
+ comment: "",
647
+ required: false,
648
+ isCommentedOut: false,
649
+ type: "network/https_url",
650
+ };
651
+ expect(validateValue("https://example.com", v)).toBeNull();
652
+ expect(validateValue("http://example.com", v)).toMatch(/valid network\/https_url/);
653
+ });
654
+ it("validates visual/hex_color", () => {
655
+ const v = {
656
+ key: "C",
657
+ defaultValue: "#000000",
658
+ comment: "",
659
+ required: false,
660
+ isCommentedOut: false,
661
+ type: "visual/hex_color",
662
+ };
663
+ expect(validateValue("#3b82f6", v)).toBeNull();
664
+ expect(validateValue("#fff", v)).toBeNull();
665
+ expect(validateValue("red", v)).toMatch(/valid visual\/hex_color/);
666
+ });
667
+ it("validates structured/json requires JSON.parse", () => {
668
+ const v = {
669
+ key: "J",
670
+ defaultValue: "",
671
+ comment: "",
672
+ required: false,
673
+ isCommentedOut: false,
674
+ type: "structured/json",
675
+ };
676
+ expect(validateValue('{"x":1}', v)).toBeNull();
677
+ expect(validateValue("{not json}", v)).toMatch(/valid JSON/);
678
+ });
679
+ it("validates string constraints (minLength, maxLength, pattern)", () => {
680
+ const v = {
681
+ key: "S",
682
+ defaultValue: "",
683
+ comment: "",
684
+ required: false,
685
+ isCommentedOut: false,
686
+ type: "string",
687
+ constraints: { minLength: "3", maxLength: "10", pattern: "^[a-z]+$" },
688
+ };
689
+ expect(validateValue("hello", v)).toBeNull();
690
+ expect(validateValue("ab", v)).toMatch(/at least 3/);
691
+ expect(validateValue("toolongvalue", v)).toMatch(/at most 10/);
692
+ expect(validateValue("UPPER", v)).toMatch(/match pattern/);
693
+ });
694
+ it("returns null for optional empty value", () => {
695
+ const v = {
696
+ key: "OPT",
697
+ defaultValue: "",
698
+ comment: "",
699
+ required: false,
700
+ isCommentedOut: false,
701
+ type: "network/url",
702
+ };
703
+ expect(validateValue("", v)).toBeNull();
704
+ });
705
+ });
706
+ describe("coerceToType", () => {
707
+ it("coerces to integer", () => {
708
+ expect(coerceToType("42", "integer")).toBe("42");
709
+ expect(coerceToType(" 99 ", "integer")).toBe("99");
710
+ expect(coerceToType("3.7", "integer")).toBe("3");
711
+ });
712
+ it("coerces to float", () => {
713
+ expect(coerceToType("3.14", "float")).toBe("3.14");
714
+ expect(coerceToType(" 99 ", "float")).toBe("99");
715
+ });
716
+ it("coerces to boolean", () => {
717
+ expect(coerceToType("true", "boolean")).toBe("true");
718
+ expect(coerceToType("yes", "boolean")).toBe("true");
719
+ expect(coerceToType("false", "boolean")).toBe("false");
720
+ expect(coerceToType("no", "boolean")).toBe("false");
721
+ });
722
+ it("leaves string types as-is (trimmed)", () => {
723
+ expect(coerceToType("hello", "string")).toBe("hello");
724
+ expect(coerceToType("https://x.com", "network/url")).toBe("https://x.com");
725
+ });
726
+ it("returns value unchanged for unknown type", () => {
727
+ expect(coerceToType("test", "nonexistent")).toBe("test");
728
+ });
729
+ });
730
+ describe("validateEnv", () => {
731
+ it("returns valid when .env matches schema and required present", () => {
732
+ const rootDir = path.join(FIXTURES_DIR, "minimal");
733
+ const envPath = path.join(rootDir, ".env");
734
+ const before = fs.existsSync(envPath)
735
+ ? fs.readFileSync(envPath, "utf-8")
736
+ : null;
737
+ fs.writeFileSync(envPath, '# ENV_SCHEMA_VERSION="2.0"\nNODE_ENV=development\nSOME_KEY=default_value\n', "utf-8");
738
+ try {
739
+ const result = validateEnv(rootDir, { envFile: ".env" });
740
+ expect(result.valid).toBe(true);
741
+ expect(result.errors).toHaveLength(0);
742
+ }
743
+ finally {
744
+ if (before !== null)
745
+ fs.writeFileSync(envPath, before, "utf-8");
746
+ else if (fs.existsSync(envPath))
747
+ fs.unlinkSync(envPath);
748
+ }
749
+ });
750
+ it("returns invalid when required variable is missing", () => {
751
+ fs.writeFileSync(FULL_FIXTURE_ENV_EXAMPLE, FULL_FIXTURE_CONTENT, "utf-8");
752
+ const rootDir = path.join(FIXTURES_DIR, "full");
753
+ const envPath = path.join(rootDir, ".env");
754
+ fs.writeFileSync(envPath, "NODE_ENV=development\n", "utf-8");
755
+ try {
756
+ const result = validateEnv(rootDir, { envFile: ".env" });
757
+ expect(result.valid).toBe(false);
758
+ expect(result.errors.some((e) => e.includes("DATABASE_URL") && e.includes("required"))).toBe(true);
759
+ }
760
+ finally {
761
+ if (fs.existsSync(envPath))
762
+ fs.unlinkSync(envPath);
763
+ }
764
+ });
765
+ it("returns valid when env file does not exist", () => {
766
+ const rootDir = path.join(FIXTURES_DIR, "minimal");
767
+ const result = validateEnv(rootDir, { envFile: ".env.nonexistent" });
768
+ expect(result.valid).toBe(true);
769
+ expect(result.errors).toHaveLength(0);
770
+ });
771
+ });
772
+ describe("initEnvExample", () => {
773
+ it("creates .env.example from scratch when no source exists", () => {
774
+ const tmpDir = path.join(FIXTURES_DIR, "..", "fixtures-init-scratch");
775
+ const envExamplePath = path.join(tmpDir, ".env.example");
776
+ fs.mkdirSync(tmpDir, { recursive: true });
777
+ try {
778
+ initEnvExample(tmpDir);
779
+ expect(fs.existsSync(envExamplePath)).toBe(true);
780
+ const content = fs.readFileSync(envExamplePath, "utf-8");
781
+ expect(content).toMatch(/NODE_ENV/);
782
+ expect(content).toMatch(/PORT/);
783
+ expect(content).toMatch(/ENV_SCHEMA_VERSION/);
784
+ }
785
+ finally {
786
+ try {
787
+ fs.unlinkSync(envExamplePath);
788
+ fs.rmdirSync(tmpDir);
789
+ }
790
+ catch {
791
+ /* ignore */
792
+ }
793
+ }
794
+ });
795
+ it("creates .env.example from an existing .env file", () => {
796
+ const tmpDir = path.join(FIXTURES_DIR, "..", "fixtures-init-from");
797
+ const envPath = path.join(tmpDir, ".env");
798
+ const envExamplePath = path.join(tmpDir, ".env.example");
799
+ fs.mkdirSync(tmpDir, { recursive: true });
800
+ fs.writeFileSync(envPath, "MY_VAR=hello\nDEBUG=true\n", "utf-8");
801
+ try {
802
+ initEnvExample(tmpDir);
803
+ expect(fs.existsSync(envExamplePath)).toBe(true);
804
+ const content = fs.readFileSync(envExamplePath, "utf-8");
805
+ expect(content).toMatch(/MY_VAR=hello/);
806
+ expect(content).toMatch(/DEBUG=true/);
807
+ }
808
+ finally {
809
+ try {
810
+ fs.unlinkSync(envPath);
811
+ fs.unlinkSync(envExamplePath);
812
+ fs.rmdirSync(tmpDir);
813
+ }
814
+ catch {
815
+ /* ignore */
816
+ }
817
+ }
818
+ });
819
+ it("throws when .env.example already exists", () => {
820
+ const fixtureDir = path.join(FIXTURES_DIR, "full");
821
+ expect(() => initEnvExample(fixtureDir)).toThrow(/already exists/);
822
+ });
823
+ });
824
+ describe("inferDescription", () => {
825
+ it("strips meta tags and returns plain description", () => {
826
+ const v = {
827
+ key: "PORT",
828
+ defaultValue: "3000",
829
+ comment: "Server port [REQUIRED] [TYPE: integer] [CONSTRAINTS: min=1,max=65535] Default: 3000",
830
+ required: true,
831
+ isCommentedOut: false,
832
+ type: "integer",
833
+ };
834
+ expect(inferDescription(v)).toBe("Server port");
835
+ });
836
+ it("falls back to humanized key name", () => {
837
+ const v = {
838
+ key: "DATABASE_URL",
839
+ defaultValue: "",
840
+ comment: "",
841
+ required: false,
842
+ isCommentedOut: false,
843
+ };
844
+ expect(inferDescription(v)).toBe("Database Url");
845
+ });
846
+ });