mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-29 22:02:05 +00:00
This major update replaces the fixed `generations/` directory with support for storing projects in any directory on the filesystem. Projects are now tracked via a cross-platform registry system. ## New Features ### Project Registry (`registry.py`) - Cross-platform registry storing project name-to-path mappings - Platform-specific config locations: - Windows: %APPDATA%\autonomous-coder\projects.json - macOS: ~/Library/Application Support/autonomous-coder/projects.json - Linux: ~/.config/autonomous-coder/projects.json - POSIX path format for cross-platform compatibility - File locking for concurrent access safety (fcntl/msvcrt) - Atomic writes via temp file + rename to prevent corruption - Fixed Windows file locking issue with tempfile.mkstemp() ### Filesystem Browser API (`server/routers/filesystem.py`) - REST endpoints for browsing directories server-side - Cross-platform support with blocked system paths: - Windows: C:\Windows, Program Files, ProgramData, etc. - macOS: /System, /Library, /private, etc. - Linux: /etc, /var, /usr, /bin, etc. - Universal blocked paths: .ssh, .aws, .gnupg, .docker, etc. - Hidden file detection (Unix dot-prefix + Windows attributes) - UNC path blocking for security - Windows drive enumeration via ctypes - Directory creation with validation - Added `has_children` field to DirectoryEntry schema ### UI Folder Browser (`ui/src/components/FolderBrowser.tsx`) - React component for selecting project directories - Breadcrumb navigation with clickable segments - Windows drive selector - New folder creation inline - Fixed text visibility with explicit color values ## Updated Components ### Server Routers - `projects.py`: Uses registry instead of fixed generations/ directory - `agent.py`: Uses registry for project path lookups - `features.py`: Uses registry for database path resolution - `spec_creation.py`: Uses registry for WebSocket project resolution ### Process Manager (`server/services/process_manager.py`) - Fixed sandbox issue: subprocess now uses project_dir as cwd - This allows the Claude SDK sandbox to access external project directories ### Schemas (`server/schemas.py`) - Added `has_children` to DirectoryEntry - Added `in_progress` to ProjectStats - Added path field to ProjectSummary and ProjectDetail ### UI Components - `NewProjectModal.tsx`: Multi-step wizard with folder selection - Added clarifying text about subfolder creation - Fixed text color visibility issues ### API Client (`ui/src/lib/api.ts`) - Added filesystem API functions (listDirectory, createDirectory) - Fixed Windows path splitting for directory creation ### Documentation - Updated CLAUDE.md with registry system details - Updated command examples for absolute paths ## Security Improvements - Blocked `.` and `..` in directory names to prevent traversal - Added path blocking check in project creation - UNC path blocking throughout filesystem API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
493 lines
14 KiB
Python
493 lines
14 KiB
Python
"""
|
|
Project Registry Module
|
|
=======================
|
|
|
|
Cross-platform project registry for storing project name to path mappings.
|
|
Supports Windows, macOS, and Linux with platform-specific config directories.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import shutil
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Module logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Exceptions
|
|
# =============================================================================
|
|
|
|
class RegistryError(Exception):
|
|
"""Base registry exception."""
|
|
pass
|
|
|
|
|
|
class RegistryNotFound(RegistryError):
|
|
"""Registry file doesn't exist."""
|
|
pass
|
|
|
|
|
|
class RegistryCorrupted(RegistryError):
|
|
"""Registry JSON is malformed."""
|
|
pass
|
|
|
|
|
|
class RegistryPermissionDenied(RegistryError):
|
|
"""Can't read/write registry file."""
|
|
pass
|
|
|
|
|
|
# =============================================================================
|
|
# Registry Lock (Cross-Platform)
|
|
# =============================================================================
|
|
|
|
class RegistryLock:
|
|
"""
|
|
Context manager for registry file locking.
|
|
Uses fcntl on Unix and msvcrt on Windows.
|
|
"""
|
|
|
|
def __init__(self, registry_path: Path):
|
|
self.registry_path = registry_path
|
|
self.lock_path = registry_path.with_suffix('.lock')
|
|
self._file = None
|
|
|
|
def __enter__(self):
|
|
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._file = open(self.lock_path, 'w')
|
|
|
|
try:
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
# Windows: msvcrt.LK_NBLCK is non-blocking, so we retry with backoff
|
|
max_attempts = 10
|
|
for attempt in range(max_attempts):
|
|
try:
|
|
msvcrt.locking(self._file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
break # Lock acquired
|
|
except OSError:
|
|
if attempt == max_attempts - 1:
|
|
raise # Give up after max attempts
|
|
time.sleep(0.1 * (attempt + 1)) # Exponential backoff
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(self._file.fileno(), fcntl.LOCK_EX)
|
|
except Exception as e:
|
|
self._file.close()
|
|
raise RegistryError(f"Could not acquire registry lock: {e}") from e
|
|
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
if self._file:
|
|
try:
|
|
if sys.platform != "win32":
|
|
import fcntl
|
|
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
|
|
finally:
|
|
self._file.close()
|
|
try:
|
|
self.lock_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# =============================================================================
|
|
# Registry Path Functions
|
|
# =============================================================================
|
|
|
|
def get_config_dir() -> Path:
|
|
"""
|
|
Get the platform-specific config directory for the application.
|
|
|
|
Returns:
|
|
- Windows: %APPDATA%/autonomous-coder/
|
|
- macOS: ~/Library/Application Support/autonomous-coder/
|
|
- Linux: ~/.config/autonomous-coder/ (or $XDG_CONFIG_HOME)
|
|
"""
|
|
if sys.platform == "win32":
|
|
base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
elif sys.platform == "darwin":
|
|
base = Path.home() / "Library" / "Application Support"
|
|
else: # Linux and other Unix-like
|
|
base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
|
|
config_dir = base / "autonomous-coder"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
return config_dir
|
|
|
|
|
|
def get_registry_path() -> Path:
|
|
"""Get the path to the projects registry file."""
|
|
return get_config_dir() / "projects.json"
|
|
|
|
|
|
# =============================================================================
|
|
# Registry I/O Functions
|
|
# =============================================================================
|
|
|
|
def _create_empty_registry() -> dict[str, Any]:
|
|
"""Create a new empty registry structure."""
|
|
return {
|
|
"version": 1,
|
|
"created_at": datetime.now().isoformat(),
|
|
"projects": {}
|
|
}
|
|
|
|
|
|
def load_registry(create_if_missing: bool = True) -> dict[str, Any]:
|
|
"""
|
|
Load the registry from disk.
|
|
|
|
Args:
|
|
create_if_missing: If True, create a new registry if none exists.
|
|
|
|
Returns:
|
|
The registry dictionary.
|
|
|
|
Raises:
|
|
RegistryNotFound: If registry doesn't exist and create_if_missing is False.
|
|
RegistryCorrupted: If registry JSON is malformed.
|
|
RegistryPermissionDenied: If can't read the registry file.
|
|
"""
|
|
registry_path = get_registry_path()
|
|
|
|
# Case 1: File doesn't exist
|
|
if not registry_path.exists():
|
|
if create_if_missing:
|
|
registry = _create_empty_registry()
|
|
save_registry(registry)
|
|
return registry
|
|
else:
|
|
raise RegistryNotFound(f"Registry not found: {registry_path}")
|
|
|
|
# Case 2: Read the file
|
|
try:
|
|
content = registry_path.read_text(encoding='utf-8')
|
|
except PermissionError as e:
|
|
raise RegistryPermissionDenied(f"Cannot read registry: {e}") from e
|
|
except OSError as e:
|
|
raise RegistryError(f"Error reading registry: {e}") from e
|
|
|
|
# Case 3: Parse JSON
|
|
try:
|
|
data = json.loads(content)
|
|
except json.JSONDecodeError as e:
|
|
# Try to recover from backup
|
|
backup_path = registry_path.with_suffix('.json.backup')
|
|
logger.warning("Registry corrupted, attempting recovery from backup: %s", backup_path)
|
|
if backup_path.exists():
|
|
try:
|
|
backup_content = backup_path.read_text(encoding='utf-8')
|
|
data = json.loads(backup_content)
|
|
# Restore from backup
|
|
shutil.copy2(backup_path, registry_path)
|
|
logger.info("Successfully recovered registry from backup")
|
|
return data
|
|
except Exception as recovery_error:
|
|
logger.error("Failed to recover from backup: %s", recovery_error)
|
|
raise RegistryCorrupted(
|
|
f"Registry corrupted: {e}\nBackup location: {backup_path}"
|
|
) from e
|
|
|
|
# Ensure required structure
|
|
if "projects" not in data:
|
|
data["projects"] = {}
|
|
if "version" not in data:
|
|
data["version"] = 1
|
|
|
|
return data
|
|
|
|
|
|
def save_registry(registry: dict[str, Any]) -> None:
|
|
"""
|
|
Save the registry to disk atomically.
|
|
|
|
Uses temp file + rename for atomic writes to prevent corruption.
|
|
|
|
Args:
|
|
registry: The registry dictionary to save.
|
|
|
|
Raises:
|
|
RegistryPermissionDenied: If can't write to the registry.
|
|
RegistryError: If write fails for other reasons.
|
|
"""
|
|
registry_path = get_registry_path()
|
|
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create backup before modification (if file exists)
|
|
if registry_path.exists():
|
|
backup_path = registry_path.with_suffix('.json.backup')
|
|
try:
|
|
shutil.copy2(registry_path, backup_path)
|
|
except Exception as e:
|
|
logger.warning("Failed to create registry backup: %s", e)
|
|
|
|
# Write to temp file in same directory (ensures same filesystem for atomic rename)
|
|
# On Windows, we must close the file before renaming it
|
|
tmp_path = None
|
|
try:
|
|
# Create temp file
|
|
fd, tmp_name = tempfile.mkstemp(suffix='.json', dir=registry_path.parent)
|
|
tmp_path = Path(tmp_name)
|
|
|
|
try:
|
|
# Write content
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
|
json.dump(registry, tmp_file, indent=2)
|
|
tmp_file.flush()
|
|
os.fsync(tmp_file.fileno())
|
|
# File is now closed, safe to rename on Windows
|
|
|
|
# Atomic rename
|
|
tmp_path.replace(registry_path)
|
|
|
|
# Set restrictive permissions (owner read/write only)
|
|
# On Windows, this is a best-effort operation
|
|
try:
|
|
if sys.platform != "win32":
|
|
registry_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
|
except Exception:
|
|
pass # Best effort - don't fail if permissions can't be set
|
|
except Exception:
|
|
if tmp_path and tmp_path.exists():
|
|
tmp_path.unlink(missing_ok=True)
|
|
raise
|
|
except PermissionError as e:
|
|
raise RegistryPermissionDenied(f"Cannot write registry: {e}") from e
|
|
except OSError as e:
|
|
raise RegistryError(f"Failed to write registry: {e}") from e
|
|
|
|
|
|
# =============================================================================
|
|
# Project CRUD Functions
|
|
# =============================================================================
|
|
|
|
def register_project(name: str, path: Path) -> None:
|
|
"""
|
|
Register a new project in the registry.
|
|
|
|
Args:
|
|
name: The project name (unique identifier).
|
|
path: The absolute path to the project directory.
|
|
|
|
Raises:
|
|
ValueError: If project name is invalid or path is not absolute.
|
|
RegistryError: If a project with that name already exists.
|
|
"""
|
|
# Validate name
|
|
import re
|
|
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
|
raise ValueError(
|
|
"Invalid project name. Use only letters, numbers, hyphens, "
|
|
"and underscores (1-50 chars)."
|
|
)
|
|
|
|
# Ensure path is absolute
|
|
path = Path(path).resolve()
|
|
|
|
with RegistryLock(get_registry_path()):
|
|
registry = load_registry()
|
|
|
|
if name in registry["projects"]:
|
|
logger.warning("Attempted to register duplicate project: %s", name)
|
|
raise RegistryError(f"Project '{name}' already exists in registry")
|
|
|
|
# Store path as POSIX format (forward slashes) for cross-platform consistency
|
|
registry["projects"][name] = {
|
|
"path": path.as_posix(),
|
|
"created_at": datetime.now().isoformat()
|
|
}
|
|
|
|
save_registry(registry)
|
|
logger.info("Registered project '%s' at path: %s", name, path)
|
|
|
|
|
|
def unregister_project(name: str) -> bool:
|
|
"""
|
|
Remove a project from the registry.
|
|
|
|
Args:
|
|
name: The project name to remove.
|
|
|
|
Returns:
|
|
True if removed, False if project wasn't found.
|
|
"""
|
|
with RegistryLock(get_registry_path()):
|
|
registry = load_registry()
|
|
|
|
if name not in registry["projects"]:
|
|
logger.debug("Attempted to unregister non-existent project: %s", name)
|
|
return False
|
|
|
|
del registry["projects"][name]
|
|
save_registry(registry)
|
|
logger.info("Unregistered project: %s", name)
|
|
return True
|
|
|
|
|
|
def get_project_path(name: str) -> Path | None:
|
|
"""
|
|
Look up a project's path by name.
|
|
|
|
Args:
|
|
name: The project name.
|
|
|
|
Returns:
|
|
The project Path, or None if not found.
|
|
"""
|
|
registry = load_registry()
|
|
project = registry["projects"].get(name)
|
|
|
|
if project is None:
|
|
return None
|
|
|
|
# Convert POSIX path string back to Path object
|
|
return Path(project["path"])
|
|
|
|
|
|
def list_registered_projects() -> dict[str, dict[str, Any]]:
|
|
"""
|
|
Get all registered projects.
|
|
|
|
Returns:
|
|
Dictionary mapping project names to their info dictionaries.
|
|
"""
|
|
registry = load_registry()
|
|
return registry.get("projects", {})
|
|
|
|
|
|
def get_project_info(name: str) -> dict[str, Any] | None:
|
|
"""
|
|
Get full info about a project.
|
|
|
|
Args:
|
|
name: The project name.
|
|
|
|
Returns:
|
|
Project info dictionary, or None if not found.
|
|
"""
|
|
registry = load_registry()
|
|
return registry["projects"].get(name)
|
|
|
|
|
|
def update_project_path(name: str, new_path: Path) -> bool:
|
|
"""
|
|
Update a project's path (for relocating projects).
|
|
|
|
Args:
|
|
name: The project name.
|
|
new_path: The new absolute path.
|
|
|
|
Returns:
|
|
True if updated, False if project wasn't found.
|
|
"""
|
|
new_path = Path(new_path).resolve()
|
|
|
|
with RegistryLock(get_registry_path()):
|
|
registry = load_registry()
|
|
|
|
if name not in registry["projects"]:
|
|
return False
|
|
|
|
registry["projects"][name]["path"] = new_path.as_posix()
|
|
save_registry(registry)
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Validation Functions
|
|
# =============================================================================
|
|
|
|
def validate_project_path(path: Path) -> tuple[bool, str]:
|
|
"""
|
|
Validate that a project path is accessible and writable.
|
|
|
|
Args:
|
|
path: The path to validate.
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message).
|
|
"""
|
|
path = Path(path).resolve()
|
|
|
|
# Check if path exists
|
|
if not path.exists():
|
|
return False, f"Path does not exist: {path}"
|
|
|
|
# Check if it's a directory
|
|
if not path.is_dir():
|
|
return False, f"Path is not a directory: {path}"
|
|
|
|
# Check read permissions
|
|
if not os.access(path, os.R_OK):
|
|
return False, f"No read permission: {path}"
|
|
|
|
# Check write permissions
|
|
if not os.access(path, os.W_OK):
|
|
return False, f"No write permission: {path}"
|
|
|
|
return True, ""
|
|
|
|
|
|
def cleanup_stale_projects() -> list[str]:
|
|
"""
|
|
Remove projects from registry whose paths no longer exist.
|
|
|
|
Returns:
|
|
List of removed project names.
|
|
"""
|
|
removed = []
|
|
|
|
with RegistryLock(get_registry_path()):
|
|
registry = load_registry()
|
|
projects = registry.get("projects", {})
|
|
|
|
stale_names = []
|
|
for name, info in projects.items():
|
|
path = Path(info["path"])
|
|
if not path.exists():
|
|
stale_names.append(name)
|
|
|
|
for name in stale_names:
|
|
del projects[name]
|
|
removed.append(name)
|
|
|
|
if removed:
|
|
save_registry(registry)
|
|
|
|
return removed
|
|
|
|
|
|
def list_valid_projects() -> list[dict[str, Any]]:
|
|
"""
|
|
List all projects that have valid, accessible paths.
|
|
|
|
Returns:
|
|
List of project info dicts with additional 'name' field.
|
|
"""
|
|
registry = load_registry()
|
|
projects = registry.get("projects", {})
|
|
|
|
valid = []
|
|
for name, info in projects.items():
|
|
path = Path(info["path"])
|
|
is_valid, _ = validate_project_path(path)
|
|
if is_valid:
|
|
valid.append({
|
|
"name": name,
|
|
"path": info["path"],
|
|
"created_at": info.get("created_at")
|
|
})
|
|
|
|
return valid
|