Skip to content

Commit 97a8de1

Browse files
authored
feat: update handling of env files in registry (#7896)
* feat: handle env update * tests(shadcn): add tests for env helpers * test(shadcn): update files test * feat(shadcn): implement file alternatives * test(shadcn): fix alternative handling * fix(shadcn): env var logging * test(shadcn): add tests for multi line env * chore: changeset * ci: update
1 parent 19d7fbb commit 97a8de1

6 files changed

Lines changed: 924 additions & 30 deletions

File tree

.changeset/famous-laws-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shadcn": minor
3+
---
4+
5+
add support for env vars in registry

.github/changeset-version.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ import { exec } from "child_process"
88
// So we also run `npm install`, which does this update.
99
// This is a workaround until this is handled automatically by `changeset version`.
1010
// See https://github.com/changesets/changesets/issues/421.
11-
exec("npx changeset version")
12-
exec("npm install")
11+
exec("pnpm dlx changeset version")
12+
exec("pnpm install")
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
import { existsSync } from "fs"
2+
import { beforeEach, describe, expect, test, vi } from "vitest"
3+
4+
import {
5+
findExistingEnvFile,
6+
getNewEnvKeys,
7+
isEnvFile,
8+
mergeEnvContent,
9+
parseEnvContent,
10+
} from "./env-helpers"
11+
12+
// Mock fs module
13+
vi.mock("fs", () => ({
14+
existsSync: vi.fn(),
15+
}))
16+
17+
describe("isEnvFile", () => {
18+
test("should identify .env files", () => {
19+
expect(isEnvFile("/path/to/.env")).toBe(true)
20+
expect(isEnvFile(".env")).toBe(true)
21+
expect(isEnvFile("/path/to/.env.local")).toBe(true)
22+
expect(isEnvFile(".env.local")).toBe(true)
23+
expect(isEnvFile(".env.example")).toBe(true)
24+
expect(isEnvFile(".env.development.local")).toBe(true)
25+
expect(isEnvFile(".env.production.local")).toBe(true)
26+
expect(isEnvFile(".env.test.local")).toBe(true)
27+
})
28+
29+
test("should not identify non-.env files", () => {
30+
expect(isEnvFile("/path/to/file.txt")).toBe(false)
31+
expect(isEnvFile("environment.ts")).toBe(false)
32+
expect(isEnvFile("/path/to/.environment")).toBe(false)
33+
expect(isEnvFile("env.config")).toBe(false)
34+
})
35+
})
36+
37+
describe("parseEnvContent", () => {
38+
test("should parse basic key-value pairs", () => {
39+
const content = `KEY1=value1
40+
KEY2=value2`
41+
expect(parseEnvContent(content)).toEqual({
42+
KEY1: "value1",
43+
KEY2: "value2",
44+
})
45+
})
46+
47+
test("should handle comments and empty lines", () => {
48+
const content = `# This is a comment
49+
KEY1=value1
50+
51+
# Another comment
52+
KEY2=value2
53+
`
54+
expect(parseEnvContent(content)).toEqual({
55+
KEY1: "value1",
56+
KEY2: "value2",
57+
})
58+
})
59+
60+
test("should handle quoted values", () => {
61+
const content = `KEY1="value with spaces"
62+
KEY2='single quotes'`
63+
expect(parseEnvContent(content)).toEqual({
64+
KEY1: "value with spaces",
65+
KEY2: "single quotes",
66+
})
67+
})
68+
69+
test("should handle values with equals signs", () => {
70+
const content = `DATABASE_URL=postgresql://user:pass@host:5432/db?ssl=true`
71+
expect(parseEnvContent(content)).toEqual({
72+
DATABASE_URL: "postgresql://user:pass@host:5432/db?ssl=true",
73+
})
74+
})
75+
76+
test("should handle empty values", () => {
77+
const content = `EMPTY_KEY=
78+
KEY_WITH_VALUE=value`
79+
expect(parseEnvContent(content)).toEqual({
80+
EMPTY_KEY: "",
81+
KEY_WITH_VALUE: "value",
82+
})
83+
})
84+
85+
test("should skip malformed lines", () => {
86+
const content = `VALID_KEY=value
87+
this is not a valid line
88+
ANOTHER_KEY=another_value`
89+
expect(parseEnvContent(content)).toEqual({
90+
VALID_KEY: "value",
91+
ANOTHER_KEY: "another_value",
92+
})
93+
})
94+
95+
test("should handle multi-line values (current limitation: breaks them)", () => {
96+
// This test documents that multi-line values are NOT properly supported
97+
const content = `SINGLE_LINE="This is fine"
98+
MULTI_LINE="This is line 1
99+
This is line 2
100+
This is line 3"
101+
NEXT_KEY=value`
102+
103+
const result = parseEnvContent(content)
104+
105+
// Current behavior: only gets first line of multi-line value
106+
expect(result.SINGLE_LINE).toBe("This is fine")
107+
expect(result.MULTI_LINE).toBe("This is line 1")
108+
// The other lines are lost/treated as malformed
109+
expect(result["This is line 2"]).toBeUndefined()
110+
expect(result.NEXT_KEY).toBe("value")
111+
})
112+
113+
test("should handle escaped newlines in values", () => {
114+
const content = `KEY_WITH_ESCAPED_NEWLINE="Line 1\\nLine 2\\nLine 3"
115+
REGULAR_KEY=regular_value`
116+
117+
const result = parseEnvContent(content)
118+
119+
// Escaped newlines are preserved as literal \n
120+
expect(result.KEY_WITH_ESCAPED_NEWLINE).toBe("Line 1\\nLine 2\\nLine 3")
121+
expect(result.REGULAR_KEY).toBe("regular_value")
122+
})
123+
124+
test("should handle values with unmatched quotes", () => {
125+
const content = `GOOD_KEY="proper quotes"
126+
BAD_KEY="unmatched quote
127+
NEXT_KEY=value`
128+
129+
const result = parseEnvContent(content)
130+
131+
expect(result.GOOD_KEY).toBe("proper quotes")
132+
// Current behavior: strips the opening quote even if unmatched
133+
expect(result.BAD_KEY).toBe("unmatched quote")
134+
expect(result.NEXT_KEY).toBe("value")
135+
})
136+
137+
test("should handle backtick quotes (not supported)", () => {
138+
const content = 'KEY1=`backtick value`\nKEY2="double quotes"'
139+
140+
const result = parseEnvContent(content)
141+
142+
// Backticks are not treated as quotes
143+
expect(result.KEY1).toBe("`backtick value`")
144+
expect(result.KEY2).toBe("double quotes")
145+
})
146+
})
147+
148+
describe("mergeEnvContent", () => {
149+
test("should append only new keys", () => {
150+
const existing = `KEY1=value1`
151+
const newContent = `KEY2=value2`
152+
const result = mergeEnvContent(existing, newContent)
153+
expect(result).toBe(`KEY1=value1
154+
155+
KEY2=value2
156+
`)
157+
})
158+
159+
test("should preserve existing values and NOT overwrite them", () => {
160+
const existing = `KEY1=existing_value
161+
KEY2=value2`
162+
const newContent = `KEY1=new_value_should_be_ignored
163+
KEY3=value3`
164+
const result = mergeEnvContent(existing, newContent)
165+
expect(result).toBe(`KEY1=existing_value
166+
KEY2=value2
167+
168+
KEY3=value3
169+
`)
170+
171+
expect(result).toContain("KEY1=existing_value")
172+
expect(result).not.toContain("KEY1=new_value_should_be_ignored")
173+
})
174+
175+
test("should handle empty existing content", () => {
176+
const existing = ""
177+
const newContent = "KEY1=value1"
178+
const result = mergeEnvContent(existing, newContent)
179+
expect(result).toBe(`KEY1=value1
180+
`)
181+
})
182+
183+
test("should not add any content if all keys already exist", () => {
184+
const existing = `KEY1=value1
185+
KEY2=value2`
186+
const newContent = `KEY1=ignored
187+
KEY2=ignored`
188+
const result = mergeEnvContent(existing, newContent)
189+
190+
expect(result).toBe(`KEY1=value1
191+
KEY2=value2
192+
`)
193+
})
194+
195+
test("should return unchanged content when all keys exist and formatting is correct", () => {
196+
const existing = `KEY1=value1
197+
KEY2=value2
198+
`
199+
const newContent = `KEY1=ignored
200+
KEY2=ignored`
201+
const result = mergeEnvContent(existing, newContent)
202+
203+
expect(result).toBe(existing)
204+
})
205+
206+
test("should handle existing content with comments", () => {
207+
const existing = `# Production configuration
208+
KEY1=value1
209+
# API Keys
210+
KEY2=value2`
211+
const newContent = `KEY3=value3
212+
KEY1=should_be_ignored`
213+
const result = mergeEnvContent(existing, newContent)
214+
215+
expect(result).toBe(`# Production configuration
216+
KEY1=value1
217+
# API Keys
218+
KEY2=value2
219+
220+
KEY3=value3
221+
`)
222+
})
223+
224+
test("should maintain proper formatting", () => {
225+
const existing = `KEY1=value1
226+
KEY2=value2
227+
`
228+
const newContent = `KEY3=value3`
229+
const result = mergeEnvContent(existing, newContent)
230+
231+
expect(result).toBe(`KEY1=value1
232+
KEY2=value2
233+
234+
KEY3=value3
235+
`)
236+
})
237+
238+
test("should handle multiple new keys", () => {
239+
const existing = `KEY1=value1`
240+
const newContent = `KEY2=value2
241+
KEY3=value3
242+
KEY4=value4`
243+
const result = mergeEnvContent(existing, newContent)
244+
245+
expect(result).toBe(`KEY1=value1
246+
247+
KEY2=value2
248+
KEY3=value3
249+
KEY4=value4
250+
`)
251+
})
252+
253+
test("should handle multi-line values in merge (current limitation)", () => {
254+
const existing = `EXISTING_KEY=existing_value`
255+
const newContent = `MULTI_LINE_KEY="Line 1
256+
Line 2
257+
Line 3"
258+
SIMPLE_KEY=simple`
259+
260+
const result = mergeEnvContent(existing, newContent)
261+
262+
// Current behavior: only the first line is added
263+
expect(result).toBe(`EXISTING_KEY=existing_value
264+
265+
MULTI_LINE_KEY=Line 1
266+
SIMPLE_KEY=simple
267+
`)
268+
269+
// The multi-line value is broken
270+
expect(result).not.toContain("Line 2")
271+
expect(result).not.toContain("Line 3")
272+
})
273+
})
274+
275+
describe("getNewEnvKeys", () => {
276+
test("should identify new keys", () => {
277+
const existing = `KEY1=value1
278+
KEY2=value2`
279+
const newContent = `KEY1=ignored
280+
KEY3=value3
281+
KEY4=value4`
282+
283+
const result = getNewEnvKeys(existing, newContent)
284+
expect(result).toEqual(["KEY3", "KEY4"])
285+
})
286+
287+
test("should return empty array when all keys exist", () => {
288+
const existing = `KEY1=value1
289+
KEY2=value2`
290+
const newContent = `KEY1=different
291+
KEY2=different`
292+
293+
const result = getNewEnvKeys(existing, newContent)
294+
expect(result).toEqual([])
295+
})
296+
297+
test("should handle empty existing content", () => {
298+
const existing = ""
299+
const newContent = `KEY1=value1
300+
KEY2=value2`
301+
302+
const result = getNewEnvKeys(existing, newContent)
303+
expect(result).toEqual(["KEY1", "KEY2"])
304+
})
305+
})
306+
307+
describe("findExistingEnvFile", () => {
308+
beforeEach(() => {
309+
vi.clearAllMocks()
310+
})
311+
312+
test("should return .env if it exists", () => {
313+
vi.mocked(existsSync).mockImplementation((path) => {
314+
const pathStr = typeof path === "string" ? path : path.toString()
315+
return pathStr.endsWith(".env")
316+
})
317+
318+
const result = findExistingEnvFile("/test/dir")
319+
expect(result).toBe("/test/dir/.env")
320+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
321+
expect(existsSync).toHaveBeenCalledTimes(1)
322+
})
323+
324+
test("should return .env.local if .env doesn't exist", () => {
325+
vi.mocked(existsSync).mockImplementation((path) => {
326+
const pathStr = typeof path === "string" ? path : path.toString()
327+
return pathStr.endsWith(".env.local")
328+
})
329+
330+
const result = findExistingEnvFile("/test/dir")
331+
expect(result).toBe("/test/dir/.env.local")
332+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
333+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
334+
expect(existsSync).toHaveBeenCalledTimes(2)
335+
})
336+
337+
test("should return .env.development.local if earlier variants don't exist", () => {
338+
vi.mocked(existsSync).mockImplementation((path) => {
339+
const pathStr = typeof path === "string" ? path : path.toString()
340+
return pathStr.endsWith(".env.development.local")
341+
})
342+
343+
const result = findExistingEnvFile("/test/dir")
344+
expect(result).toBe("/test/dir/.env.development.local")
345+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
346+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
347+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
348+
expect(existsSync).toHaveBeenCalledTimes(3)
349+
})
350+
351+
test("should return null if no env files exist", () => {
352+
vi.mocked(existsSync).mockReturnValue(false)
353+
354+
const result = findExistingEnvFile("/test/dir")
355+
expect(result).toBeNull()
356+
expect(existsSync).toHaveBeenCalledTimes(4)
357+
})
358+
359+
test("should check all variants in correct order", () => {
360+
vi.mocked(existsSync).mockReturnValue(false)
361+
362+
findExistingEnvFile("/test/dir")
363+
364+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
365+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
366+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
367+
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development")
368+
})
369+
})

0 commit comments

Comments
 (0)