feat(shadcn): add view command (#7994)

* feat(shadcn): add view command

* test(shadcn): add tests for view command

* feat(shadcn): allow shadow config in view command

* chore: changeset

* test(shadcn): skip view

* test(shadcn): update view port number

* feat(shadcn): add list command

* fix

* feat(shadcn): implement search command

* fix: tests

* fix

* chore: update changesets
This commit is contained in:
shadcn
2025-08-11 13:01:05 +04:00
committed by GitHub
parent b5b8deedde
commit 4f5333ea7a
17 changed files with 2731 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": major
---
add view and search commands

View File

@@ -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/*\"",

View File

@@ -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",

View File

@@ -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("<registries...>", "the registry names to search items from")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-q, --query <query>", "query string")
.option(
"-l, --limit <number>",
"maximum number of items to display per registry",
"100"
)
.option("-o, --offset <number>", "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()
}
})

View File

@@ -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("<items...>", "the item names or URLs to view")
.option(
"-c, --cwd <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()
}
})

View File

@@ -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)

View File

@@ -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"
)
}
}
}
})
})

View File

@@ -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<Config>
) {
// 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`
}

View File

@@ -1,5 +1,7 @@
export { getRegistryItems, resolveRegistryItems, getRegistry } from "./api"
export { searchRegistries } from "./search"
export {
RegistryError,
RegistryNotFoundError,

View File

@@ -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()
})
})

View File

@@ -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<Parameters<typeof getRegistry>[0]>,
options?: {
query?: string
limit?: number
offset?: number
},
config?: Partial<Config>
) {
let allItems: z.infer<typeof searchResultItemSchema>[] = []
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<typeof searchResultItemSchema>[]
}
// 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<typeof searchResultsSchema> = {
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<typeof searchableItemSchema>
function searchItems<
T extends {
name: string
type?: string
description?: string
[key: string]: any
} = SearchableItem
>(
items: T[],
options: {
query: string
} & Pick<Parameters<typeof fuzzysort.go>[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)
}

View File

@@ -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()
}

View File

@@ -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 <button>Click me</button>\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 <div>Card Component</div>\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 <div>AlertDialog Component</div>\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 <div>Foo Component from Registry 1</div>\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 <div>Bar Component from Registry 1</div>\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 <div>Component ${
i + 1
}</div> }`,
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 <div>Item Component from Registry 2</div>\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 <div>Secure Item</div>\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()
})
})

View File

@@ -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 <button>Click me</button>\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 <div>Card Component</div>\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 <div>AlertDialog Component</div>\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 <div>Foo Component from Registry 1</div>\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 <div>Bar Component from Registry 1</div>\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 <div>Complex Component</div>\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 <div>Item Component from Registry 2</div>\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 <div>Secure Item</div>\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 <div>Foo Component from Registry 1</div>
}",
"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")
})
})

View File

@@ -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}/(?:.*/)?([^/]+)$`)
)

8
pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -4,3 +4,4 @@ packages:
- "!**/test/**"
- "!**/fixtures/**"
- "!**/temp/**"
- "!packages/tests/temp/**"