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 { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||||
|
import fsExtra from "fs-extra"
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import { search } from "./search"
|
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", () => ({
|
vi.mock("fs-extra", () => ({
|
||||||
default: {
|
default: {
|
||||||
existsSync: vi.fn(() => false),
|
existsSync: vi.fn(() => false),
|
||||||
@@ -62,8 +89,11 @@ vi.mock("@/src/registry/validator", () => ({
|
|||||||
validateRegistryConfigForItems: vi.fn(),
|
validateRegistryConfigForItems: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@/src/registry/search", () => ({
|
// Stub searchRegistries but keep the real printSearchResults (both now live
|
||||||
searchRegistries: vi.fn(() => []),
|
// 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", () => ({
|
vi.mock("@/src/registry/context", () => ({
|
||||||
@@ -112,6 +142,185 @@ describe("search command", () => {
|
|||||||
log.mockRestore()
|
log.mockRestore()
|
||||||
exit.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() {
|
function mockProcessExit() {
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { configWithDefaults } from "@/src/registry/config"
|
import { configWithDefaults } from "@/src/registry/config"
|
||||||
import { clearRegistryContext } from "@/src/registry/context"
|
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 { validateRegistryConfigForItems } from "@/src/registry/validator"
|
||||||
import { rawConfigSchema } from "@/src/schema"
|
import { rawConfigSchema } from "@/src/schema"
|
||||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||||
import { handleError } from "@/src/utils/handle-error"
|
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 { ensureRegistriesInConfig } from "@/src/utils/registries"
|
||||||
import { Command } from "commander"
|
import { Command } from "commander"
|
||||||
import fsExtra from "fs-extra"
|
import fsExtra from "fs-extra"
|
||||||
@@ -15,6 +23,7 @@ import { z } from "zod"
|
|||||||
const searchOptionsSchema = z.object({
|
const searchOptionsSchema = z.object({
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
|
types: z.array(z.string()).optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
offset: z.number().optional(),
|
offset: z.number().optional(),
|
||||||
})
|
})
|
||||||
@@ -27,8 +36,8 @@ export const search = new Command()
|
|||||||
.alias("list")
|
.alias("list")
|
||||||
.description("search items from registries")
|
.description("search items from registries")
|
||||||
.argument(
|
.argument(
|
||||||
"<registries...>",
|
"[registries...]",
|
||||||
"the registry addresses to search. Supports namespaces, GitHub sources and URLs."
|
"the registry addresses to search. Supports namespaces, GitHub sources and URLs. When omitted, searches all registries configured in components.json."
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"-c, --cwd <cwd>",
|
"-c, --cwd <cwd>",
|
||||||
@@ -37,20 +46,44 @@ export const search = new Command()
|
|||||||
)
|
)
|
||||||
.option("-q, --query <query>", "query string")
|
.option("-q, --query <query>", "query string")
|
||||||
.option(
|
.option(
|
||||||
"-l, --limit <number>",
|
"-t, --type <type>",
|
||||||
"maximum number of items to display per registry",
|
"filter by item type, e.g. ui, block, hook. Comma-separated for multiple."
|
||||||
"100"
|
|
||||||
)
|
)
|
||||||
|
.option("-l, --limit <number>", "maximum number of items to display", "100")
|
||||||
.option("-o, --offset <number>", "number of items to skip", "0")
|
.option("-o, --offset <number>", "number of items to skip", "0")
|
||||||
|
.option("--json", "output as JSON.", false)
|
||||||
.action(async (registries: string[], opts) => {
|
.action(async (registries: string[], opts) => {
|
||||||
try {
|
try {
|
||||||
const options = searchOptionsSchema.parse({
|
const options = searchOptionsSchema.parse({
|
||||||
cwd: path.resolve(opts.cwd),
|
cwd: path.resolve(opts.cwd),
|
||||||
query: opts.query,
|
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,
|
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
|
||||||
offset: opts.offset ? parseInt(opts.offset, 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)
|
await loadEnvFiles(options.cwd)
|
||||||
|
|
||||||
// Start with a shadow config to support partial components.json.
|
// 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).
|
// Check if there's a components.json file (partial or complete).
|
||||||
const componentsJsonPath = path.resolve(options.cwd, "components.json")
|
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 existingConfig = await fsExtra.readJson(componentsJsonPath)
|
||||||
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
|
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
|
||||||
shadowConfig = configWithDefaults({
|
shadowConfig = configWithDefaults({
|
||||||
@@ -85,6 +119,33 @@ export const search = new Command()
|
|||||||
// Use shadow config if getConfig fails (partial components.json).
|
// 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 } =
|
const { config: updatedConfig, newRegistries } =
|
||||||
await ensureRegistriesInConfig(
|
await ensureRegistriesInConfig(
|
||||||
registries
|
registries
|
||||||
@@ -100,19 +161,63 @@ export const search = new Command()
|
|||||||
config.registries = updatedConfig.registries
|
config.registries = updatedConfig.registries
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate registries early for better error messages.
|
// When no registry is passed, "search all" resolves to every configured
|
||||||
validateRegistryConfigForItems(registries, config)
|
// registry, excluding builtins (e.g. @shadcn).
|
||||||
|
const registriesToSearch = resolveSearchRegistries(registries, config)
|
||||||
|
|
||||||
// Use searchRegistries for both search and non-search cases
|
if (searchAllConfigured && registriesToSearch.length === 0) {
|
||||||
const results = await searchRegistries(registries as `@${string}`[], {
|
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,
|
query: options.query,
|
||||||
|
types: options.types,
|
||||||
limit: options.limit,
|
limit: options.limit,
|
||||||
offset: options.offset,
|
offset: options.offset,
|
||||||
config,
|
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))
|
// In search-all mode, failures are tolerated and collected. If *every*
|
||||||
process.exit(0)
|
// 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) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { getRegistryItems, searchRegistries } from "@/src/registry"
|
import { getRegistryItems, searchRegistries } from "@/src/registry"
|
||||||
import { RegistryError } from "@/src/registry/errors"
|
import { RegistryError } from "@/src/registry/errors"
|
||||||
|
import {
|
||||||
|
resolveSearchRegistries,
|
||||||
|
SEARCHABLE_TYPES,
|
||||||
|
} from "@/src/registry/search"
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
@@ -10,9 +14,11 @@ import { z } from "zod"
|
|||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
findUnknownTypesMessage,
|
||||||
formatItemExamples,
|
formatItemExamples,
|
||||||
formatRegistryItems,
|
formatRegistryItems,
|
||||||
formatSearchResultsWithPagination,
|
formatSearchResultsWithPagination,
|
||||||
|
formatSkippedRegistries,
|
||||||
getMcpConfig,
|
getMcpConfig,
|
||||||
npxShadcn,
|
npxShadcn,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
@@ -47,13 +53,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
z.object({
|
z.object({
|
||||||
registries: z
|
registries: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
.describe(
|
.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
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.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
|
offset: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -69,18 +84,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
z.object({
|
z.object({
|
||||||
registries: z
|
registries: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
.describe(
|
.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
|
query: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"Search query string for fuzzy matching against item names and descriptions"
|
"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
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.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
|
offset: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -110,8 +134,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
z.object({
|
z.object({
|
||||||
registries: z
|
registries: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
.describe(
|
.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
|
query: z
|
||||||
.string()
|
.string()
|
||||||
@@ -196,21 +221,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
|
|
||||||
case "search_items_in_registries": {
|
case "search_items_in_registries": {
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
registries: z.array(z.string()),
|
registries: z.array(z.string()).optional(),
|
||||||
query: z.string(),
|
query: z.string(),
|
||||||
|
types: z.array(z.string()).optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
offset: z.number().optional(),
|
offset: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const args = inputSchema.parse(request.params.arguments)
|
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,
|
query: args.query,
|
||||||
limit: args.limit,
|
types: args.types,
|
||||||
|
limit: args.limit ?? 100,
|
||||||
offset: args.offset,
|
offset: args.offset,
|
||||||
config: await getMcpConfig(process.cwd()),
|
config,
|
||||||
useCache: false,
|
useCache: false,
|
||||||
|
continueOnError: searchAll,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const skippedNote = formatSkippedRegistries(results)
|
||||||
|
|
||||||
if (results.items.length === 0) {
|
if (results.items.length === 0) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -218,9 +278,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
type: "text",
|
type: "text",
|
||||||
text: dedent`No items found matching "${
|
text: dedent`No items found matching "${
|
||||||
args.query
|
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: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: formatSearchResultsWithPagination(results, {
|
text:
|
||||||
query: args.query,
|
formatSearchResultsWithPagination(results, {
|
||||||
registries: args.registries,
|
query: args.query,
|
||||||
}),
|
registries,
|
||||||
|
}) + skippedNote,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -241,28 +302,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
|
|
||||||
case "list_items_in_registries": {
|
case "list_items_in_registries": {
|
||||||
const inputSchema = z.object({
|
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(),
|
limit: z.number().optional(),
|
||||||
offset: z.number().optional(),
|
offset: z.number().optional(),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const args = inputSchema.parse(request.params.arguments)
|
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,
|
offset: args.offset,
|
||||||
config: await getMcpConfig(process.cwd()),
|
config,
|
||||||
useCache: false,
|
useCache: false,
|
||||||
|
continueOnError: listAll,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const skippedNote = formatSkippedRegistries(results)
|
||||||
|
|
||||||
if (results.items.length === 0) {
|
if (results.items.length === 0) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
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: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: formatSearchResultsWithPagination(results, {
|
text:
|
||||||
registries: args.registries,
|
formatSearchResultsWithPagination(results, {
|
||||||
}),
|
registries,
|
||||||
|
}) + skippedNote,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -321,16 +416,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
case "get_item_examples_from_registries": {
|
case "get_item_examples_from_registries": {
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
query: z.string(),
|
query: z.string(),
|
||||||
registries: z.array(z.string()),
|
registries: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const args = inputSchema.parse(request.params.arguments)
|
const args = inputSchema.parse(request.params.arguments)
|
||||||
const config = await getMcpConfig()
|
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,
|
query: args.query,
|
||||||
config,
|
config,
|
||||||
useCache: false,
|
useCache: false,
|
||||||
|
continueOnError: searchAll,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (results.items.length === 0) {
|
if (results.items.length === 0) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getRegistriesConfig } from "@/src/registry/api"
|
import { getRegistriesConfig } from "@/src/registry/api"
|
||||||
|
import { findUnknownSearchTypes, SEARCHABLE_TYPES } from "@/src/registry/search"
|
||||||
import { registryItemSchema, searchResultsSchema } from "@/src/schema"
|
import { registryItemSchema, searchResultsSchema } from "@/src/schema"
|
||||||
import { getPackageRunner } from "@/src/utils/get-package-manager"
|
import { getPackageRunner } from "@/src/utils/get-package-manager"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -78,6 +79,41 @@ export function formatSearchResultsWithPagination(
|
|||||||
return output
|
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(
|
export function formatRegistryItems(
|
||||||
items: z.infer<typeof registryItemSchema>[]
|
items: z.infer<typeof registryItemSchema>[]
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ export const searchResultItemSchema = z.object({
|
|||||||
addCommandArgument: z.string(),
|
addCommandArgument: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const searchResultErrorSchema = z.object({
|
||||||
|
registry: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export const searchResultsSchema = z.object({
|
export const searchResultsSchema = z.object({
|
||||||
pagination: z.object({
|
pagination: z.object({
|
||||||
total: z.number(),
|
total: z.number(),
|
||||||
@@ -295,6 +300,10 @@ export const searchResultsSchema = z.object({
|
|||||||
hasMore: z.boolean(),
|
hasMore: z.boolean(),
|
||||||
}),
|
}),
|
||||||
items: z.array(searchResultItemSchema),
|
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.
|
// Legacy schema for getRegistriesIndex() backward compatibility.
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import { getRegistry } from "./api"
|
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", () => {
|
describe("searchRegistries", () => {
|
||||||
it("should fetch and return registries in flat format", async () => {
|
it("should fetch and return registries in flat format", async () => {
|
||||||
@@ -152,6 +163,177 @@ describe("searchRegistries", () => {
|
|||||||
mockGetRegistry.mockRestore()
|
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 () => {
|
it("should return empty items when search has no matches", async () => {
|
||||||
vi.mock("./api", () => ({
|
vi.mock("./api", () => ({
|
||||||
getRegistry: vi.fn(),
|
getRegistry: vi.fn(),
|
||||||
@@ -653,3 +835,255 @@ describe("buildRegistryItemNameFromRegistry", () => {
|
|||||||
expect(result).toBe(expected)
|
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 { Config } from "@/src/utils/get-config"
|
||||||
|
import { highlighter } from "@/src/utils/highlighter"
|
||||||
|
import { logger } from "@/src/utils/logger"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { resolveGitHubRegistrySource } from "./address"
|
import { resolveGitHubRegistrySource } from "./address"
|
||||||
import { getRegistry } from "./api"
|
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(
|
export async function searchRegistries(
|
||||||
registries: string[],
|
registries: string[],
|
||||||
options?: {
|
options?: {
|
||||||
query?: string
|
query?: string
|
||||||
|
types?: string[]
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
config?: Partial<Config>
|
config?: Partial<Config>
|
||||||
useCache?: boolean
|
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>[] = []
|
let allItems: z.infer<typeof searchResultItemSchema>[] = []
|
||||||
|
const errors: z.infer<typeof searchResultErrorSchema>[] = []
|
||||||
|
|
||||||
for (const registry of registries) {
|
// Fetch registries concurrently (capped), then process the results in the
|
||||||
const registryData = await getRegistry(registry, { config, useCache })
|
// 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,
|
name: item.name,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
registry: registry,
|
registry,
|
||||||
addCommandArgument: buildRegistryItemNameFromRegistry(
|
addCommandArgument: buildRegistryItemNameFromRegistry(
|
||||||
item.name,
|
item.name,
|
||||||
registry
|
registry
|
||||||
@@ -37,6 +140,19 @@ export async function searchRegistries(
|
|||||||
allItems = allItems.concat(itemsWithRegistry)
|
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) {
|
if (query) {
|
||||||
allItems = searchItems(allItems, {
|
allItems = searchItems(allItems, {
|
||||||
query,
|
query,
|
||||||
@@ -57,6 +173,9 @@ export async function searchRegistries(
|
|||||||
hasMore: paginationOffset + paginationLimit < totalItems,
|
hasMore: paginationOffset + paginationLimit < totalItems,
|
||||||
},
|
},
|
||||||
items: allItems.slice(paginationOffset, paginationOffset + paginationLimit),
|
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)
|
return searchResultsSchema.parse(result)
|
||||||
@@ -179,3 +298,144 @@ export function buildRegistryItemNameFromRegistry(
|
|||||||
|
|
||||||
return hostPart + updatedPath + updatedQuery
|
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 { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
|
||||||
import { configureRegistries, createRegistryServer } from "../utils/registry"
|
import { configureRegistries, createRegistryServer } from "../utils/registry"
|
||||||
|
|
||||||
|
async function runSearch(fixturePath: string, args: string[]) {
|
||||||
|
return npxShadcn(fixturePath, [...args, "--json"])
|
||||||
|
}
|
||||||
|
|
||||||
const registryShadcn = await createRegistryServer(
|
const registryShadcn = await createRegistryServer(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -186,7 +190,7 @@ describe("shadcn search", () => {
|
|||||||
await configureRegistries(fixturePath, {
|
await configureRegistries(fixturePath, {
|
||||||
"@shadcn": "http://localhost:9180/r/{name}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -225,7 +229,7 @@ describe("shadcn search", () => {
|
|||||||
"@two": "http://localhost:9182/registry/{name}",
|
"@two": "http://localhost:9182/registry/{name}",
|
||||||
})
|
})
|
||||||
|
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"@one",
|
"@one",
|
||||||
@@ -249,7 +253,7 @@ describe("shadcn search", () => {
|
|||||||
"@one": "http://localhost:9181/r/{name}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -337,7 +341,7 @@ describe("shadcn search", () => {
|
|||||||
try {
|
try {
|
||||||
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN"
|
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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -379,7 +383,7 @@ describe("shadcn search", () => {
|
|||||||
await configureRegistries(fixturePath, {
|
await configureRegistries(fixturePath, {
|
||||||
"@shadcn": "http://localhost:9180/r/{name}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -419,7 +423,7 @@ describe("shadcn search", () => {
|
|||||||
"@one": "http://localhost:9181/r/{name}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -522,7 +526,7 @@ describe("shadcn search", () => {
|
|||||||
"@two": "http://localhost:9182/registry/{name}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -549,7 +553,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// List from both registries
|
// 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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
expect(parsed).toHaveProperty("items")
|
expect(parsed).toHaveProperty("items")
|
||||||
@@ -577,7 +581,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Search for "button" with pagination
|
// Search for "button" with pagination
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -605,7 +609,7 @@ describe("shadcn search", () => {
|
|||||||
"@shadcn": "http://localhost:9180/r/{name}",
|
"@shadcn": "http://localhost:9180/r/{name}",
|
||||||
})
|
})
|
||||||
|
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -644,7 +648,7 @@ describe("shadcn search", () => {
|
|||||||
|
|
||||||
// Test button typos
|
// Test button typos
|
||||||
for (const typo of typos.slice(0, 4)) {
|
for (const typo of typos.slice(0, 4)) {
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -658,7 +662,7 @@ describe("shadcn search", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test dialog typo
|
// Test dialog typo
|
||||||
const dialogOutput = await npxShadcn(fixturePath, [
|
const dialogOutput = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -671,7 +675,7 @@ describe("shadcn search", () => {
|
|||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
// Test alert typo
|
// Test alert typo
|
||||||
const alertOutput = await npxShadcn(fixturePath, [
|
const alertOutput = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -699,7 +703,7 @@ describe("shadcn search", () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const { query, expected } of partialQueries) {
|
for (const { query, expected } of partialQueries) {
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -720,19 +724,19 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Search with different cases
|
// Search with different cases
|
||||||
const output1 = await npxShadcn(fixturePath, [
|
const output1 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
"BUTTON",
|
"BUTTON",
|
||||||
])
|
])
|
||||||
const output2 = await npxShadcn(fixturePath, [
|
const output2 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
"button",
|
"button",
|
||||||
])
|
])
|
||||||
const output3 = await npxShadcn(fixturePath, [
|
const output3 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -757,7 +761,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Test searching for components with hyphens
|
// Test searching for components with hyphens
|
||||||
const output1 = await npxShadcn(fixturePath, [
|
const output1 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -769,7 +773,7 @@ describe("shadcn search", () => {
|
|||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
// Test searching with just the hyphen part
|
// Test searching with just the hyphen part
|
||||||
const output2 = await npxShadcn(fixturePath, [
|
const output2 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -781,7 +785,7 @@ describe("shadcn search", () => {
|
|||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
// Test with spaces (should still work)
|
// Test with spaces (should still work)
|
||||||
const output3 = await npxShadcn(fixturePath, [
|
const output3 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -800,7 +804,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Search for "bar" which should match "bar" exactly
|
// Search for "bar" which should match "bar" exactly
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@one",
|
"@one",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -821,7 +825,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Use 'search' instead of 'list'
|
// Use 'search' instead of 'list'
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--query",
|
"--query",
|
||||||
@@ -846,7 +850,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Test with limit 0 - should return all items
|
// Test with limit 0 - should return all items
|
||||||
const output1 = await npxShadcn(fixturePath, [
|
const output1 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--limit",
|
"--limit",
|
||||||
@@ -856,7 +860,7 @@ describe("shadcn search", () => {
|
|||||||
expect(parsed1.items.length).toBeGreaterThan(0)
|
expect(parsed1.items.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Test with very large limit
|
// Test with very large limit
|
||||||
const output2 = await npxShadcn(fixturePath, [
|
const output2 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"--limit",
|
"--limit",
|
||||||
@@ -875,7 +879,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Search across multiple registries with pagination
|
// Search across multiple registries with pagination
|
||||||
const output = await npxShadcn(fixturePath, [
|
const output = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@shadcn",
|
"@shadcn",
|
||||||
"@one",
|
"@one",
|
||||||
@@ -900,7 +904,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// First page
|
// First page
|
||||||
const output1 = await npxShadcn(fixturePath, [
|
const output1 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@large",
|
"@large",
|
||||||
"--limit",
|
"--limit",
|
||||||
@@ -920,7 +924,7 @@ describe("shadcn search", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Middle page
|
// Middle page
|
||||||
const output2 = await npxShadcn(fixturePath, [
|
const output2 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@large",
|
"@large",
|
||||||
"--limit",
|
"--limit",
|
||||||
@@ -935,7 +939,7 @@ describe("shadcn search", () => {
|
|||||||
expect(parsed2.pagination.hasMore).toBe(true)
|
expect(parsed2.pagination.hasMore).toBe(true)
|
||||||
|
|
||||||
// Last page (partial)
|
// Last page (partial)
|
||||||
const output3 = await npxShadcn(fixturePath, [
|
const output3 = await runSearch(fixturePath, [
|
||||||
"search",
|
"search",
|
||||||
"@large",
|
"@large",
|
||||||
"--limit",
|
"--limit",
|
||||||
@@ -957,7 +961,7 @@ describe("shadcn search", () => {
|
|||||||
"@one": "http://localhost:9181/r/{name}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
|
|
||||||
// Check that we only get name, type, description, registry, and addCommand fields
|
// 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}",
|
"@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)
|
const parsed = JSON.parse(output.stdout)
|
||||||
|
|
||||||
// Check @one registry items have correct addCommand
|
// Check @one registry items have correct addCommand
|
||||||
@@ -1001,4 +1005,115 @@ describe("shadcn search", () => {
|
|||||||
)
|
)
|
||||||
expect(itemItem.addCommandArgument).toBe("@two/item")
|
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 @shadcn -q "sidebar"
|
||||||
npx shadcn@latest search @tailark -q "stats"
|
npx shadcn@latest search @tailark -q "stats"
|
||||||
npx shadcn@latest search owner/repo -q "login"
|
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.
|
# Get component docs and example URLs.
|
||||||
npx shadcn@latest docs button dialog select
|
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
|
### `search` — Search registries
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx shadcn@latest search <registries...> [options]
|
npx shadcn@latest search [registries...] [options]
|
||||||
```
|
```
|
||||||
|
|
||||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||||
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
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 |
|
| Flag | Short | Description | Default |
|
||||||
| ------------------- | ----- | ---------------------- | ------- |
|
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||||
| `--query <query>` | `-q` | Search query | — |
|
| `--query <query>` | `-q` | Search query | — |
|
||||||
| `--limit <number>` | `-l` | Max items per registry | `100` |
|
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||||
|
| `--json` | | Output as JSON | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
### `view` — View item details
|
### `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
|
Lists all items from one or more registries. Registries can be configured
|
||||||
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
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`
|
### `shadcn:search_items_in_registries`
|
||||||
|
|
||||||
Fuzzy search across registries. Registries can be configured namespaces, public
|
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`
|
### `shadcn:view_items_in_registries`
|
||||||
|
|
||||||
@@ -57,9 +60,10 @@ View item details including full file contents.
|
|||||||
|
|
||||||
### `shadcn:get_item_examples_from_registries`
|
### `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`
|
### `shadcn:get_add_command_for_items`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user