diff --git a/.changeset/dirty-teachers-raise.md b/.changeset/dirty-teachers-raise.md new file mode 100644 index 0000000000..4f3d41a284 --- /dev/null +++ b/.changeset/dirty-teachers-raise.md @@ -0,0 +1,5 @@ +--- +"shadcn": major +--- + +add view and search commands diff --git a/package.json b/package.json index 97debdf7d2..04a22dfd62 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", "preview": "turbo run preview", - "typecheck": "turbo run typecheck", + "typecheck": "turbo run typecheck --filter=!www", "format:write": "turbo run format:write", "format:check": "turbo run format:check", "sync:templates": "./scripts/sync-templates.sh \"templates/*\"", diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index ab5c83cd7e..32e7f08072 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -76,6 +76,7 @@ "execa": "^7.0.0", "fast-glob": "^3.3.2", "fs-extra": "^11.1.0", + "fuzzysort": "^3.1.0", "https-proxy-agent": "^6.2.0", "kleur": "^4.1.5", "msw": "^2.7.1", diff --git a/packages/shadcn/src/commands/search.ts b/packages/shadcn/src/commands/search.ts new file mode 100644 index 0000000000..14c32a88f0 --- /dev/null +++ b/packages/shadcn/src/commands/search.ts @@ -0,0 +1,104 @@ +import path from "path" +import { configWithDefaults } from "@/src/registry/config" +import { clearRegistryContext } from "@/src/registry/context" +import { searchRegistries } from "@/src/registry/search" +import { validateRegistryConfigForItems } from "@/src/registry/validator" +import { rawConfigSchema } from "@/src/schema" +import { loadEnvFiles } from "@/src/utils/env-loader" +import { createConfig, getConfig } from "@/src/utils/get-config" +import { handleError } from "@/src/utils/handle-error" +import { Command } from "commander" +import fsExtra from "fs-extra" +import { z } from "zod" + +const searchOptionsSchema = z.object({ + cwd: z.string(), + query: z.string().optional(), + limit: z.number().optional(), + offset: z.number().optional(), +}) + +// TODO: We're duplicating logic for shadowConfig here. +// Revisit and properly abstract this. + +export const search = new Command() + .name("search") + .description("search items from registries") + .argument("", "the registry names to search items from") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd() + ) + .option("-q, --query ", "query string") + .option( + "-l, --limit ", + "maximum number of items to display per registry", + "100" + ) + .option("-o, --offset ", "number of items to skip", "0") + .action(async (registries: string[], opts) => { + try { + const options = searchOptionsSchema.parse({ + cwd: path.resolve(opts.cwd), + query: opts.query, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + }) + + await loadEnvFiles(options.cwd) + + // Start with a shadow config to support partial components.json. + // Use createConfig to get proper default paths + const defaultConfig = createConfig({ + style: "new-york", + resolvedPaths: { + cwd: options.cwd, + }, + }) + let shadowConfig = configWithDefaults(defaultConfig) + + // Check if there's a components.json file (partial or complete). + const componentsJsonPath = path.resolve(options.cwd, "components.json") + if (fsExtra.existsSync(componentsJsonPath)) { + const existingConfig = await fsExtra.readJson(componentsJsonPath) + const partialConfig = rawConfigSchema.partial().parse(existingConfig) + shadowConfig = configWithDefaults({ + ...defaultConfig, + ...partialConfig, + }) + } + + // Try to get the full config, but fall back to shadow config if it fails. + let config = shadowConfig + try { + const fullConfig = await getConfig(options.cwd) + if (fullConfig) { + config = configWithDefaults(fullConfig) + } + } catch { + // Use shadow config if getConfig fails (partial components.json). + } + + // Validate registries early for better error messages. + validateRegistryConfigForItems(registries, config) + + // Use searchRegistries for both search and non-search cases + const results = await searchRegistries( + registries as `@${string}`[], + { + query: options.query, + limit: options.limit, + offset: options.offset, + }, + config + ) + + console.log(JSON.stringify(results, null, 2)) + process.exit(0) + } catch (error) { + handleError(error) + } finally { + clearRegistryContext() + } + }) diff --git a/packages/shadcn/src/commands/view.ts b/packages/shadcn/src/commands/view.ts new file mode 100644 index 0000000000..cae2df708f --- /dev/null +++ b/packages/shadcn/src/commands/view.ts @@ -0,0 +1,68 @@ +import path from "path" +import { getRegistryItems } from "@/src/registry/api" +import { configWithDefaults } from "@/src/registry/config" +import { clearRegistryContext } from "@/src/registry/context" +import { validateRegistryConfigForItems } from "@/src/registry/validator" +import { rawConfigSchema } from "@/src/schema" +import { loadEnvFiles } from "@/src/utils/env-loader" +import { getConfig } from "@/src/utils/get-config" +import { handleError } from "@/src/utils/handle-error" +import { Command } from "commander" +import fsExtra from "fs-extra" +import { z } from "zod" + +const viewOptionsSchema = z.object({ + cwd: z.string(), +}) + +export const view = new Command() + .name("view") + .description("view items from the registry") + .argument("", "the item names or URLs to view") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd() + ) + .action(async (items: string[], opts) => { + try { + const options = viewOptionsSchema.parse({ + cwd: path.resolve(opts.cwd), + }) + + await loadEnvFiles(options.cwd) + + // Start with a shadow config to support partial components.json. + let shadowConfig = configWithDefaults({}) + + // Check if there's a components.json file (partial or complete). + const componentsJsonPath = path.resolve(options.cwd, "components.json") + if (fsExtra.existsSync(componentsJsonPath)) { + const existingConfig = await fsExtra.readJson(componentsJsonPath) + const partialConfig = rawConfigSchema.partial().parse(existingConfig) + shadowConfig = configWithDefaults(partialConfig) + } + + // Try to get the full config, but fall back to shadow config if it fails. + let config = shadowConfig + try { + const fullConfig = await getConfig(options.cwd) + if (fullConfig) { + config = configWithDefaults(fullConfig) + } + } catch { + // Use shadow config if getConfig fails (partial components.json). + } + + // Validate registries early for better error messages. + validateRegistryConfigForItems(items, config) + + const payload = await getRegistryItems(items, config) + console.log(JSON.stringify(payload, null, 2)) + process.exit(0) + } catch (error) { + handleError(error) + } finally { + clearRegistryContext() + } + }) diff --git a/packages/shadcn/src/index.ts b/packages/shadcn/src/index.ts index ed45887653..fc1423c889 100644 --- a/packages/shadcn/src/index.ts +++ b/packages/shadcn/src/index.ts @@ -7,6 +7,8 @@ import { init } from "@/src/commands/init" import { migrate } from "@/src/commands/migrate" import { build as registryBuild } from "@/src/commands/registry/build" import { mcp as registryMcp } from "@/src/commands/registry/mcp" +import { search } from "@/src/commands/search" +import { view } from "@/src/commands/view" import { Command } from "commander" import packageJson from "../package.json" @@ -28,6 +30,8 @@ async function main() { .addCommand(init) .addCommand(add) .addCommand(diff) + .addCommand(view) + .addCommand(search) .addCommand(migrate) .addCommand(info) .addCommand(build) diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index eb169b3cfb..6d2fbe1784 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -1002,4 +1002,149 @@ describe("getRegistry", () => { } } }) + + it("should throw RegistryParseError for registry name without @ prefix", async () => { + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + try { + // Cast to bypass TypeScript checking since we're testing runtime validation + await getRegistry("invalid-name" as `@${string}`, mockConfig) + expect.fail("Should have thrown RegistryParseError") + } catch (error) { + expect(error).toBeInstanceOf(RegistryParseError) + if (error instanceof RegistryParseError) { + expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR) + expect(error.message).toContain("Failed to parse registry item") + expect(error.message).toContain("invalid-name") + expect(error.parseError).toBeDefined() + if (error.parseError instanceof z.ZodError) { + const zodError = error.parseError as z.ZodError + expect(zodError.errors[0].message).toContain( + "Registry name must start with @" + ) + } + } + } + }) + + it("should throw RegistryParseError for empty registry name (@)", async () => { + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + try { + await getRegistry("@" as `@${string}`, mockConfig) + expect.fail("Should have thrown RegistryParseError") + } catch (error) { + expect(error).toBeInstanceOf(RegistryParseError) + if (error instanceof RegistryParseError) { + expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR) + expect(error.message).toContain("Failed to parse registry item") + expect(error.parseError).toBeDefined() + if (error.parseError instanceof z.ZodError) { + const zodError = error.parseError as z.ZodError + expect(zodError.errors[0].message).toContain( + "Registry name must start with @ followed by alphanumeric characters" + ) + } + } + } + }) + + it("should accept valid registry names with hyphens and underscores", async () => { + const registryData = { + name: "@test-123_abc/registry", + homepage: "https://test.com", + items: [], + } + + server.use( + http.get("https://test.com/registry.json", () => { + return HttpResponse.json(registryData) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@test-123_abc": { + url: "https://test.com/{name}.json", + }, + }, + } as any + + const result = await getRegistry("@test-123_abc", mockConfig) + expect(result).toMatchObject(registryData) + }) + + it("should throw RegistryParseError for registry name with invalid characters", async () => { + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@test$invalid": { + url: "https://test.com/{name}.json", + }, + }, + } as any + + try { + await getRegistry("@test$invalid" as `@${string}`, mockConfig) + expect.fail("Should have thrown RegistryParseError") + } catch (error) { + expect(error).toBeInstanceOf(RegistryParseError) + if (error instanceof RegistryParseError) { + expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR) + expect(error.message).toContain("Failed to parse registry item") + expect(error.parseError).toBeDefined() + if (error.parseError instanceof z.ZodError) { + const zodError = error.parseError as z.ZodError + expect(zodError.errors[0].message).toContain( + "Registry name must start with @ followed by alphanumeric characters, hyphens, or underscores" + ) + } + } + } + }) + + it("should throw RegistryParseError for registry name starting with @-", async () => { + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: {}, + } as any + + try { + await getRegistry("@-invalid" as `@${string}`, mockConfig) + expect.fail("Should have thrown RegistryParseError") + } catch (error) { + expect(error).toBeInstanceOf(RegistryParseError) + if (error instanceof RegistryParseError) { + expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR) + expect(error.message).toContain("Failed to parse registry item") + expect(error.parseError).toBeDefined() + if (error.parseError instanceof z.ZodError) { + const zodError = error.parseError as z.ZodError + expect(zodError.errors[0].message).toContain( + "Registry name must start with @ followed by alphanumeric characters" + ) + } + } + } + }) }) diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index bb213792ac..af8524f983 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -28,10 +28,25 @@ import { handleError } from "@/src/utils/handle-error" import { logger } from "@/src/utils/logger" import { z } from "zod" +// Schema for validating registry names +const registryNameSchema = z + .string() + .regex( + /^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/, + "Registry name must start with @ followed by alphanumeric characters, hyphens, or underscores" + ) + export async function getRegistry( name: `@${string}`, config?: Partial ) { + // Validate the registry name using Zod schema + try { + registryNameSchema.parse(name.split("/")[0]) + } catch (error) { + throw new RegistryParseError(name, error) + } + if (!name.endsWith("/registry")) { name = `${name}/registry` } diff --git a/packages/shadcn/src/registry/index.ts b/packages/shadcn/src/registry/index.ts index ae1970fe04..42715f9bc3 100644 --- a/packages/shadcn/src/registry/index.ts +++ b/packages/shadcn/src/registry/index.ts @@ -1,5 +1,7 @@ export { getRegistryItems, resolveRegistryItems, getRegistry } from "./api" +export { searchRegistries } from "./search" + export { RegistryError, RegistryNotFoundError, diff --git a/packages/shadcn/src/registry/search.test.ts b/packages/shadcn/src/registry/search.test.ts new file mode 100644 index 0000000000..3d0ba20013 --- /dev/null +++ b/packages/shadcn/src/registry/search.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it, vi } from "vitest" + +import { getRegistry } from "./api" +import { searchRegistries } from "./search" + +describe("searchRegistries", () => { + it("should fetch and return registries in flat format", async () => { + // Mock getRegistry + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@shadcn" || name === "@shadcn/registry") { + return { + name: "shadcn/ui", + homepage: "https://ui.shadcn.com", + items: [ + { + name: "button", + type: "registry:ui", + description: "A button component", + }, + { + name: "card", + type: "registry:ui", + description: "A card component", + }, + ], + } + } + if (name === "@custom" || name === "@custom/registry") { + return { + name: "custom/components", + homepage: "https://custom.com", + items: [ + { + name: "header", + type: "registry:component", + description: "A header component", + }, + ], + } + } + throw new Error(`Unknown registry: ${name}`) + }) + + const results = await searchRegistries(["@shadcn", "@custom"]) + + expect(results).toEqual({ + items: [ + { + name: "button", + type: "registry:ui", + description: "A button component", + registry: "@shadcn", + }, + { + name: "card", + type: "registry:ui", + description: "A card component", + registry: "@shadcn", + }, + { + name: "header", + type: "registry:component", + description: "A header component", + registry: "@custom", + }, + ], + pagination: { + total: 3, + offset: 0, + limit: 3, + hasMore: false, + }, + }) + + mockGetRegistry.mockRestore() + }) + + it("should apply search filter when query is provided", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@shadcn" || name === "@shadcn/registry") { + return { + name: "shadcn/ui", + homepage: "https://ui.shadcn.com", + items: [ + { + name: "button", + type: "registry:ui", + description: "A button component", + }, + { + name: "card", + type: "registry:ui", + description: "A card component", + }, + { + name: "dialog", + type: "registry:ui", + description: "A dialog component", + }, + ], + } + } + throw new Error(`Unknown registry: ${name}`) + }) + + const results = await searchRegistries(["@shadcn"], { query: "button" }) + + expect(results.items).toHaveLength(1) + expect(results.items[0].name).toBe("button") + expect(results.items[0].registry).toBe("@shadcn") + expect(results.pagination).toEqual({ + total: 1, + offset: 0, + limit: 1, + hasMore: false, + }) + + mockGetRegistry.mockRestore() + }) + + it("should fail fast on registry error", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + throw new Error(`Registry not found: ${name}`) + }) + + await expect(searchRegistries(["@unknown"])).rejects.toThrow( + "Registry not found" + ) + + mockGetRegistry.mockRestore() + }) + + it("should return empty items when search has no matches", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async () => ({ + name: "test/registry", + homepage: "https://test.com", + items: [{ name: "button", type: "registry:ui", description: "A button" }], + })) + + const results = await searchRegistries(["@test"], { query: "nonexistent" }) + + expect(results.items).toHaveLength(0) + expect(results.pagination).toEqual({ + total: 0, + offset: 0, + limit: 0, + hasMore: false, + }) + + mockGetRegistry.mockRestore() + }) + + it("should handle fuzzy search", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async () => ({ + name: "test/registry", + homepage: "https://test.com", + items: [ + { + name: "button", + type: "registry:ui", + description: "A button component", + }, + { + name: "dialog", + type: "registry:ui", + description: "A dialog overlay", + }, + ], + })) + + const results = await searchRegistries(["@test"], { query: "butto" }) + + expect(results.items).toHaveLength(1) + expect(results.items[0].name).toBe("button") + expect(results.pagination).toEqual({ + total: 1, + offset: 0, + limit: 1, + hasMore: false, + }) + + mockGetRegistry.mockRestore() + }) + + it("should search in descriptions", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async () => ({ + name: "test/registry", + homepage: "https://test.com", + items: [ + { + name: "button", + type: "registry:ui", + description: "A clickable element", + }, + { name: "dialog", type: "registry:ui", description: "A modal overlay" }, + ], + })) + + const results = await searchRegistries(["@test"], { query: "modal" }) + + expect(results.items).toHaveLength(1) + expect(results.items[0].name).toBe("dialog") + expect(results.pagination).toEqual({ + total: 1, + offset: 0, + limit: 1, + hasMore: false, + }) + + mockGetRegistry.mockRestore() + }) + + it("should respect limit option", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async () => ({ + name: "test/registry", + homepage: "https://test.com", + items: [ + { name: "alert", type: "registry:ui", description: "Alert component" }, + { + name: "avatar", + type: "registry:ui", + description: "Avatar component", + }, + { + name: "accordion", + type: "registry:ui", + description: "Accordion component", + }, + { + name: "aspect-ratio", + type: "registry:ui", + description: "Aspect ratio component", + }, + ], + })) + + const results = await searchRegistries(["@test"], { query: "a", limit: 2 }) + + expect(results.items.length).toBeLessThanOrEqual(2) + expect(results.pagination.limit).toBe(2) + expect(results.pagination.offset).toBe(0) + + mockGetRegistry.mockRestore() + }) + + it("should handle offset and limit for pagination", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async () => ({ + name: "test/registry", + homepage: "https://test.com", + items: [ + { name: "item1", type: "registry:ui", description: "Item 1" }, + { name: "item2", type: "registry:ui", description: "Item 2" }, + { name: "item3", type: "registry:ui", description: "Item 3" }, + { name: "item4", type: "registry:ui", description: "Item 4" }, + { name: "item5", type: "registry:ui", description: "Item 5" }, + ], + })) + + const results = await searchRegistries(["@test"], { offset: 2, limit: 2 }) + + expect(results.items).toHaveLength(2) + expect(results.items[0].name).toBe("item3") + expect(results.items[1].name).toBe("item4") + expect(results.pagination).toEqual({ + total: 5, + offset: 2, + limit: 2, + hasMore: true, + }) + + mockGetRegistry.mockRestore() + }) + + it("should set hasMore to false when no more items", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async () => ({ + name: "test/registry", + homepage: "https://test.com", + items: [ + { name: "item1", type: "registry:ui", description: "Item 1" }, + { name: "item2", type: "registry:ui", description: "Item 2" }, + { name: "item3", type: "registry:ui", description: "Item 3" }, + ], + })) + + const results = await searchRegistries(["@test"], { offset: 2, limit: 2 }) + + expect(results.items).toHaveLength(1) + expect(results.items[0].name).toBe("item3") + expect(results.pagination.hasMore).toBe(false) + + mockGetRegistry.mockRestore() + }) + + it("should handle pagination across multiple registries", async () => { + vi.mock("./api", () => ({ + getRegistry: vi.fn(), + })) + + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@one") { + return { + name: "one", + homepage: "https://one.com", + items: [ + { name: "item1", type: "registry:ui", description: "Item 1" }, + { name: "item2", type: "registry:ui", description: "Item 2" }, + { name: "item3", type: "registry:ui", description: "Item 3" }, + ], + } + } + if (name === "@two") { + return { + name: "two", + homepage: "https://two.com", + items: [ + { name: "item4", type: "registry:ui", description: "Item 4" }, + { name: "item5", type: "registry:ui", description: "Item 5" }, + ], + } + } + throw new Error("Unknown registry") + }) + + const results = await searchRegistries(["@one", "@two"], { + offset: 1, + limit: 3, + }) + + expect(results.items).toHaveLength(3) + expect(results.items[0].name).toBe("item2") + expect(results.items[0].registry).toBe("@one") + expect(results.items[1].name).toBe("item3") + expect(results.items[1].registry).toBe("@one") + expect(results.items[2].name).toBe("item4") + expect(results.items[2].registry).toBe("@two") + expect(results.pagination).toEqual({ + total: 5, + offset: 1, + limit: 3, + hasMore: true, + }) + + mockGetRegistry.mockRestore() + }) +}) diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts new file mode 100644 index 0000000000..7e9e2aae41 --- /dev/null +++ b/packages/shadcn/src/registry/search.ts @@ -0,0 +1,116 @@ +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(), +}) + +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: Array[0]>, + options?: { + query?: string + limit?: number + offset?: number + }, + config?: Partial +) { + let allItems: z.infer[] = [] + + for (const registry of registries) { + const registryData = await getRegistry(registry, config) + + const itemsWithRegistry = (registryData.items || []).map((item: any) => ({ + name: item.name, + type: item.type, + description: item.description, + registry: registry, + })) + + allItems = allItems.concat(itemsWithRegistry) + } + + // Apply search if query is provided + if (options?.query) { + allItems = searchItems(allItems, { + query: options.query, + // No limit here - we want to search all items then paginate + limit: allItems.length, + keys: ["name", "description"], + }) as z.infer[] + } + + // Apply offset and limit pagination + const offset = options?.offset || 0 + const limit = options?.limit || allItems.length + const totalItems = allItems.length + + // Build result with pagination + const result: z.infer = { + pagination: { + total: totalItems, + offset, + limit, + hasMore: offset + limit < totalItems, + }, + items: allItems.slice(offset, offset + limit), + } + + return searchResultsSchema.parse(result) +} + +const searchableItemSchema = z + .object({ + name: z.string(), + type: z.string().optional(), + description: z.string().optional(), + registry: z.string().optional(), // Optional for backward compatibility + }) + .passthrough() + +type SearchableItem = z.infer + +function searchItems< + T extends { + name: string + type?: string + description?: string + [key: string]: any + } = SearchableItem +>( + items: T[], + options: { + query: string + } & Pick[2], "keys" | "threshold" | "limit"> +) { + options = { + limit: 100, + threshold: -10000, + ...options, + } + + const searchResults = fuzzysort.go(options.query, items, { + keys: options.keys, + threshold: options.threshold, + limit: options.limit, + }) + + const results = searchResults.map((result) => result.obj) + + return z.array(searchableItemSchema).parse(results) +} diff --git a/packages/shadcn/src/registry/validator.ts b/packages/shadcn/src/registry/validator.ts index e8ef9e3f35..aa73a57c1f 100644 --- a/packages/shadcn/src/registry/validator.ts +++ b/packages/shadcn/src/registry/validator.ts @@ -1,6 +1,10 @@ +import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder" +import { configWithDefaults } from "@/src/registry/config" +import { clearRegistryContext } from "@/src/registry/context" import { extractEnvVars } from "@/src/registry/env" import { RegistryMissingEnvironmentVariablesError } from "@/src/registry/errors" import { registryConfigItemSchema } from "@/src/schema" +import { Config } from "@/src/utils/get-config" import { z } from "zod" export function extractEnvVarsFromRegistryConfig( @@ -40,3 +44,15 @@ export function validateRegistryConfig( throw new RegistryMissingEnvironmentVariablesError(registryName, missing) } } + +export function validateRegistryConfigForItems( + items: string[], + config?: Config +): void { + for (const item of items) { + buildUrlAndHeadersForRegistryItem(item, configWithDefaults(config)) + } + + // Clear the registry context after validation. + clearRegistryContext() +} diff --git a/packages/tests/src/tests/search.test.ts b/packages/tests/src/tests/search.test.ts new file mode 100644 index 0000000000..f21b24bb30 --- /dev/null +++ b/packages/tests/src/tests/search.test.ts @@ -0,0 +1,985 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers" +import { configureRegistries, createRegistryServer } from "../utils/registry" + +const registryShadcn = await createRegistryServer( + [ + { + name: "button", + type: "registry:ui", + description: "A button component", + dependencies: ["@radix-ui/react-slot"], + devDependencies: ["@types/react"], + files: [ + { + path: "components/ui/button.tsx", + content: + "export function Button() {\n return \n}", + type: "registry:ui", + }, + ], + }, + { + name: "card", + type: "registry:ui", + description: "A card component", + files: [ + { + path: "components/ui/card.tsx", + content: + "export function Card() {\n return
Card Component
\n}", + type: "registry:ui", + }, + ], + }, + { + name: "alert-dialog", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "components/ui/alert-dialog.tsx", + content: + "export function AlertDialog() {\n return
AlertDialog Component
\n}", + type: "registry:ui", + }, + ], + }, + ], + { + port: 9180, + path: "/r", + } +) + +const registryOne = await createRegistryServer( + [ + { + name: "foo", + type: "registry:component", + description: "Foo component from registry one", + dependencies: ["clsx", "tailwind-merge"], + files: [ + { + path: "components/foo.tsx", + content: + "export function Foo() {\n return
Foo Component from Registry 1
\n}", + type: "registry:component", + }, + ], + tailwind: { + config: { + theme: { + extend: { + colors: { + foo: "#ff0000", + }, + }, + }, + }, + }, + cssVars: { + light: { + "foo-color": "#ff0000", + }, + dark: { + "foo-color": "#00ff00", + }, + }, + }, + { + name: "bar", + type: "registry:component", + registryDependencies: ["@one/foo"], + files: [ + { + path: "components/bar.tsx", + content: + "export function Bar() {\n return
Bar Component from Registry 1
\n}", + type: "registry:component", + }, + ], + }, + ], + { + port: 9181, + path: "/r", + } +) + +// Create a registry with many items for pagination testing +const registryLarge = await createRegistryServer( + Array.from({ length: 20 }, (_, i) => ({ + name: `component-${i + 1}`, + type: "registry:ui", + description: `Component number ${i + 1}`, + files: [ + { + path: `components/ui/component-${i + 1}.tsx`, + content: `export function Component${i + 1}() { return
Component ${ + i + 1 + }
}`, + type: "registry:ui", + }, + ], + })), + { + port: 9184, + path: "/large", + } +) + +const registryTwo = await createRegistryServer( + [ + { + name: "item", + type: "registry:ui", + description: "Item component from registry two", + files: [ + { + path: "components/ui/item.tsx", + content: + "export function Item() {\n return
Item Component from Registry 2
\n}", + type: "registry:ui", + }, + ], + }, + { + name: "secure-item", + type: "registry:component", + description: "Secure item requiring authentication", + registryDependencies: ["@one/foo"], + files: [ + { + path: "components/secure-item.tsx", + content: + "export function SecureItem() {\n return
Secure Item
\n}", + type: "registry:component", + }, + ], + }, + ], + { + port: 9182, + path: "/registry", + } +) + +beforeAll(async () => { + await registryShadcn.start() + await registryOne.start() + await registryTwo.start() + await registryLarge.start() +}) + +afterAll(async () => { + await registryShadcn.stop() + await registryOne.stop() + await registryTwo.stop() + await registryLarge.stop() +}) + +describe("shadcn search", () => { + it("should search items from shadcn registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + const output = await npxShadcn(fixturePath, ["search", "@shadcn"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual( + expect.arrayContaining([ + { + name: "button", + type: "registry:ui", + description: "A button component", + registry: "@shadcn", + }, + { + name: "card", + type: "registry:ui", + description: "A card component", + registry: "@shadcn", + }, + { + name: "alert-dialog", + type: "registry:ui", + description: undefined, + registry: "@shadcn", + }, + ]) + ) + }) + + it("should list items from multiple registries", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + "@one": "http://localhost:9181/r/{name}", + "@two": "http://localhost:9182/registry/{name}", + }) + + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "@one", + "@two", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual(expect.any(Array)) + + // Check that items from all three registries are present + const registries = parsed.items.map((item: any) => item.registry) + expect(registries).toContain("@shadcn") + expect(registries).toContain("@one") + expect(registries).toContain("@two") + }) + + it("should list from configured registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@one"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual( + expect.arrayContaining([ + { + name: "foo", + type: "registry:component", + description: "Foo component from registry one", + registry: "@one", + }, + { + name: "bar", + type: "registry:component", + registry: "@one", + }, + ]) + ) + }) + + it("should handle non-existent registry gracefully", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["search", "@unknown"]) + + expect(output.stdout).toContain('Unknown registry "@unknown"') + }) + + it("should handle authentication for secured registries", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@two": { + url: "http://localhost:9182/registry/bearer/{name}", + headers: { + Authorization: "Bearer EXAMPLE_BEARER_TOKEN", + }, + }, + }) + + const output = await npxShadcn(fixturePath, ["search", "@two"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "item", + type: "registry:ui", + description: "Item component from registry two", + registry: "@two", + }), + expect.objectContaining({ + name: "secure-item", + type: "registry:component", + description: "Secure item requiring authentication", + registry: "@two", + }), + ]) + ) + }) + + it("should fail when listing secured registry without authentication", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@two": "http://localhost:9182/registry/bearer/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@two"]) + expect(output.stdout).toContain("Unauthorized") + }) + + it("should handle authentication with environment variables", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@two": { + url: "http://localhost:9182/registry/bearer/{name}", + headers: { + Authorization: "Bearer ${BEARER_TOKEN}", + }, + }, + }) + + const originalBearerToken = process.env.BEARER_TOKEN + try { + process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN" + + const output = await npxShadcn(fixturePath, ["search", "@two"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "item", + registry: "@two", + }), + ]) + ) + } finally { + if (originalBearerToken !== undefined) { + process.env.BEARER_TOKEN = originalBearerToken + } else { + delete process.env.BEARER_TOKEN + } + } + }) + + it("should handle missing environment variables for authenticated registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@auth": { + url: "http://localhost:9182/registry/bearer/{name}", + headers: { + Authorization: "Bearer ${MISSING_ENV_VAR}", + }, + }, + }) + + const output = await npxShadcn(fixturePath, ["search", "@auth"]) + + expect(output.stdout).toContain("MISSING_ENV_VAR") + }) + + it("should work with @shadcn namespace", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + const output = await npxShadcn(fixturePath, ["search", "@shadcn"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "button", + registry: "@shadcn", + }), + expect.objectContaining({ + name: "card", + registry: "@shadcn", + }), + ]) + ) + }) + + it("should handle namespace with special characters", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["search", "@test-123"]) + + expect(output.stdout).toContain('Unknown registry "@test-123"') + }) + + it("should handle empty registry name", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["search", "@"]) + + expect(output.stdout).toContain("Failed to parse registry item: @") + expect(output.stdout).toContain( + "Registry name must start with @ followed by alphanumeric characters" + ) + }) + + it("should handle namespace without @ prefix", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["search", "one"]) + + // Without @ prefix, it should show an error + expect(output.stdout).toContain("Failed to parse registry item: one") + expect(output.stdout).toContain("Registry name must start with @") + }) + + it("should list from components.json with registries config only (shadow config)", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@one"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items.length).toBeGreaterThan(0) + + // Verify items are from @one registry + expect(parsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + registry: "@one", + }), + ]) + ) + }) + + it("should error when listing from non-existent registry with configured registries", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + + // Configure @one registry, but try to list from @two + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@two"]) + + expect(output.stdout).toContain('Unknown registry "@two"') + }) + + it("should handle validation errors", async () => { + // Create a server that returns invalid schema + const badServer = await createRegistryServer( + [ + { + name: "invalid-schema", + type: "invalid-type", // Invalid type + files: [ + { + path: "components/bad.tsx", + content: "export function Bad() { return null }", + // Missing required 'type' field + }, + ], + } as any, + ], + { + port: 9183, + path: "/bad", + } + ) + + await badServer.start() + + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@bad": "http://localhost:9183/bad/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@bad"]) + + // Should handle validation error + expect(output.stdout.toLowerCase()).toContain("failed to parse") + + await badServer.stop() + }) + + it("should handle network timeouts gracefully", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@timeout": "http://localhost:9999/timeout/{name}", // Non-existent server + }) + + const output = await npxShadcn(fixturePath, ["search", "@timeout"]) + + // Check for connection error in the output + expect(output.stdout.toLowerCase()).toContain("failed, reason:") + }) + + it("should list multiple registries with mixed success and failure", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + }) + + const output = await npxShadcn(fixturePath, [ + "search", + "@one", + "@non-existent", + ]) + + // Should fail fast on first error + expect(output.stdout).toContain('Unknown registry "@non-existent"') + }) + + it("should handle partial components.json without other required fields", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + + // Create a partial components.json with only registries + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + "@two": "http://localhost:9182/registry/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@one", "@two"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual(expect.any(Array)) + + // Check that items from both registries are present + const registries = parsed.items.map((item: any) => item.registry) + expect(registries).toContain("@one") + expect(registries).toContain("@two") + }) + + it("should handle dependencies that require authentication", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + // Configure both registries - @two requires auth, @one doesn't + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + "@two": { + url: "http://localhost:9182/registry/bearer/{name}", + headers: { + Authorization: "Bearer EXAMPLE_BEARER_TOKEN", + }, + }, + }) + + // List from both registries + const output = await npxShadcn(fixturePath, ["search", "@one", "@two"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveProperty("items") + expect(parsed.items).toEqual(expect.any(Array)) + + // Check that items from both registries are present + expect(parsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "foo", + registry: "@one", + }), + expect.objectContaining({ + name: "item", + registry: "@two", + }), + ]) + ) + }) + + it("should handle search with pagination", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Search for "button" with pagination + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "button", + "--limit", + "1", + "--offset", + "0", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed.items).toHaveLength(1) + expect(parsed.items[0].name).toBe("button") + expect(parsed.pagination).toEqual({ + total: 1, + offset: 0, + limit: 1, + hasMore: false, + }) + }) + + it("should handle empty search results with pagination", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "nonexistent", + "--limit", + "10", + "--offset", + "5", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed.items).toHaveLength(0) + expect(parsed.pagination).toEqual({ + total: 0, + offset: 5, + limit: 10, + hasMore: false, + }) + }) + + it("should handle typos in search (fuzzy matching)", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Test various typos that should still match "button" + const typos = [ + "buton", // missing 't' + "buttn", // missing 'o' + "buttno", // 'no' instead of 'on' + "btton", // missing 'u' + "dialg", // typo in 'dialog' + "alrt", // typo in 'alert' + ] + + // Test button typos + for (const typo of typos.slice(0, 4)) { + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + typo, + ]) + const parsed = JSON.parse(output.stdout) + expect( + parsed.items.some((item: any) => item.name === "button"), + `Typo '${typo}' should match 'button'` + ).toBe(true) + } + + // Test dialog typo + const dialogOutput = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "dialg", + ]) + const dialogParsed = JSON.parse(dialogOutput.stdout) + expect( + dialogParsed.items.some((item: any) => item.name === "alert-dialog"), + "Typo 'dialg' should match 'alert-dialog'" + ).toBe(true) + + // Test alert typo + const alertOutput = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "alrt", + ]) + const alertParsed = JSON.parse(alertOutput.stdout) + expect( + alertParsed.items.some((item: any) => item.name === "alert-dialog"), + "Typo 'alrt' should match 'alert-dialog'" + ).toBe(true) + }) + + it("should handle partial word matching", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Test partial matches + const partialQueries = [ + { query: "btn", expected: "button" }, + { query: "crd", expected: "card" }, + { query: "alert", expected: "alert-dialog" }, + { query: "dial", expected: "alert-dialog" }, + ] + + for (const { query, expected } of partialQueries) { + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + query, + ]) + const parsed = JSON.parse(output.stdout) + expect( + parsed.items.some((item: any) => item.name === expected), + `Partial '${query}' should match '${expected}'` + ).toBe(true) + } + }) + + it("should handle case-insensitive search", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Search with different cases + const output1 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "BUTTON", + ]) + const output2 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "button", + ]) + const output3 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "BuTtOn", + ]) + + const parsed1 = JSON.parse(output1.stdout) + const parsed2 = JSON.parse(output2.stdout) + const parsed3 = JSON.parse(output3.stdout) + + // All should find the button component + expect(parsed1.items).toHaveLength(1) + expect(parsed2.items).toHaveLength(1) + expect(parsed3.items).toHaveLength(1) + expect(parsed1.items[0].name).toBe("button") + }) + + it("should handle special characters in search", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Test searching for components with hyphens + const output1 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "alert-dialog", + ]) + const parsed1 = JSON.parse(output1.stdout) + expect( + parsed1.items.some((item: any) => item.name === "alert-dialog") + ).toBe(true) + + // Test searching with just the hyphen part + const output2 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "-dialog", + ]) + const parsed2 = JSON.parse(output2.stdout) + expect( + parsed2.items.some((item: any) => item.name === "alert-dialog") + ).toBe(true) + + // Test with spaces (should still work) + const output3 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "alert dialog", + ]) + const parsed3 = JSON.parse(output3.stdout) + expect( + parsed3.items.some((item: any) => item.name === "alert-dialog") + ).toBe(true) + }) + + it("should rank exact matches higher than fuzzy matches", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + }) + + // Search for "bar" which should match "bar" exactly + const output = await npxShadcn(fixturePath, [ + "search", + "@one", + "--query", + "bar", + ]) + const parsed = JSON.parse(output.stdout) + + // If we have results, "bar" should be first (exact match) + if (parsed.items.length > 0) { + expect(parsed.items[0].name).toBe("bar") + } + }) + + it("should work with 'search' command alias", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Use 'search' instead of 'list' + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--query", + "button", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "button", + registry: "@shadcn", + }), + ]) + ) + }) + + it("should handle limit edge cases", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + }) + + // Test with limit 0 - should return all items + const output1 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--limit", + "0", + ]) + const parsed1 = JSON.parse(output1.stdout) + expect(parsed1.items.length).toBeGreaterThan(0) + + // Test with very large limit + const output2 = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "--limit", + "9999", + ]) + const parsed2 = JSON.parse(output2.stdout) + expect(parsed2.items.length).toBeLessThanOrEqual(9999) + expect(parsed2.pagination.hasMore).toBe(false) + }) + + it("should handle pagination across registries with search", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9180/r/{name}", + "@one": "http://localhost:9181/r/{name}", + }) + + // Search across multiple registries with pagination + const output = await npxShadcn(fixturePath, [ + "search", + "@shadcn", + "@one", + "--query", + "o", // Should match "button", "foo", etc. + "--limit", + "2", + "--offset", + "1", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed.items).toHaveLength(2) + expect(parsed.pagination.limit).toBe(2) + expect(parsed.pagination.offset).toBe(1) + }) + + it("should handle large dataset pagination properly", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@large": "http://localhost:9184/large/{name}", + }) + + // First page + const output1 = await npxShadcn(fixturePath, [ + "search", + "@large", + "--limit", + "5", + "--offset", + "0", + ]) + const parsed1 = JSON.parse(output1.stdout) + expect(parsed1.items).toHaveLength(5) + expect(parsed1.items[0].name).toBe("component-1") + expect(parsed1.items[4].name).toBe("component-5") + expect(parsed1.pagination).toEqual({ + total: 20, + offset: 0, + limit: 5, + hasMore: true, + }) + + // Middle page + const output2 = await npxShadcn(fixturePath, [ + "search", + "@large", + "--limit", + "5", + "--offset", + "10", + ]) + const parsed2 = JSON.parse(output2.stdout) + expect(parsed2.items).toHaveLength(5) + expect(parsed2.items[0].name).toBe("component-11") + expect(parsed2.items[4].name).toBe("component-15") + expect(parsed2.pagination.hasMore).toBe(true) + + // Last page (partial) + const output3 = await npxShadcn(fixturePath, [ + "search", + "@large", + "--limit", + "5", + "--offset", + "18", + ]) + const parsed3 = JSON.parse(output3.stdout) + expect(parsed3.items).toHaveLength(2) // Only 2 items left + expect(parsed3.items[0].name).toBe("component-19") + expect(parsed3.items[1].name).toBe("component-20") + expect(parsed3.pagination.hasMore).toBe(false) + }) + + it("should list with only name, type, and description fields", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + await configureRegistries(fixturePath, { + "@one": "http://localhost:9181/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["search", "@one"]) + const parsed = JSON.parse(output.stdout) + + // Check that we only get name, type, description, and registry fields + expect(parsed.items).toContainEqual({ + name: "foo", + type: "registry:component", + description: "Foo component from registry one", + registry: "@one", + }) + + // Verify that other fields are not included + const fooItem = parsed.items.find((item: any) => item.name === "foo") + expect(fooItem).toBeDefined() + expect(fooItem.dependencies).toBeUndefined() + expect(fooItem.files).toBeUndefined() + expect(fooItem.tailwind).toBeUndefined() + expect(fooItem.cssVars).toBeUndefined() + }) +}) diff --git a/packages/tests/src/tests/view.test.ts b/packages/tests/src/tests/view.test.ts new file mode 100644 index 0000000000..53638f14fd --- /dev/null +++ b/packages/tests/src/tests/view.test.ts @@ -0,0 +1,835 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers" +import { configureRegistries, createRegistryServer } from "../utils/registry" + +const registryShadcn = await createRegistryServer( + [ + { + name: "button", + type: "registry:ui", + description: "A button component", + dependencies: ["@radix-ui/react-slot"], + devDependencies: ["@types/react"], + files: [ + { + path: "components/ui/button.tsx", + content: + "export function Button() {\n return \n}", + type: "registry:ui", + }, + ], + }, + { + name: "card", + type: "registry:ui", + description: "A card component", + files: [ + { + path: "components/ui/card.tsx", + content: + "export function Card() {\n return
Card Component
\n}", + type: "registry:ui", + }, + ], + }, + { + name: "alert-dialog", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "components/ui/alert-dialog.tsx", + content: + "export function AlertDialog() {\n return
AlertDialog Component
\n}", + type: "registry:ui", + }, + ], + }, + ], + { + port: 9080, + path: "/r", + } +) + +const registryOne = await createRegistryServer( + [ + { + name: "foo", + type: "registry:component", + description: "Foo component from registry one", + dependencies: ["clsx", "tailwind-merge"], + files: [ + { + path: "components/foo.tsx", + content: + "export function Foo() {\n return
Foo Component from Registry 1
\n}", + type: "registry:component", + }, + ], + tailwind: { + config: { + theme: { + extend: { + colors: { + foo: "#ff0000", + }, + }, + }, + }, + }, + cssVars: { + light: { + "foo-color": "#ff0000", + }, + dark: { + "foo-color": "#00ff00", + }, + }, + }, + { + name: "bar", + type: "registry:component", + registryDependencies: ["@one/foo"], + files: [ + { + path: "components/bar.tsx", + content: + "export function Bar() {\n return
Bar Component from Registry 1
\n}", + type: "registry:component", + }, + ], + }, + { + name: "complex", + type: "registry:component", + registryDependencies: ["@two/item", "@one/foo"], + files: [ + { + path: "components/complex.tsx", + content: + "export function Complex() {\n return
Complex Component
\n}", + type: "registry:component", + }, + ], + }, + ], + { + port: 9081, + path: "/r", + } +) + +const registryTwo = await createRegistryServer( + [ + { + name: "item", + type: "registry:ui", + description: "Item component from registry two", + files: [ + { + path: "components/ui/item.tsx", + content: + "export function Item() {\n return
Item Component from Registry 2
\n}", + type: "registry:ui", + }, + ], + }, + { + name: "secure-item", + type: "registry:component", + description: "Secure item requiring authentication", + registryDependencies: ["@one/foo"], + files: [ + { + path: "components/secure-item.tsx", + content: + "export function SecureItem() {\n return
Secure Item
\n}", + type: "registry:component", + }, + ], + }, + ], + { + port: 9082, + path: "/registry", + } +) + +beforeAll(async () => { + await registryShadcn.start() + await registryOne.start() + await registryTwo.start() +}) + +afterAll(async () => { + await registryShadcn.stop() + await registryOne.stop() + await registryTwo.stop() +}) + +describe("shadcn view", () => { + it("should view a single component from shadcn registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@shadcn": "http://localhost:9080/r/{name}", + }) + const output = await npxShadcn(fixturePath, ["view", "button"]) + + const parsed = JSON.parse(output.stdout) + + expect(parsed[0]).toMatchObject({ + name: "button", + type: "registry:ui", + dependencies: ["@radix-ui/react-slot"], + files: expect.arrayContaining([ + expect.objectContaining({ + path: "registry/new-york-v4/ui/button.tsx", + type: "registry:ui", + }), + ]), + }) + }) + + it("should view multiple components from shadcn registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "button", "card"]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveLength(2) + expect(parsed.map((p: any) => p.name)).toEqual(["button", "card"]) + }) + + it("should view component with registry dependencies", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "alert-dialog"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "alert-dialog", + type: "registry:ui", + dependencies: ["@radix-ui/react-alert-dialog"], + registryDependencies: ["button"], + files: expect.arrayContaining([ + expect.objectContaining({ + path: "registry/new-york-v4/ui/alert-dialog.tsx", + type: "registry:ui", + }), + ]), + }, + ]) + }) + + it("should view component from URL without needing config", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + const output = await npxShadcn(fixturePath, [ + "view", + "http://localhost:9081/r/foo.json", + ]) + + expect(JSON.parse(output.stdout)).toMatchInlineSnapshot(` + [ + { + "cssVars": { + "dark": { + "foo-color": "#00ff00", + }, + "light": { + "foo-color": "#ff0000", + }, + }, + "dependencies": [ + "clsx", + "tailwind-merge", + ], + "description": "Foo component from registry one", + "files": [ + { + "content": "export function Foo() { + return
Foo Component from Registry 1
+ }", + "path": "components/foo.tsx", + "type": "registry:component", + }, + ], + "name": "foo", + "tailwind": { + "config": { + "theme": { + "extend": { + "colors": { + "foo": "#ff0000", + }, + }, + }, + }, + }, + "type": "registry:component", + }, + ] + `) + }) + + it("should view multiple URLs without needing config", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + const output = await npxShadcn(fixturePath, [ + "view", + "http://localhost:9081/r/foo.json", + "http://localhost:9082/registry/item.json", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveLength(2) + expect(parsed.map((p: any) => p.name)).toEqual(["foo", "item"]) + }) + + it("should view component from configured registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@one/foo"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "foo", + type: "registry:component", + description: "Foo component from registry one", + dependencies: ["clsx", "tailwind-merge"], + tailwind: { + config: { + theme: { + extend: { + colors: { + foo: "#ff0000", + }, + }, + }, + }, + }, + }, + ]) + }) + + it("should view multiple components from different registries", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + "@two": "http://localhost:9082/registry/{name}", + }) + + const output = await npxShadcn(fixturePath, [ + "view", + "@one/foo", + "@two/item", + "button", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveLength(3) + expect(parsed.map((p: any) => p.name)).toEqual(["foo", "item", "button"]) + }) + + it("should view component with nested registry dependencies", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@one/bar"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "bar", + type: "registry:component", + registryDependencies: ["@one/foo"], + files: expect.arrayContaining([ + expect.objectContaining({ + path: "components/bar.tsx", + type: "registry:component", + }), + ]), + }, + ]) + }) + + it("should view component with cross-registry dependencies", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + "@two": "http://localhost:9082/registry/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@one/complex"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "complex", + type: "registry:component", + registryDependencies: expect.arrayContaining(["@two/item", "@one/foo"]), + files: expect.arrayContaining([ + expect.objectContaining({ + path: "components/complex.tsx", + type: "registry:component", + }), + ]), + }, + ]) + }) + + it("should handle authentication for secured registries", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@two": { + url: "http://localhost:9082/registry/bearer/{name}", + headers: { + Authorization: "Bearer EXAMPLE_BEARER_TOKEN", + }, + }, + }) + + const output = await npxShadcn(fixturePath, ["view", "@two/secure-item"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "secure-item", + type: "registry:component", + description: "Secure item requiring authentication", + registryDependencies: ["@one/foo"], + files: expect.arrayContaining([ + expect.objectContaining({ + path: "components/secure-item.tsx", + }), + ]), + }, + ]) + }) + + it("should fail when viewing secured item without authentication", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@two": "http://localhost:9082/registry/bearer/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@two/secure-item"]) + expect(output.stdout).toContain("Unauthorized") + }) + + it("should handle authentication with environment variables", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + "@two": { + url: "http://localhost:9082/registry/bearer/{name}", + headers: { + Authorization: "Bearer ${BEARER_TOKEN}", + }, + }, + }) + + process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN" + + const output = await npxShadcn(fixturePath, [ + "view", + "@two/secure-item", + "@one/foo", + ]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "secure-item", + type: "registry:component", + registryDependencies: ["@one/foo"], + }, + { + name: "foo", + type: "registry:component", + dependencies: ["clsx", "tailwind-merge"], + }, + ]) + + delete process.env.BEARER_TOKEN + }) + + it("should mix URLs and named components", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, [ + "view", + "http://localhost:9082/registry/item.json", + "@one/foo", + "button", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveLength(3) + expect(parsed.map((p: any) => p.name)).toEqual(["item", "foo", "button"]) + }) + + it("should handle non-existent component gracefully", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "non-existent"]) + + expect(output.stdout).toContain("not found") + }) + + it("should handle non-existent registry gracefully", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "@unknown/component"]) + + expect(output.stdout).toContain('Unknown registry "@unknown"') + }) + + it("should work with @shadcn namespace", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, [ + "view", + "@shadcn/button", + "@shadcn/card", + ]) + + const parsed = JSON.parse(output.stdout) + expect(parsed).toHaveLength(2) + expect(parsed.map((p: any) => p.name)).toEqual(["button", "card"]) + }) + + it("should handle 404 for non-existent URL", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + const output = await npxShadcn(fixturePath, [ + "view", + "http://localhost:9081/r/does-not-exist.json", + ]) + + expect(output.stdout).toContain("not found") + }) + + it("should handle multiple errors in batch", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, [ + "view", + "non-existent", + "@one/does-not-exist", + ]) + + // Should fail on first error - non-existent component + expect(output.stdout.toLowerCase()).toContain("not found") + }) + + it("should handle invalid URL format", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + const output = await npxShadcn(fixturePath, ["view", "not-a-valid-url"]) + + // With defaults in place, it will try to fetch as a component and fail + expect(output.stdout.toLowerCase()).toContain("not found") + }) + + it("should handle network timeouts gracefully", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + const output = await npxShadcn(fixturePath, [ + "view", + "http://localhost:9999/timeout.json", // Non-existent server + ]) + + // Check for connection error in the output + expect(output.stdout.toLowerCase()).toContain("failed, reason:") + }) + + it("should handle mixed success and failure", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, [ + "view", + "button", + "non-existent-component", + ]) + + // Should fail fast on first error + expect(output.stdout).not.toContain('"name": "button"') + expect(output.stdout).toContain("not found") + }) + + it("should handle missing environment variables for authenticated registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + await configureRegistries(fixturePath, { + "@auth": { + url: "http://localhost:9082/registry/bearer/{name}", + headers: { + Authorization: "Bearer ${MISSING_ENV_VAR}", + }, + }, + }) + + const output = await npxShadcn(fixturePath, ["view", "@auth/secure-item"]) + + expect(output.stdout).toContain("MISSING_ENV_VAR") + }) + + it("should handle validation errors", async () => { + // Create a server that returns invalid schema + const badServer = await createRegistryServer( + [ + { + name: "invalid-schema", + type: "invalid-type", // Invalid type + files: [ + { + path: "components/bad.tsx", + content: "export function Bad() { return null }", + // Missing required 'type' field + }, + ], + } as any, + ], + { + port: 9083, + path: "/bad", + } + ) + + await badServer.start() + + const fixturePath = await createFixtureTestDirectory("next-app") + const output = await npxShadcn(fixturePath, [ + "view", + "http://localhost:9083/bad/invalid-schema.json", + ]) + + // Should handle validation error + expect(output.stdout.toLowerCase()).toContain("failed to parse") + + await badServer.stop() + }) + + it("should handle dependencies that require authentication", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + // Configure both registries - @two requires auth, @one doesn't + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + "@two": { + url: "http://localhost:9082/registry/bearer/{name}", + headers: { + Authorization: "Bearer EXAMPLE_BEARER_TOKEN", + }, + }, + }) + + // complex depends on @two/item which requires auth + const output = await npxShadcn(fixturePath, ["view", "@one/complex"]) + + // Should just show the component metadata, not fail on auth + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "complex", + type: "registry:component", + registryDependencies: expect.arrayContaining(["@two/item", "@one/foo"]), + files: expect.arrayContaining([ + expect.objectContaining({ + path: "components/complex.tsx", + type: "registry:component", + }), + ]), + }, + ]) + }) + + it("should fail when viewing component with unauthenticated dependencies", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + // Configure registries - @two requires auth but no token provided + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + "@two": "http://localhost:9082/registry/bearer/{name}", + }) + + // Try to view complex which depends on @two/item (requires auth) + // Note: This should succeed for view command as it just shows metadata + const output = await npxShadcn(fixturePath, ["view", "@one/complex"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "complex", + type: "registry:component", + registryDependencies: expect.arrayContaining(["@two/item"]), + }, + ]) + }) + + it("should view authenticated dependencies when directly requested", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + // Configure with proper auth + await configureRegistries(fixturePath, { + "@two": { + url: "http://localhost:9082/registry/bearer/{name}", + headers: { + Authorization: "Bearer EXAMPLE_BEARER_TOKEN", + }, + }, + }) + + // Directly view the authenticated item + const output = await npxShadcn(fixturePath, ["view", "@two/item"]) + + expect(JSON.parse(output.stdout)).toMatchObject([ + { + name: "item", + type: "registry:ui", + description: "Item component from registry two", + files: expect.arrayContaining([ + expect.objectContaining({ + path: "components/ui/item.tsx", + type: "registry:ui", + }), + ]), + }, + ]) + }) + + it("should view component with all metadata fields", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@one/foo"]) + const parsed = JSON.parse(output.stdout) + + expect(parsed[0]).toMatchObject({ + name: "foo", + type: "registry:component", + description: "Foo component from registry one", + dependencies: ["clsx", "tailwind-merge"], + files: expect.arrayContaining([ + expect.objectContaining({ + path: "components/foo.tsx", + type: "registry:component", + }), + ]), + tailwind: expect.objectContaining({ + config: expect.objectContaining({ + theme: expect.objectContaining({ + extend: expect.objectContaining({ + colors: expect.objectContaining({ + foo: "#ff0000", + }), + }), + }), + }), + }), + cssVars: expect.objectContaining({ + light: expect.objectContaining({ + "foo-color": "#ff0000", + }), + dark: expect.objectContaining({ + "foo-color": "#00ff00", + }), + }), + }) + }) + + it("should handle namespace with special characters", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "@test-123/component"]) + + expect(output.stdout).toContain('Unknown registry "@test-123"') + }) + + it("should handle empty component name in namespace", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "@shadcn/"]) + + expect(output.stdout).toContain("not found") + }) + + it("should handle namespace without @ prefix", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "one/foo"]) + + // Without @ prefix, it's treated as a regular component name + expect(output.stdout).toContain("not found") + }) + + it("should handle double namespace", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "@@test/component"]) + + expect(output.stdout).toContain("not found") + }) + + it("should two error for unknown registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, ["view", "@test/component"]) + + expect(output.stdout).toContain('Unknown registry "@test"') + }) + + it("should two error for unknown registry not in first position", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, [ + "view", + "@shadcn/component", + "@does-not-exist/component", + ]) + + expect(output.stdout).toContain('Unknown registry "@does-not-exist"') + }) + + it("should handle namespace with multiple slashes", async () => { + const fixturePath = await createFixtureTestDirectory("next-app-init") + const output = await npxShadcn(fixturePath, [ + "view", + "@test/path/to/component", + ]) + + expect(output.stdout).toContain('Unknown registry "@test"') + }) + + it("should view from components.json with registries config only", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@one/foo"]) + + expect(output.stdout).toContain("Foo component from registry one") + }) + + it("should error when viewing from non-existent registry with configured registries", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + + // Configure @one registry, but try to view from @two + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + const output = await npxShadcn(fixturePath, ["view", "@two/item"]) + + expect(output.stdout).toContain('Unknown registry "@two"') + }) + + it("should error when viewing non-existent item from configured registry", async () => { + const fixturePath = await createFixtureTestDirectory("next-app") + + // Configure @one registry + await configureRegistries(fixturePath, { + "@one": "http://localhost:9081/r/{name}", + }) + + // Try to view an item that doesn't exist in @one registry + const output = await npxShadcn(fixturePath, ["view", "@one/does-not-exist"]) + + expect(output.stdout).toContain("not found") + }) +}) diff --git a/packages/tests/src/utils/registry.ts b/packages/tests/src/utils/registry.ts index df47fd2e11..a706787fd4 100644 --- a/packages/tests/src/utils/registry.ts +++ b/packages/tests/src/utils/registry.ts @@ -125,6 +125,30 @@ export async function createRegistryServer( return } + // Check if this is a registry.json request + if (urlWithoutQuery?.endsWith("/registry")) { + // Check if this requires bearer authentication + if (request.url?.includes("/bearer/")) { + // Validate bearer token + const token = request.headers.authorization?.split(" ")[1] + if (token !== "EXAMPLE_BEARER_TOKEN") { + response.writeHead(401, { "Content-Type": "application/json" }) + response.end(JSON.stringify({ error: "Unauthorized" })) + return + } + } + + response.writeHead(200, { "Content-Type": "application/json" }) + response.end( + JSON.stringify({ + name: "Test Registry", + homepage: "https://example.com", + items: items, + }) + ) + return + } + const match = urlWithoutQuery?.match( new RegExp(`^${path}/(?:.*/)?([^/]+)$`) ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f1f41b185..37e0686990 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -740,6 +740,9 @@ importers: fs-extra: specifier: ^11.1.0 version: 11.3.0 + fuzzysort: + specifier: ^3.1.0 + version: 3.1.0 https-proxy-agent: specifier: ^6.2.0 version: 6.2.1 @@ -6084,6 +6087,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + geist@1.3.1: resolution: {integrity: sha512-Q4gC1pBVPN+D579pBaz0TRRnGA4p9UK6elDY/xizXdFk/g4EKR5g0I+4p/Kj6gM0SajDBZ/0FvDV9ey9ud7BWw==} peerDependencies: @@ -16091,6 +16097,8 @@ snapshots: functions-have-names@1.2.3: {} + fuzzysort@3.1.0: {} + geist@1.3.1(next@14.3.0-canary.43(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: next: 14.3.0-canary.43(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f9f260ce9..2e9981834f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,4 @@ packages: - "!**/test/**" - "!**/fixtures/**" - "!**/temp/**" + - "!packages/tests/temp/**"