mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
import * as path from "path"
|
|
import * as fs from "fs/promises"
|
|
import * as vscode from "vscode"
|
|
import * as yaml from "js-yaml"
|
|
import { SimpleGit } from "simple-git"
|
|
import { validateAnyMetadata } from "./schemas"
|
|
import {
|
|
ComponentMetadata,
|
|
ComponentType,
|
|
LocalizationOptions,
|
|
LocalizedMetadata,
|
|
PackageManagerItem,
|
|
PackageMetadata,
|
|
} from "./types"
|
|
import { getUserLocale } from "./utils"
|
|
|
|
/**
|
|
* Handles component discovery and metadata loading
|
|
*/
|
|
export class MetadataScanner {
|
|
private readonly git?: SimpleGit
|
|
private localizationOptions: LocalizationOptions
|
|
private originalRootDir: string | null = null
|
|
|
|
constructor(git?: SimpleGit, localizationOptions?: LocalizationOptions) {
|
|
this.git = git
|
|
this.localizationOptions = localizationOptions || {
|
|
userLocale: getUserLocale(),
|
|
fallbackLocale: "en",
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scans a directory for components
|
|
* @param rootDir The root directory to scan
|
|
* @param repoUrl The repository URL
|
|
* @param sourceName Optional source repository name
|
|
* @returns Array of discovered items
|
|
*/
|
|
async scanDirectory(
|
|
rootDir: string,
|
|
repoUrl: string,
|
|
sourceName?: string,
|
|
isRecursiveCall: boolean = false,
|
|
): Promise<PackageManagerItem[]> {
|
|
const items: PackageManagerItem[] = []
|
|
|
|
// Only set originalRootDir on the first call, not recursive calls
|
|
if (!isRecursiveCall && !this.originalRootDir) {
|
|
this.originalRootDir = rootDir
|
|
}
|
|
|
|
try {
|
|
const entries = await fs.readdir(rootDir, { withFileTypes: true })
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
|
|
const componentDir = path.join(rootDir, entry.name)
|
|
// Always calculate paths relative to the original root directory
|
|
const relativePath = path.relative(this.originalRootDir || rootDir, componentDir).replace(/\\/g, "/")
|
|
const metadata = await this.loadComponentMetadata(componentDir)
|
|
// If no metadata found, or metadata validation fails, try recursing
|
|
if (!metadata || !this.getLocalizedMetadata(metadata)) {
|
|
// Pass the current directory as the root for this recursive call
|
|
const subItems = await this.scanDirectory(componentDir, repoUrl, sourceName, true)
|
|
items.push(...subItems)
|
|
continue
|
|
}
|
|
|
|
// Get localized metadata with fallback
|
|
const localizedMetadata = this.getLocalizedMetadata(metadata)
|
|
if (!localizedMetadata) continue
|
|
|
|
// Always use the original root directory for path calculations
|
|
const item = await this.createPackageManagerItem(
|
|
localizedMetadata,
|
|
componentDir,
|
|
repoUrl,
|
|
this.originalRootDir || rootDir,
|
|
sourceName,
|
|
)
|
|
if (item) {
|
|
// If this is a package, scan for subcomponents
|
|
if (this.isPackageMetadata(localizedMetadata)) {
|
|
// Load metadata for items listed in package metadata
|
|
if (localizedMetadata.items) {
|
|
const subcomponents = await Promise.all(
|
|
localizedMetadata.items.map(async (subItem) => {
|
|
const subPath = path.join(componentDir, subItem.path)
|
|
const subRelativePath = path.relative(rootDir, subPath).replace(/\\/g, "/")
|
|
const subMetadata = await this.loadComponentMetadata(subPath)
|
|
|
|
// Skip if no metadata found
|
|
if (!subMetadata) return null
|
|
|
|
// Get localized metadata with fallback
|
|
const localizedSubMetadata = this.getLocalizedMetadata(subMetadata)
|
|
if (!localizedSubMetadata) return null
|
|
|
|
return {
|
|
type: subItem.type,
|
|
path: subItem.path,
|
|
metadata: localizedSubMetadata,
|
|
lastUpdated: await this.getLastModifiedDate(subPath),
|
|
}
|
|
}),
|
|
)
|
|
item.items = subcomponents.filter((sub): sub is NonNullable<typeof sub> => sub !== null)
|
|
}
|
|
|
|
// Also scan directory for unlisted subcomponents
|
|
await this.scanPackageSubcomponents(componentDir, item)
|
|
}
|
|
items.push(item)
|
|
// Skip recursion if this is a package directory
|
|
if (this.isPackageMetadata(localizedMetadata)) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Recursively scan subdirectories only if not in a package
|
|
if (!metadata || !this.isPackageMetadata(localizedMetadata)) {
|
|
const subItems = await this.scanDirectory(componentDir, repoUrl, sourceName, true)
|
|
items.push(...subItems)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error scanning directory ${rootDir}:`, error)
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Gets localized metadata with fallback
|
|
* @param metadata The localized metadata object
|
|
* @returns The metadata in the user's locale or fallback locale, or null if neither is available
|
|
*/
|
|
private getLocalizedMetadata(metadata: LocalizedMetadata<ComponentMetadata>): ComponentMetadata | null {
|
|
const { userLocale, fallbackLocale } = this.localizationOptions
|
|
|
|
// First try user's locale
|
|
if (metadata[userLocale]) {
|
|
return metadata[userLocale]
|
|
}
|
|
|
|
// Fall back to fallbackLocale (typically English)
|
|
if (metadata[fallbackLocale]) {
|
|
return metadata[fallbackLocale]
|
|
}
|
|
|
|
// No suitable metadata found
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Loads metadata for a component
|
|
* @param componentDir The component directory
|
|
* @returns Localized metadata or null if no metadata found
|
|
*/
|
|
private async loadComponentMetadata(componentDir: string): Promise<LocalizedMetadata<ComponentMetadata> | null> {
|
|
const metadata: LocalizedMetadata<ComponentMetadata> = {}
|
|
try {
|
|
const entries = await fs.readdir(componentDir, { withFileTypes: true })
|
|
|
|
// Look for metadata.{locale}.yml files
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue
|
|
|
|
const match = entry.name.match(/^metadata\.([a-z]{2})\.yml$/)
|
|
if (!match) continue
|
|
|
|
const locale = match[1]
|
|
const metadataPath = path.join(componentDir, entry.name)
|
|
|
|
try {
|
|
const content = await fs.readFile(metadataPath, "utf-8")
|
|
const parsed = yaml.load(content) as Record<string, any>
|
|
|
|
// Add type field if missing but has a parent directory indicating type
|
|
if (!parsed.type) {
|
|
const parentDir = path.basename(componentDir)
|
|
if (parentDir === "mcp servers" || parentDir === "mcp-servers") {
|
|
parsed.type = "mcp server"
|
|
}
|
|
}
|
|
|
|
metadata[locale] = validateAnyMetadata(parsed) as ComponentMetadata
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
console.error(`Error loading metadata from ${metadataPath}:`, error)
|
|
|
|
// Show validation errors to user
|
|
if (errorMessage.includes("Invalid metadata:")) {
|
|
vscode.window.showErrorMessage(
|
|
`Invalid metadata in ${path.basename(metadataPath)}: ${errorMessage.replace("Invalid metadata:", "").trim()}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error reading directory ${componentDir}:`, error)
|
|
}
|
|
|
|
return Object.keys(metadata).length > 0 ? metadata : null
|
|
}
|
|
|
|
/**
|
|
* Creates a PackageManagerItem from component metadata
|
|
* @param metadata The component metadata
|
|
* @param componentDir The component directory
|
|
* @param repoUrl The repository URL
|
|
* @param sourceName Optional source repository name
|
|
* @returns PackageManagerItem or null if invalid
|
|
*/
|
|
private async createPackageManagerItem(
|
|
metadata: ComponentMetadata,
|
|
componentDir: string,
|
|
repoUrl: string,
|
|
rootDir: string,
|
|
sourceName?: string,
|
|
): Promise<PackageManagerItem | null> {
|
|
// Skip if no type or invalid type
|
|
if (!metadata.type || !this.isValidComponentType(metadata.type)) {
|
|
return null
|
|
}
|
|
// Always use the original root directory for path calculations
|
|
const effectiveRootDir = this.originalRootDir || rootDir
|
|
// Always calculate path relative to the original root directory
|
|
const fullPath = path.relative(effectiveRootDir, componentDir).replace(/\\/g, "/")
|
|
// Don't encode spaces in URL to match test expectations
|
|
const urlPath = fullPath
|
|
.split("/")
|
|
.map((part) => encodeURIComponent(part))
|
|
.join("/")
|
|
// Create the item with the correct path and URL
|
|
return {
|
|
name: metadata.name,
|
|
description: metadata.description,
|
|
type: metadata.type,
|
|
version: metadata.version,
|
|
tags: metadata.tags,
|
|
url: `${repoUrl}/tree/main/${urlPath}`,
|
|
repoUrl,
|
|
sourceName,
|
|
path: fullPath,
|
|
lastUpdated: await this.getLastModifiedDate(componentDir),
|
|
items: [], // Initialize empty items array for all components
|
|
author: metadata.author,
|
|
authorUrl: metadata.authorUrl,
|
|
sourceUrl: metadata.sourceUrl,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the last modified date for a component using git history
|
|
* @param componentDir The component directory
|
|
* @returns ISO date string
|
|
*/
|
|
private async getLastModifiedDate(componentDir: string): Promise<string> {
|
|
if (this.git) {
|
|
try {
|
|
// Get the latest commit date for the directory and its contents
|
|
const result = await this.git.raw([
|
|
"log",
|
|
"-1",
|
|
"--format=%aI", // ISO 8601 format
|
|
"--",
|
|
componentDir,
|
|
])
|
|
if (result) {
|
|
return result.trim()
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error getting git history for ${componentDir}:`, error)
|
|
// Fall through to fs.stat fallback
|
|
}
|
|
}
|
|
|
|
// Fallback to fs.stat if git is not available or fails
|
|
try {
|
|
const stats = await fs.stat(componentDir)
|
|
return stats.mtime.toISOString()
|
|
} catch {
|
|
return new Date().toISOString()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively scans a package directory for subcomponents
|
|
* @param packageDir The package directory to scan
|
|
* @param packageItem The package item to add subcomponents to
|
|
*/
|
|
private async scanPackageSubcomponents(
|
|
packageDir: string,
|
|
packageItem: PackageManagerItem,
|
|
parentPath: string = "",
|
|
): Promise<void> {
|
|
const entries = await fs.readdir(packageDir, { withFileTypes: true })
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue
|
|
|
|
const subPath = path.join(packageDir, entry.name)
|
|
// Normalize path to use forward slashes
|
|
const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name
|
|
|
|
// Try to load metadata directly
|
|
const subMetadata = await this.loadComponentMetadata(subPath)
|
|
|
|
if (subMetadata) {
|
|
// Get localized metadata with fallback
|
|
const localizedSubMetadata = this.getLocalizedMetadata(subMetadata)
|
|
if (localizedSubMetadata) {
|
|
const isListed = packageItem.items?.some((i) => i.path === relativePath)
|
|
|
|
if (!isListed) {
|
|
const subItem = {
|
|
type: localizedSubMetadata.type,
|
|
path: relativePath,
|
|
metadata: localizedSubMetadata,
|
|
lastUpdated: await this.getLastModifiedDate(subPath),
|
|
}
|
|
packageItem.items = packageItem.items || []
|
|
packageItem.items.push(subItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recursively scan this directory
|
|
await this.scanPackageSubcomponents(subPath, packageItem, relativePath)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Type guard for component types
|
|
* @param type The type to check
|
|
* @returns Whether the type is valid
|
|
*/
|
|
private isValidComponentType(type: string): type is ComponentType {
|
|
return ["role", "mcp server", "storage", "mode", "prompt", "package"].includes(type)
|
|
}
|
|
|
|
/**
|
|
* Type guard for package metadata
|
|
* @param metadata The metadata to check
|
|
* @returns Whether the metadata is for a package
|
|
*/
|
|
private isPackageMetadata(metadata: ComponentMetadata): metadata is PackageMetadata {
|
|
return metadata.type === "package"
|
|
}
|
|
}
|