Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions .env.example

This file was deleted.

5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 61 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
70 changes: 70 additions & 0 deletions sample/monorepo/README.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions sample/monorepo/backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Backend service
JWT_SECRET={{ JWT_SECRET }}
API_PORT={{ auto_port() }}
3 changes: 3 additions & 0 deletions sample/monorepo/frontend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Frontend service
REACT_APP_API_KEY={{ REACT_APP_API_KEY }}
FRONTEND_PORT={{ auto_port() }}
59 changes: 49 additions & 10 deletions src/sprout/commands/create.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Implementation of the create command."""

import re
from pathlib import Path
from typing import Never

Expand All @@ -12,6 +13,7 @@
branch_exists,
ensure_sprout_dir,
get_git_root,
get_used_ports,
is_git_repository,
parse_env_template,
run_command,
Expand All @@ -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
Expand Down Expand Up @@ -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]")
Expand Down
36 changes: 29 additions & 7 deletions src/sprout/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}")

Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading