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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Log out and back in for changes to apply.

### Containers fail to start

**Problem**
**Problem**
Agent containers exit immediately.

**Fix**
Expand Down
21 changes: 21 additions & 0 deletions src/weft/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion src/weft/cli/runtime/helpers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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":
Expand Down
130 changes: 129 additions & 1 deletion tests/unit/weft/cli/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down