From 296feb28a220627958f3a558dcb2384875c2d7a1 Mon Sep 17 00:00:00 2001 From: shadcn Date: Wed, 13 Aug 2025 11:15:26 +0400 Subject: [PATCH] feat(shadcn): new mcp server (#8012) * feat(shadcn): add getRegistriesConfig api * feat(shadcn): add new mcp command * feat(shadcn): add get_item_examples_from_registries and get_add_command_for_items * feat: remove getRegistriesConfig * chore: changeset --- .changeset/chatty-windows-stop.md | 5 + packages/shadcn/package.json | 4 +- packages/shadcn/src/commands/mcp.ts | 23 + packages/shadcn/src/commands/registry/mcp.ts | 21 +- packages/shadcn/src/commands/search.ts | 1 + packages/shadcn/src/index.ts | 3 +- packages/shadcn/src/mcp/index.ts | 472 +++++++++++++------ packages/shadcn/src/mcp/utils.ts | 132 ++++++ packages/shadcn/src/registry/api.test.ts | 465 +++++++++++++++++- packages/shadcn/src/registry/api.ts | 48 +- packages/shadcn/src/registry/errors.ts | 35 ++ packages/shadcn/src/registry/schema.ts | 18 + packages/shadcn/src/registry/search.ts | 19 +- packages/shadcn/src/utils/get-config.ts | 2 +- pnpm-lock.yaml | 17 +- 15 files changed, 1076 insertions(+), 189 deletions(-) create mode 100644 .changeset/chatty-windows-stop.md create mode 100644 packages/shadcn/src/commands/mcp.ts create mode 100644 packages/shadcn/src/mcp/utils.ts diff --git a/.changeset/chatty-windows-stop.md b/.changeset/chatty-windows-stop.md new file mode 100644 index 0000000000..21b316f1d6 --- /dev/null +++ b/.changeset/chatty-windows-stop.md @@ -0,0 +1,5 @@ +--- +"shadcn": major +--- + +add new mcp server and command diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 6f7995ebdb..24e395d198 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -60,7 +60,8 @@ "pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next", "pub:release": "pnpm build && pnpm publish --access public", "test": "vitest run", - "test:dev": "REGISTRY_URL=http://localhost:4000/r vitest run" + "test:dev": "REGISTRY_URL=http://localhost:4000/r vitest run", + "mcp:inspect": "pnpm dlx @modelcontextprotocol/inspector node dist/index.js mcp" }, "dependencies": { "@antfu/ni": "^25.0.0", @@ -71,6 +72,7 @@ "@modelcontextprotocol/sdk": "^1.17.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", diff --git a/packages/shadcn/src/commands/mcp.ts b/packages/shadcn/src/commands/mcp.ts new file mode 100644 index 0000000000..36860b15bb --- /dev/null +++ b/packages/shadcn/src/commands/mcp.ts @@ -0,0 +1,23 @@ +import { server } from "@/src/mcp" +import { handleError } from "@/src/utils/handle-error" +import { logger } from "@/src/utils/logger" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { Command } from "commander" + +export const mcp = new Command() + .name("mcp") + .description("starts the registry MCP server") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd() + ) + .action(async () => { + try { + const transport = new StdioServerTransport() + await server.connect(transport) + } catch (error) { + logger.break() + handleError(error) + } + }) diff --git a/packages/shadcn/src/commands/registry/mcp.ts b/packages/shadcn/src/commands/registry/mcp.ts index 78b829dd63..9e46c296fb 100644 --- a/packages/shadcn/src/commands/registry/mcp.ts +++ b/packages/shadcn/src/commands/registry/mcp.ts @@ -1,23 +1,22 @@ -import { server } from "@/src/mcp" -import { handleError } from "@/src/utils/handle-error" +import { highlighter } from "@/src/utils/highlighter" import { logger } from "@/src/utils/logger" -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { Command } from "commander" export const mcp = new Command() .name("registry:mcp") - .description("starts the registry MCP server [EXPERIMENTAL]") + .description("starts the registry MCP server [DEPRECATED]") .option( "-c, --cwd ", "the working directory. defaults to the current directory.", process.cwd() ) .action(async () => { - try { - const transport = new StdioServerTransport() - await server.connect(transport) - } catch (error) { - logger.break() - handleError(error) - } + logger.warn( + `The ${highlighter.info( + "shadcn registry:mcp" + )} command is deprecated. Use the ${highlighter.info( + "shadcn mcp" + )} command instead.` + ) + logger.break() }) diff --git a/packages/shadcn/src/commands/search.ts b/packages/shadcn/src/commands/search.ts index 31cfbb3a50..f224a88363 100644 --- a/packages/shadcn/src/commands/search.ts +++ b/packages/shadcn/src/commands/search.ts @@ -23,6 +23,7 @@ const searchOptionsSchema = z.object({ export const search = new Command() .name("search") + .alias("list") .description("search items from registries") .argument( "", diff --git a/packages/shadcn/src/index.ts b/packages/shadcn/src/index.ts index 227d6e9cc9..d64c3d02f8 100644 --- a/packages/shadcn/src/index.ts +++ b/packages/shadcn/src/index.ts @@ -4,6 +4,7 @@ import { build } from "@/src/commands/build" import { diff } from "@/src/commands/diff" import { info } from "@/src/commands/info" import { init } from "@/src/commands/init" +import { mcp } from "@/src/commands/mcp" import { migrate } from "@/src/commands/migrate" import { build as registryBuild } from "@/src/commands/registry/build" import { mcp as registryMcp } from "@/src/commands/registry/mcp" @@ -35,7 +36,7 @@ async function main() { .addCommand(migrate) .addCommand(info) .addCommand(build) - + .addCommand(mcp) // Registry commands program.addCommand(registryBuild).addCommand(registryMcp) diff --git a/packages/shadcn/src/mcp/index.ts b/packages/shadcn/src/mcp/index.ts index f9dae85191..e722ce72a1 100644 --- a/packages/shadcn/src/mcp/index.ts +++ b/packages/shadcn/src/mcp/index.ts @@ -1,18 +1,26 @@ -import { getRegistryItems } from "@/src/registry/api" -import { fetchRegistry } from "@/src/registry/fetcher" -import { registrySchema } from "@/src/schema" +import { getRegistryItems, searchRegistries } from "@/src/registry" +import { RegistryError } from "@/src/registry/errors" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js" +import dedent from "dedent" import { z } from "zod" import { zodToJsonSchema } from "zod-to-json-schema" +import { + formatItemExamples, + formatRegistryItems, + formatSearchResultsWithPagination, + getMcpConfig, + npxShadcn, +} from "./utils" + export const server = new Server( { name: "shadcn", - version: "0.0.1", + version: "1.0.0", }, { capabilities: { @@ -26,38 +34,103 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { - name: "init", + name: "get_project_registries", description: - "Initialize a new project using a registry style project structure.", + "Get configured registry names from components.json - Returns error if no components.json exists (use init_project to create one)", inputSchema: zodToJsonSchema(z.object({})), }, { - name: "get_items", - description: "List all the available items in the registry", - inputSchema: zodToJsonSchema(z.object({})), - }, - { - name: "get_item", - description: "Get an item from the registry", + name: "list_items_in_registries", + description: + "List items from registries (requires components.json - use init_project if missing)", inputSchema: zodToJsonSchema( z.object({ - name: z + registries: z + .array(z.string()) + .describe( + "Array of registry names to search (e.g., ['@shadcn', '@acme'])" + ), + limit: z + .number() + .optional() + .describe("Maximum number of items to return"), + offset: z + .number() + .optional() + .describe("Number of items to skip for pagination"), + }) + ), + }, + { + name: "search_items_in_registries", + description: + "Search for components in registries using fuzzy matching (requires components.json). After finding an item, use get_item_examples_from_registries to see usage examples.", + inputSchema: zodToJsonSchema( + z.object({ + registries: z + .array(z.string()) + .describe( + "Array of registry names to search (e.g., ['@shadcn', '@acme'])" + ), + query: z .string() .describe( - "The name of the item to get from the registry. This is required." + "Search query string for fuzzy matching against item names and descriptions" + ), + limit: z + .number() + .optional() + .describe("Maximum number of items to return"), + offset: z + .number() + .optional() + .describe("Number of items to skip for pagination"), + }) + ), + }, + { + name: "view_items_in_registries", + description: + "View detailed information about specific registry items including the name, description, type and files content. For usage examples, use get_item_examples_from_registries instead.", + inputSchema: zodToJsonSchema( + z.object({ + items: z + .array(z.string()) + .describe( + "Array of item names with registry prefix (e.g., ['@shadcn/button', '@shadcn/card'])" ), }) ), }, { - name: "add_item", - description: "Add an item from the registry", + name: "get_item_examples_from_registries", + description: + "Find usage examples and demos with their complete code. Search for patterns like 'accordion-demo', 'button example', 'card-demo', etc. Returns full implementation code with dependencies.", inputSchema: zodToJsonSchema( z.object({ - name: z + registries: z + .array(z.string()) + .describe( + "Array of registry names to search (e.g., ['@shadcn', '@acme'])" + ), + query: z .string() .describe( - "The name of the item to add to the registry. This is required." + "Search query for examples (e.g., 'accordion-demo', 'button demo', 'card example', 'tooltip-demo', 'example-booking-form', 'example-hero'). Common patterns: '{item-name}-demo', '{item-name} example', 'example {item-name}'" + ), + }) + ), + }, + { + name: "get_add_command_for_items", + description: + "Get the shadcn CLI add command for specific items in a registry. This is useful for adding one or more components to your project.", + inputSchema: zodToJsonSchema( + z.object({ + items: z + .array(z.string()) + .describe( + "Array of items to get the add command for prefixed with the registry name (e.g., ['@shadcn/button', '@shadcn/card'])" ), }) ), @@ -69,52 +142,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params.arguments) { - throw new Error("Arguments are required") - } - - const REGISTRY_URL = process.env.REGISTRY_URL - - if (!REGISTRY_URL) { - throw new Error("REGISTRY_URL is not set") + throw new Error("No tool arguments provided.") } switch (request.params.name) { - case "init": { - const registry = await getRegistry(REGISTRY_URL) - const style = registry.items.find( - (item) => item.type === "registry:style" - ) + case "get_project_registries": { + const config = await getMcpConfig(process.cwd()) - let text = `To initialize a new project, run the following command: - \`\`\`bash - npx shadcn@canary init - \`\`\` - - This will install all the dependencies and theme for the project. - - If running the init command installs a rules i.e registry.mdc file, you should follow the instructions in the file to configure the project. - ` - - const rules = registry.items.find( - (item) => item.type === "registry:file" && item.name === "rules" - ) - - if (rules) { - text += ` - You should also install the rules for the project. - \`\`\`bash - npx shadcn@canary add ${getRegistryItemUrl( - rules.name, - REGISTRY_URL - )} - \`\`\` - ` - } - - if (!style) { + if (!config?.registries) { return { content: [ { type: "text", - text, + text: dedent`No components.json found or no registries configured. + + To fix this: + 1. Use the \`init\` command to create a components.json file + 2. Or manually create components.json with a registries section`, }, ], } @@ -124,29 +168,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [ { type: "text", - text: `To initialize a new project using the ${ - style.name - } style, run the following command: - \`\`\`bash - npx shadcn@canary init ${getRegistryItemUrl( - style.name, - REGISTRY_URL - )} - \`\`\` - `, + text: dedent`The following registries are configured in the current project: + + ${Object.keys(config.registries) + .map((registry) => `- ${registry}`) + .join("\n")} + + You can view the items in a registry by running: + \`${await npxShadcn("view @name-of-registry")}\` + + For example: \`${await npxShadcn( + "view @shadcn" + )}\` or \`${await npxShadcn( + "view @shadcn @acme" + )}\` to view multiple registries. + `, }, ], } } - case "get_items": { - const registry = await getRegistry(REGISTRY_URL) - if (!registry.items) { + case "search_items_in_registries": { + const inputSchema = z.object({ + registries: z.array(z.string()), + query: z.string(), + limit: z.number().optional(), + offset: z.number().optional(), + }) + + const args = inputSchema.parse(request.params.arguments) + const results = await searchRegistries(args.registries, { + query: args.query, + limit: args.limit, + offset: args.offset, + config: await getMcpConfig(process.cwd()), + useCache: false, + }) + + if (results.items.length === 0) { return { content: [ { type: "text", - text: "No items found in the registry", + text: dedent`No items found matching "${ + args.query + }" in registries ${args.registries.join( + ", " + )}, Try searching with a different query or registry.`, }, ], } @@ -156,111 +224,213 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [ { type: "text", - text: `The following items are available in the registry: - ${JSON.stringify( - registry.items.map( - (item) => `- ${item.name} (${item.type}): ${item.description}` - ), - null, - 2 - )}. - - To install and use an item in your project, you run the following command: - \`\`\`bash - npx shadcn@canary add ${getRegistryItemUrl( - "NAME_OF_THE_ITEM", - REGISTRY_URL - )} - \`\`\` - - Example: npx shadcn@canary add ${getRegistryItemUrl( - registry.items[0].name, - REGISTRY_URL - )} to install the ${registry.items[0].name} item. - - To install multiple registry.items, you can do the following: - \`\`\`bash - npx shadcn@canary add ${getRegistryItemUrl( - "NAME_OF_THE_ITEM_1", - REGISTRY_URL - )} ${getRegistryItemUrl("NAME_OF_THE_ITEM_2", REGISTRY_URL)} - \`\`\` - - Before using any item, you need to add it first. - - Adding the items will install all dependencies for the item and format the code as per the project. - - Example components should not be installed directly unless asked. These components should be used as a reference to build other components. - `, + text: formatSearchResultsWithPagination(results, { + query: args.query, + registries: args.registries, + }), }, ], } } - case "get_item": { - const name = z.string().parse(request.params.arguments.name) + case "list_items_in_registries": { + const inputSchema = z.object({ + registries: z.array(z.string()), + limit: z.number().optional(), + offset: z.number().optional(), + cwd: z.string().optional(), + }) - if (!name) { - throw new Error("Name is required") + const args = inputSchema.parse(request.params.arguments) + const results = await searchRegistries(args.registries, { + limit: args.limit, + offset: args.offset, + config: await getMcpConfig(process.cwd()), + useCache: false, + }) + + if (results.items.length === 0) { + return { + content: [ + { + type: "text", + text: dedent`No items found in registries ${args.registries.join( + ", " + )}.`, + }, + ], + } } - const [item] = await getRegistryItems([name], { + return { + content: [ + { + type: "text", + text: formatSearchResultsWithPagination(results, { + registries: args.registries, + }), + }, + ], + } + } + + case "view_items_in_registries": { + const inputSchema = z.object({ + items: z.array(z.string()), + }) + + const args = inputSchema.parse(request.params.arguments) + const registryItems = await getRegistryItems(args.items, { + config: await getMcpConfig(process.cwd()), + useCache: false, + }) + + if (registryItems?.length === 0) { + return { + content: [ + { + type: "text", + text: dedent`No items found for: ${args.items.join(", ")} + + Make sure the item names are correct and include the registry prefix (e.g., @shadcn/button).`, + }, + ], + } + } + + const formattedItems = formatRegistryItems(registryItems) + + return { + content: [ + { + type: "text", + text: dedent`Item Details: + + ${formattedItems.join("\n\n---\n\n")}`, + }, + ], + } + } + + case "get_item_examples_from_registries": { + const inputSchema = z.object({ + query: z.string(), + registries: z.array(z.string()), + }) + + const args = inputSchema.parse(request.params.arguments) + const config = await getMcpConfig() + + const results = await searchRegistries(args.registries, { + query: args.query, + config, + useCache: false, + }) + + if (results.items.length === 0) { + return { + content: [ + { + type: "text", + text: dedent`No examples found for query "${args.query}". + + Try searching with patterns like: + - "accordion-demo" for accordion examples + - "button demo" or "button example" + - Component name followed by "-demo" or "example" + + You can also: + 1. Use search_items_in_registries to find all items matching your query + 2. View the main component with view_items_in_registries for inline usage documentation`, + }, + ], + } + } + + const itemNames = results.items.map((item) => item.addCommandArgument) + const fullItems = await getRegistryItems(itemNames, { + config, useCache: false, }) - return { - content: [{ type: "text", text: JSON.stringify(item, null, 2) }], - } - } - - case "add_item": { - const name = z.string().parse(request.params.arguments.name) - - if (!name) { - throw new Error("Name is required") - } - - const itemUrl = getRegistryItemUrl(name, REGISTRY_URL) - const item = await getRegistryItems([itemUrl], { - useCache: false, - }) - - if (!item) { - return { - content: [ - { - type: "text", - text: `Item ${name} not found in the registry.`, - }, - ], - } - } return { content: [ { type: "text", - text: `To install the ${name} item, run the following command: - \`\`\`bash - npx shadcn@canary add ${itemUrl} - \`\`\``, + text: formatItemExamples(fullItems, args.query), }, ], } } + + case "get_add_command_for_items": { + const args = z + .object({ + items: z.array(z.string()), + }) + .parse(request.params.arguments) + + return { + content: [ + { + type: "text", + text: await npxShadcn(`add ${args.items.join(" ")}`), + }, + ], + } + } + default: throw new Error(`Tool ${request.params.name} not found`) } } catch (error) { if (error instanceof z.ZodError) { - throw new Error(`Invalid input: ${JSON.stringify(error.errors)}`) + return { + content: [ + { + type: "text", + text: dedent`Invalid input parameters: + ${error.errors + .map((e) => `- ${e.path.join(".")}: ${e.message}`) + .join("\n")} + `, + }, + ], + isError: true, + } } - throw error + if (error instanceof RegistryError) { + let errorMessage = error.message + + if (error.suggestion) { + errorMessage += `\n\n💡 ${error.suggestion}` + } + + if (error.context) { + errorMessage += `\n\nContext: ${JSON.stringify(error.context, null, 2)}` + } + + return { + content: [ + { + type: "text", + text: dedent`Error (${error.code}): ${errorMessage}`, + }, + ], + isError: true, + } + } + + const errorMessage = error instanceof Error ? error.message : String(error) + return { + content: [ + { + type: "text", + text: dedent`Error: ${errorMessage}`, + }, + ], + isError: true, + } } }) - -async function getRegistry(registryUrl: string) { - const [registryJson] = await fetchRegistry([registryUrl], { - useCache: false, - }) - return registrySchema.parse(registryJson) -} - -function getRegistryItemUrl(itemName: string, registryUrl: string) { - const registryBaseUrl = registryUrl.replace(/\/registry\.json$/, "") - return `${registryBaseUrl}/${itemName}.json` -} diff --git a/packages/shadcn/src/mcp/utils.ts b/packages/shadcn/src/mcp/utils.ts new file mode 100644 index 0000000000..1a2322ec0c --- /dev/null +++ b/packages/shadcn/src/mcp/utils.ts @@ -0,0 +1,132 @@ +import { getRegistriesConfig } from "@/src/registry/api" +import { registryItemSchema, searchResultsSchema } from "@/src/schema" +import { getPackageRunner } from "@/src/utils/get-package-manager" +import { z } from "zod" + +const SHADCN_CLI_COMMAND = "shadcn@beta" + +export async function npxShadcn(command: string) { + const packageRunner = await getPackageRunner(process.cwd()) + return `${packageRunner} ${SHADCN_CLI_COMMAND} ${command}` +} + +export async function getMcpConfig(cwd = process.cwd()) { + const config = await getRegistriesConfig(cwd, { + useCache: false, + }) + + return { + registries: config.registries, + } +} + +export function formatSearchResultsWithPagination( + results: z.infer, + options?: { + query?: string + registries?: string[] + } +) { + const { query, registries } = options || {} + + const formattedItems = results.items.map((item) => { + const parts: string[] = [`- ${item.name}`] + + if (item.type) { + parts.push(`(${item.type})`) + } + + if (item.description) { + parts.push(`- ${item.description}`) + } + + if (item.registry) { + parts.push(`[${item.registry}]`) + } + + parts.push( + `\n Add command: \`${npxShadcn(`add ${item.addCommandArgument}`)}\`` + ) + + return parts.join(" ") + }) + + let header = `Found ${results.pagination.total} items` + if (query) { + header += ` matching "${query}"` + } + if (registries && registries.length > 0) { + header += ` in registries ${registries.join(", ")}` + } + header += ":" + + const showingRange = `Showing items ${ + results.pagination.offset + 1 + }-${Math.min( + results.pagination.offset + results.pagination.limit, + results.pagination.total + )} of ${results.pagination.total}:` + + let output = `${header}\n\n${showingRange}\n\n${formattedItems.join("\n\n")}` + + if (results.pagination.hasMore) { + output += `\n\nMore items available. Use offset: ${ + results.pagination.offset + results.pagination.limit + } to see the next page.` + } + + return output +} + +export function formatRegistryItems( + items: z.infer[] +) { + return items.map((item) => { + const parts: string[] = [ + `## ${item.name}`, + item.description ? `\n${item.description}\n` : "", + item.type ? `**Type:** ${item.type}` : "", + item.files && item.files.length > 0 + ? `**Files:** ${item.files.length} file(s)` + : "", + item.dependencies && item.dependencies.length > 0 + ? `**Dependencies:** ${item.dependencies.join(", ")}` + : "", + item.devDependencies && item.devDependencies.length > 0 + ? `**Dev Dependencies:** ${item.devDependencies.join(", ")}` + : "", + ] + return parts.filter(Boolean).join("\n") + }) +} + +export function formatItemExamples( + items: z.infer[], + query: string +) { + const sections = items.map((item) => { + const parts: string[] = [ + `## Example: ${item.name}`, + item.description ? `\n${item.description}\n` : "", + ] + + if (item.files?.length) { + item.files.forEach((file) => { + if (file.content) { + parts.push(`### Code (${file.path}):\n`) + parts.push("```tsx") + parts.push(file.content) + parts.push("```") + } + }) + } + + return parts.filter(Boolean).join("\n") + }) + + const header = `# Usage Examples\n\nFound ${items.length} example${ + items.length > 1 ? "s" : "" + } matching "${query}":\n` + + return header + sections.join("\n\n---\n\n") +} diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index 385a4d017e..a3f869df56 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -1,8 +1,9 @@ import { promises as fs } from "fs" import { tmpdir } from "os" import path from "path" -import { REGISTRY_URL } from "@/src/registry/constants" +import { BUILTIN_REGISTRIES, REGISTRY_URL } from "@/src/registry/constants" import { + ConfigParseError, RegistryErrorCode, RegistryFetchError, RegistryForbiddenError, @@ -26,7 +27,7 @@ import { } from "vitest" import { z } from "zod" -import { getRegistry, getRegistryItems } from "./api" +import { getRegistriesConfig, getRegistry, getRegistryItems } from "./api" vi.mock("@/src/utils/handle-error", () => ({ handleError: vi.fn(), @@ -1190,3 +1191,463 @@ describe("getRegistry", () => { expect(nameResult).toBeDefined() }) }) + +describe("getRegistriesConfig", () => { + it("should return built-in registries when no components.json exists", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + + try { + const result = await getRegistriesConfig(tempDir) + + expect(result).toEqual({ + registries: BUILTIN_REGISTRIES, + }) + } finally { + await fs.rmdir(tempDir) + } + }) + + it("should parse valid components.json with registries", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const config = { + style: "new-york", + tailwind: { + config: "./tailwind.config.ts", + css: "./src/app/globals.css", + baseColor: "neutral", + cssVariables: true, + }, + registries: { + "@acme": "https://acme.com/{name}.json", + "@private": { + url: "https://private.com/{name}.json", + headers: { + Authorization: "Bearer token", + }, + }, + }, + } + + await fs.writeFile(configFile, JSON.stringify(config, null, 2)) + + try { + const result = await getRegistriesConfig(tempDir) + + // The result includes both custom and built-in registries + expect(result.registries).toMatchObject({ + "@acme": "https://acme.com/{name}.json", + "@private": { + url: "https://private.com/{name}.json", + headers: { + Authorization: "Bearer token", + }, + }, + "@shadcn": expect.any(String), + }) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should throw ConfigParseError for invalid components.json", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const invalidConfig = { + style: "new-york", + registries: { + "@invalid": { + // Missing required 'url' field + headers: { + Authorization: "Bearer token", + }, + }, + }, + } + + await fs.writeFile(configFile, JSON.stringify(invalidConfig, null, 2)) + + try { + await getRegistriesConfig(tempDir) + expect.fail("Should have thrown ConfigParseError") + } catch (error) { + expect(error).toBeInstanceOf(ConfigParseError) + if (error instanceof ConfigParseError) { + expect(error.cwd).toBe(tempDir) + expect(error.message).toContain("Invalid components.json") + } + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should handle components.json with no registries field", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const config = { + style: "new-york", + tailwind: { + config: "./tailwind.config.ts", + css: "./src/app/globals.css", + baseColor: "neutral", + cssVariables: true, + }, + // No registries field + } + + await fs.writeFile(configFile, JSON.stringify(config, null, 2)) + + try { + const result = await getRegistriesConfig(tempDir) + + // When no registries are defined, built-in registries are still included + expect(result.registries).toEqual(BUILTIN_REGISTRIES) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should handle malformed JSON file", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + await fs.writeFile(configFile, "{ invalid json }") + + try { + // Malformed JSON should throw an error from cosmiconfig + await getRegistriesConfig(tempDir) + expect.fail("Should have thrown an error") + } catch (error) { + // cosmiconfig throws a JSONError for malformed JSON + expect((error as Error).message).toContain("JSON Error") + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should include error details in ConfigParseError", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const invalidConfig = { + style: "new-york", + registries: { + "@invalid": { + url: 123, // Should be string + }, + }, + } + + await fs.writeFile(configFile, JSON.stringify(invalidConfig, null, 2)) + + try { + await getRegistriesConfig(tempDir) + expect.fail("Should have thrown ConfigParseError") + } catch (error) { + expect(error).toBeInstanceOf(ConfigParseError) + if (error instanceof ConfigParseError) { + expect(error.cwd).toBe(tempDir) + expect(error.message).toContain("Invalid components.json") + expect(error.message).toContain(tempDir) + expect(error.code).toBe(RegistryErrorCode.INVALID_CONFIG) + expect(error.suggestion).toContain("Check your components.json") + } + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should handle complex registry configurations", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const config = { + style: "new-york", + registries: { + "@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json", + "@acme": "https://acme.com/registry/{name}.json", + "@private": { + url: "https://private.registry.com/{name}.json", + headers: { + Authorization: "Bearer ${REGISTRY_TOKEN}", + "X-Custom-Header": "value", + }, + }, + "@local": "file:///local/registry/{name}.json", + }, + } + + await fs.writeFile(configFile, JSON.stringify(config, null, 2)) + + try { + const result = await getRegistriesConfig(tempDir) + + expect(result.registries).toBeDefined() + expect(result.registries?.["@shadcn"]).toBe( + "https://ui.shadcn.com/r/styles/{style}/{name}.json" + ) + expect(result.registries?.["@acme"]).toBe( + "https://acme.com/registry/{name}.json" + ) + expect(result.registries?.["@private"]).toEqual({ + url: "https://private.registry.com/{name}.json", + headers: { + Authorization: "Bearer ${REGISTRY_TOKEN}", + "X-Custom-Header": "value", + }, + }) + expect(result.registries?.["@local"]).toBe( + "file:///local/registry/{name}.json" + ) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + describe("caching behavior", () => { + it("should use cache by default", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const initialConfig = { + style: "new-york", + registries: { + "@cached": "https://cached.com/{name}.json", + }, + } + + await fs.writeFile(configFile, JSON.stringify(initialConfig, null, 2)) + + try { + // First call - should read from file + const result1 = await getRegistriesConfig(tempDir) + expect(result1.registries?.["@cached"]).toBe( + "https://cached.com/{name}.json" + ) + + // Modify the file + const updatedConfig = { + style: "new-york", + registries: { + "@cached": "https://updated.com/{name}.json", + }, + } + await fs.writeFile(configFile, JSON.stringify(updatedConfig, null, 2)) + + // Second call - should still return cached result (default behavior) + const result2 = await getRegistriesConfig(tempDir) + expect(result2.registries?.["@cached"]).toBe( + "https://cached.com/{name}.json" + ) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should use cache when explicitly enabled", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const initialConfig = { + style: "new-york", + registries: { + "@test": "https://test.com/{name}.json", + }, + } + + await fs.writeFile(configFile, JSON.stringify(initialConfig, null, 2)) + + try { + // First call with cache enabled + const result1 = await getRegistriesConfig(tempDir, { useCache: true }) + expect(result1.registries?.["@test"]).toBe( + "https://test.com/{name}.json" + ) + + // Modify the file + const updatedConfig = { + style: "new-york", + registries: { + "@test": "https://modified.com/{name}.json", + }, + } + await fs.writeFile(configFile, JSON.stringify(updatedConfig, null, 2)) + + // Second call with cache enabled - should return cached result + const result2 = await getRegistriesConfig(tempDir, { useCache: true }) + expect(result2.registries?.["@test"]).toBe( + "https://test.com/{name}.json" + ) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should bypass cache when useCache is false", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const initialConfig = { + style: "new-york", + registries: { + "@nocache": "https://nocache.com/{name}.json", + }, + } + + await fs.writeFile(configFile, JSON.stringify(initialConfig, null, 2)) + + try { + // First call - should read from file + const result1 = await getRegistriesConfig(tempDir, { useCache: false }) + expect(result1.registries?.["@nocache"]).toBe( + "https://nocache.com/{name}.json" + ) + + // Modify the file + const updatedConfig = { + style: "new-york", + registries: { + "@nocache": "https://fresh.com/{name}.json", + }, + } + await fs.writeFile(configFile, JSON.stringify(updatedConfig, null, 2)) + + // Second call with cache disabled - should read fresh data + const result2 = await getRegistriesConfig(tempDir, { useCache: false }) + expect(result2.registries?.["@nocache"]).toBe( + "https://fresh.com/{name}.json" + ) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should clear cache for subsequent calls when useCache is false", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const config1 = { + style: "new-york", + registries: { + "@step1": "https://step1.com/{name}.json", + }, + } + + await fs.writeFile(configFile, JSON.stringify(config1, null, 2)) + + try { + // First call with cache enabled + const result1 = await getRegistriesConfig(tempDir, { useCache: true }) + expect(result1.registries?.["@step1"]).toBe( + "https://step1.com/{name}.json" + ) + + // Update config + const config2 = { + style: "new-york", + registries: { + "@step2": "https://step2.com/{name}.json", + }, + } + await fs.writeFile(configFile, JSON.stringify(config2, null, 2)) + + // Call with cache disabled - should clear cache and read fresh + const result2 = await getRegistriesConfig(tempDir, { useCache: false }) + expect(result2.registries?.["@step2"]).toBe( + "https://step2.com/{name}.json" + ) + expect(result2.registries?.["@step1"]).toBeUndefined() + + // Update config again + const config3 = { + style: "new-york", + registries: { + "@step3": "https://step3.com/{name}.json", + }, + } + await fs.writeFile(configFile, JSON.stringify(config3, null, 2)) + + // Call with cache disabled again to ensure we get fresh data + const result3 = await getRegistriesConfig(tempDir, { useCache: false }) + expect(result3.registries?.["@step3"]).toBe( + "https://step3.com/{name}.json" + ) + expect(result3.registries?.["@step2"]).toBeUndefined() + + // Now a call with cache enabled should cache the current state + const result4 = await getRegistriesConfig(tempDir, { useCache: true }) + expect(result4.registries?.["@step3"]).toBe( + "https://step3.com/{name}.json" + ) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + + it("should handle missing config with cache options", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + + try { + // With cache enabled (default) + const result1 = await getRegistriesConfig(tempDir) + expect(result1.registries).toEqual(BUILTIN_REGISTRIES) + + // With cache explicitly enabled + const result2 = await getRegistriesConfig(tempDir, { useCache: true }) + expect(result2.registries).toEqual(BUILTIN_REGISTRIES) + + // With cache disabled + const result3 = await getRegistriesConfig(tempDir, { useCache: false }) + expect(result3.registries).toEqual(BUILTIN_REGISTRIES) + } finally { + await fs.rmdir(tempDir) + } + }) + + it("should handle invalid config with cache options", async () => { + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-")) + const configFile = path.join(tempDir, "components.json") + + const invalidConfig = { + style: "new-york", + registries: { + "@invalid": { + // Missing required 'url' field + headers: { + Authorization: "Bearer token", + }, + }, + }, + } + + await fs.writeFile(configFile, JSON.stringify(invalidConfig, null, 2)) + + try { + // Should throw regardless of cache setting + await expect( + getRegistriesConfig(tempDir, { useCache: true }) + ).rejects.toThrow(ConfigParseError) + + await expect( + getRegistriesConfig(tempDir, { useCache: false }) + ).rejects.toThrow(ConfigParseError) + } finally { + await fs.unlink(configFile) + await fs.rmdir(tempDir) + } + }) + }) +}) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index 62508e4ae2..f529f0020e 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -1,12 +1,13 @@ import path from "path" import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder" import { configWithDefaults } from "@/src/registry/config" -import { BASE_COLORS } from "@/src/registry/constants" +import { BASE_COLORS, BUILTIN_REGISTRIES } from "@/src/registry/constants" import { clearRegistryContext, setRegistryHeaders, } from "@/src/registry/context" import { + ConfigParseError, RegistryInvalidNamespaceError, RegistryNotFoundError, RegistryParseError, @@ -19,13 +20,15 @@ import { import { isUrl } from "@/src/registry/utils" import { iconsSchema, + rawConfigSchema, registryBaseColorSchema, + registryConfigSchema, registryIndexSchema, registryItemSchema, registrySchema, stylesSchema, } from "@/src/schema" -import { Config } from "@/src/utils/get-config" +import { Config, explorer } from "@/src/utils/get-config" import { handleError } from "@/src/utils/handle-error" import { logger } from "@/src/utils/logger" import { z } from "zod" @@ -108,6 +111,47 @@ export async function resolveRegistryItems( return resolveRegistryTree(items, configWithDefaults(config), { useCache }) } +export async function getRegistriesConfig( + cwd: string, + options?: { useCache?: boolean } +) { + const { useCache = true } = options || {} + + // Clear cache if requested + if (!useCache) { + explorer.clearCaches() + } + + const configResult = await explorer.search(cwd) + + if (!configResult) { + // Do not throw an error if the config is missing. + // We still have access to the built-in registries. + return { + registries: BUILTIN_REGISTRIES, + } + } + + // Parse just the registries field from the config + const registriesConfig = z + .object({ + registries: registryConfigSchema.optional(), + }) + .safeParse(configResult.config) + + if (!registriesConfig.success) { + throw new ConfigParseError(cwd, registriesConfig.error) + } + + // Merge built-in registries with user registries + return { + registries: { + ...BUILTIN_REGISTRIES, + ...(registriesConfig.data.registries || {}), + }, + } +} + export async function getShadcnRegistryIndex() { try { const [result] = await fetchRegistry(["index.json"]) diff --git a/packages/shadcn/src/registry/errors.ts b/packages/shadcn/src/registry/errors.ts index 0dd88ddc39..c5e45a8fe5 100644 --- a/packages/shadcn/src/registry/errors.ts +++ b/packages/shadcn/src/registry/errors.ts @@ -250,3 +250,38 @@ export class RegistryInvalidNamespaceError extends RegistryError { this.name = "RegistryInvalidNamespaceError" } } + +export class ConfigMissingError extends RegistryError { + constructor(public readonly cwd: string) { + const message = `No components.json found in ${cwd} or parent directories.` + + super(message, { + code: RegistryErrorCode.NOT_CONFIGURED, + context: { cwd }, + suggestion: + "Run 'npx shadcn@latest init' to create a components.json file, or check that you're in the correct directory.", + }) + this.name = "ConfigMissingError" + } +} + +export class ConfigParseError extends RegistryError { + constructor(public readonly cwd: string, parseError: unknown) { + let message = `Invalid components.json configuration in ${cwd}.` + + if (parseError instanceof z.ZodError) { + message = `Invalid components.json configuration in ${cwd}:\n${parseError.errors + .map((e) => ` - ${e.path.join(".")}: ${e.message}`) + .join("\n")}` + } + + super(message, { + code: RegistryErrorCode.INVALID_CONFIG, + cause: parseError, + context: { cwd }, + suggestion: + "Check your components.json file for syntax errors or invalid configuration. Run 'npx shadcn@latest init' to regenerate a valid configuration.", + }) + this.name = "ConfigParseError" + } +} diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts index 10ece0f1f2..37263b07a4 100644 --- a/packages/shadcn/src/registry/schema.ts +++ b/packages/shadcn/src/registry/schema.ts @@ -197,3 +197,21 @@ export const configSchema = rawConfigSchema.extend({ // TODO: type the key. // Okay for now since I don't want a breaking change. export const workspaceConfigSchema = z.record(configSchema) + +export const searchResultItemSchema = z.object({ + name: z.string(), + type: z.string().optional(), + description: z.string().optional(), + registry: z.string(), + addCommandArgument: z.string(), +}) + +export const searchResultsSchema = z.object({ + pagination: z.object({ + total: z.number(), + offset: z.number(), + limit: z.number(), + hasMore: z.boolean(), + }), + items: z.array(searchResultItemSchema), +}) diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts index e802089c2f..0d9cb5b753 100644 --- a/packages/shadcn/src/registry/search.ts +++ b/packages/shadcn/src/registry/search.ts @@ -1,27 +1,10 @@ +import { searchResultItemSchema, searchResultsSchema } from "@/src/schema" import { Config } from "@/src/utils/get-config" import fuzzysort from "fuzzysort" import { z } from "zod" import { getRegistry } from "./api" -const searchResultItemSchema = z.object({ - name: z.string(), - type: z.string().optional(), - description: z.string().optional(), - registry: z.string(), - addCommandArgument: z.string(), -}) - -const searchResultsSchema = z.object({ - pagination: z.object({ - total: z.number(), - offset: z.number(), - limit: z.number(), - hasMore: z.boolean(), - }), - items: z.array(searchResultItemSchema), -}) - export async function searchRegistries( registries: string[], options?: { diff --git a/packages/shadcn/src/utils/get-config.ts b/packages/shadcn/src/utils/get-config.ts index 95f04a69e6..c51c6cf590 100644 --- a/packages/shadcn/src/utils/get-config.ts +++ b/packages/shadcn/src/utils/get-config.ts @@ -22,7 +22,7 @@ export const DEFAULT_TAILWIND_BASE_COLOR = "slate" // TODO: Figure out if we want to support all cosmiconfig formats. // A simple components.json file would be nice. -const explorer = cosmiconfig("components", { +export const explorer = cosmiconfig("components", { searchPlaces: ["components.json"], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15c098c7a7..5e4df0818e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -725,6 +725,9 @@ importers: cosmiconfig: specifier: ^9.0.0 version: 9.0.0(typescript@5.9.2) + dedent: + specifier: ^1.6.0 + version: 1.6.0 deepmerge: specifier: ^4.3.1 version: 4.3.1 @@ -4558,6 +4561,14 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + dedent@1.6.0: + resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -9384,7 +9395,7 @@ snapshots: '@types/node': 20.5.1 chalk: 4.1.2 cosmiconfig: 8.3.6(typescript@5.9.2) - cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.9.2))(typescript@5.9.2) + cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.19.10)(typescript@5.9.2))(typescript@5.9.2) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -13379,7 +13390,7 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.9.2))(typescript@5.9.2): + cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.9.2))(ts-node@10.9.2(@types/node@20.19.10)(typescript@5.9.2))(typescript@5.9.2): dependencies: '@types/node': 20.5.1 cosmiconfig: 8.3.6(typescript@5.9.2) @@ -13543,6 +13554,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + dedent@1.6.0: {} + deep-eql@5.0.2: {} deep-extend@0.6.0: {}