mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
feat(cli): improve search command (#10886)
- Search across multiple registries and make the registry argument optional: omit it to search every registry configured in components.json (builtins like @shadcn excluded). Without a components.json or configured registries, a clear usage error is printed. - Add a --type filter (accepts "ui" or "registry:ui", comma-separated) with validation against the known item types. - Fetch registries concurrently with a capped worker pool, preserving result order. - Tolerate per-registry failures when searching all configured registries (reported in a structured `errors` field); exit non-zero when every registry fails. Usage errors print directly instead of routing through handleError. - MCP parity: optional registries (search-all), a `types` filter, and type validation across the search/list/examples tools. - Keep the public registry surface to `searchRegistries` and make it self-contained (clears its own context, useCache defaults to false). - Consolidate search formatting into registry/search, add the `errors` field to searchResultsSchema, and update the skill docs.
This commit is contained in:
5
.changeset/pink-rivers-turn.md
Normal file
5
.changeset/pink-rivers-turn.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": minor
|
||||
---
|
||||
|
||||
improve search command
|
||||
@@ -1,4 +1,7 @@
|
||||
import { searchRegistries } from "@/src/registry/search"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import fsExtra from "fs-extra"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { search } from "./search"
|
||||
@@ -35,6 +38,30 @@ const baseConfig = {
|
||||
},
|
||||
}
|
||||
|
||||
const mockResults = {
|
||||
pagination: {
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
hasMore: false,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
registry: "@shadcn",
|
||||
addCommandArgument: "@shadcn/button",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
registry: "@shadcn",
|
||||
addCommandArgument: "@shadcn/card",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
vi.mock("fs-extra", () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => false),
|
||||
@@ -62,8 +89,11 @@ vi.mock("@/src/registry/validator", () => ({
|
||||
validateRegistryConfigForItems: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/search", () => ({
|
||||
searchRegistries: vi.fn(() => []),
|
||||
// Stub searchRegistries but keep the real printSearchResults (both now live
|
||||
// in @/src/registry/search) so the human-readable output is exercised.
|
||||
vi.mock("@/src/registry/search", async (importActual) => ({
|
||||
...(await importActual<typeof import("@/src/registry/search")>()),
|
||||
searchRegistries: vi.fn(() => mockResults),
|
||||
}))
|
||||
|
||||
vi.mock("@/src/registry/context", () => ({
|
||||
@@ -112,6 +142,185 @@ describe("search command", () => {
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("prints human-readable output by default", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
await expect(
|
||||
search.parseAsync(["@shadcn", "--cwd", "/tmp/test-project"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:0")
|
||||
|
||||
expect(searchRegistries).toHaveBeenCalled()
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Found 2 items in @shadcn")
|
||||
)
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("@shadcn/button"))
|
||||
expect(log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('"pagination"')
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("prints JSON output with --json", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
await expect(
|
||||
search.parseAsync(["@shadcn", "--cwd", "/tmp/test-project", "--json"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:0")
|
||||
|
||||
expect(log).toHaveBeenCalledWith(JSON.stringify(mockResults, null, 2))
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("requires a registry when no components.json is present", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
// fs-extra.existsSync is mocked to return false (no components.json).
|
||||
// This is a usage error, so it prints a message and exits 1 directly
|
||||
// instead of routing through handleError.
|
||||
await expect(
|
||||
search.parseAsync(["--cwd", "/tmp/test-project"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Provide a registry or namespace to search")
|
||||
)
|
||||
expect(searchRegistries).not.toHaveBeenCalled()
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("requires a registry when components.json has no registries", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
|
||||
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
|
||||
// components.json present but with no configured registries (only the
|
||||
// builtin @shadcn, which is excluded from "search all").
|
||||
vi.mocked(getConfig).mockReturnValueOnce({ ...baseConfig } as never)
|
||||
|
||||
await expect(
|
||||
search.parseAsync(["--cwd", "/tmp/test-project"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No registries are configured")
|
||||
)
|
||||
expect(searchRegistries).not.toHaveBeenCalled()
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("errors on an unknown --type", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
await expect(
|
||||
search.parseAsync(
|
||||
["@shadcn", "--type", "bogus", "--cwd", "/tmp/test-project"],
|
||||
{
|
||||
from: "user",
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Unknown type"))
|
||||
expect(searchRegistries).not.toHaveBeenCalled()
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("searches all configured registries when none are provided", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
|
||||
// readJson returns a raw (unresolved) components.json shape.
|
||||
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
|
||||
vi.mocked(getConfig).mockReturnValueOnce({
|
||||
...baseConfig,
|
||||
registries: {
|
||||
"@acme": "https://acme.com/{name}.json",
|
||||
"@internal": "https://internal.com/{name}.json",
|
||||
},
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
search.parseAsync(["--cwd", "/tmp/test-project"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:0")
|
||||
|
||||
// No explicit namespace args, so nothing to discover.
|
||||
expect(ensureRegistriesInConfig).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Only the configured registries are searched (builtin @shadcn is
|
||||
// excluded), and per-registry failures are tolerated.
|
||||
expect(searchRegistries).toHaveBeenCalledWith(
|
||||
["@acme", "@internal"],
|
||||
expect.objectContaining({ continueOnError: true })
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
|
||||
it("exits non-zero when every registry fails in search-all", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const exit = mockProcessExit()
|
||||
|
||||
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
|
||||
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
|
||||
vi.mocked(getConfig).mockReturnValueOnce({
|
||||
...baseConfig,
|
||||
registries: {
|
||||
"@acme": "https://acme.com/{name}.json",
|
||||
"@internal": "https://internal.com/{name}.json",
|
||||
},
|
||||
} as never)
|
||||
|
||||
// Both configured registries failed to load.
|
||||
vi.mocked(searchRegistries).mockReturnValueOnce({
|
||||
pagination: { total: 0, offset: 0, limit: 0, hasMore: false },
|
||||
items: [],
|
||||
errors: [
|
||||
{ registry: "@acme", message: "boom" },
|
||||
{ registry: "@internal", message: "boom" },
|
||||
],
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
search.parseAsync(["--cwd", "/tmp/test-project"], {
|
||||
from: "user",
|
||||
})
|
||||
).rejects.toThrow("process.exit:1")
|
||||
|
||||
log.mockRestore()
|
||||
exit.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
function mockProcessExit() {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import path from "path"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { searchRegistries } from "@/src/registry/search"
|
||||
import {
|
||||
findUnknownSearchTypes,
|
||||
printSearchResults,
|
||||
resolveSearchRegistries,
|
||||
SEARCHABLE_TYPES,
|
||||
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 { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||
import { Command } from "commander"
|
||||
import fsExtra from "fs-extra"
|
||||
@@ -15,6 +23,7 @@ import { z } from "zod"
|
||||
const searchOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
query: z.string().optional(),
|
||||
types: z.array(z.string()).optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
@@ -27,8 +36,8 @@ export const search = new Command()
|
||||
.alias("list")
|
||||
.description("search items from registries")
|
||||
.argument(
|
||||
"<registries...>",
|
||||
"the registry addresses to search. Supports namespaces, GitHub sources and URLs."
|
||||
"[registries...]",
|
||||
"the registry addresses to search. Supports namespaces, GitHub sources and URLs. When omitted, searches all registries configured in components.json."
|
||||
)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
@@ -37,20 +46,44 @@ export const search = new Command()
|
||||
)
|
||||
.option("-q, --query <query>", "query string")
|
||||
.option(
|
||||
"-l, --limit <number>",
|
||||
"maximum number of items to display per registry",
|
||||
"100"
|
||||
"-t, --type <type>",
|
||||
"filter by item type, e.g. ui, block, hook. Comma-separated for multiple."
|
||||
)
|
||||
.option("-l, --limit <number>", "maximum number of items to display", "100")
|
||||
.option("-o, --offset <number>", "number of items to skip", "0")
|
||||
.option("--json", "output as JSON.", false)
|
||||
.action(async (registries: string[], opts) => {
|
||||
try {
|
||||
const options = searchOptionsSchema.parse({
|
||||
cwd: path.resolve(opts.cwd),
|
||||
query: opts.query,
|
||||
types: opts.type
|
||||
? opts.type
|
||||
.split(",")
|
||||
.map((type: string) => type.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
|
||||
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
|
||||
})
|
||||
|
||||
// Validate type filters up front so an unknown type fails clearly
|
||||
// instead of silently returning no results.
|
||||
if (options.types?.length) {
|
||||
const unknownTypes = findUnknownSearchTypes(options.types)
|
||||
if (unknownTypes.length > 0) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`Unknown ${unknownTypes.length === 1 ? "type" : "types"}: ${unknownTypes
|
||||
.map((type) => highlighter.info(type))
|
||||
.join(", ")}.`
|
||||
)
|
||||
logger.error(`Valid types: ${SEARCHABLE_TYPES.join(", ")}.`)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
// Start with a shadow config to support partial components.json.
|
||||
@@ -65,7 +98,8 @@ export const search = new Command()
|
||||
|
||||
// Check if there's a components.json file (partial or complete).
|
||||
const componentsJsonPath = path.resolve(options.cwd, "components.json")
|
||||
if (fsExtra.existsSync(componentsJsonPath)) {
|
||||
const hasComponentsJson = fsExtra.existsSync(componentsJsonPath)
|
||||
if (hasComponentsJson) {
|
||||
const existingConfig = await fsExtra.readJson(componentsJsonPath)
|
||||
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
|
||||
shadowConfig = configWithDefaults({
|
||||
@@ -85,6 +119,33 @@ export const search = new Command()
|
||||
// Use shadow config if getConfig fails (partial components.json).
|
||||
}
|
||||
|
||||
// When no registry is provided, search across every registry configured
|
||||
// in components.json. This only makes sense when a components.json is
|
||||
// present to enumerate; otherwise there is nothing to search and we ask
|
||||
// for an explicit registry/namespace argument.
|
||||
const searchAllConfigured = registries.length === 0
|
||||
if (searchAllConfigured && !hasComponentsJson) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`Provide a registry or namespace to search, e.g. ${highlighter.info(
|
||||
"shadcn search @shadcn"
|
||||
)}.`
|
||||
)
|
||||
logger.break()
|
||||
logger.error(
|
||||
`If you have a ${highlighter.info(
|
||||
"components.json"
|
||||
)} with registries configured, run ${highlighter.info(
|
||||
"shadcn search"
|
||||
)} with no arguments to search all of them.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Only namespace registries passed explicitly need to be discovered and
|
||||
// added to the config. Registries already configured in components.json
|
||||
// are resolved directly from the config below.
|
||||
const { config: updatedConfig, newRegistries } =
|
||||
await ensureRegistriesInConfig(
|
||||
registries
|
||||
@@ -100,19 +161,63 @@ export const search = new Command()
|
||||
config.registries = updatedConfig.registries
|
||||
}
|
||||
|
||||
// Validate registries early for better error messages.
|
||||
validateRegistryConfigForItems(registries, config)
|
||||
// When no registry is passed, "search all" resolves to every configured
|
||||
// registry, excluding builtins (e.g. @shadcn).
|
||||
const registriesToSearch = resolveSearchRegistries(registries, config)
|
||||
|
||||
// Use searchRegistries for both search and non-search cases
|
||||
const results = await searchRegistries(registries as `@${string}`[], {
|
||||
if (searchAllConfigured && registriesToSearch.length === 0) {
|
||||
logger.break()
|
||||
logger.error(
|
||||
`No registries are configured in ${highlighter.info(
|
||||
"components.json"
|
||||
)}.`
|
||||
)
|
||||
logger.error(
|
||||
`Provide a registry or namespace to search, e.g. ${highlighter.info(
|
||||
"shadcn search @shadcn"
|
||||
)}.`
|
||||
)
|
||||
logger.break()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// For explicitly requested registries we validate up front so the user
|
||||
// gets a clear error (e.g. missing env vars). When searching every
|
||||
// configured registry we skip strict validation and instead tolerate
|
||||
// individual registry failures (see continueOnError below).
|
||||
if (!searchAllConfigured) {
|
||||
validateRegistryConfigForItems(registriesToSearch, config)
|
||||
}
|
||||
|
||||
const results = await searchRegistries(registriesToSearch, {
|
||||
query: options.query,
|
||||
types: options.types,
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
config,
|
||||
// Tolerate per-registry failures when searching every configured
|
||||
// registry; failures are returned in `results.errors` so they can be
|
||||
// surfaced to humans (printSearchResults) and machines (--json) alike.
|
||||
continueOnError: searchAllConfigured,
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.exit(0)
|
||||
// In search-all mode, failures are tolerated and collected. If *every*
|
||||
// registry failed, the search did not succeed — exit non-zero.
|
||||
const allRegistriesFailed =
|
||||
searchAllConfigured &&
|
||||
results.errors?.length === registriesToSearch.length
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
} else {
|
||||
printSearchResults(results, {
|
||||
query: options.query,
|
||||
types: options.types,
|
||||
registries: registriesToSearch,
|
||||
})
|
||||
}
|
||||
|
||||
process.exit(allRegistriesFailed ? 1 : 0)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getRegistryItems, searchRegistries } from "@/src/registry"
|
||||
import { RegistryError } from "@/src/registry/errors"
|
||||
import {
|
||||
resolveSearchRegistries,
|
||||
SEARCHABLE_TYPES,
|
||||
} from "@/src/registry/search"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -10,9 +14,11 @@ import { z } from "zod"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
|
||||
import {
|
||||
findUnknownTypesMessage,
|
||||
formatItemExamples,
|
||||
formatRegistryItems,
|
||||
formatSearchResultsWithPagination,
|
||||
formatSkippedRegistries,
|
||||
getMcpConfig,
|
||||
npxShadcn,
|
||||
} from "./utils"
|
||||
@@ -47,13 +53,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
z.object({
|
||||
registries: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
|
||||
"Array of registry names to list (e.g., ['@shadcn', '@acme']). Omit to list from every registry configured in components.json."
|
||||
),
|
||||
types: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
`Filter by item type. One of: ${SEARCHABLE_TYPES.join(", ")}.`
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum number of items to return"),
|
||||
.describe(
|
||||
"Maximum number of items to return (defaults to 100; use 0 for no limit)"
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -69,18 +84,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
z.object({
|
||||
registries: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
|
||||
"Array of registry names to search (e.g., ['@shadcn', '@acme']). Omit to search every registry configured in components.json."
|
||||
),
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query string for fuzzy matching against item names and descriptions"
|
||||
),
|
||||
types: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
`Filter by item type. One of: ${SEARCHABLE_TYPES.join(", ")}.`
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum number of items to return"),
|
||||
.describe(
|
||||
"Maximum number of items to return (defaults to 100; use 0 for no limit)"
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -110,8 +134,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
z.object({
|
||||
registries: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Array of registry names to search (e.g., ['@shadcn', '@acme'])"
|
||||
"Array of registry names to search (e.g., ['@shadcn', '@acme']). Omit to search every registry configured in components.json."
|
||||
),
|
||||
query: z
|
||||
.string()
|
||||
@@ -196,21 +221,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
|
||||
case "search_items_in_registries": {
|
||||
const inputSchema = z.object({
|
||||
registries: z.array(z.string()),
|
||||
registries: z.array(z.string()).optional(),
|
||||
query: z.string(),
|
||||
types: z.array(z.string()).optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
|
||||
const args = inputSchema.parse(request.params.arguments)
|
||||
const results = await searchRegistries(args.registries, {
|
||||
|
||||
const unknownTypesMessage = findUnknownTypesMessage(args.types)
|
||||
if (unknownTypesMessage) {
|
||||
return {
|
||||
content: [{ type: "text", text: unknownTypesMessage }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const config = await getMcpConfig(process.cwd())
|
||||
|
||||
// When registries are omitted, search every configured registry and
|
||||
// tolerate individual failures.
|
||||
const searchAll = !args.registries?.length
|
||||
const registries = resolveSearchRegistries(
|
||||
args.registries ?? [],
|
||||
config
|
||||
)
|
||||
|
||||
if (registries.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const results = await searchRegistries(registries, {
|
||||
query: args.query,
|
||||
limit: args.limit,
|
||||
types: args.types,
|
||||
limit: args.limit ?? 100,
|
||||
offset: args.offset,
|
||||
config: await getMcpConfig(process.cwd()),
|
||||
config,
|
||||
useCache: false,
|
||||
continueOnError: searchAll,
|
||||
})
|
||||
|
||||
const skippedNote = formatSkippedRegistries(results)
|
||||
|
||||
if (results.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
@@ -218,9 +278,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
type: "text",
|
||||
text: dedent`No items found matching "${
|
||||
args.query
|
||||
}" in registries ${args.registries.join(
|
||||
}" in registries ${registries.join(
|
||||
", "
|
||||
)}, Try searching with a different query or registry.`,
|
||||
)}, Try searching with a different query or registry.${skippedNote}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -230,10 +290,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatSearchResultsWithPagination(results, {
|
||||
query: args.query,
|
||||
registries: args.registries,
|
||||
}),
|
||||
text:
|
||||
formatSearchResultsWithPagination(results, {
|
||||
query: args.query,
|
||||
registries,
|
||||
}) + skippedNote,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -241,28 +302,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
|
||||
case "list_items_in_registries": {
|
||||
const inputSchema = z.object({
|
||||
registries: z.array(z.string()),
|
||||
registries: z.array(z.string()).optional(),
|
||||
types: z.array(z.string()).optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
cwd: z.string().optional(),
|
||||
})
|
||||
|
||||
const args = inputSchema.parse(request.params.arguments)
|
||||
const results = await searchRegistries(args.registries, {
|
||||
limit: args.limit,
|
||||
|
||||
const unknownTypesMessage = findUnknownTypesMessage(args.types)
|
||||
if (unknownTypesMessage) {
|
||||
return {
|
||||
content: [{ type: "text", text: unknownTypesMessage }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
const config = await getMcpConfig(process.cwd())
|
||||
|
||||
const listAll = !args.registries?.length
|
||||
const registries = resolveSearchRegistries(
|
||||
args.registries ?? [],
|
||||
config
|
||||
)
|
||||
|
||||
if (registries.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const results = await searchRegistries(registries, {
|
||||
types: args.types,
|
||||
limit: args.limit ?? 100,
|
||||
offset: args.offset,
|
||||
config: await getMcpConfig(process.cwd()),
|
||||
config,
|
||||
useCache: false,
|
||||
continueOnError: listAll,
|
||||
})
|
||||
|
||||
const skippedNote = formatSkippedRegistries(results)
|
||||
|
||||
if (results.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: dedent`No items found in registries ${args.registries.join(
|
||||
text: dedent`No items found in registries ${registries.join(
|
||||
", "
|
||||
)}.`,
|
||||
)}.${skippedNote}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -272,9 +366,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatSearchResultsWithPagination(results, {
|
||||
registries: args.registries,
|
||||
}),
|
||||
text:
|
||||
formatSearchResultsWithPagination(results, {
|
||||
registries,
|
||||
}) + skippedNote,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -321,16 +416,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
case "get_item_examples_from_registries": {
|
||||
const inputSchema = z.object({
|
||||
query: z.string(),
|
||||
registries: z.array(z.string()),
|
||||
registries: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const args = inputSchema.parse(request.params.arguments)
|
||||
const config = await getMcpConfig()
|
||||
|
||||
const results = await searchRegistries(args.registries, {
|
||||
const searchAll = !args.registries?.length
|
||||
const registries = resolveSearchRegistries(
|
||||
args.registries ?? [],
|
||||
config
|
||||
)
|
||||
|
||||
if (registries.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const results = await searchRegistries(registries, {
|
||||
query: args.query,
|
||||
config,
|
||||
useCache: false,
|
||||
continueOnError: searchAll,
|
||||
})
|
||||
|
||||
if (results.items.length === 0) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getRegistriesConfig } from "@/src/registry/api"
|
||||
import { findUnknownSearchTypes, SEARCHABLE_TYPES } from "@/src/registry/search"
|
||||
import { registryItemSchema, searchResultsSchema } from "@/src/schema"
|
||||
import { getPackageRunner } from "@/src/utils/get-package-manager"
|
||||
import { z } from "zod"
|
||||
@@ -78,6 +79,41 @@ export function formatSearchResultsWithPagination(
|
||||
return output
|
||||
}
|
||||
|
||||
// Validates type filters the same way the CLI does. Returns an error message
|
||||
// listing the valid types when any are unknown, or null when all are valid.
|
||||
export function findUnknownTypesMessage(types?: string[]): string | null {
|
||||
if (!types?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const unknown = findUnknownSearchTypes(types)
|
||||
if (unknown.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `Unknown type${
|
||||
unknown.length === 1 ? "" : "s"
|
||||
}: ${unknown.join(", ")}. Valid types: ${SEARCHABLE_TYPES.join(", ")}.`
|
||||
}
|
||||
|
||||
// When searching across all configured registries, some may fail to load.
|
||||
// Returns a note listing them (empty string when there were no failures).
|
||||
export function formatSkippedRegistries(
|
||||
results: z.infer<typeof searchResultsSchema>
|
||||
) {
|
||||
if (!results.errors?.length) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lines = results.errors.map(
|
||||
(error) => `- ${error.registry}: ${error.message}`
|
||||
)
|
||||
|
||||
return `\n\nSkipped ${results.errors.length} registr${
|
||||
results.errors.length === 1 ? "y" : "ies"
|
||||
} that failed to load:\n${lines.join("\n")}`
|
||||
}
|
||||
|
||||
export function formatRegistryItems(
|
||||
items: z.infer<typeof registryItemSchema>[]
|
||||
) {
|
||||
|
||||
@@ -287,6 +287,11 @@ export const searchResultItemSchema = z.object({
|
||||
addCommandArgument: z.string(),
|
||||
})
|
||||
|
||||
export const searchResultErrorSchema = z.object({
|
||||
registry: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
|
||||
export const searchResultsSchema = z.object({
|
||||
pagination: z.object({
|
||||
total: z.number(),
|
||||
@@ -295,6 +300,10 @@ export const searchResultsSchema = z.object({
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
items: z.array(searchResultItemSchema),
|
||||
// Registries that failed to load during the search. Only present when a
|
||||
// search tolerates per-registry failures (see searchRegistries'
|
||||
// continueOnError) and at least one registry was skipped.
|
||||
errors: z.array(searchResultErrorSchema).optional(),
|
||||
})
|
||||
|
||||
// Legacy schema for getRegistriesIndex() backward compatibility.
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { getRegistry } from "./api"
|
||||
import { buildRegistryItemNameFromRegistry, searchRegistries } from "./search"
|
||||
import {
|
||||
buildRegistryItemNameFromRegistry,
|
||||
findUnknownSearchTypes,
|
||||
formatSearchResultDescription,
|
||||
formatSearchResultType,
|
||||
printSearchResults,
|
||||
resolveSearchRegistries,
|
||||
SEARCH_CONCURRENCY,
|
||||
SEARCH_RESULT_DESCRIPTION_MAX_LENGTH,
|
||||
SEARCHABLE_TYPES,
|
||||
searchRegistries,
|
||||
} from "./search"
|
||||
|
||||
describe("searchRegistries", () => {
|
||||
it("should fetch and return registries in flat format", async () => {
|
||||
@@ -152,6 +163,177 @@ describe("searchRegistries", () => {
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("collects errors and continues when continueOnError is set", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
if (name === "@ok") {
|
||||
return {
|
||||
name: "ok/registry",
|
||||
homepage: "https://ok.com",
|
||||
items: [
|
||||
{ name: "button", type: "registry:ui", description: "A button" },
|
||||
],
|
||||
}
|
||||
}
|
||||
throw new Error(`Registry not found: ${name}`)
|
||||
})
|
||||
|
||||
const results = await searchRegistries(["@ok", "@broken"], {
|
||||
continueOnError: true,
|
||||
})
|
||||
|
||||
// Items from the working registry are still returned.
|
||||
expect(results.items).toHaveLength(1)
|
||||
expect(results.items[0].name).toBe("button")
|
||||
|
||||
// The failing registry is recorded in errors instead of throwing.
|
||||
expect(results.errors).toEqual([
|
||||
{
|
||||
registry: "@broken",
|
||||
message: "Registry not found: @broken",
|
||||
},
|
||||
])
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("preserves argument order even when responses resolve out of order", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
// @slow resolves after @fast, but its items must still come first because
|
||||
// it is listed first. Guards the parallel fetch / ordered processing.
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
if (name === "@slow") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return {
|
||||
name: "slow",
|
||||
homepage: "https://slow.com",
|
||||
items: [{ name: "slow-item", type: "registry:ui", description: "" }],
|
||||
}
|
||||
}
|
||||
if (name === "@fast") {
|
||||
return {
|
||||
name: "fast",
|
||||
homepage: "https://fast.com",
|
||||
items: [{ name: "fast-item", type: "registry:ui", description: "" }],
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown registry: ${name}`)
|
||||
})
|
||||
|
||||
const results = await searchRegistries(["@slow", "@fast"])
|
||||
|
||||
expect(results.items.map((item) => item.name)).toEqual([
|
||||
"slow-item",
|
||||
"fast-item",
|
||||
])
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("caps how many registries are fetched concurrently", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
active++
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
active--
|
||||
return {
|
||||
name,
|
||||
homepage: "https://test.com",
|
||||
items: [{ name: `${name}-item`, type: "registry:ui", description: "" }],
|
||||
}
|
||||
})
|
||||
|
||||
const registries = Array.from({ length: 20 }, (_, i) => `@r${i}`)
|
||||
const results = await searchRegistries(registries)
|
||||
|
||||
// All registries are still fetched...
|
||||
expect(results.items).toHaveLength(20)
|
||||
// ...but never more than the concurrency cap at once.
|
||||
expect(maxActive).toBeLessThanOrEqual(SEARCH_CONCURRENCY)
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("filters by type (shorthand and full namespace, multiple)", 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: "" },
|
||||
{ name: "dashboard", type: "registry:block", description: "" },
|
||||
{ name: "use-foo", type: "registry:hook", description: "" },
|
||||
],
|
||||
}))
|
||||
|
||||
// Shorthand, multiple types.
|
||||
const multiple = await searchRegistries(["@test"], {
|
||||
types: ["ui", "hook"],
|
||||
})
|
||||
expect(multiple.items.map((item) => item.name)).toEqual([
|
||||
"button",
|
||||
"use-foo",
|
||||
])
|
||||
|
||||
// Full namespaced form is accepted too.
|
||||
const full = await searchRegistries(["@test"], {
|
||||
types: ["registry:block"],
|
||||
})
|
||||
expect(full.items.map((item) => item.name)).toEqual(["dashboard"])
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("combines a type filter with a query", 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" },
|
||||
{ name: "button-group", type: "registry:block", description: "" },
|
||||
],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], {
|
||||
query: "button",
|
||||
types: ["ui"],
|
||||
})
|
||||
|
||||
// Both match the query, but only the ui item survives the type filter.
|
||||
expect(results.items.map((item) => item.name)).toEqual(["button"])
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should return empty items when search has no matches", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
@@ -653,3 +835,255 @@ describe("buildRegistryItemNameFromRegistry", () => {
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSearchResultType", () => {
|
||||
it("strips the registry prefix", () => {
|
||||
expect(formatSearchResultType("registry:ui")).toBe("ui")
|
||||
expect(formatSearchResultType("registry:block")).toBe("block")
|
||||
})
|
||||
|
||||
it("returns other types unchanged", () => {
|
||||
expect(formatSearchResultType("custom:type")).toBe("custom:type")
|
||||
expect(formatSearchResultType(undefined)).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSearchResultDescription", () => {
|
||||
it("returns short descriptions unchanged", () => {
|
||||
expect(formatSearchResultDescription("A simple login form.")).toBe(
|
||||
"A simple login form."
|
||||
)
|
||||
})
|
||||
|
||||
it("truncates long descriptions with an ellipsis", () => {
|
||||
const description =
|
||||
"A dashboard with sidebar, charts, data table, filters, and many other widgets for managing your application."
|
||||
|
||||
const formatted = formatSearchResultDescription(description)
|
||||
|
||||
expect(formatted.length).toBeLessThanOrEqual(
|
||||
SEARCH_RESULT_DESCRIPTION_MAX_LENGTH
|
||||
)
|
||||
expect(formatted.endsWith("...")).toBe(true)
|
||||
expect(formatted).not.toBe(description)
|
||||
})
|
||||
})
|
||||
|
||||
describe("printSearchResults", () => {
|
||||
it("prints type and description inline", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
printSearchResults(
|
||||
{
|
||||
pagination: {
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
hasMore: false,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
registry: "@shadcn",
|
||||
addCommandArgument: "@shadcn/button",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
registry: "@shadcn",
|
||||
addCommandArgument: "@shadcn/card",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
query: "button",
|
||||
registries: ["@shadcn"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Found 2 items matching "button" in @shadcn')
|
||||
)
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Showing 1-2 of 2")
|
||||
)
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/- @shadcn\/button \(ui\) — A button component\n- @shadcn\/card \(ui\)$/
|
||||
)
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("includes the type filter in the header (normalized for display)", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
printSearchResults(
|
||||
{
|
||||
pagination: { total: 1, offset: 0, limit: 100, hasMore: false },
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
registry: "@shadcn",
|
||||
addCommandArgument: "@shadcn/button",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Full namespaced form on input is shown as the shorthand.
|
||||
types: ["registry:ui"],
|
||||
registries: ["@shadcn"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Found 1 item of type ui in @shadcn")
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints registry when searching multiple registries", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
printSearchResults(
|
||||
{
|
||||
pagination: {
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
hasMore: false,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: "header",
|
||||
type: "registry:component",
|
||||
description: "A header component",
|
||||
registry: "@custom",
|
||||
addCommandArgument: "@custom/header",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
registries: ["@shadcn", "@custom"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/- @custom\/header \(component\) · @custom — A header component/
|
||||
)
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints a warning for each skipped registry", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
printSearchResults(
|
||||
{
|
||||
pagination: {
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
hasMore: false,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
registry: "@ok",
|
||||
addCommandArgument: "@ok/button",
|
||||
},
|
||||
],
|
||||
errors: [{ registry: "@broken", message: "Not found" }],
|
||||
},
|
||||
{
|
||||
registries: ["@ok", "@broken"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Skipped @broken: Not found")
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("prints a warning when no items are found", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
printSearchResults(
|
||||
{
|
||||
pagination: {
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
hasMore: false,
|
||||
},
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
query: "missing",
|
||||
registries: ["@shadcn"],
|
||||
}
|
||||
)
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No items found matching "missing" in @shadcn')
|
||||
)
|
||||
|
||||
log.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSearchRegistries", () => {
|
||||
it("returns explicitly provided registries unchanged", () => {
|
||||
expect(
|
||||
resolveSearchRegistries(["@one", "@two"], {
|
||||
registries: { "@shadcn": "x/{name}.json", "@one": "y/{name}.json" },
|
||||
})
|
||||
).toEqual(["@one", "@two"])
|
||||
})
|
||||
|
||||
it("returns all configured registries (excluding builtins) when none given", () => {
|
||||
expect(
|
||||
resolveSearchRegistries([], {
|
||||
registries: {
|
||||
"@shadcn": "x/{name}.json",
|
||||
"@one": "y/{name}.json",
|
||||
"@two": "z/{name}.json",
|
||||
},
|
||||
})
|
||||
).toEqual(["@one", "@two"])
|
||||
})
|
||||
|
||||
it("returns empty when none given and nothing is configured", () => {
|
||||
expect(resolveSearchRegistries([], { registries: {} })).toEqual([])
|
||||
expect(resolveSearchRegistries([], undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("findUnknownSearchTypes", () => {
|
||||
it("accepts known types in shorthand and full form", () => {
|
||||
expect(findUnknownSearchTypes(["ui", "registry:block", "HOOK"])).toEqual([])
|
||||
})
|
||||
|
||||
it("returns the unknown types", () => {
|
||||
expect(findUnknownSearchTypes(["ui", "bogus", "blok"])).toEqual([
|
||||
"bogus",
|
||||
"blok",
|
||||
])
|
||||
})
|
||||
|
||||
it("does not offer internal-only types", () => {
|
||||
expect(SEARCHABLE_TYPES).not.toContain("example")
|
||||
expect(SEARCHABLE_TYPES).not.toContain("internal")
|
||||
expect(findUnknownSearchTypes(["internal"])).toEqual(["internal"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,136 @@
|
||||
import { searchResultItemSchema, searchResultsSchema } from "@/src/schema"
|
||||
import {
|
||||
registryItemTypeSchema,
|
||||
searchResultErrorSchema,
|
||||
searchResultItemSchema,
|
||||
searchResultsSchema,
|
||||
} from "@/src/schema"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { highlighter } from "@/src/utils/highlighter"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { z } from "zod"
|
||||
|
||||
import { resolveGitHubRegistrySource } from "./address"
|
||||
import { getRegistry } from "./api"
|
||||
import { BUILTIN_REGISTRIES } from "./constants"
|
||||
import { clearRegistryContext } from "./context"
|
||||
|
||||
// Resolves which registries a search should target. When none are provided
|
||||
// explicitly, returns every registry configured in the project, excluding
|
||||
// builtin registries (e.g. @shadcn) — "search all" means the registries the
|
||||
// user actually configured. Shared by the CLI command and the MCP server so
|
||||
// both resolve "search all" the same way.
|
||||
export function resolveSearchRegistries(
|
||||
registries: string[],
|
||||
config?: Partial<Config>
|
||||
): string[] {
|
||||
if (registries.length > 0) {
|
||||
return registries
|
||||
}
|
||||
|
||||
return Object.keys(config?.registries ?? {}).filter(
|
||||
(registry) => !(registry in BUILTIN_REGISTRIES)
|
||||
)
|
||||
}
|
||||
|
||||
// Cap how many registries we fetch at once so searching many configured
|
||||
// registries does not open an unbounded number of connections.
|
||||
export const SEARCH_CONCURRENCY = 8
|
||||
|
||||
// Like Promise.allSettled, but runs at most `limit` tasks at a time and
|
||||
// preserves input order in the returned results.
|
||||
async function mapSettledWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<PromiseSettledResult<R>[]> {
|
||||
const results: PromiseSettledResult<R>[] = new Array(items.length)
|
||||
let cursor = 0
|
||||
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++
|
||||
try {
|
||||
results[index] = { status: "fulfilled", value: await fn(items[index]) }
|
||||
} catch (reason) {
|
||||
results[index] = { status: "rejected", reason }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, () =>
|
||||
worker()
|
||||
)
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function searchRegistries(
|
||||
registries: string[],
|
||||
options?: {
|
||||
query?: string
|
||||
types?: string[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
config?: Partial<Config>
|
||||
useCache?: boolean
|
||||
// When true, a registry that fails to load is skipped (and recorded in the
|
||||
// returned `errors`) instead of throwing. Use this when searching across
|
||||
// many registries (e.g. all configured registries) so one broken registry
|
||||
// does not abort the entire search.
|
||||
continueOnError?: boolean
|
||||
}
|
||||
) {
|
||||
const { query, limit, offset, config, useCache } = options || {}
|
||||
const {
|
||||
query,
|
||||
types,
|
||||
limit,
|
||||
offset,
|
||||
config,
|
||||
useCache = false,
|
||||
continueOnError,
|
||||
} = options || {}
|
||||
|
||||
// Start from a clean slate so this call does not inherit registry header
|
||||
// context from a previous operation. Matches getRegistryItems/resolve.
|
||||
clearRegistryContext()
|
||||
|
||||
let allItems: z.infer<typeof searchResultItemSchema>[] = []
|
||||
const errors: z.infer<typeof searchResultErrorSchema>[] = []
|
||||
|
||||
for (const registry of registries) {
|
||||
const registryData = await getRegistry(registry, { config, useCache })
|
||||
// Fetch registries concurrently (capped), then process the results in the
|
||||
// original order so the output is deterministic regardless of which
|
||||
// responses land first. This matters most when searching many registries.
|
||||
const outcomes = await mapSettledWithConcurrency(
|
||||
registries,
|
||||
SEARCH_CONCURRENCY,
|
||||
(registry) => getRegistry(registry, { config, useCache })
|
||||
)
|
||||
|
||||
const itemsWithRegistry = (registryData.items || []).map((item) => ({
|
||||
for (let index = 0; index < registries.length; index++) {
|
||||
const registry = registries[index]
|
||||
const outcome = outcomes[index]
|
||||
|
||||
if (outcome.status === "rejected") {
|
||||
if (!continueOnError) {
|
||||
throw outcome.reason
|
||||
}
|
||||
errors.push({
|
||||
registry,
|
||||
message:
|
||||
outcome.reason instanceof Error
|
||||
? outcome.reason.message
|
||||
: String(outcome.reason),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const itemsWithRegistry = (outcome.value.items || []).map((item) => ({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
description: item.description,
|
||||
registry: registry,
|
||||
registry,
|
||||
addCommandArgument: buildRegistryItemNameFromRegistry(
|
||||
item.name,
|
||||
registry
|
||||
@@ -37,6 +140,19 @@ export async function searchRegistries(
|
||||
allItems = allItems.concat(itemsWithRegistry)
|
||||
}
|
||||
|
||||
// Filter by type before the fuzzy query. Accepts both shorthand ("ui") and
|
||||
// the full namespaced form ("registry:ui"), case-insensitively.
|
||||
if (types?.length) {
|
||||
const wantedTypes = new Set(
|
||||
types.map((type) => formatSearchResultType(type).toLowerCase())
|
||||
)
|
||||
allItems = allItems.filter(
|
||||
(item) =>
|
||||
item.type &&
|
||||
wantedTypes.has(formatSearchResultType(item.type).toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
if (query) {
|
||||
allItems = searchItems(allItems, {
|
||||
query,
|
||||
@@ -57,6 +173,9 @@ export async function searchRegistries(
|
||||
hasMore: paginationOffset + paginationLimit < totalItems,
|
||||
},
|
||||
items: allItems.slice(paginationOffset, paginationOffset + paginationLimit),
|
||||
// Only surface errors when present so consumers parsing successful
|
||||
// searches see the same shape as before.
|
||||
...(errors.length > 0 ? { errors } : {}),
|
||||
}
|
||||
|
||||
return searchResultsSchema.parse(result)
|
||||
@@ -179,3 +298,144 @@ export function buildRegistryItemNameFromRegistry(
|
||||
|
||||
return hostPart + updatedPath + updatedQuery
|
||||
}
|
||||
|
||||
export const SEARCH_RESULT_DESCRIPTION_MAX_LENGTH = 80
|
||||
|
||||
export function formatSearchResultType(type?: string) {
|
||||
if (!type) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return type.startsWith("registry:") ? type.slice("registry:".length) : type
|
||||
}
|
||||
|
||||
// Internal-only types that should not be offered as a --type filter.
|
||||
const INTERNAL_TYPES = ["registry:example", "registry:internal"]
|
||||
|
||||
// The item types accepted by the --type filter, in shorthand form (e.g. "ui").
|
||||
export const SEARCHABLE_TYPES = registryItemTypeSchema.options
|
||||
.filter((type) => !INTERNAL_TYPES.includes(type))
|
||||
.map((type) => formatSearchResultType(type))
|
||||
|
||||
// Returns the provided types that are not valid searchable types. Accepts both
|
||||
// shorthand ("ui") and the full namespaced form ("registry:ui").
|
||||
export function findUnknownSearchTypes(types: string[]): string[] {
|
||||
const valid = new Set(SEARCHABLE_TYPES.map((type) => type.toLowerCase()))
|
||||
return types.filter(
|
||||
(type) => !valid.has(formatSearchResultType(type).toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
export function formatSearchResultDescription(
|
||||
description: string,
|
||||
maxLength = SEARCH_RESULT_DESCRIPTION_MAX_LENGTH
|
||||
) {
|
||||
const normalized = description.trim().replace(/\s+/g, " ")
|
||||
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const truncated = normalized.slice(0, maxLength - 3).trimEnd()
|
||||
const lastSpace = truncated.lastIndexOf(" ")
|
||||
const base =
|
||||
lastSpace > maxLength * 0.6 ? truncated.slice(0, lastSpace) : truncated
|
||||
|
||||
return `${base.trimEnd()}...`
|
||||
}
|
||||
|
||||
function formatSearchResultItem(
|
||||
item: z.infer<typeof searchResultsSchema>["items"][number],
|
||||
options: {
|
||||
showRegistry: boolean
|
||||
}
|
||||
) {
|
||||
const name = item.addCommandArgument ?? item.name
|
||||
const type = formatSearchResultType(item.type)
|
||||
const typeSuffix = type ? ` (${type})` : ""
|
||||
const registrySuffix =
|
||||
options.showRegistry && item.registry ? ` · ${item.registry}` : ""
|
||||
const descriptionSuffix = item.description
|
||||
? ` — ${formatSearchResultDescription(item.description)}`
|
||||
: ""
|
||||
|
||||
return `- ${highlighter.info(name)}${typeSuffix}${registrySuffix}${descriptionSuffix}`
|
||||
}
|
||||
|
||||
// Describes what was searched, e.g. ` of type ui matching "button" in @one`.
|
||||
// Shared by the results header and the empty-state message so they stay in
|
||||
// sync. Types are normalized for display ("registry:ui" → "ui") to match how
|
||||
// types are shown in the results themselves.
|
||||
function formatSearchScope(options: {
|
||||
query?: string
|
||||
types?: string[]
|
||||
registries: string[]
|
||||
}) {
|
||||
const { query, types, registries } = options
|
||||
|
||||
let scope = ""
|
||||
if (types?.length) {
|
||||
scope += ` of type ${types
|
||||
.map((type) => formatSearchResultType(type))
|
||||
.join(", ")}`
|
||||
}
|
||||
if (query) {
|
||||
scope += ` matching ${highlighter.info(`"${query}"`)}`
|
||||
}
|
||||
if (registries.length > 0) {
|
||||
scope += ` in ${registries.join(", ")}`
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
export function printSearchResults(
|
||||
results: z.infer<typeof searchResultsSchema>,
|
||||
options: {
|
||||
query?: string
|
||||
types?: string[]
|
||||
registries: string[]
|
||||
}
|
||||
) {
|
||||
const { pagination, items, errors } = results
|
||||
const showRegistry = options.registries.length > 1
|
||||
|
||||
// Surface any registries that were skipped during the search so users know
|
||||
// the results may be incomplete.
|
||||
if (errors?.length) {
|
||||
for (const { registry, message } of errors) {
|
||||
logger.warn(`Skipped ${registry}: ${message}`)
|
||||
}
|
||||
logger.break()
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
logger.warn(`No items found${formatSearchScope(options)}.`)
|
||||
return
|
||||
}
|
||||
|
||||
const itemCount = `${pagination.total} item${
|
||||
pagination.total === 1 ? "" : "s"
|
||||
}`
|
||||
logger.info(`Found ${itemCount}${formatSearchScope(options)}`)
|
||||
|
||||
const start = pagination.offset + 1
|
||||
const end = Math.min(pagination.offset + pagination.limit, pagination.total)
|
||||
logger.log(`Showing ${start}-${end} of ${pagination.total}`)
|
||||
logger.break()
|
||||
|
||||
logger.log(
|
||||
items
|
||||
.map((item) => formatSearchResultItem(item, { showRegistry }))
|
||||
.join("\n")
|
||||
)
|
||||
|
||||
if (pagination.hasMore) {
|
||||
logger.break()
|
||||
logger.log(
|
||||
`More items available. Use ${highlighter.info(
|
||||
`--offset ${pagination.offset + pagination.limit}`
|
||||
)} to see the next page.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
|
||||
import { configureRegistries, createRegistryServer } from "../utils/registry"
|
||||
|
||||
async function runSearch(fixturePath: string, args: string[]) {
|
||||
return npxShadcn(fixturePath, [...args, "--json"])
|
||||
}
|
||||
|
||||
const registryShadcn = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
@@ -186,7 +190,7 @@ describe("shadcn search", () => {
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
const output = await npxShadcn(fixturePath, ["search", "@shadcn"])
|
||||
const output = await runSearch(fixturePath, ["search", "@shadcn"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -225,7 +229,7 @@ describe("shadcn search", () => {
|
||||
"@two": "http://localhost:9182/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"@one",
|
||||
@@ -249,7 +253,7 @@ describe("shadcn search", () => {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one"])
|
||||
const output = await runSearch(fixturePath, ["search", "@one"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -290,7 +294,7 @@ describe("shadcn search", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@two"])
|
||||
const output = await runSearch(fixturePath, ["search", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -337,7 +341,7 @@ describe("shadcn search", () => {
|
||||
try {
|
||||
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN"
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@two"])
|
||||
const output = await runSearch(fixturePath, ["search", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -379,7 +383,7 @@ describe("shadcn search", () => {
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
const output = await npxShadcn(fixturePath, ["search", "@shadcn"])
|
||||
const output = await runSearch(fixturePath, ["search", "@shadcn"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -419,7 +423,7 @@ describe("shadcn search", () => {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one"])
|
||||
const output = await runSearch(fixturePath, ["search", "@one"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -522,7 +526,7 @@ describe("shadcn search", () => {
|
||||
"@two": "http://localhost:9182/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
|
||||
const output = await runSearch(fixturePath, ["search", "@one", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -549,7 +553,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// List from both registries
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
|
||||
const output = await runSearch(fixturePath, ["search", "@one", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
@@ -577,7 +581,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Search for "button" with pagination
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -605,7 +609,7 @@ describe("shadcn search", () => {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -644,7 +648,7 @@ describe("shadcn search", () => {
|
||||
|
||||
// Test button typos
|
||||
for (const typo of typos.slice(0, 4)) {
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -658,7 +662,7 @@ describe("shadcn search", () => {
|
||||
}
|
||||
|
||||
// Test dialog typo
|
||||
const dialogOutput = await npxShadcn(fixturePath, [
|
||||
const dialogOutput = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -671,7 +675,7 @@ describe("shadcn search", () => {
|
||||
).toBe(true)
|
||||
|
||||
// Test alert typo
|
||||
const alertOutput = await npxShadcn(fixturePath, [
|
||||
const alertOutput = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -699,7 +703,7 @@ describe("shadcn search", () => {
|
||||
]
|
||||
|
||||
for (const { query, expected } of partialQueries) {
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -720,19 +724,19 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Search with different cases
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
const output1 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"BUTTON",
|
||||
])
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
const output2 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"button",
|
||||
])
|
||||
const output3 = await npxShadcn(fixturePath, [
|
||||
const output3 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -757,7 +761,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Test searching for components with hyphens
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
const output1 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -769,7 +773,7 @@ describe("shadcn search", () => {
|
||||
).toBe(true)
|
||||
|
||||
// Test searching with just the hyphen part
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
const output2 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -781,7 +785,7 @@ describe("shadcn search", () => {
|
||||
).toBe(true)
|
||||
|
||||
// Test with spaces (should still work)
|
||||
const output3 = await npxShadcn(fixturePath, [
|
||||
const output3 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -800,7 +804,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Search for "bar" which should match "bar" exactly
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@one",
|
||||
"--query",
|
||||
@@ -821,7 +825,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Use 'search' instead of 'list'
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
@@ -846,7 +850,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Test with limit 0 - should return all items
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
const output1 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--limit",
|
||||
@@ -856,7 +860,7 @@ describe("shadcn search", () => {
|
||||
expect(parsed1.items.length).toBeGreaterThan(0)
|
||||
|
||||
// Test with very large limit
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
const output2 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--limit",
|
||||
@@ -875,7 +879,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Search across multiple registries with pagination
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
const output = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"@one",
|
||||
@@ -900,7 +904,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// First page
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
const output1 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@large",
|
||||
"--limit",
|
||||
@@ -920,7 +924,7 @@ describe("shadcn search", () => {
|
||||
})
|
||||
|
||||
// Middle page
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
const output2 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@large",
|
||||
"--limit",
|
||||
@@ -935,7 +939,7 @@ describe("shadcn search", () => {
|
||||
expect(parsed2.pagination.hasMore).toBe(true)
|
||||
|
||||
// Last page (partial)
|
||||
const output3 = await npxShadcn(fixturePath, [
|
||||
const output3 = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@large",
|
||||
"--limit",
|
||||
@@ -957,7 +961,7 @@ describe("shadcn search", () => {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one"])
|
||||
const output = await runSearch(fixturePath, ["search", "@one"])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
// Check that we only get name, type, description, registry, and addCommand fields
|
||||
@@ -986,7 +990,7 @@ describe("shadcn search", () => {
|
||||
"@two": "http://localhost:9182/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
|
||||
const output = await runSearch(fixturePath, ["search", "@one", "@two"])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
// Check @one registry items have correct addCommand
|
||||
@@ -1001,4 +1005,115 @@ describe("shadcn search", () => {
|
||||
)
|
||||
expect(itemItem.addCommandArgument).toBe("@two/item")
|
||||
})
|
||||
|
||||
it("filters results by type (shorthand and full namespace)", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
// @shadcn items are registry:ui, @one items are registry:component.
|
||||
const uiOutput = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"@one",
|
||||
"--type",
|
||||
"ui",
|
||||
])
|
||||
const ui = JSON.parse(uiOutput.stdout)
|
||||
expect(ui.items.length).toBeGreaterThan(0)
|
||||
expect(ui.items.every((item: any) => item.type === "registry:ui")).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
// Full namespaced form is accepted too.
|
||||
const componentOutput = await runSearch(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"@one",
|
||||
"--type",
|
||||
"registry:component",
|
||||
])
|
||||
const component = JSON.parse(componentOutput.stdout)
|
||||
expect(component.items.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
component.items.every((item: any) => item.type === "registry:component")
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("errors on an unknown --type", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--type",
|
||||
"bogus",
|
||||
])
|
||||
|
||||
expect(output.stdout).toContain("Unknown type")
|
||||
expect(output.stdout).toContain("bogus")
|
||||
})
|
||||
|
||||
it("searches all configured registries when no registry is provided", 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 runSearch(fixturePath, ["search"])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
const registries = parsed.items.map((item: any) => item.registry)
|
||||
// Configured registries are searched...
|
||||
expect(registries).toContain("@one")
|
||||
expect(registries).toContain("@two")
|
||||
// ...but the builtin @shadcn is excluded from "search all".
|
||||
expect(registries).not.toContain("@shadcn")
|
||||
})
|
||||
|
||||
it("exits non-zero when every registry fails under search-all", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
// Both registries point at an auth-protected endpoint with no credentials,
|
||||
// so every registry deterministically fails to load (401). Using a running
|
||||
// server avoids depending on a port being free, which races with other
|
||||
// test files under vitest's concurrent file execution.
|
||||
await configureRegistries(fixturePath, {
|
||||
"@locked1": "http://localhost:9182/registry/bearer/{name}",
|
||||
"@locked2": "http://localhost:9182/registry/bearer/{name}",
|
||||
})
|
||||
|
||||
const output = await runSearch(fixturePath, ["search"])
|
||||
|
||||
expect(output.exitCode).not.toBe(0)
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed.items).toHaveLength(0)
|
||||
expect(parsed.errors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("skips registries that fail to load when searching all and reports them", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}", // works
|
||||
"@locked": "http://localhost:9182/registry/bearer/{name}", // 401, no creds
|
||||
})
|
||||
|
||||
const output = await runSearch(fixturePath, ["search"])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
// The working registry still returns items.
|
||||
expect(parsed.items.some((item: any) => item.registry === "@one")).toBe(
|
||||
true
|
||||
)
|
||||
// The failing registry is reported in errors instead of aborting.
|
||||
expect(parsed.errors).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ registry: "@locked" })])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -240,6 +240,8 @@ npx shadcn@latest add owner/repo/item --dry-run
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest search # all configured registries
|
||||
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
@@ -130,19 +130,22 @@ See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the fu
|
||||
### `search` — Search registries
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search <registries...> [options]
|
||||
npx shadcn@latest search [registries...] [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||
and registry catalog URLs. Without `-q`, lists all items.
|
||||
and registry catalog URLs. Without `-q`, lists all items. When no registries are
|
||||
passed, searches every registry configured in `components.json`.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ---------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--limit <number>` | `-l` | Max items per registry | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--json` | | Output as JSON | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
### `view` — View item details
|
||||
|
||||
|
||||
@@ -37,16 +37,19 @@ Returns registry names from `components.json`. Errors if no `components.json` ex
|
||||
|
||||
Lists all items from one or more registries. Registries can be configured
|
||||
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
||||
registry catalog URLs.
|
||||
registry catalog URLs. Omit `registries` to list from every registry configured
|
||||
in `components.json`.
|
||||
|
||||
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||
|
||||
### `shadcn:search_items_in_registries`
|
||||
|
||||
Fuzzy search across registries. Registries can be configured namespaces, public
|
||||
GitHub sources, or registry catalog URLs.
|
||||
GitHub sources, or registry catalog URLs. Omit `registries` to search every
|
||||
registry configured in `components.json` — e.g. "find me a hero" across all
|
||||
configured registries.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||
|
||||
### `shadcn:view_items_in_registries`
|
||||
|
||||
@@ -57,9 +60,10 @@ View item details including full file contents.
|
||||
|
||||
### `shadcn:get_item_examples_from_registries`
|
||||
|
||||
Find usage examples and demos with source code.
|
||||
Find usage examples and demos with source code. Omit `registries` to search
|
||||
every registry configured in `components.json`.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||
|
||||
### `shadcn:get_add_command_for_items`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user