Files
autocoder/server/routers/projects.py
Auto aede8f720e fix: decouple project name from folder path in project creation
Remove automatic subfolder creation when creating projects. Users now
select the exact folder they want to use, enabling support for existing
projects without requiring folder names to match project names.

Changes:
- NewProjectModal.tsx: Remove path concatenation that appended project
  name to selected folder. Update instruction text to clarify users
  select THE project folder, not a parent location.
- FolderBrowser.tsx: Add visual indicator "This folder will contain
  all project files" to clarify selection behavior.
- projects.py: Add duplicate path validation to prevent multiple
  projects from registering the same directory. Includes case-insensitive
  path comparison on Windows for proper cross-platform support.

This allows users to:
- Use Auto Coder on existing projects by selecting their folder directly
- Have project names that differ from folder names (name is a registry label)
- Get clear feedback when a path is already registered under another name

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:30:34 +02:00

358 lines
11 KiB
Python

"""
Projects Router
===============
API endpoints for project management.
Uses project registry for path lookups instead of fixed generations/ directory.
"""
import re
import shutil
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import (
ProjectCreate,
ProjectDetail,
ProjectPrompts,
ProjectPromptsUpdate,
ProjectStats,
ProjectSummary,
)
# Lazy imports to avoid circular dependencies
_imports_initialized = False
_check_spec_exists = None
_scaffold_project_prompts = None
_get_project_prompts_dir = None
_count_passing_tests = None
def _init_imports():
"""Lazy import of project-level modules."""
global _imports_initialized, _check_spec_exists
global _scaffold_project_prompts, _get_project_prompts_dir
global _count_passing_tests
if _imports_initialized:
return
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from progress import count_passing_tests
from prompts import get_project_prompts_dir, scaffold_project_prompts
from start import check_spec_exists
_check_spec_exists = check_spec_exists
_scaffold_project_prompts = scaffold_project_prompts
_get_project_prompts_dir = get_project_prompts_dir
_count_passing_tests = count_passing_tests
_imports_initialized = True
def _get_registry_functions():
"""Get registry functions with lazy import."""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from registry import (
get_project_path,
list_registered_projects,
register_project,
unregister_project,
validate_project_path,
)
return register_project, unregister_project, get_project_path, list_registered_projects, validate_project_path
router = APIRouter(prefix="/api/projects", tags=["projects"])
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)."
)
return name
def get_project_stats(project_dir: Path) -> ProjectStats:
"""Get statistics for a project."""
_init_imports()
passing, in_progress, total = _count_passing_tests(project_dir)
percentage = (passing / total * 100) if total > 0 else 0.0
return ProjectStats(
passing=passing,
in_progress=in_progress,
total=total,
percentage=round(percentage, 1)
)
@router.get("", response_model=list[ProjectSummary])
async def list_projects():
"""List all registered projects."""
_init_imports()
_, _, _, list_registered_projects, validate_project_path = _get_registry_functions()
projects = list_registered_projects()
result = []
for name, info in projects.items():
project_dir = Path(info["path"])
# Skip if path no longer exists
is_valid, _ = validate_project_path(project_dir)
if not is_valid:
continue
has_spec = _check_spec_exists(project_dir)
stats = get_project_stats(project_dir)
result.append(ProjectSummary(
name=name,
path=info["path"],
has_spec=has_spec,
stats=stats,
))
return result
@router.post("", response_model=ProjectSummary)
async def create_project(project: ProjectCreate):
"""Create a new project at the specified path."""
_init_imports()
register_project, _, get_project_path, list_registered_projects, _ = _get_registry_functions()
name = validate_project_name(project.name)
project_path = Path(project.path).resolve()
# Check if project name already registered
existing = get_project_path(name)
if existing:
raise HTTPException(
status_code=409,
detail=f"Project '{name}' already exists at {existing}"
)
# Check if path already registered under a different name
all_projects = list_registered_projects()
for existing_name, info in all_projects.items():
existing_path = Path(info["path"]).resolve()
# Case-insensitive comparison on Windows
if sys.platform == "win32":
paths_match = str(existing_path).lower() == str(project_path).lower()
else:
paths_match = existing_path == project_path
if paths_match:
raise HTTPException(
status_code=409,
detail=f"Path '{project_path}' is already registered as project '{existing_name}'"
)
# Security: Check if path is in a blocked location
from .filesystem import is_path_blocked
if is_path_blocked(project_path):
raise HTTPException(
status_code=403,
detail="Cannot create project in system or sensitive directory"
)
# Validate the path is usable
if project_path.exists():
if not project_path.is_dir():
raise HTTPException(
status_code=400,
detail="Path exists but is not a directory"
)
else:
# Create the directory
try:
project_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to create directory: {e}"
)
# Scaffold prompts
_scaffold_project_prompts(project_path)
# Register in registry
try:
register_project(name, project_path)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to register project: {e}"
)
return ProjectSummary(
name=name,
path=project_path.as_posix(),
has_spec=False, # Just created, no spec yet
stats=ProjectStats(passing=0, total=0, percentage=0.0),
)
@router.get("/{name}", response_model=ProjectDetail)
async def get_project(name: str):
"""Get detailed information about a project."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail=f"Project directory no longer exists: {project_dir}")
has_spec = _check_spec_exists(project_dir)
stats = get_project_stats(project_dir)
prompts_dir = _get_project_prompts_dir(project_dir)
return ProjectDetail(
name=name,
path=project_dir.as_posix(),
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
)
@router.delete("/{name}")
async def delete_project(name: str, delete_files: bool = False):
"""
Delete a project from the registry.
Args:
name: Project name to delete
delete_files: If True, also delete the project directory and files
"""
_init_imports()
_, unregister_project, get_project_path, _, _ = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
# Check if agent is running
lock_file = project_dir / ".agent.lock"
if lock_file.exists():
raise HTTPException(
status_code=409,
detail="Cannot delete project while agent is running. Stop the agent first."
)
# Optionally delete files
if delete_files and project_dir.exists():
try:
shutil.rmtree(project_dir)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete project files: {e}")
# Unregister from registry
unregister_project(name)
return {
"success": True,
"message": f"Project '{name}' deleted" + (" (files removed)" if delete_files else " (files preserved)")
}
@router.get("/{name}/prompts", response_model=ProjectPrompts)
async def get_project_prompts(name: str):
"""Get the content of project prompt files."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
prompts_dir = _get_project_prompts_dir(project_dir)
def read_file(filename: str) -> str:
filepath = prompts_dir / filename
if filepath.exists():
try:
return filepath.read_text(encoding="utf-8")
except Exception:
return ""
return ""
return ProjectPrompts(
app_spec=read_file("app_spec.txt"),
initializer_prompt=read_file("initializer_prompt.md"),
coding_prompt=read_file("coding_prompt.md"),
)
@router.put("/{name}/prompts")
async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
"""Update project prompt files."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
prompts_dir = _get_project_prompts_dir(project_dir)
prompts_dir.mkdir(parents=True, exist_ok=True)
def write_file(filename: str, content: str | None):
if content is not None:
filepath = prompts_dir / filename
filepath.write_text(content, encoding="utf-8")
write_file("app_spec.txt", prompts.app_spec)
write_file("initializer_prompt.md", prompts.initializer_prompt)
write_file("coding_prompt.md", prompts.coding_prompt)
return {"success": True, "message": "Prompts updated"}
@router.get("/{name}/stats", response_model=ProjectStats)
async def get_project_stats_endpoint(name: str):
"""Get current progress statistics for a project."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
return get_project_stats(project_dir)