mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-02-09 02:49:29 +08:00
feat(shadcn): add envVars to schema (#7902)
* feat(shadcn): add envVars to schema * fix(shadcn): tests * chore: changeset
This commit is contained in:
5
.changeset/chilled-comics-beam.md
Normal file
5
.changeset/chilled-comics-beam.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
add support for envVars in schema
|
||||
@@ -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": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata for the registry item. This is an object with any key value pairs.",
|
||||
|
||||
@@ -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(
|
||||
payload.map((item) => item.dependencies ?? [])
|
||||
),
|
||||
@@ -489,6 +494,12 @@ export async function registryResolveItemsTree(
|
||||
css,
|
||||
docs,
|
||||
})
|
||||
|
||||
if (Object.keys(envVars).length > 0) {
|
||||
parsed.envVars = envVars
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return null
|
||||
|
||||
@@ -65,6 +65,8 @@ export const registryItemCssSchema = z.record(
|
||||
)
|
||||
)
|
||||
|
||||
export const registryItemEnvVarsSchema = z.record(z.string(), z.string())
|
||||
|
||||
export const registryItemSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
extends: z.string().optional(),
|
||||
@@ -80,6 +82,7 @@ export const registryItemSchema = z.object({
|
||||
tailwind: registryItemTailwindSchema.optional(),
|
||||
cssVars: registryItemCssVarsSchema.optional(),
|
||||
css: registryItemCssSchema.optional(),
|
||||
envVars: registryItemEnvVarsSchema.optional(),
|
||||
meta: z.record(z.string(), z.any()).optional(),
|
||||
docs: z.string().optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
@@ -127,5 +130,6 @@ export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
|
||||
tailwind: true,
|
||||
cssVars: true,
|
||||
css: true,
|
||||
envVars: true,
|
||||
docs: true,
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import { spinner } from "@/src/utils/spinner"
|
||||
import { updateCss } from "@/src/utils/updaters/update-css"
|
||||
import { updateCssVars } from "@/src/utils/updaters/update-css-vars"
|
||||
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 { updateTailwindConfig } from "@/src/utils/updaters/update-tailwind-config"
|
||||
import { z } from "zod"
|
||||
@@ -116,6 +117,10 @@ async function addProjectComponents(
|
||||
silent: options.silent,
|
||||
})
|
||||
|
||||
await updateEnvVars(tree.envVars, config, {
|
||||
silent: options.silent,
|
||||
})
|
||||
|
||||
await updateDependencies(tree.dependencies, tree.devDependencies, config, {
|
||||
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(
|
||||
component.dependencies,
|
||||
component.devDependencies,
|
||||
@@ -242,7 +254,7 @@ async function addWorkspaceComponents(
|
||||
}
|
||||
)
|
||||
|
||||
// 5. Update files.
|
||||
// 6. Update files.
|
||||
const files = await updateFiles(component.files, targetConfig, {
|
||||
overwrite: options.overwrite,
|
||||
silent: true,
|
||||
|
||||
@@ -309,19 +309,7 @@ describe("findExistingEnvFile", () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("should return .env 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", () => {
|
||||
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.local")
|
||||
@@ -329,8 +317,20 @@ describe("findExistingEnvFile", () => {
|
||||
|
||||
const result = findExistingEnvFile("/test/dir")
|
||||
expect(result).toBe("/test/dir/.env.local")
|
||||
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -342,8 +342,8 @@ describe("findExistingEnvFile", () => {
|
||||
|
||||
const result = findExistingEnvFile("/test/dir")
|
||||
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")
|
||||
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env.development.local")
|
||||
expect(existsSync).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -361,8 +361,8 @@ describe("findExistingEnvFile", () => {
|
||||
|
||||
findExistingEnvFile("/test/dir")
|
||||
|
||||
expect(existsSync).toHaveBeenCalledWith("/test/dir/.env")
|
||||
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")
|
||||
})
|
||||
|
||||
@@ -12,8 +12,8 @@ export function isEnvFile(filePath: string) {
|
||||
*/
|
||||
export function findExistingEnvFile(targetDir: string) {
|
||||
const variants = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env",
|
||||
".env.development.local",
|
||||
".env.development",
|
||||
]
|
||||
|
||||
183
packages/shadcn/src/utils/updaters/update-env-vars.test.ts
Normal file
183
packages/shadcn/src/utils/updaters/update-env-vars.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
108
packages/shadcn/src/utils/updaters/update-env-vars.ts
Normal file
108
packages/shadcn/src/utils/updaters/update-env-vars.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user