diff --git a/.env.example b/.env.example deleted file mode 100644 index 1c04d61..0000000 --- a/.env.example +++ /dev/null @@ -1,12 +0,0 @@ -# Example environment configuration -API_KEY={{ API_KEY }} -WEB_PORT={{ auto_port() }} -DB_PORT={{ auto_port() }} -REDIS_PORT={{ auto_port() }} - -# Static configuration -NODE_ENV=development -LOG_LEVEL=debug - -# Docker Compose variables (preserved) -COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-sprout} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d19a38..53b4e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Support for multiple `.env.example` files throughout the repository, enabling monorepo workflows +- Recursive scanning of `.env` files for port allocation to ensure global uniqueness across all services ### Changed +- Port allocation now ensures uniqueness across all services in all worktrees, preventing Docker host port conflicts +- `sprout create` now processes all `.env.example` files found in the repository while maintaining directory structure +- Only git-tracked `.env.example` files are now processed, preventing unwanted processing of files in `.sprout/` worktrees ### Deprecated diff --git a/README.md b/README.md index a88cb75..cf7b34d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ pip install -e ".[dev]" ## Quick Start -1. Create a `.env.example` template in your project root: +1. Create a `.env.example` template in your project root (and optionally in subdirectories): ```env # API Configuration API_KEY={{ API_KEY }} @@ -43,6 +43,16 @@ DB_PORT={{ auto_port() }} # DB_NAME=${DB_NAME} ``` +For monorepo or multi-service projects, you can create `.env.example` files in subdirectories: +``` +repo/ + .env.example # Root configuration + service-a/ + .env.example # Service A specific config + service-b/ + .env.example # Service B specific config +``` + 2. Create and navigate to a new development environment in one command: ```bash cd $(sprout create feature-branch --path) @@ -145,8 +155,9 @@ sprout supports two types of placeholders in `.env.example`: 2. **Auto Port Assignment**: `{{ auto_port() }}` - Automatically assigns available ports - - Avoids conflicts with other sprout environments + - Avoids conflicts across ALL services in ALL sprout environments - Checks system port availability + - Ensures global uniqueness even in monorepo setups 3. **Docker Compose Syntax (Preserved)**: `${VARIABLE}` - NOT processed by sprout - passed through as-is @@ -168,6 +179,54 @@ sprout create another-branch # → Enter a value for 'DATABASE_URL': [user input required] ``` +## Monorepo Tutorial + +Try out the monorepo functionality with the included sample: + +1. **Navigate to the sample monorepo**: + ```bash + cd sample/monorepo + ``` + +2. **Set required environment variables**: + ```bash + export API_KEY="your-api-key" + export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp" + export REACT_APP_API_KEY="your-frontend-api-key" + export JWT_SECRET="your-jwt-secret" + export SMTP_USER="your-smtp-username" + export SMTP_PASS="your-smtp-password" + ``` + +3. **Create a development environment**: + ```bash + sprout create monorepo-feature + ``` + +4. **Navigate to the created environment**: + ```bash + cd .sprout/monorepo-feature + ``` + +5. **Verify all services have unique ports**: + ```bash + find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \; + ``` + +6. **Start all services**: + ```bash + cd sample/monorepo + docker-compose up -d + ``` + +The sample includes: +- **Root service**: Database and Redis with shared configuration +- **Frontend**: React app with API integration +- **Backend**: REST API with authentication +- **Shared**: Utilities with message queue and monitoring + +Each service gets unique, conflict-free ports automatically! + ## Documentation - [Architecture Overview](docs/sprout-cli/overview.md) - Design philosophy, architecture, and implementation details diff --git a/sample/monorepo/README.md b/sample/monorepo/README.md new file mode 100644 index 0000000..b97ce13 --- /dev/null +++ b/sample/monorepo/README.md @@ -0,0 +1,70 @@ +# Sample Monorepo for Sprout Testing + +This directory contains a sample monorepo structure to demonstrate sprout's multiple `.env.example` file support. + +## Structure + +``` +sample/monorepo/ +├── .env.example # Root configuration (database, redis, common settings) +├── docker-compose.yml # Multi-service Docker setup +├── frontend/ +│ └── .env.example # Frontend-specific environment variables +├── backend/ +│ └── .env.example # Backend API configuration +└── shared/ + └── .env.example # Shared utilities configuration +``` + +## Environment Variables Required + +Before testing, set these environment variables: + +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp" +export REACT_APP_API_KEY="your-frontend-api-key" +export JWT_SECRET="your-jwt-secret-key" +export SMTP_USER="your-smtp-username" +export SMTP_PASS="your-smtp-password" +``` + +## Testing with Sprout + +1. Navigate to this directory: + ```bash + cd sample/monorepo + ``` + +2. Initialize as a git repository (if not already): + ```bash + git init + git add . + git commit -m "Initial monorepo setup" + ``` + +3. Create a sprout worktree: + ```bash + sprout create feature-test + ``` + +4. Navigate to the created worktree: + ```bash + cd .sprout/feature-test + ``` + +5. Verify all `.env` files were created with unique ports: + ```bash + find . -name "*.env" -exec echo "=== {} ===" \; -exec cat {} \; + ``` + +6. Start the services: + ```bash + docker-compose up -d + ``` + +## Expected Behavior + +- Sprout should detect all 4 `.env.example` files +- Each `{{ auto_port() }}` should get a unique port number +- All `.env` files should be created in their respective directories +- No port conflicts should occur when running multiple worktrees \ No newline at end of file diff --git a/sample/monorepo/backend/.env.example b/sample/monorepo/backend/.env.example new file mode 100644 index 0000000..5bcce62 --- /dev/null +++ b/sample/monorepo/backend/.env.example @@ -0,0 +1,3 @@ +# Backend service +JWT_SECRET={{ JWT_SECRET }} +API_PORT={{ auto_port() }} \ No newline at end of file diff --git a/sample/monorepo/frontend/.env.example b/sample/monorepo/frontend/.env.example new file mode 100644 index 0000000..c03781c --- /dev/null +++ b/sample/monorepo/frontend/.env.example @@ -0,0 +1,3 @@ +# Frontend service +REACT_APP_API_KEY={{ REACT_APP_API_KEY }} +FRONTEND_PORT={{ auto_port() }} \ No newline at end of file diff --git a/src/sprout/commands/create.py b/src/sprout/commands/create.py index 00d9f3f..427f28c 100644 --- a/src/sprout/commands/create.py +++ b/src/sprout/commands/create.py @@ -1,5 +1,6 @@ """Implementation of the create command.""" +import re from pathlib import Path from typing import Never @@ -12,6 +13,7 @@ branch_exists, ensure_sprout_dir, get_git_root, + get_used_ports, is_git_repository, parse_env_template, run_command, @@ -33,14 +35,22 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: raise typer.Exit(1) git_root = get_git_root() - env_example = git_root / ".env.example" - if not env_example.exists(): + # Find all .env.example files that are tracked by git + result = run_command(["git", "ls-files", "*.env.example", "**/*.env.example"]) + env_examples = [] + if result.stdout.strip(): + for file_path in result.stdout.strip().split("\n"): + full_path = git_root / file_path + if full_path.exists(): + env_examples.append(full_path) + + if not env_examples: if not path_only: - console.print("[red]Error: .env.example file not found[/red]") - console.print(f"Expected at: {env_example}") + console.print("[red]Error: No .env.example files found[/red]") + console.print(f"Expected at least one .env.example file in: {git_root}") else: - typer.echo(f"Error: .env.example file not found at {env_example}", err=True) + typer.echo(f"Error: No .env.example files found in {git_root}", err=True) raise typer.Exit(1) # Check if worktree already exists @@ -77,13 +87,42 @@ def create_worktree(branch_name: BranchName, path_only: bool = False) -> Never: typer.echo(f"Error creating worktree: {e}", err=True) raise typer.Exit(1) from e - # Generate .env file + # Generate .env files if not path_only: - console.print("Generating .env file...") + console.print(f"Generating .env files from {len(env_examples)} template(s)...") + + # Get all currently used ports to avoid conflicts + all_used_ports = get_used_ports() + session_ports: set[int] = set() + try: - env_content = parse_env_template(env_example, silent=path_only) - env_file = worktree_path / ".env" - env_file.write_text(env_content) + for env_example in env_examples: + # Calculate relative path from git root + relative_dir = env_example.parent.relative_to(git_root) + + # Create target directory in worktree if needed + if relative_dir != Path("."): + target_dir = worktree_path / relative_dir + target_dir.mkdir(parents=True, exist_ok=True) + env_file = target_dir / ".env" + else: + env_file = worktree_path / ".env" + + # Parse template with combined used ports + env_content = parse_env_template( + env_example, silent=path_only, used_ports=all_used_ports | session_ports + ) + + # Extract ports from generated content and add to session_ports + port_matches = re.findall(r"=(\d{4,5})\b", env_content) + for port_str in port_matches: + port = int(port_str) + if 1024 <= port <= 65535: + session_ports.add(port) + + # Write the .env file + env_file.write_text(env_content) + except SproutError as e: if not path_only: console.print(f"[red]Error generating .env file: {e}[/red]") diff --git a/src/sprout/utils.py b/src/sprout/utils.py index 885b317..156328d 100644 --- a/src/sprout/utils.py +++ b/src/sprout/utils.py @@ -83,8 +83,8 @@ def get_used_ports() -> PortSet: if not sprout_dir.exists(): return used_ports - # Scan all .env files in .sprout/*/ - for env_file in sprout_dir.glob("*/.env"): + # Scan all .env files recursively in .sprout/ + for env_file in sprout_dir.rglob("*.env"): if env_file.is_file(): try: content = env_file.read_text() @@ -125,8 +125,16 @@ def find_available_port() -> PortNumber: raise SproutError("Could not find an available port after 1000 attempts") -def parse_env_template(template_path: Path, silent: bool = False) -> str: - """Parse .env.example template and process placeholders.""" +def parse_env_template( + template_path: Path, silent: bool = False, used_ports: PortSet | None = None +) -> str: + """Parse .env.example template and process placeholders. + + Args: + template_path: Path to the .env.example template file + silent: If True, use stderr for prompts to keep stdout clean + used_ports: Set of ports already in use (in addition to system-wide used ports) + """ if not template_path.exists(): raise SproutError(f".env.example file not found at {template_path}") @@ -138,6 +146,9 @@ def parse_env_template(template_path: Path, silent: bool = False) -> str: lines: list[str] = [] # Track used ports within this file to avoid duplicates file_ports: PortSet = set() + # Include any additional used ports passed in + if used_ports: + file_ports.update(used_ports) for line in content.splitlines(): # Process {{ auto_port() }} placeholders @@ -156,13 +167,24 @@ def replace_variable(match: re.Match[str]) -> str: # Check environment variable first value = os.environ.get(var_name) if value is None: - # Prompt user for value + # Create a relative path for display + try: + display_path = template_path.relative_to(Path.cwd()) + except ValueError: + display_path = template_path + + # Prompt user for value with file context if silent: # Use stderr for prompts in silent mode to keep stdout clean - typer.echo(f"Enter a value for '{var_name}': ", err=True, nl=False) + prompt = f"Enter a value for '{var_name}' (from {display_path}): " + typer.echo(prompt, err=True, nl=False) value = input() else: - value = console.input(f"Enter a value for '{var_name}': ") + prompt = ( + f"Enter a value for '[cyan]{var_name}[/cyan]' " + f"(from [dim]{display_path}[/dim]): " + ) + value = console.input(prompt) return value line = re.sub(r"{{\s*([^}]+)\s*}}", replace_variable, line) diff --git a/tests/test_commands.py b/tests/test_commands.py index 983308b..3aa89dd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -38,6 +38,18 @@ def test_create_success_new_branch(self, mocker, tmp_path): # Mock command execution mock_run = mocker.patch("sprout.commands.create.run_command") + # Mock git ls-files to return .env.example + mock_run.side_effect = lambda cmd, **kwargs: ( + Mock(stdout=".env.example\n", returncode=0) + if cmd[1] == "ls-files" + else Mock(returncode=0) + ) + # Mock git ls-files to return .env.example + mock_run.side_effect = lambda cmd, **kwargs: ( + Mock(stdout=".env.example\n", returncode=0) + if cmd[1] == "ls-files" + else Mock(returncode=0) + ) # Run command result = runner.invoke(app, ["create", "feature-branch"]) @@ -84,6 +96,12 @@ def test_create_with_path_flag_success(self, mocker, tmp_path): # Mock command execution mock_run = mocker.patch("sprout.commands.create.run_command") + # Mock git ls-files to return .env.example + mock_run.side_effect = lambda cmd, **kwargs: ( + Mock(stdout=".env.example\n", returncode=0) + if cmd[1] == "ls-files" + else Mock(returncode=0) + ) # Run command with --path flag result = runner.invoke(app, ["create", "feature-branch", "--path"]) @@ -111,26 +129,32 @@ def test_create_with_path_flag_error(self, mocker): def test_create_no_env_example(self, mocker): """Test error when .env.example doesn't exist.""" mocker.patch("sprout.commands.create.is_git_repository", return_value=True) - mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project")) + mock_git_root = Path("/project") + mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root) - mock_env_example = Mock() - mock_env_example.exists.return_value = False - mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example) + # Mock git ls-files to return empty list + mock_run = mocker.patch("sprout.commands.create.run_command") + mock_run.return_value = Mock(stdout="", returncode=0) result = runner.invoke(app, ["create", "feature-branch"]) assert result.exit_code == 1 - assert ".env.example file not found" in result.stdout + assert "No .env.example files found" in result.stdout def test_create_worktree_exists(self, mocker): """Test error when worktree already exists.""" mocker.patch("sprout.commands.create.is_git_repository", return_value=True) - mocker.patch("sprout.commands.create.get_git_root", return_value=Path("/project")) + mock_git_root = Path("/project") + mocker.patch("sprout.commands.create.get_git_root", return_value=mock_git_root) mocker.patch("sprout.commands.create.worktree_exists", return_value=True) - mock_env_example = Mock() - mock_env_example.exists.return_value = True - mocker.patch("pathlib.Path.__truediv__", return_value=mock_env_example) + # Mock git ls-files to return .env.example + mock_run = mocker.patch("sprout.commands.create.run_command") + mock_run.return_value = Mock(stdout=".env.example\n", returncode=0) + + # Mock Path.exists to return True for .env.example + mock_exists = mocker.patch("pathlib.Path.exists") + mock_exists.return_value = True result = runner.invoke(app, ["create", "feature-branch"]) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2c98a58..6eeb5a8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -57,6 +57,10 @@ def git_repo(tmp_path): "COMPOSE_VAR=${COMPOSE_VAR:-default}\n" ) + # Add .env.example to git + subprocess.run(["git", "add", ".env.example"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-m", "Add .env.example"], cwd=tmp_path, check=True) + return tmp_path, default_branch @@ -84,6 +88,10 @@ def test_placeholder_substitution_from_env(self, git_repo, monkeypatch): "COMPOSE_VAR=${COMPOSE_VAR:-default}\n" ) + # Add to git + subprocess.run(["git", "add", ".env.example"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add .env.example"], cwd=git_repo, check=True) + # Create worktree - SECRET_TOKEN should be prompted but we can't test that # So let's set it too monkeypatch.setenv("SECRET_TOKEN", "secret123") @@ -219,7 +227,7 @@ def test_error_cases(self, git_repo, monkeypatch, tmp_path): (git_repo / ".env.example").unlink() result = runner.invoke(app, ["create", "another-branch"]) assert result.exit_code == 1 - assert ".env.example file not found" in result.stdout + assert "No .env.example files found" in result.stdout # Test outside git repo using a separate temp directory import tempfile diff --git a/tests/test_multi_env.py b/tests/test_multi_env.py new file mode 100644 index 0000000..ef581fa --- /dev/null +++ b/tests/test_multi_env.py @@ -0,0 +1,190 @@ +"""Tests for multiple .env.example files functionality.""" + +import subprocess + +from typer.testing import CliRunner + +from sprout.cli import app +from sprout.utils import get_used_ports + +from .test_integration import git_repo # noqa: F401 + +runner = CliRunner() + + +class TestMultipleEnvExamples: + """Test handling of multiple .env.example files.""" + + def test_create_with_multiple_env_examples(self, git_repo, monkeypatch): # noqa: F811 + """Test creating worktree with multiple .env.example files.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") + monkeypatch.setenv("DB_PASSWORD", "test_password") + + # Create service directories with .env.example files + service_a = git_repo / "service-a" + service_a.mkdir() + (service_a / ".env.example").write_text("""# Service A Configuration +API_KEY={{ API_KEY }} +API_PORT={{ auto_port() }} +""") + + service_b = git_repo / "service-b" + service_b.mkdir() + (service_b / ".env.example").write_text("""# Service B Configuration +DB_PASSWORD={{ DB_PASSWORD }} +DB_PORT={{ auto_port() }} +""") + + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add services"], cwd=git_repo, check=True) + + # Create worktree + result = runner.invoke(app, ["create", "feature-multi"]) + assert result.exit_code == 0 + assert "Generating .env files from 3 template(s)" in result.stdout + + # Check that all .env files were created with correct structure + worktree_path = git_repo / ".sprout" / "feature-multi" + assert (worktree_path / ".env").exists() + assert (worktree_path / "service-a" / ".env").exists() + assert (worktree_path / "service-b" / ".env").exists() + + # Check content + root_env = (worktree_path / ".env").read_text() + assert "API_KEY=test_key" in root_env + + service_a_env = (worktree_path / "service-a" / ".env").read_text() + assert "API_KEY=test_key" in service_a_env + assert "API_PORT=" in service_a_env + + service_b_env = (worktree_path / "service-b" / ".env").read_text() + assert "DB_PASSWORD=test_password" in service_b_env + assert "DB_PORT=" in service_b_env + + def test_port_uniqueness_across_services(self, git_repo, monkeypatch): # noqa: F811 + """Test that auto_port() generates unique ports across all services.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example + + # Create multiple services with port requirements + for i in range(3): + service_dir = git_repo / f"service-{i}" + service_dir.mkdir() + (service_dir / ".env.example").write_text(f"""# Service {i} +PORT1={{{{ auto_port() }}}} +PORT2={{{{ auto_port() }}}} +""") + + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add services"], cwd=git_repo, check=True) + + # Create worktree + result = runner.invoke(app, ["create", "test-ports"]) + assert result.exit_code == 0 + + # Collect all ports + all_ports = set() + worktree_path = git_repo / ".sprout" / "test-ports" + + for i in range(3): + env_content = (worktree_path / f"service-{i}" / ".env").read_text() + lines = env_content.strip().split("\n") + for line in lines: + if "=" in line and not line.startswith("#"): + port = int(line.split("=")[1]) + assert port not in all_ports, f"Port {port} is duplicated" + all_ports.add(port) + + # Should have 6 unique ports (2 per service × 3 services) + assert len(all_ports) == 6 + + def test_global_port_uniqueness_across_worktrees(self, git_repo, monkeypatch): # noqa: F811 + """Test that ports are unique across different worktrees.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example + + # Create service with ports + service_dir = git_repo / "service" + service_dir.mkdir() + (service_dir / ".env.example").write_text(""" +PORT1={{ auto_port() }} +PORT2={{ auto_port() }} +""") + + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add service"], cwd=git_repo, check=True) + + # Create first worktree + result = runner.invoke(app, ["create", "branch1"]) + assert result.exit_code == 0 + + # Get ports from first worktree + env1 = (git_repo / ".sprout" / "branch1" / "service" / ".env").read_text() + ports1 = set() + for line in env1.strip().split("\n"): + if "=" in line and not line.startswith("#"): + ports1.add(int(line.split("=")[1])) + + # Create second worktree + result = runner.invoke(app, ["create", "branch2"]) + assert result.exit_code == 0 + + # Get ports from second worktree + env2 = (git_repo / ".sprout" / "branch2" / "service" / ".env").read_text() + ports2 = set() + for line in env2.strip().split("\n"): + if "=" in line and not line.startswith("#"): + ports2.add(int(line.split("=")[1])) + + # Ensure no overlap + assert len(ports1.intersection(ports2)) == 0, "Ports should not overlap between worktrees" + + def test_nested_directory_structure(self, git_repo, monkeypatch): # noqa: F811 + """Test handling of nested directory structures.""" + git_repo, default_branch = git_repo + monkeypatch.chdir(git_repo) + monkeypatch.setenv("API_KEY", "test_key") # Set env var for root .env.example + + # Create nested structure + nested_path = git_repo / "services" / "backend" / "api" + nested_path.mkdir(parents=True) + (nested_path / ".env.example").write_text("NESTED_PORT={{ auto_port() }}") + + # Add files to git + subprocess.run(["git", "add", "."], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-m", "Add nested service"], cwd=git_repo, check=True) + + # Create worktree + result = runner.invoke(app, ["create", "nested-test"]) + assert result.exit_code == 0 + + # Check nested .env was created + worktree_path = git_repo / ".sprout" / "nested-test" + nested_env_path = worktree_path / "services" / "backend" / "api" / ".env" + assert nested_env_path.exists() + assert "NESTED_PORT=" in nested_env_path.read_text() + + def test_get_used_ports_recursive(self, tmp_path, monkeypatch): + """Test that get_used_ports now searches recursively.""" + monkeypatch.setattr("sprout.utils.get_sprout_dir", lambda: tmp_path) + + # Create nested structure with .env files + (tmp_path / "branch1").mkdir() + (tmp_path / "branch1" / ".env").write_text("PORT1=8080") + + (tmp_path / "branch1" / "service-a").mkdir() + (tmp_path / "branch1" / "service-a" / ".env").write_text("PORT2=8081") + + (tmp_path / "branch2" / "nested" / "deep").mkdir(parents=True) + (tmp_path / "branch2" / "nested" / "deep" / ".env").write_text("PORT3=8082") + + # Test recursive port collection + ports = get_used_ports() + assert ports == {8080, 8081, 8082}