feat(shadcn): add envVars to schema (#7902)

* feat(shadcn): add envVars to schema

* fix(shadcn): tests

* chore: changeset
This commit is contained in:
shadcn
2025-07-28 12:14:46 +04:00
committed by GitHub
parent 97a8de1c1b
commit e6778dee87
9 changed files with 350 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add support for envVars in schema

View File

@@ -191,6 +191,13 @@
] ]
} }
}, },
"envVars": {
"type": "object",
"description": "Environment variables required by the registry item. Key-value pairs that will be added to the project's .env file.",
"additionalProperties": {
"type": "string"
}
},
"meta": { "meta": {
"type": "object", "type": "object",
"description": "Additional metadata for the registry item. This is an object with any key value pairs.", "description": "Additional metadata for the registry item. This is an object with any key value pairs.",

View File

@@ -476,7 +476,12 @@ export async function registryResolveItemsTree(
} }
}) })
return registryResolvedItemsTreeSchema.parse({ let envVars = {}
payload.forEach((item) => {
envVars = deepmerge(envVars, item.envVars ?? {})
})
const parsed = registryResolvedItemsTreeSchema.parse({
dependencies: deepmerge.all( dependencies: deepmerge.all(
payload.map((item) => item.dependencies ?? []) payload.map((item) => item.dependencies ?? [])
), ),
@@ -489,6 +494,12 @@ export async function registryResolveItemsTree(
css, css,
docs, docs,
}) })
if (Object.keys(envVars).length > 0) {
parsed.envVars = envVars
}
return parsed
} catch (error) { } catch (error) {
handleError(error) handleError(error)
return null return null

View File

@@ -65,6 +65,8 @@ export const registryItemCssSchema = z.record(
) )
) )
export const registryItemEnvVarsSchema = z.record(z.string(), z.string())
export const registryItemSchema = z.object({ export const registryItemSchema = z.object({
$schema: z.string().optional(), $schema: z.string().optional(),
extends: z.string().optional(), extends: z.string().optional(),
@@ -80,6 +82,7 @@ export const registryItemSchema = z.object({
tailwind: registryItemTailwindSchema.optional(), tailwind: registryItemTailwindSchema.optional(),
cssVars: registryItemCssVarsSchema.optional(), cssVars: registryItemCssVarsSchema.optional(),
css: registryItemCssSchema.optional(), css: registryItemCssSchema.optional(),
envVars: registryItemEnvVarsSchema.optional(),
meta: z.record(z.string(), z.any()).optional(), meta: z.record(z.string(), z.any()).optional(),
docs: z.string().optional(), docs: z.string().optional(),
categories: z.array(z.string()).optional(), categories: z.array(z.string()).optional(),
@@ -127,5 +130,6 @@ export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
tailwind: true, tailwind: true,
cssVars: true, cssVars: true,
css: true, css: true,
envVars: true,
docs: true, docs: true,
}) })

View File

@@ -27,6 +27,7 @@ import { spinner } from "@/src/utils/spinner"
import { updateCss } from "@/src/utils/updaters/update-css" import { updateCss } from "@/src/utils/updaters/update-css"
import { updateCssVars } from "@/src/utils/updaters/update-css-vars" import { updateCssVars } from "@/src/utils/updaters/update-css-vars"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies" import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import { updateEnvVars } from "@/src/utils/updaters/update-env-vars"
import { updateFiles } from "@/src/utils/updaters/update-files" import { updateFiles } from "@/src/utils/updaters/update-files"
import { updateTailwindConfig } from "@/src/utils/updaters/update-tailwind-config" import { updateTailwindConfig } from "@/src/utils/updaters/update-tailwind-config"
import { z } from "zod" import { z } from "zod"
@@ -116,6 +117,10 @@ async function addProjectComponents(
silent: options.silent, silent: options.silent,
}) })
await updateEnvVars(tree.envVars, config, {
silent: options.silent,
})
await updateDependencies(tree.dependencies, tree.devDependencies, config, { await updateDependencies(tree.dependencies, tree.devDependencies, config, {
silent: options.silent, silent: options.silent,
}) })
@@ -232,7 +237,14 @@ async function addWorkspaceComponents(
) )
} }
// 4. Update dependencies. // 4. Update environment variables
if (component.envVars) {
await updateEnvVars(component.envVars, targetConfig, {
silent: true,
})
}
// 5. Update dependencies.
await updateDependencies( await updateDependencies(
component.dependencies, component.dependencies,
component.devDependencies, component.devDependencies,
@@ -242,7 +254,7 @@ async function addWorkspaceComponents(
} }
) )
// 5. Update files. // 6. Update files.
const files = await updateFiles(component.files, targetConfig, { const files = await updateFiles(component.files, targetConfig, {
overwrite: options.overwrite, overwrite: options.overwrite,
silent: true, silent: true,

View File

@@ -309,19 +309,7 @@ describe("findExistingEnvFile", () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
test("should return .env if it exists", () => { test("should return .env.local if it exists", () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env")
})
const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledTimes(1)
})
test("should return .env.local if .env doesn't exist", () => {
vi.mocked(existsSync).mockImplementation((path) => { vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString() const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env.local") return pathStr.endsWith(".env.local")
@@ -329,8 +317,20 @@ describe("findExistingEnvFile", () => {
const result = findExistingEnvFile("/test/dir") const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env.local") expect(result).toBe("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local") expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledTimes(1)
})
test("should return .env if .env.local doesn't exist", () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env")
})
const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledTimes(2) expect(existsSync).toHaveBeenCalledTimes(2)
}) })
@@ -342,8 +342,8 @@ describe("findExistingEnvFile", () => {
const result = findExistingEnvFile("/test/dir") const result = findExistingEnvFile("/test/dir")
expect(result).toBe("/test/dir/.env.development.local") expect(result).toBe("/test/dir/.env.development.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local") expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local") expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
expect(existsSync).toHaveBeenCalledTimes(3) expect(existsSync).toHaveBeenCalledTimes(3)
}) })
@@ -361,8 +361,8 @@ describe("findExistingEnvFile", () => {
findExistingEnvFile("/test/dir") findExistingEnvFile("/test/dir")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local") expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local") expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development") expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development")
}) })

View File

@@ -12,8 +12,8 @@ export function isEnvFile(filePath: string) {
*/ */
export function findExistingEnvFile(targetDir: string) { export function findExistingEnvFile(targetDir: string) {
const variants = [ const variants = [
".env",
".env.local", ".env.local",
".env",
".env.development.local", ".env.development.local",
".env.development", ".env.development",
] ]

View File

@@ -0,0 +1,183 @@
import { existsSync, promises as fs } from "fs"
import type { Config } from "@/src/utils/get-config"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
import { updateEnvVars } from "./update-env-vars"
vi.mock("fs", () => ({
existsSync: vi.fn(),
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
info: vi.fn(),
log: vi.fn(),
success: vi.fn(),
break: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn(),
succeed: vi.fn(),
})),
}))
const mockConfig: Config = {
style: "default",
rsc: false,
tailwind: {
config: "tailwind.config.js",
css: "app/globals.css",
baseColor: "slate",
prefix: "",
cssVariables: false,
},
tsx: true,
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
utils: "@/utils",
},
resolvedPaths: {
cwd: "/test/project",
tailwindConfig: "/test/project/tailwind.config.js",
tailwindCss: "/test/project/app/globals.css",
components: "/test/project/components",
ui: "/test/project/components/ui",
lib: "/test/project/lib",
hooks: "/test/project/hooks",
utils: "/test/project/utils",
},
}
describe("updateEnvVars", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.resetAllMocks()
})
test("should create new .env.local file when none exists", async () => {
vi.mocked(existsSync).mockReturnValue(false)
const envVars = {
API_KEY: "test-key",
API_URL: "https://api.example.com",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(
"/test/project/.env.local",
"API_KEY=test-key\nAPI_URL=https://api.example.com\n",
"utf-8"
)
expect(result).toEqual({
envVarsAdded: ["API_KEY", "API_URL"],
envFileUpdated: null,
envFileCreated: ".env.local",
})
})
test("should update existing .env.local file with new variables", async () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue("EXISTING_KEY=existing-value\n")
const envVars = {
NEW_KEY: "new-value",
ANOTHER_KEY: "another-value",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(
"/test/project/.env.local",
"EXISTING_KEY=existing-value\n\nNEW_KEY=new-value\nANOTHER_KEY=another-value\n",
"utf-8"
)
expect(result).toEqual({
envVarsAdded: ["NEW_KEY", "ANOTHER_KEY"],
envFileUpdated: ".env.local",
envFileCreated: null,
})
})
test("should skip when all variables already exist", async () => {
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(fs.readFile).mockResolvedValue(
"API_KEY=existing-key\nAPI_URL=existing-url\n"
)
const envVars = {
API_KEY: "new-key",
API_URL: "new-url",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalled()
expect(result).toEqual({
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
})
})
test("should find and use .env.local when .env doesn't exist", async () => {
vi.mocked(existsSync).mockImplementation((path) => {
const pathStr = typeof path === "string" ? path : path.toString()
return pathStr.endsWith(".env.local")
})
vi.mocked(fs.readFile).mockResolvedValue("EXISTING_VAR=value\n")
const envVars = {
NEW_VAR: "new-value",
}
const result = await updateEnvVars(envVars, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(
"/test/project/.env.local",
"EXISTING_VAR=value\n\nNEW_VAR=new-value\n",
"utf-8"
)
expect(result).toEqual({
envVarsAdded: ["NEW_VAR"],
envFileUpdated: ".env.local",
envFileCreated: null,
})
})
test("should return early when no env vars provided", async () => {
const result = await updateEnvVars(undefined, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalled()
expect(result).toEqual({
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
})
})
test("should return early when empty env vars object provided", async () => {
const result = await updateEnvVars({}, mockConfig, { silent: true })
expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalled()
expect(result).toEqual({
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
})
})
})

View File

@@ -0,0 +1,108 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { registryItemEnvVarsSchema } from "@/src/registry/schema"
import {
findExistingEnvFile,
getNewEnvKeys,
mergeEnvContent,
} from "@/src/utils/env-helpers"
import { Config } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { z } from "zod"
export async function updateEnvVars(
envVars: z.infer<typeof registryItemEnvVarsSchema> | undefined,
config: Config,
options: {
silent?: boolean
}
) {
if (!envVars || Object.keys(envVars).length === 0) {
return {
envVarsAdded: [],
envFileUpdated: null,
envFileCreated: null,
}
}
options = {
silent: false,
...options,
}
const envSpinner = spinner(`Adding environment variables.`, {
silent: options.silent,
})?.start()
const projectRoot = config.resolvedPaths.cwd
// Find existing env file or use .env.local as default.
let envFilePath = path.join(projectRoot, ".env.local")
const existingEnvFile = findExistingEnvFile(projectRoot)
if (existingEnvFile) {
envFilePath = existingEnvFile
}
const envFileExists = existsSync(envFilePath)
const envFileName = path.basename(envFilePath)
// Convert envVars object to env file format
const newEnvContent = Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join("\n")
let envVarsAdded: string[] = []
let envFileUpdated: string | null = null
let envFileCreated: string | null = null
if (envFileExists) {
const existingContent = await fs.readFile(envFilePath, "utf-8")
const mergedContent = mergeEnvContent(existingContent, newEnvContent)
envVarsAdded = getNewEnvKeys(existingContent, newEnvContent)
if (envVarsAdded.length > 0) {
await fs.writeFile(envFilePath, mergedContent, "utf-8")
envFileUpdated = path.relative(projectRoot, envFilePath)
envSpinner?.succeed(
`Added the following variables to ${highlighter.info(envFileName)}:`
)
if (!options.silent) {
for (const key of envVarsAdded) {
logger.log(` ${highlighter.success("+")} ${key}`)
}
}
} else {
envSpinner?.stop()
}
} else {
// Create new env file
await fs.writeFile(envFilePath, newEnvContent + "\n", "utf-8")
envFileCreated = path.relative(projectRoot, envFilePath)
envVarsAdded = Object.keys(envVars)
envSpinner?.succeed(
`Added the following variables to ${highlighter.info(envFileName)}:`
)
if (!options.silent) {
for (const key of envVarsAdded) {
logger.log(` ${highlighter.success("+")} ${key}`)
}
}
}
if (!options.silent && envVarsAdded.length > 0) {
logger.break()
}
return {
envVarsAdded,
envFileUpdated,
envFileCreated,
}
}