import { readdirSync, statSync, readFileSync } from 'fs'; import { join, extname } from 'path'; export interface CommandParameter { name: string; type: 'param' | 'option' | 'flag'; required: boolean; help?: string; shortFlag?: string; } export interface CommandMetadata { namespace: string; commandName: string; methodName: string; help?: string; parameters: CommandParameter[]; filePath: string; className: string; isReadOnly: boolean; } export interface CommandRegistry { commands: CommandMetadata[]; namespaces: string[]; readOnlyCommands: CommandMetadata[]; stateChangingCommands: CommandMetadata[]; } export class CommandDiscoveryService { private readonly commandsPath: string; private readonly readOnlyPatterns = [ // Method name patterns that indicate read-only operations /^(list|get|show|info|status|whoami|whois|search|find|view|display)$/i, // Help-related patterns /help/i, // Status/info patterns /^(runtime)?status$/i ]; private readonly stateChangingPatterns = [ // Method name patterns that indicate state-changing operations /^(create|init|add|update|edit|modify|delete|remove|destroy|terminate|trigger|install|uninstall|publish|unpublish|register|abandon|prepare|package|upgrade|set)$/i ]; constructor(commandsPath: string = 'src/commands') { this.commandsPath = commandsPath; } /** * Discover all commands in the commands directory */ public discoverCommands(): CommandRegistry { const commands: CommandMetadata[] = []; this.scanDirectory(this.commandsPath, commands); const namespaces = [...new Set(commands.map(cmd => cmd.namespace))].sort(); const readOnlyCommands = commands.filter(cmd => cmd.isReadOnly); const stateChangingCommands = commands.filter(cmd => !cmd.isReadOnly); return { commands, namespaces, readOnlyCommands, stateChangingCommands }; } /** * Get commands for a specific namespace */ public getCommandsForNamespace(namespace: string): CommandMetadata[] { const registry = this.discoverCommands(); return registry.commands.filter(cmd => cmd.namespace === namespace); } /** * Get command by namespace and command name */ public getCommand(namespace: string, commandName: string): CommandMetadata | undefined { const registry = this.discoverCommands(); return registry.commands.find(cmd => cmd.namespace === namespace && cmd.commandName === commandName ); } /** * Recursively scan directory for TypeScript command files */ private scanDirectory(dirPath: string, commands: CommandMetadata[]): void { try { const entries = readdirSync(dirPath); for (const entry of entries) { const fullPath = join(dirPath, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { this.scanDirectory(fullPath, commands); } else if (stat.isFile() && extname(entry) === '.ts') { const commandMetadata = this.parseCommandFile(fullPath); if (commandMetadata) { commands.push(commandMetadata); } } } } catch (error) { console.warn(`Warning: Could not scan directory ${dirPath}:`, error); } } /** * Parse a TypeScript file to extract command metadata */ private parseCommandFile(filePath: string): CommandMetadata | null { try { const content = readFileSync(filePath, 'utf-8'); // Extract namespace from @namespace decorator const namespaceMatch = content.match(/@namespace\(['"`]([^'"`]+)['"`]\)/); if (!namespaceMatch) { return null; // Not a command file if no namespace } const namespace = namespaceMatch[1]; // Extract class name const classMatch = content.match(/export\s+class\s+(\w+)/); if (!classMatch) { return null; } const className = classMatch[1]; // Find method with @command decorator const commandMethodMatch = content.match(/@command[\s\S]*?public\s+async\s+(\w+)\s*\(/); if (!commandMethodMatch) { return null; } const methodName = commandMethodMatch[1]; const commandName = methodName; // Extract help text for the command method (look for @help before the @command method) const commandMethodStart = content.indexOf(`public async ${methodName}`); const commandSection = content.substring(0, commandMethodStart); const lastHelpMatch = commandSection.match(/@help\(['"`]([^'"`]+)['"`]\)(?![\s\S]*@help)/); const help = lastHelpMatch ? lastHelpMatch[1] : undefined; // Extract parameters const parameters = this.extractParameters(content); // Determine if command is read-only const isReadOnly = this.isReadOnlyCommand(methodName, help, content); return { namespace, commandName, methodName, help, parameters, filePath, className, isReadOnly }; } catch (error) { console.warn(`Warning: Could not parse command file ${filePath}:`, error); return null; } } /** * Extract parameter information from command file content */ private extractParameters(content: string): CommandParameter[] { const parameters: CommandParameter[] = []; // Find property declarations with their decorators // Look for patterns like: // @param // @optional // @help('...') // private propertyName: type const propertyBlocks = content.split(/(?=@(?:param|option|flag)(?:\(['"`][^'"`]*['"`]\))?(?:\s|$))/); for (const block of propertyBlocks) { if (!block.trim()) continue; // Extract decorator type - look for the first occurrence at the start of the block const decoratorMatch = block.match(/^@(param|option|flag)(?:\(['"`]([^'"`]*)['"`]\))?/); if (!decoratorMatch) continue; const [, decoratorType, shortFlag] = decoratorMatch; // Check for @optional const isOptional = block.includes('@optional'); // Extract help text const helpMatch = block.match(/@help\(['"`]([^'"`]+)['"`]\)/); const help = helpMatch ? helpMatch[1] : undefined; // Extract property name const propMatch = block.match(/(private|public)\s+(\w+)(?:\?)?(?:!)?:\s*/); if (!propMatch) continue; const [, , propertyName] = propMatch; parameters.push({ name: propertyName, type: decoratorType as 'param' | 'option' | 'flag', required: !isOptional && decoratorType !== 'flag', help, shortFlag }); } return parameters; } /** * Determine if a command is read-only based on method name, help text, and content analysis */ private isReadOnlyCommand(methodName: string, help?: string, content?: string): boolean { // Check method name against read-only patterns if (this.readOnlyPatterns.some(pattern => pattern.test(methodName))) { return true; } // Check method name against state-changing patterns if (this.stateChangingPatterns.some(pattern => pattern.test(methodName))) { return false; } // Check help text for read-only indicators if (help) { const readOnlyHelpPatterns = [ /^(list|show|display|get|view|info|status)/i, /information/i, /details/i ]; const stateChangingHelpPatterns = [ /^(create|add|update|delete|remove|set|install|publish)/i, /modify/i, /change/i ]; if (readOnlyHelpPatterns.some(pattern => pattern.test(help))) { return true; } if (stateChangingHelpPatterns.some(pattern => pattern.test(help))) { return false; } } // Analyze content for API calls that suggest read-only vs state-changing if (content) { // Look for HTTP methods or API calls that suggest read-only operations const readOnlyContentPatterns = [ /\.get\(/i, /\.search\(/i, /\.whoami\(/i, /\.list\(/i, /\.info\(/i, /\.status\(/i ]; const stateChangingContentPatterns = [ /\.post\(/i, /\.put\(/i, /\.patch\(/i, /\.delete\(/i, /\.create\(/i, /\.update\(/i, /\.remove\(/i, /\.install\(/i, /\.publish\(/i ]; if (readOnlyContentPatterns.some(pattern => pattern.test(content))) { return true; } if (stateChangingContentPatterns.some(pattern => pattern.test(content))) { return false; } } // Default to read-only if uncertain (safer for testing) return true; } /** * Generate a full command signature for CLI execution */ public generateCommandSignature(command: CommandMetadata): string { let signature = `${command.namespace} ${command.commandName}`; // Add required parameters const requiredParams = command.parameters.filter(p => p.required && p.type === 'param'); for (const param of requiredParams) { signature += ` <${param.name}>`; } // Add optional parameters const optionalParams = command.parameters.filter(p => !p.required && p.type === 'param'); for (const param of optionalParams) { signature += ` [${param.name}]`; } // Add options and flags const options = command.parameters.filter(p => p.type === 'option'); const flags = command.parameters.filter(p => p.type === 'flag'); if (options.length > 0 || flags.length > 0) { signature += ' [OPTIONS]'; } return signature; } }