diff --git a/CLAUDE.md b/CLAUDE.md index f7895bd..61e73ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -404,6 +404,7 @@ Check: - Format: `{type}({scope}): {subject}` - Types: feat, fix, docs, chore, refactor, test - Scope: agent name, component, or module +- **Do NOT include AI co-author attribution** (e.g., no `Co-Authored-By: Claude`) - Examples: - `feat(watcher): add retry logic for failed AI calls` - `docs(deployment): update Docker compose example` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 617b983..5ab8ffd 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -44,7 +44,7 @@ weft init [OPTIONS] | Flag | Type | Description | |---------------------------|---------|---------------------------| -| `--project-name TEXT` | String | Project name | +| `--project-name TEXT` | String | Project name (lowercase letters, numbers, hyphens, and underscores only) | | `--project-type CHOICE` | Choice | `backend` \| `frontend` \| `fullstack` | | `--ai-provider CHOICE` | Choice | `claude` \| `ollama` \| `other` | | `--ai-history-path PATH` | Path | AI history repo path | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ee1b1ee..2f96c42 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -129,7 +129,7 @@ Log out and back in for changes to apply. ### Containers fail to start -**Problem** +**Problem** Agent containers exit immediately. **Fix** diff --git a/src/weft/cli/init.py b/src/weft/cli/init.py index 93bdaa4..a373900 100644 --- a/src/weft/cli/init.py +++ b/src/weft/cli/init.py @@ -195,6 +195,27 @@ def project_init( ) prompted_for_any = True + # Validate project name for Docker Compose compatibility + from weft.cli.runtime.helpers import sanitize_docker_project_name + + sanitized_name = sanitize_docker_project_name(project_name) + if sanitized_name != project_name: + click.echo( + f"\nℹ️ Note: Project name '{project_name}' contains characters not supported by Docker Compose." + ) + click.echo( + " Docker Compose project names must contain only lowercase letters, numbers, hyphens, and underscores." + ) + click.echo(f" Recommended name: '{sanitized_name}'\n") + + if click.confirm("Use recommended name?", default=True): + project_name = sanitized_name + click.echo(f"✓ Using project name: {project_name}\n") + else: + click.echo( + f"ℹ️ Continuing with '{project_name}'. Docker Compose will use '{sanitized_name}' internally.\n" + ) + if not project_type: project_type = click.prompt( "Project type", diff --git a/src/weft/cli/runtime/helpers.py b/src/weft/cli/runtime/helpers.py index 56d7c91..363864d 100644 --- a/src/weft/cli/runtime/helpers.py +++ b/src/weft/cli/runtime/helpers.py @@ -1,6 +1,8 @@ """Shared helper functions for runtime commands.""" +import logging import os +import re import subprocess from pathlib import Path from typing import Literal @@ -9,6 +11,31 @@ from weft.config.project import load_weftrc from weft.utils.project import get_project_root +logger = logging.getLogger(__name__) + + +def sanitize_docker_project_name(name: str) -> str: + """Convert project name to Docker Compose-compatible format.""" + # Convert to lowercase + sanitized = name.lower() + + # Replace any sequence of invalid characters with a single hyphen + # Valid: a-z, 0-9, hyphen, underscore + sanitized = re.sub(r"[^a-z0-9_-]+", "-", sanitized) + + # Remove leading/trailing hyphens or underscores + sanitized = sanitized.strip("-_") + + # Ensure it starts with alphanumeric (not hyphen/underscore) + if sanitized and not sanitized[0].isalnum(): + sanitized = "project-" + sanitized + + # Fallback if empty after sanitization + if not sanitized: + sanitized = "weft-project" + + return sanitized + def get_docker_compose_path() -> Path: """Get docker-compose.yml path. @@ -95,7 +122,14 @@ def setup_docker_env(for_command: Literal["up", "down", "logs"] = "up") -> dict[ # Set COMPOSE_PROJECT_NAME for docker compose to namespace containers/networks # Docker Compose automatically prefixes all resource names with this value - env["COMPOSE_PROJECT_NAME"] = config.project.name + # Sanitize project name to meet Docker Compose requirements + sanitized_name = sanitize_docker_project_name(config.project.name) + if sanitized_name != config.project.name: + logger.info( + f"Project name '{config.project.name}' sanitized to '{sanitized_name}' for Docker Compose compatibility. " + f"Docker Compose project names must contain only lowercase letters, numbers, hyphens, and underscores." + ) + env["COMPOSE_PROJECT_NAME"] = sanitized_name # Set weft package directory for docker build context (only needed for up) if for_command == "up": diff --git a/tests/unit/weft/cli/test_runtime.py b/tests/unit/weft/cli/test_runtime.py index d20c887..cf62a16 100644 --- a/tests/unit/weft/cli/test_runtime.py +++ b/tests/unit/weft/cli/test_runtime.py @@ -6,7 +6,90 @@ from click.testing import CliRunner from weft.cli.runtime import down, logs, up -from weft.cli.runtime.helpers import validate_docker +from weft.cli.runtime.helpers import sanitize_docker_project_name, validate_docker + + +class TestSanitizeDockerProjectName: + """Tests for Docker Compose project name sanitization.""" + + def test_valid_name_unchanged(self): + """Test that valid names are not modified.""" + assert sanitize_docker_project_name("myproject") == "myproject" + assert sanitize_docker_project_name("my-project") == "my-project" + assert sanitize_docker_project_name("my_project") == "my_project" + assert sanitize_docker_project_name("project123") == "project123" + assert sanitize_docker_project_name("123project") == "123project" + + def test_uppercase_converted_to_lowercase(self): + """Test that uppercase letters are converted to lowercase.""" + assert sanitize_docker_project_name("MyProject") == "myproject" + assert sanitize_docker_project_name("MY-PROJECT") == "my-project" + assert sanitize_docker_project_name("MY_PROJECT") == "my_project" + + def test_periods_replaced_with_hyphens(self): + """Test that periods are replaced with hyphens.""" + assert sanitize_docker_project_name("my.project") == "my-project" + assert sanitize_docker_project_name("example.app") == "example-app" + assert sanitize_docker_project_name("app.example.com") == "app-example-com" + + def test_multiple_invalid_chars_replaced(self): + """Test that sequences of invalid characters are replaced with single hyphen.""" + assert sanitize_docker_project_name("my..project") == "my-project" + assert sanitize_docker_project_name("my...project") == "my-project" + # Note: existing hyphens are valid and preserved, so "my.-.project" keeps all hyphens + assert sanitize_docker_project_name("my.-.project") == "my---project" + assert sanitize_docker_project_name("my@#$project") == "my-project" + + def test_special_characters_replaced(self): + """Test that special characters are replaced with hyphens.""" + assert sanitize_docker_project_name("my@project") == "my-project" + assert sanitize_docker_project_name("my!project") == "my-project" + assert sanitize_docker_project_name("my project") == "my-project" # space + assert sanitize_docker_project_name("my&project") == "my-project" + + def test_leading_trailing_hyphens_removed(self): + """Test that leading and trailing hyphens are removed.""" + assert sanitize_docker_project_name("-myproject") == "myproject" + assert sanitize_docker_project_name("myproject-") == "myproject" + assert sanitize_docker_project_name("-myproject-") == "myproject" + assert sanitize_docker_project_name("---myproject---") == "myproject" + + def test_leading_trailing_underscores_removed(self): + """Test that leading and trailing underscores are removed.""" + assert sanitize_docker_project_name("_myproject") == "myproject" + assert sanitize_docker_project_name("myproject_") == "myproject" + assert sanitize_docker_project_name("_myproject_") == "myproject" + + def test_starts_with_non_alphanumeric_prepends_prefix(self): + """Test that names starting with non-alphanumeric get prefix.""" + # After stripping leading hyphens/underscores, if it still doesn't start with alphanumeric + # Actually, our function strips leading hyphens/underscores first, so this case is rare + # But we test it anyway for edge cases + assert ( + sanitize_docker_project_name("123project") == "123project" + ) # Starts with number (valid) + assert sanitize_docker_project_name("_project") == "project" # Underscore stripped + + def test_empty_string_returns_fallback(self): + """Test that empty string returns fallback name.""" + assert sanitize_docker_project_name("") == "weft-project" + assert sanitize_docker_project_name(" ") == "weft-project" + assert sanitize_docker_project_name("...") == "weft-project" + assert sanitize_docker_project_name("@@@") == "weft-project" + + def test_only_invalid_characters(self): + """Test names with only invalid characters.""" + assert sanitize_docker_project_name("...") == "weft-project" + assert sanitize_docker_project_name("@#$%") == "weft-project" + assert sanitize_docker_project_name(" ") == "weft-project" + + def test_complex_real_world_names(self): + """Test complex real-world project names.""" + assert sanitize_docker_project_name("MyApp.Backend.API") == "myapp-backend-api" + assert sanitize_docker_project_name("company.com-app") == "company-com-app" + # Note: underscores are valid characters and preserved + assert sanitize_docker_project_name("Project_2024.v1.0") == "project_2024-v1-0" + assert sanitize_docker_project_name("my-AWESOME-app!") == "my-awesome-app" class TestValidateDocker: @@ -89,6 +172,51 @@ def test_up_success( assert "watcher-meta" in call_args assert "watcher-architect" in call_args + @patch("subprocess.run") + @patch("weft.cli.runtime.up.validate_docker") + @patch("weft.cli.runtime.up.check_docker_daemon") + @patch("weft.cli.runtime.up.load_weftrc") + @patch("weft.cli.runtime.helpers.get_project_root") + def test_up_sanitizes_project_name_with_periods( + self, + mock_get_root, + mock_load_config, + mock_check_daemon, + mock_validate, + mock_run, + tmp_path: Path, + monkeypatch, + ): + """Test that project names with periods are sanitized for Docker Compose.""" + monkeypatch.chdir(tmp_path) + mock_get_root.return_value = tmp_path + + # Create project with name containing period + (tmp_path / ".weftrc.yaml").write_text("project:\n name: example.app\n type: backend\n") + (tmp_path / "docker-compose.yml").write_text("version: '3'\n") + + # Mock config with project name containing period + mock_config = Mock() + mock_config.project.name = "example.app" # Contains period (invalid for Docker Compose) + mock_config.agents.enabled = ["meta"] + mock_config.ai.provider = "anthropic" + mock_config.ai.model = "claude-3-5-sonnet-20241022" + mock_config.ai.history_path = "./weft-ai-history" + mock_load_config.return_value = mock_config + + mock_validate.return_value = True + mock_check_daemon.return_value = True + mock_run.return_value = Mock(returncode=0) + + runner = CliRunner() + result = runner.invoke(up) + + assert result.exit_code == 0 + + # Verify docker compose was called with sanitized project name in environment + env_vars = mock_run.call_args[1]["env"] + assert env_vars["COMPOSE_PROJECT_NAME"] == "example-app" # Sanitized + @patch("weft.cli.runtime.up.validate_docker") def test_up_docker_not_installed(self, mock_validate, tmp_path: Path, monkeypatch): """Test 'weft up' when docker not installed."""