Files
gh-rafaelcalleja-claude-mar…/skills/web-frameworks/scripts/turborepo_migrate.py
2025-11-30 08:48:52 +08:00

395 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Turborepo Migration Script
Convert existing monorepo to Turborepo with intelligent pipeline generation.
"""
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set
class TurborepoMigrator:
"""Migrate existing monorepo to Turborepo."""
def __init__(
self,
path: Path,
dry_run: bool = False,
package_manager: str = "npm",
):
"""
Initialize TurborepoMigrator.
Args:
path: Path to existing monorepo
dry_run: Preview changes without writing files
package_manager: Package manager (npm, yarn, pnpm, bun)
"""
self.path = path.resolve()
self.dry_run = dry_run
self.package_manager = package_manager
self.packages: List[Dict] = []
self.workspace_config: Dict = {}
def validate_path(self) -> None:
"""Validate monorepo path."""
if not self.path.exists():
raise FileNotFoundError(f"Path '{self.path}' does not exist")
if not self.path.is_dir():
raise NotADirectoryError(f"Path '{self.path}' is not a directory")
package_json = self.path / "package.json"
if not package_json.exists():
raise FileNotFoundError(
f"No package.json found in '{self.path}'. Not a valid monorepo."
)
def analyze_workspace(self) -> None:
"""Analyze existing workspace configuration."""
print("Analyzing workspace...")
package_json = self.path / "package.json"
with open(package_json) as f:
root_config = json.load(f)
# Detect workspace configuration
if "workspaces" in root_config:
self.workspace_config = {
"type": "npm/yarn",
"patterns": root_config["workspaces"],
}
elif (self.path / "pnpm-workspace.yaml").exists():
self.workspace_config = {
"type": "pnpm",
"file": "pnpm-workspace.yaml",
}
else:
raise ValueError(
"No workspace configuration found. Monorepo structure not detected."
)
print(f" Workspace type: {self.workspace_config['type']}")
def discover_packages(self) -> None:
"""Discover all packages in workspace."""
print("Discovering packages...")
if self.workspace_config["type"] == "npm/yarn":
patterns = self.workspace_config["patterns"]
if isinstance(patterns, dict):
patterns = patterns.get("packages", [])
else:
# Parse pnpm-workspace.yaml
yaml_file = self.path / "pnpm-workspace.yaml"
patterns = self._parse_pnpm_workspace(yaml_file)
# Find all packages matching patterns
for pattern in patterns:
self._find_packages_by_pattern(pattern)
print(f" Found {len(self.packages)} packages")
for pkg in self.packages:
print(f" - {pkg['name']} ({pkg['path'].relative_to(self.path)})")
def _parse_pnpm_workspace(self, yaml_file: Path) -> List[str]:
"""Parse pnpm-workspace.yaml file."""
patterns = []
with open(yaml_file) as f:
in_packages = False
for line in f:
line = line.strip()
if line.startswith("packages:"):
in_packages = True
continue
if in_packages and line.startswith("- "):
pattern = line[2:].strip().strip("'\"")
patterns.append(pattern)
return patterns
def _find_packages_by_pattern(self, pattern: str) -> None:
"""Find packages matching glob pattern."""
import glob
# Convert pattern to absolute path
search_pattern = str(self.path / pattern)
for match in glob.glob(search_pattern):
match_path = Path(match)
package_json = match_path / "package.json"
if package_json.exists():
with open(package_json) as f:
pkg_data = json.load(f)
self.packages.append(
{
"name": pkg_data.get("name", match_path.name),
"path": match_path,
"scripts": pkg_data.get("scripts", {}),
"dependencies": pkg_data.get("dependencies", {}),
"devDependencies": pkg_data.get("devDependencies", {}),
}
)
def analyze_scripts(self) -> Dict[str, Set[str]]:
"""Analyze common scripts across packages."""
print("Analyzing scripts...")
script_map: Dict[str, Set[str]] = {}
for pkg in self.packages:
for script_name in pkg["scripts"]:
if script_name not in script_map:
script_map[script_name] = set()
script_map[script_name].add(pkg["name"])
common_scripts = {
name: packages
for name, packages in script_map.items()
if len(packages) >= 2 # Present in at least 2 packages
}
print(f" Found {len(common_scripts)} common scripts:")
for script, packages in common_scripts.items():
print(f" - {script} ({len(packages)} packages)")
return common_scripts
def generate_turbo_config(self, common_scripts: Dict[str, Set[str]]) -> Dict:
"""Generate turbo.json configuration."""
print("Generating turbo.json configuration...")
pipeline = {}
# Build task
if "build" in common_scripts:
pipeline["build"] = {
"dependsOn": ["^build"],
"outputs": self._infer_build_outputs(),
}
# Test task
if "test" in common_scripts:
pipeline["test"] = {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
}
# Lint task
if "lint" in common_scripts:
pipeline["lint"] = {"dependsOn": ["^build"]}
# Typecheck task
if "typecheck" in common_scripts or "type-check" in common_scripts:
task_name = "typecheck" if "typecheck" in common_scripts else "type-check"
pipeline[task_name] = {"dependsOn": ["^build"]}
# Dev task
if "dev" in common_scripts or "start" in common_scripts:
dev_task = "dev" if "dev" in common_scripts else "start"
pipeline[dev_task] = {"cache": False, "persistent": True}
# Clean task
if "clean" in common_scripts:
pipeline["clean"] = {"cache": False}
turbo_config = {
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": pipeline,
}
return turbo_config
def _infer_build_outputs(self) -> List[str]:
"""Infer build output directories from packages."""
outputs = set()
for pkg in self.packages:
pkg_path = pkg["path"]
# Check common output directories
if (pkg_path / "dist").exists():
outputs.add("dist/**")
if (pkg_path / "build").exists():
outputs.add("build/**")
if (pkg_path / ".next").exists():
outputs.add(".next/**")
outputs.add("!.next/cache/**")
if (pkg_path / "out").exists():
outputs.add("out/**")
return sorted(list(outputs)) or ["dist/**"]
def update_root_package_json(self) -> Dict:
"""Update root package.json with Turborepo scripts."""
print("Updating root package.json...")
package_json_path = self.path / "package.json"
with open(package_json_path) as f:
package_json = json.load(f)
# Add turbo to devDependencies
if "devDependencies" not in package_json:
package_json["devDependencies"] = {}
package_json["devDependencies"]["turbo"] = "latest"
# Update scripts to use turbo
if "scripts" not in package_json:
package_json["scripts"] = {}
common_tasks = ["build", "dev", "test", "lint", "typecheck", "clean"]
for task in common_tasks:
# Check if task exists in any package
if any(task in pkg["scripts"] for pkg in self.packages):
package_json["scripts"][task] = f"turbo run {task}"
return package_json
def generate_migration_report(
self, turbo_config: Dict, updated_package_json: Dict
) -> str:
"""Generate migration report."""
report = []
report.append("=" * 60)
report.append("TURBOREPO MIGRATION REPORT")
report.append("=" * 60)
report.append("")
report.append(f"Monorepo Path: {self.path}")
report.append(f"Package Manager: {self.package_manager}")
report.append(f"Total Packages: {len(self.packages)}")
report.append("")
report.append("PACKAGES:")
for pkg in self.packages:
rel_path = pkg["path"].relative_to(self.path)
report.append(f" - {pkg['name']} ({rel_path})")
report.append("")
report.append("TURBO.JSON PIPELINE:")
for task, config in turbo_config["pipeline"].items():
report.append(f" {task}:")
for key, value in config.items():
report.append(f" {key}: {value}")
report.append("")
report.append("ROOT PACKAGE.JSON SCRIPTS:")
for script, command in updated_package_json.get("scripts", {}).items():
report.append(f" {script}: {command}")
report.append("")
report.append("RECOMMENDATIONS:")
report.append(" 1. Review generated turbo.json pipeline configuration")
report.append(" 2. Adjust output directories based on your build tools")
report.append(" 3. Configure remote caching: turbo login && turbo link")
report.append(" 4. Run 'npm install' to install Turborepo")
report.append(" 5. Test with: turbo run build --dry-run")
report.append("")
if self.dry_run:
report.append("DRY RUN MODE: No files were modified")
else:
report.append("FILES CREATED/MODIFIED:")
report.append(f" - {self.path / 'turbo.json'}")
report.append(f" - {self.path / 'package.json'}")
report.append("")
report.append("=" * 60)
return "\n".join(report)
def write_files(self, turbo_config: Dict, updated_package_json: Dict) -> None:
"""Write configuration files."""
if self.dry_run:
print("\nDRY RUN - Files that would be created/modified:")
print(f" - {self.path / 'turbo.json'}")
print(f" - {self.path / 'package.json'}")
return
print("Writing files...")
# Write turbo.json
turbo_json_path = self.path / "turbo.json"
with open(turbo_json_path, "w") as f:
json.dump(turbo_config, f, indent=2)
print(f" ✓ Created {turbo_json_path}")
# Write updated package.json
package_json_path = self.path / "package.json"
with open(package_json_path, "w") as f:
json.dump(updated_package_json, f, indent=2)
print(f" ✓ Updated {package_json_path}")
def migrate(self) -> None:
"""Run migration process."""
try:
print(f"Migrating monorepo to Turborepo: {self.path}")
print(f"Dry run: {self.dry_run}")
print()
self.validate_path()
self.analyze_workspace()
self.discover_packages()
common_scripts = self.analyze_scripts()
turbo_config = self.generate_turbo_config(common_scripts)
updated_package_json = self.update_root_package_json()
print()
self.write_files(turbo_config, updated_package_json)
print()
report = self.generate_migration_report(turbo_config, updated_package_json)
print(report)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="Migrate existing monorepo to Turborepo"
)
parser.add_argument(
"--path",
type=Path,
default=Path.cwd(),
help="Path to monorepo (default: current directory)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without writing files",
)
parser.add_argument(
"--package-manager",
choices=["npm", "yarn", "pnpm", "bun"],
default="npm",
help="Package manager (default: npm)",
)
args = parser.parse_args()
migrator = TurborepoMigrator(
path=args.path,
dry_run=args.dry_run,
package_manager=args.package_manager,
)
migrator.migrate()
if __name__ == "__main__":
main()