395 lines
13 KiB
Python
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()
|