""" Dependency management tools for Clover - A terminal assistant for AI-powered project management """ import json import os import re import subprocess import sys from pathlib import Path from typing import Any, Dict, List, Optional, Tuple # Add the current directory to Python path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from config.settings import load_config from models.api_client import APIClient from tools.file_tools import create_file, read_file, update_file class DependencyManager: """Handle dependency management operations across different package managers""" def __init__(self): self.config = load_config() self.api_client = APIClient() self.package_managers = { "python": { "files": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"], "install_cmd": ["pip", "install"], "uninstall_cmd": ["pip", "uninstall", "-y"], "list_cmd": ["pip", "list", "--format=json"], "outdated_cmd": ["pip", "list", "--outdated", "--format=json"], }, "javascript": { "files": ["package.json", "package-lock.json", "yarn.lock"], "install_cmd": ["npm", "install"], "uninstall_cmd": ["npm", "uninstall"], "list_cmd": ["npm", "list", "--json"], "outdated_cmd": ["npm", "outdated", "--json"], }, "rust": { "files": ["Cargo.toml", "Cargo.lock"], "install_cmd": ["cargo", "add"], "uninstall_cmd": ["cargo", "remove"], "list_cmd": ["cargo", "tree"], "outdated_cmd": ["cargo", "outdated"], }, "go": { "files": ["go.mod", "go.sum"], "install_cmd": ["go", "get"], "uninstall_cmd": ["go", "mod", "edit", "-droprequire"], "list_cmd": ["go", "list", "-m", "all"], "outdated_cmd": ["go", "list", "-u", "-m", "all"], }, "ruby": { "files": ["Gemfile", "Gemfile.lock"], "install_cmd": ["gem", "install"], "uninstall_cmd": ["gem", "uninstall"], "list_cmd": ["gem", "list"], "outdated_cmd": ["gem", "outdated"], }, } def _detect_project_type(self, project_path: str = ".") -> List[str]: """ Detect project type(s) based on dependency files Args: project_path (str): Path to project directory Returns: List of detected project types """ detected_types = [] for project_type, config in self.package_managers.items(): for dep_file in config["files"]: if os.path.exists(os.path.join(project_path, dep_file)): detected_types.append(project_type) break return detected_types or ["unknown"] def _run_command(self, cmd: List[str], cwd: str = ".") -> Dict[str, Any]: """ Execute a command and return structured result Args: cmd (List[str]): Command to execute cwd (str): Working directory Returns: Dict containing command result """ try: result = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=300, # 5 minute timeout ) return { "success": result.returncode == 0, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "return_code": result.returncode, } except subprocess.TimeoutExpired: return { "success": False, "error": f"Command timed out: {' '.join(cmd)}", "return_code": -1, } except Exception as e: return { "success": False, "error": f"Error executing command: {str(e)}", "return_code": -1, } def _parse_requirements_txt(self, filepath: str) -> List[Dict[str, Any]]: """ Parse requirements.txt file Args: filepath (str): Path to requirements.txt Returns: List of dependency dictionaries """ try: content = read_file(filepath) dependencies = [] for line in content.splitlines(): line = line.strip() if not line or line.startswith("#"): continue # Parse dependency line # Handle various formats: package==1.0.0, package>=1.0.0, package, etc. match = re.match(r"^([a-zA-Z0-9\-_.]+)([><=!~]*)([\d\w\-.*]*)", line) if match: name, operator, version = match.groups() dependencies.append( { "name": name, "version": version if version else None, "operator": operator if operator else None, "raw": line, } ) return dependencies except Exception as e: return [{"error": f"Error parsing requirements.txt: {str(e)}"}] def _parse_package_json(self, filepath: str) -> Dict[str, List[Dict[str, Any]]]: """ Parse package.json file Args: filepath (str): Path to package.json Returns: Dict containing dependencies and devDependencies """ try: content = read_file(filepath) data = json.loads(content) result = {"dependencies": [], "devDependencies": []} # Parse regular dependencies if "dependencies" in data: for name, version in data["dependencies"].items(): result["dependencies"].append( {"name": name, "version": version, "type": "production"} ) # Parse dev dependencies if "devDependencies" in data: for name, version in data["devDependencies"].items(): result["devDependencies"].append( {"name": name, "version": version, "type": "development"} ) return result except Exception as e: return { "error": f"Error parsing package.json: {str(e)}", "dependencies": [], "devDependencies": [], } def _parse_pyproject_toml(self, filepath: str) -> List[Dict[str, Any]]: """ Parse pyproject.toml file Args: filepath (str): Path to pyproject.toml Returns: List of dependency dictionaries """ try: content = read_file(filepath) dependencies = [] # Simple TOML parsing for dependencies # This is a basic implementation - for production use, consider using a TOML library in_dependencies = False for line in content.splitlines(): line = line.strip() if ( line == "[tool.poetry.dependencies]" or line == "[project.dependencies]" ): in_dependencies = True continue elif line.startswith("[") and in_dependencies: in_dependencies = False continue if in_dependencies and "=" in line: parts = line.split("=", 1) if len(parts) == 2: name = parts[0].strip().strip('"') version = parts[1].strip().strip('"') dependencies.append( {"name": name, "version": version, "raw": line} ) return dependencies except Exception as e: return [{"error": f"Error parsing pyproject.toml: {str(e)}"}] def scan_dependencies(project_path: str = ".") -> Dict[str, Any]: """ Parse requirements.txt, pyproject.toml, package.json etc. Args: project_path (str): Path to project directory Returns: Dict containing dependency analysis """ try: manager = DependencyManager() project_types = manager._detect_project_type(project_path) results = { "project_path": project_path, "project_types": project_types, "dependency_files": {}, "total_dependencies": 0, } # Scan each detected project type for project_type in project_types: if project_type == "unknown": continue config = manager.package_managers.get(project_type, {}) dep_files = config.get("files", []) for dep_file in dep_files: file_path = os.path.join(project_path, dep_file) if os.path.exists(file_path): if dep_file == "requirements.txt": deps = manager._parse_requirements_txt(file_path) results["dependency_files"][dep_file] = { "type": "python", "dependencies": deps, "count": len([d for d in deps if "error" not in d]), } elif dep_file == "package.json": deps = manager._parse_package_json(file_path) total_deps = len(deps.get("dependencies", [])) + len( deps.get("devDependencies", []) ) results["dependency_files"][dep_file] = { "type": "javascript", "dependencies": deps, "count": total_deps, } elif dep_file == "pyproject.toml": deps = manager._parse_pyproject_toml(file_path) results["dependency_files"][dep_file] = { "type": "python", "dependencies": deps, "count": len([d for d in deps if "error" not in d]), } else: # For other files, just note their presence results["dependency_files"][dep_file] = { "type": project_type, "found": True, "count": 0, } # Calculate total dependencies for file_info in results["dependency_files"].values(): results["total_dependencies"] += file_info.get("count", 0) return results except Exception as e: return {"error": f"Error scanning dependencies: {str(e)}"} def add_dependency( package_name: str, version: str = None, project_path: str = ".", dev: bool = False ) -> Dict[str, Any]: """ Add a package to project dependencies Args: package_name (str): Name of package to add version (str): Version specification (optional) project_path (str): Path to project directory dev (bool): Whether this is a development dependency Returns: Dict containing operation result """ try: manager = DependencyManager() project_types = manager._detect_project_type(project_path) if "python" in project_types: return _add_python_dependency(package_name, version, project_path, dev) elif "javascript" in project_types: return _add_javascript_dependency(package_name, version, project_path, dev) else: return {"error": f"Unsupported project type: {project_types}"} except Exception as e: return {"error": f"Error adding dependency: {str(e)}"} def _add_python_dependency( package_name: str, version: str = None, project_path: str = ".", dev: bool = False ) -> Dict[str, Any]: """Add Python dependency""" try: manager = DependencyManager() # Try pip install first install_cmd = manager.package_managers["python"]["install_cmd"].copy() if version: package_spec = f"{package_name}=={version}" else: package_spec = package_name install_cmd.append(package_spec) result = manager._run_command(install_cmd, project_path) if not result["success"]: return { "error": f"Failed to install {package_name}: {result.get('stderr', 'Unknown error')}", "package": package_name, } # Update requirements.txt if it exists req_file = os.path.join(project_path, "requirements.txt") if os.path.exists(req_file): try: content = read_file(req_file) # Check if package already exists lines = content.splitlines() updated = False for i, line in enumerate(lines): if line.strip().startswith(package_name): # Update existing entry lines[i] = package_spec updated = True break if not updated: # Add new entry lines.append(package_spec) # Write back to file update_file(req_file, "\n".join(lines)) except Exception as e: # Installation succeeded but file update failed return { "warning": f"Package installed but failed to update requirements.txt: {str(e)}", "package": package_name, "version": version, } return { "success": True, "package": package_name, "version": version, "message": f"Successfully added {package_spec}", } except Exception as e: return {"error": f"Error adding Python dependency: {str(e)}"} def _add_javascript_dependency( package_name: str, version: str = None, project_path: str = ".", dev: bool = False ) -> Dict[str, Any]: """Add JavaScript dependency""" try: manager = DependencyManager() # Use npm install install_cmd = ["npm", "install"] if dev: install_cmd.append("--save-dev") if version: package_spec = f"{package_name}@{version}" else: package_spec = package_name install_cmd.append(package_spec) result = manager._run_command(install_cmd, project_path) if not result["success"]: return { "error": f"Failed to install {package_name}: {result.get('stderr', 'Unknown error')}", "package": package_name, } return { "success": True, "package": package_name, "version": version, "dev": dev, "message": f"Successfully added {package_spec}", } except Exception as e: return {"error": f"Error adding JavaScript dependency: {str(e)}"} def remove_dependency(package_name: str, project_path: str = ".") -> Dict[str, Any]: """ Remove a package from project dependencies Args: package_name (str): Name of package to remove project_path (str): Path to project directory Returns: Dict containing operation result """ try: manager = DependencyManager() project_types = manager._detect_project_type(project_path) if "python" in project_types: return _remove_python_dependency(package_name, project_path) elif "javascript" in project_types: return _remove_javascript_dependency(package_name, project_path) else: return {"error": f"Unsupported project type: {project_types}"} except Exception as e: return {"error": f"Error removing dependency: {str(e)}"} def _remove_python_dependency( package_name: str, project_path: str = "." ) -> Dict[str, Any]: """Remove Python dependency""" try: manager = DependencyManager() # Try pip uninstall uninstall_cmd = manager.package_managers["python"]["uninstall_cmd"].copy() uninstall_cmd.append(package_name) result = manager._run_command(uninstall_cmd, project_path) # Update requirements.txt if it exists req_file = os.path.join(project_path, "requirements.txt") if os.path.exists(req_file): try: content = read_file(req_file) lines = content.splitlines() # Remove lines that start with the package name filtered_lines = [ line for line in lines if not line.strip().startswith(package_name) ] update_file(req_file, "\n".join(filtered_lines)) except Exception as e: return { "warning": f"Package uninstalled but failed to update requirements.txt: {str(e)}", "package": package_name, } return { "success": result["success"], "package": package_name, "message": f"Removed {package_name}" if result["success"] else f"Failed to remove {package_name}", "details": result.get("stderr") if not result["success"] else None, } except Exception as e: return {"error": f"Error removing Python dependency: {str(e)}"} def _remove_javascript_dependency( package_name: str, project_path: str = "." ) -> Dict[str, Any]: """Remove JavaScript dependency""" try: manager = DependencyManager() # Use npm uninstall uninstall_cmd = manager.package_managers["javascript"]["uninstall_cmd"].copy() uninstall_cmd.append(package_name) result = manager._run_command(uninstall_cmd, project_path) return { "success": result["success"], "package": package_name, "message": f"Removed {package_name}" if result["success"] else f"Failed to remove {package_name}", "details": result.get("stderr") if not result["success"] else None, } except Exception as e: return {"error": f"Error removing JavaScript dependency: {str(e)}"} def dependency_report(project_path: str = ".") -> Dict[str, Any]: """ Generate structured dependency analysis Args: project_path (str): Path to project directory Returns: Dict containing comprehensive dependency report """ try: manager = DependencyManager() # Scan current dependencies scan_result = scan_dependencies(project_path) if "error" in scan_result: return scan_result # Get installed packages info project_types = scan_result.get("project_types", []) installed_packages = {} for project_type in project_types: if project_type == "python": result = manager._run_command( manager.package_managers["python"]["list_cmd"], project_path ) if result["success"]: try: packages = json.loads(result["stdout"]) installed_packages["python"] = packages except json.JSONDecodeError: installed_packages["python"] = { "error": "Failed to parse pip list output" } elif project_type == "javascript": result = manager._run_command( manager.package_managers["javascript"]["list_cmd"], project_path ) if result["success"]: try: packages = json.loads(result["stdout"]) installed_packages["javascript"] = packages except json.JSONDecodeError: installed_packages["javascript"] = { "error": "Failed to parse npm list output" } # Check for outdated packages outdated_packages = {} for project_type in project_types: if project_type == "python": result = manager._run_command( manager.package_managers["python"]["outdated_cmd"], project_path ) if result["success"]: try: packages = json.loads(result["stdout"]) outdated_packages["python"] = packages except json.JSONDecodeError: outdated_packages["python"] = [] # Analyze security vulnerabilities (basic check) security_issues = _check_security_issues(scan_result, project_path) # Compile comprehensive report report = { "project_path": project_path, "project_types": project_types, "dependency_scan": scan_result, "installed_packages": installed_packages, "outdated_packages": outdated_packages, "security_issues": security_issues, "summary": { "total_dependencies": scan_result.get("total_dependencies", 0), "dependency_files": len(scan_result.get("dependency_files", {})), "outdated_count": sum( len(v) for v in outdated_packages.values() if isinstance(v, list) ), "security_issues_count": len(security_issues.get("issues", [])), }, } return report except Exception as e: return {"error": f"Error generating dependency report: {str(e)}"} def _check_security_issues( scan_result: Dict[str, Any], project_path: str ) -> Dict[str, Any]: """ Basic security vulnerability check Args: scan_result (Dict): Result from dependency scan project_path (str): Path to project directory Returns: Dict containing security analysis """ try: issues = [] # Check for common vulnerable packages (basic list) vulnerable_patterns = { "python": [ { "name": "pillow", "versions": ["<8.1.1"], "issue": "PIL vulnerability", }, { "name": "urllib3", "versions": ["<1.26.5"], "issue": "SSL verification bypass", }, { "name": "requests", "versions": ["<2.25.1"], "issue": "Various security issues", }, ], "javascript": [ { "name": "lodash", "versions": ["<4.17.21"], "issue": "Prototype pollution", }, { "name": "axios", "versions": ["<0.21.1"], "issue": "SSRF vulnerability", }, { "name": "express", "versions": ["<4.17.1"], "issue": "Various security issues", }, ], } # Analyze dependencies for known vulnerabilities for file_name, file_info in scan_result.get("dependency_files", {}).items(): project_type = file_info.get("type") dependencies = file_info.get("dependencies", []) if project_type in vulnerable_patterns: for dep in dependencies: if isinstance(dep, dict) and "name" in dep: dep_name = dep["name"] dep_version = dep.get("version", "") for vuln in vulnerable_patterns[project_type]: if dep_name == vuln["name"]: # Simple version check (this is basic - real security scanners are much more sophisticated) if dep_version and any( pattern in dep_version for pattern in vuln["versions"] ): issues.append( { "package": dep_name, "version": dep_version, "issue": vuln["issue"], "severity": "medium", # Default severity "file": file_name, } ) return { "issues": issues, "total_issues": len(issues), "note": "This is a basic security check. Use specialized tools like 'safety' (Python) or 'npm audit' (JavaScript) for comprehensive security analysis.", } except Exception as e: return {"error": f"Error checking security issues: {str(e)}", "issues": []} def update_all_dependencies( project_path: str = ".", dry_run: bool = True ) -> Dict[str, Any]: """ Update all dependencies to latest versions Args: project_path (str): Path to project directory dry_run (bool): If True, only show what would be updated Returns: Dict containing update results """ try: manager = DependencyManager() project_types = manager._detect_project_type(project_path) results = { "project_path": project_path, "dry_run": dry_run, "updates": {}, } for project_type in project_types: if project_type == "python": # Get outdated packages result = manager._run_command( manager.package_managers["python"]["outdated_cmd"], project_path ) if result["success"]: try: outdated = json.loads(result["stdout"]) updates = [] for package in outdated: package_name = package.get("name") current_version = package.get("version") latest_version = package.get("latest_version") update_info = { "package": package_name, "current_version": current_version, "latest_version": latest_version, "updated": False, } if not dry_run: # Actually update the package install_result = manager._run_command( ["pip", "install", "--upgrade", package_name], project_path, ) update_info["updated"] = install_result["success"] if not install_result["success"]: update_info["error"] = install_result.get("stderr") updates.append(update_info) results["updates"]["python"] = updates except json.JSONDecodeError: results["updates"]["python"] = { "error": "Failed to parse outdated packages" } elif project_type == "javascript": if not dry_run: # Run npm update result = manager._run_command(["npm", "update"], project_path) results["updates"]["javascript"] = { "success": result["success"], "message": "Ran npm update", "details": result.get("stdout") or result.get("stderr"), } else: results["updates"]["javascript"] = { "message": "Would run npm update" } return results except Exception as e: return {"error": f"Error updating dependencies: {str(e)}"} def create_lock_file(project_path: str = ".") -> Dict[str, Any]: """ Create or update lock files for dependency pinning Args: project_path (str): Path to project directory Returns: Dict containing lock file creation results """ try: manager = DependencyManager() project_types = manager._detect_project_type(project_path) results = {"project_path": project_path, "lock_files": {}} for project_type in project_types: if project_type == "python": # Generate requirements-lock.txt with exact versions result = manager._run_command(["pip", "freeze"], project_path) if result["success"]: lock_file = os.path.join(project_path, "requirements-lock.txt") success = create_file(lock_file, result["stdout"]) results["lock_files"]["requirements-lock.txt"] = { "created": success, "path": lock_file, } elif project_type == "javascript": # package-lock.json is created automatically by npm lock_file = os.path.join(project_path, "package-lock.json") results["lock_files"]["package-lock.json"] = { "exists": os.path.exists(lock_file), "path": lock_file, } return results except Exception as e: return {"error": f"Error creating lock files: {str(e)}"} # Example usage if __name__ == "__main__": # Scan project dependencies scan_result = scan_dependencies(".") print(f"Dependencies found: {scan_result}") # Generate comprehensive report report = dependency_report(".") print(f"Dependency report: {report}")