diff --git a/.changeset/chilled-comics-beam.md b/.changeset/chilled-comics-beam.md new file mode 100644 index 000000000..178ffa00a --- /dev/null +++ b/.changeset/chilled-comics-beam.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add support for envVars in schema diff --git a/apps/v4/public/schema/registry-item.json b/apps/v4/public/schema/registry-item.json index b343f77d1..7036e44ab 100644 --- a/apps/v4/public/schema/registry-item.json +++ b/apps/v4/public/schema/registry-item.json @@ -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.", diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index 452f9b42b..af2291c43 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -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 diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts index 3b3773fd7..272663e45 100644 --- a/packages/shadcn/src/registry/schema.ts +++ b/packages/shadcn/src/registry/schema.ts @@ -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, }) diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index 50507154a..dca9280ff 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -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, diff --git a/packages/shadcn/src/utils/env-helpers.test.ts b/packages/shadcn/src/utils/env-helpers.test.ts index 20a677a6b..59025d269 100644 --- a/packages/shadcn/src/utils/env-helpers.test.ts +++ b/packages/shadcn/src/utils/env-helpers.test.ts @@ -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") }) diff --git a/packages/shadcn/src/utils/env-helpers.ts b/packages/shadcn/src/utils/env-helpers.ts index 82b3d1e91..25836145d 100644 --- a/packages/shadcn/src/utils/env-helpers.ts +++ b/packages/shadcn/src/utils/env-helpers.ts @@ -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", ] diff --git a/packages/shadcn/src/utils/updaters/update-env-vars.test.ts b/packages/shadcn/src/utils/updaters/update-env-vars.test.ts new file mode 100644 index 000000000..ff761a16c --- /dev/null +++ b/packages/shadcn/src/utils/updaters/update-env-vars.test.ts @@ -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, + }) + }) +}) diff --git a/packages/shadcn/src/utils/updaters/update-env-vars.ts b/packages/shadcn/src/utils/updaters/update-env-vars.ts new file mode 100644 index 000000000..5a0eaf845 --- /dev/null +++ b/packages/shadcn/src/utils/updaters/update-env-vars.ts @@ -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 | 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, + } +}