Skip to main content
Glama
reuvenaor

Shadcn Registry manager

by reuvenaor
api.ts20.9 kB
import { promises as fs } from "fs" import { Agent as HttpAgent } from "http" import { Agent as HttpsAgent } from "https" import { homedir } from "os" import path from "path" import { isLocalFile } from "@/src/registry/utils" import { Config, getTargetStyleFromConfig } from "@/src/utils/get-config" import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info" import { handleError } from "@/src/utils/handle-error" import { logger } from "@/src/utils/logger" import { buildTailwindThemeColorsFromCssVars } from "@/src/utils/updaters/update-tailwind-config" import { validateWorkspacePath, validateFileContent, validateComponentName } from "@/src/utils/security" import { validateRegistryUrl, validateComponentUrl, createSecureFetchOptions, validateHttpResponse, safeJsonParse } from "@/src/utils/registry-security" import deepmerge from "deepmerge" import { HttpsProxyAgent } from "https-proxy-agent" import fetch from "node-fetch" import { z } from "zod" import { iconsSchema, registryBaseColorSchema, registryIndexSchema, registryItemSchema, registryResolvedItemsTreeSchema, stylesSchema, } from "./schema" // Validate and set the registry URL from environment let REGISTRY_URL: string try { const envUrl = process.env.REGISTRY_URL ?? "http://host.docker.internal:3333/r" REGISTRY_URL = validateRegistryUrl(envUrl) } catch (error) { console.error(`[Security] Invalid REGISTRY_URL: ${error instanceof Error ? error.message : String(error)}`) REGISTRY_URL = "http://host.docker.internal:3333/r" // Safe fallback } const agent = process.env.https_proxy ? new HttpsProxyAgent(process.env.https_proxy) : undefined const registryCache = new Map<string, Promise<any>>() export const BASE_COLORS = [ { name: "neutral", label: "Neutral", }, { name: "gray", label: "Gray", }, { name: "zinc", label: "Zinc", }, { name: "stone", label: "Stone", }, { name: "slate", label: "Slate", }, ] as const export async function getRegistryIndex() { try { const [result] = await fetchRegistry(["index.json"]) return registryIndexSchema.parse(result) } catch (error) { logger.error("\n") handleError(error) } } export async function getRegistryStyles() { try { const [result] = await fetchRegistry(["styles/index.json"]) return stylesSchema.parse(result) } catch (error) { logger.error("\n") handleError(error) return [] } } export async function getRegistryIcons() { try { const [result] = await fetchRegistry(["icons/index.json"]) return iconsSchema.parse(result) } catch (error) { handleError(error) return {} } } export async function getRegistryItem(name: string, style: string) { try { // Validate and classify the component URL/name const validatedComponent = validateComponentUrl(name) // Handle local file paths if (validatedComponent.type === 'local') { return await getLocalRegistryItem(validatedComponent.value) } // Validate style parameter if (style && typeof style === 'string') { validateComponentName(style) } // Handle URLs and component names let registryPath: string if (validatedComponent.type === 'url') { registryPath = validatedComponent.value } else { // Registry component name - validate it validateComponentName(validatedComponent.value) registryPath = `styles/${style}/${validatedComponent.value}.json` } const [result] = await fetchRegistry([registryPath]) return registryItemSchema.parse(result) } catch (error) { if (error instanceof Error && ( error.message.includes("not allowed") || error.message.includes("Invalid") || error.message.includes("Dangerous") )) { logger.error(`[Security] Invalid component request blocked: ${name}`) } logger.break() handleError(error) return null } } async function getLocalRegistryItem(filePath: string) { try { // Validate the file path to prevent path traversal attacks const workspaceDir = process.env.WORKSPACE_DIR || process.cwd() // Handle tilde expansion for home directory securely let expandedPath = filePath if (filePath.startsWith("~/")) { expandedPath = path.join(homedir(), filePath.slice(2)) } // Validate and resolve the path within workspace boundaries const safePath = validateWorkspacePath(expandedPath, workspaceDir) // Additional validation for file extension if (!safePath.endsWith('.json')) { throw new Error("Only JSON files are allowed") } const content = await fs.readFile(safePath, "utf8") // Validate file content const validatedContent = validateFileContent(content) const parsed = JSON.parse(validatedContent) return registryItemSchema.parse(parsed) } catch (error) { if (error instanceof Error && error.message.includes("Path outside workspace")) { logger.error(`[Security] Path traversal attempt blocked: ${filePath}`) } else { logger.error(`Failed to read local registry file: ${filePath}`) } handleError(error) return null } } export async function getRegistryBaseColors() { return BASE_COLORS } export async function getRegistryBaseColor(baseColor: string) { try { const [result] = await fetchRegistry([`colors/${baseColor}.json`]) return registryBaseColorSchema.parse(result) } catch (error) { handleError(error) } } export async function resolveTree( index: z.infer<typeof registryIndexSchema>, names: string[] ) { const tree: z.infer<typeof registryIndexSchema> = [] for (const name of names) { const entry = index.find((entry) => entry.name === name) if (!entry) { continue } tree.push(entry) if (entry.registryDependencies) { const dependencies = await resolveTree(index, entry.registryDependencies) tree.push(...dependencies) } } return tree.filter( (component, index, self) => self.findIndex((c) => c.name === component.name) === index ) } export async function fetchTree( style: string, tree: z.infer<typeof registryIndexSchema> ) { try { const paths = tree.map((item) => `styles/${style}/${item.name}.json`) const result = await fetchRegistry(paths) return registryIndexSchema.parse(result) } catch (error) { handleError(error) } } export async function getItemTargetPath( config: Config, item: Pick<z.infer<typeof registryItemSchema>, "type">, override?: string ) { if (override) { return override } if (item.type === "registry:ui") { return config.resolvedPaths.ui ?? config.resolvedPaths.components } const [parent, type] = item.type?.split(":") ?? [] if (!(parent in config.resolvedPaths)) { return null } return path.join( config.resolvedPaths[parent as keyof typeof config.resolvedPaths], type ) } export async function fetchRegistry( paths: string[], options: { useCache?: boolean } = {} ) { options = { useCache: true, ...options, } try { const results = await Promise.all( paths.map(async (path) => { const url = getRegistryUrl(path) // Check cache first if caching is enabled if (options.useCache && registryCache.has(url)) { return registryCache.get(url) } // Store the promise in the cache before awaiting if caching is enabled const fetchPromise = (async () => { // Validate the URL before making the request const validatedUrl = validateRegistryUrl(url) // Create secure fetch options const secureOptions = createSecureFetchOptions({ agent: agent as HttpAgent | HttpsAgent, }) const response = await fetch(validatedUrl, secureOptions) // Validate the HTTP response validateHttpResponse(response) // If response is not ok, handle error cases if (!response.ok) { const errorMessages: { [key: number]: string } = { 400: "Bad request", 401: "Unauthorized", 403: "Forbidden", 404: "Not found", 500: "Internal server error", } if (response.status === 401) { throw new Error( `You are not authorized to access the component at ${url}.\nIf this is a remote registry, you may need to authenticate.` ) } if (response.status === 404) { throw new Error( `The component at ${url} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.` ) } if (response.status === 403) { throw new Error( `You do not have access to the component at ${url}.\nIf this is a remote registry, you may need to authenticate or a token.` ) } // Use safe JSON parsing for error responses try { const result = await safeJsonParse(response, 1024) // 1KB limit for error responses const message = result && typeof result === "object" && "error" in result ? result.error : response.statusText || errorMessages[response.status] throw new Error( `Failed to fetch from ${url}.\n${message}` ) } catch (parseError) { // If error parsing fails, use status text throw new Error( `Failed to fetch from ${url}.\n${response.statusText || errorMessages[response.status]}` ) } } // Use safe JSON parsing for successful responses return await safeJsonParse(response) })() if (options.useCache) { registryCache.set(url, fetchPromise) } return fetchPromise }) ) return results } catch (error) { logger.error("\n") handleError(error) return [] } } export function clearRegistryCache() { registryCache.clear() } async function resolveDependenciesRecursively( dependencies: string[], config?: Config, visited: Set<string> = new Set() ): Promise<{ items: z.infer<typeof registryItemSchema>[] registryNames: string[] }> { const items: z.infer<typeof registryItemSchema>[] = [] const registryNames: string[] = [] for (const dep of dependencies) { // Avoid infinite recursion. if (visited.has(dep)) { continue } visited.add(dep) if (isUrl(dep) || isLocalFile(dep)) { const item = await getRegistryItem(dep, "") if (item) { items.push(item) if (item.registryDependencies) { const nested = await resolveDependenciesRecursively( item.registryDependencies, config, visited ) items.push(...nested.items) registryNames.push(...nested.registryNames) } } } else { // Registry name - add it to the list registryNames.push(dep) // If we have config, we can also fetch the item to get its dependencies if (config) { const style = config.resolvedPaths?.cwd ? await getTargetStyleFromConfig( config.resolvedPaths.cwd, config.style ) : config.style try { const item = await getRegistryItem(dep, style) if (item && item.registryDependencies) { const nested = await resolveDependenciesRecursively( item.registryDependencies, config, visited ) items.push(...nested.items) registryNames.push(...nested.registryNames) } } catch (error) { // If we can't fetch the registry item, that's okay - we'll still include the name } } } } return { items, registryNames } } export async function registryResolveItemsTree( names: z.infer<typeof registryItemSchema>["name"][], config: Config ) { try { // Separate local files, URLs, and registry names. const localFiles = names.filter((name) => isLocalFile(name)) const urls = names.filter((name) => isUrl(name)) const registryNames = names.filter( (name) => !isLocalFile(name) && !isUrl(name) ) const payload: z.infer<typeof registryItemSchema>[] = [] // Handle local files and URLs directly, collecting their dependencies. const allDependencies: string[] = [] for (const localFile of localFiles) { const item = await getRegistryItem(localFile, "") if (item) { payload.push(item) if (item.registryDependencies) { allDependencies.push(...item.registryDependencies) } } else { payload.push({ name: localFile, type: 'registry:component', files: [], dependencies: [], devDependencies: [], registryDependencies: [] }) } } for (const url of urls) { const item = await getRegistryItem(url, "") if (item) { payload.push(item) if (item.registryDependencies) { allDependencies.push(...item.registryDependencies) } } else { payload.push({ name: url, type: 'registry:component', files: [], dependencies: [], devDependencies: [], registryDependencies: [] }) } } // Recursively resolve all dependencies. const { items: dependencyItems, registryNames: dependencyRegistryNames } = await resolveDependenciesRecursively(allDependencies, config) payload.push(...dependencyItems) // Handle registry names using existing resolveRegistryItems logic. const allRegistryNames = [...registryNames, ...dependencyRegistryNames] if (allRegistryNames.length > 0) { const index = await getRegistryIndex() if (!index) { // If we only have local files or URLs, that's fine. if (payload.length === 0) { return null } } else { // Remove duplicates. const uniqueRegistryNames = Array.from(new Set(allRegistryNames)) // If we're resolving the index, we want it to go first. if (uniqueRegistryNames.includes("index")) { uniqueRegistryNames.unshift("index") } let registryItems = await resolveRegistryItems( uniqueRegistryNames, config ) let result = await fetchRegistry(registryItems) const registryPayload = z.array(registryItemSchema).safeParse(result) if (registryPayload.success) { payload.push(...registryPayload.data) } else { // For each name, if not found, add a minimal placeholder for (const name of uniqueRegistryNames) { payload.push({ name, type: 'registry:component', files: [], dependencies: [], devDependencies: [], registryDependencies: [] }) } } } } if (!payload.length) { return null } // If we're resolving the index, we want to fetch // the theme item if a base color is provided. // We do this for index only. // Other components will ship with their theme tokens. if (allRegistryNames.includes("index")) { if (config.tailwind.baseColor) { const theme = await registryGetTheme(config.tailwind.baseColor, config) if (theme) { payload.unshift(theme) } } } // Sort the payload so that registry:theme is always first. payload.sort((a, b) => { if (a.type === "registry:theme") { return -1 } return 1 }) let tailwind = {} payload.forEach((item) => { tailwind = deepmerge(tailwind, item.tailwind ?? {}) }) let cssVars = {} payload.forEach((item) => { cssVars = deepmerge(cssVars, item.cssVars ?? {}) }) let css = {} payload.forEach((item) => { css = deepmerge(css, item.css ?? {}) }) let docs = "" payload.forEach((item) => { if (item.docs) { docs += `${item.docs}\n` } }) return registryResolvedItemsTreeSchema.parse({ dependencies: deepmerge.all( payload.map((item) => item.dependencies ?? []) ), devDependencies: deepmerge.all( payload.map((item) => item.devDependencies ?? []) ), files: deepmerge.all(payload.map((item) => item.files ?? [])), tailwind, cssVars, css, docs, }) } catch (error) { handleError(error) return null } } async function resolveRegistryDependencies( url: string, config: Config ): Promise<string[]> { const { registryNames } = await resolveDependenciesRecursively([url], config) const style = config.resolvedPaths?.cwd ? await getTargetStyleFromConfig(config.resolvedPaths.cwd, config.style) : config.style const urls = registryNames.map((name) => getRegistryUrl(isUrl(name) ? name : `styles/${style}/${name}.json`) ) return Array.from(new Set(urls)) } export async function registryGetTheme(name: string, config: Config) { const [baseColor, tailwindVersion] = await Promise.all([ getRegistryBaseColor(name), getProjectTailwindVersionFromConfig(config), ]) if (!baseColor) { return null } // TODO: Move this to the registry i.e registry:theme. const theme = { name, type: "registry:theme", tailwind: { config: { theme: { extend: { borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, colors: {}, }, }, }, }, cssVars: { theme: {}, light: { radius: "0.5rem", }, dark: {}, }, } satisfies z.infer<typeof registryItemSchema> if (config.tailwind.cssVariables) { theme.tailwind.config.theme.extend.colors = { ...theme.tailwind.config.theme.extend.colors, ...buildTailwindThemeColorsFromCssVars(baseColor.cssVars.dark ?? {}), } theme.cssVars = { theme: { ...baseColor.cssVars.theme, ...theme.cssVars.theme, }, light: { ...baseColor.cssVars.light, ...theme.cssVars.light, }, dark: { ...baseColor.cssVars.dark, ...theme.cssVars.dark, }, } if (tailwindVersion === "v4" && baseColor.cssVarsV4) { theme.cssVars = { theme: { ...baseColor.cssVarsV4.theme, ...theme.cssVars.theme, }, light: { radius: "0.625rem", ...baseColor.cssVarsV4.light, }, dark: { ...baseColor.cssVarsV4.dark, }, } } } return theme } function getRegistryUrl(path: string) { if (isUrl(path)) { // Validate the URL first const validatedUrl = validateRegistryUrl(path) // If the url contains /chat/b/, we assume it's the v0 registry. // We need to add the /json suffix if it's missing. const url = new URL(validatedUrl) if (url.pathname.match(/\/chat\/b\//) && !url.pathname.endsWith("/json")) { url.pathname = `${url.pathname}/json` } return url.toString() } // For relative paths, validate the path component if (path.includes('..') || path.includes('\0') || path.length > 500) { throw new Error(`Invalid registry path: ${path}`) } const fullUrl = `${REGISTRY_URL}/${path}` return validateRegistryUrl(fullUrl) } export function isUrl(path: string) { try { new URL(path) return true } catch (error) { return false } } // TODO: We're double-fetching here. Use a cache. export async function resolveRegistryItems(names: string[], config: Config) { let registryDependencies: string[] = [] // Filter out local files and URLs - these should be handled directly by getRegistryItem const registryNames = names.filter( (name) => !isLocalFile(name) && !isUrl(name) ) for (const name of registryNames) { const itemRegistryDependencies = await resolveRegistryDependencies( name, config ) registryDependencies.push(...itemRegistryDependencies) } return Array.from(new Set(registryDependencies)) } export function getRegistryTypeAliasMap() { return new Map<string, string>([ ["registry:ui", "ui"], ["registry:lib", "lib"], ["registry:hook", "hooks"], ["registry:block", "components"], ["registry:component", "components"], ]) } // Track a dependency and its parent. export function getRegistryParentMap( registryItems: z.infer<typeof registryItemSchema>[] ) { const map = new Map<string, z.infer<typeof registryItemSchema>>() registryItems.forEach((item) => { if (!item.registryDependencies) { return } item.registryDependencies.forEach((dependency) => { map.set(dependency, item) }) }) return map }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/reuvenaor/shadcn-registry-manager'

If you have feedback or need assistance with the MCP directory API, please join our Discord server