Files
autocoder/registry.py
Auto 6c99e40408 feat: Add arbitrary directory project storage with registry system
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>
2025-12-31 10:20:07 +02:00

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