From 4201326467063ea8e7fb4564b319153ae89468ba Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 3 Jun 2026 12:40:47 +0200 Subject: [PATCH] fix: remove legacy config APIs Remove stale ToolConfig and ConfigFolder backend/frontend surfaces after the ConfigProfile refactor. Drop dead routers, schemas, model exports, frontend routes, clients, pages, and tests; keep ToolType API compatibility for existing interface/is_builtin response shape. Quality gates: backend LSP diagnostics passed; backend py_compile passed; backend ruff passed; frontend ToolWorkshopPage test passed. Frontend typecheck blocked by unrelated missing xterm-addon-serialize types. --- apps/api/src/api/config_folders.py | 284 --------- apps/api/src/api/tool_configs.py | 213 ------- apps/api/src/api/tool_types.py | 101 +-- apps/api/src/api/users.py | 11 +- apps/api/src/main.py | 15 +- apps/api/src/models/__init__.py | 2 - apps/api/src/models/config_folder.py | 33 - apps/api/src/models/tool_type.py | 21 +- apps/api/src/schemas/config_folder.py | 44 -- apps/api/src/schemas/tool_config.py | 47 -- apps/api/src/seeds/builtin_tool_types.py | 19 +- apps/api/src/services/docker/__init__.py | 3 +- .../api/src/services/docker/config_staging.py | 59 +- apps/api/src/services/instance_lifecycle.py | 170 +++-- apps/web/scripts/check-structure.js | 1 - apps/web/src/api/config-folders.test.ts | 131 ---- apps/web/src/api/config-folders.ts | 77 --- apps/web/src/api/tool-configs.ts | 52 -- .../features/tool-configs/ToolConfigForm.tsx | 129 ---- .../features/tool-configs/ToolConfigList.tsx | 84 --- .../components/features/tool-configs/index.ts | 2 - .../tool-workshop/ConfigFoldersTab.tsx | 289 --------- .../features/tool-workshop/ToolConfigsTab.tsx | 460 -------------- .../features/tool-workshop/ToolTypesTab.tsx | 45 +- .../features/tool-workshop/index.ts | 2 - apps/web/src/pages/SettingsPage.tsx | 3 +- apps/web/src/pages/ToolConfigsPage.tsx | 205 ------ apps/web/src/pages/ToolWorkshopPage.test.tsx | 582 +++--------------- apps/web/src/pages/ToolWorkshopPage.tsx | 61 +- apps/web/src/router.tsx | 6 - apps/web/src/types/config-folder.ts | 36 -- apps/web/src/types/index.ts | 7 - apps/web/src/types/tool-config.ts | 28 - 33 files changed, 290 insertions(+), 2932 deletions(-) delete mode 100644 apps/api/src/api/config_folders.py delete mode 100644 apps/api/src/api/tool_configs.py delete mode 100644 apps/api/src/models/config_folder.py delete mode 100644 apps/api/src/schemas/config_folder.py delete mode 100644 apps/api/src/schemas/tool_config.py delete mode 100644 apps/web/src/api/config-folders.test.ts delete mode 100644 apps/web/src/api/config-folders.ts delete mode 100644 apps/web/src/api/tool-configs.ts delete mode 100644 apps/web/src/components/features/tool-configs/ToolConfigForm.tsx delete mode 100644 apps/web/src/components/features/tool-configs/ToolConfigList.tsx delete mode 100644 apps/web/src/components/features/tool-configs/index.ts delete mode 100644 apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx delete mode 100644 apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx delete mode 100644 apps/web/src/pages/ToolConfigsPage.tsx delete mode 100644 apps/web/src/types/config-folder.ts delete mode 100644 apps/web/src/types/tool-config.ts diff --git a/apps/api/src/api/config_folders.py b/apps/api/src/api/config_folders.py deleted file mode 100644 index ebdf7c8..0000000 --- a/apps/api/src/api/config_folders.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Config folder API endpoints.""" - -import logging -import uuid - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import Field -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from src.auth.dependencies import get_current_user_id, get_db_session -from src.models.config_folder import ConfigFolder -from src.schemas.config_folder import ( - ConfigFolderCreate, - ConfigFolderUpdate, - ConfigFolderResponse, - ProjectOverrideCreate, -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/config-folders", tags=["config-folders"]) - - -@router.get("", summary="List config folders", description="Get all config folders for the current user.") -async def list_config_folders( - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """List config folders for the current user.""" - query = select(ConfigFolder).where(ConfigFolder.user_id == user_id) - result = await session.execute(query) - folders = result.scalars().all() - - return { - "folders": [ - { - "id": str(f.id), - "user_id": str(f.user_id), - "name": f.name, - "description": f.description, - "mount_path": f.mount_path, - "files": f.files, - "project_overrides": f.project_overrides, - "is_active": f.is_active, - "created_at": f.created_at.isoformat() if f.created_at else None, - "updated_at": f.updated_at.isoformat() if f.updated_at else None, - } - for f in folders - ] - } - - -@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED) -async def create_config_folder( - data: ConfigFolderCreate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Create a config folder.""" - # Check for duplicate name - existing = await session.scalar( - select(ConfigFolder).where( - ConfigFolder.user_id == user_id, - ConfigFolder.name == data.name, - ) - ) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"config folder with name '{data.name}' already exists" - ) - - folder = ConfigFolder( - user_id=user_id, - name=data.name, - description=data.description, - mount_path=data.mount_path, - files=data.files, - ) - session.add(folder) - await session.commit() - await session.refresh(folder) - - return { - "id": str(folder.id), - "user_id": str(folder.user_id), - "name": folder.name, - "description": folder.description, - "mount_path": folder.mount_path, - "files": folder.files, - "project_overrides": folder.project_overrides, - "is_active": folder.is_active, - "created_at": folder.created_at.isoformat() if folder.created_at else None, - "updated_at": folder.updated_at.isoformat() if folder.updated_at else None, - } - - -@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.") -async def update_config_folder( - folder_id: uuid.UUID, - data: ConfigFolderUpdate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Update a config folder.""" - folder = await session.get(ConfigFolder, folder_id) - if folder is None or folder.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") - - if data.name is not None: - folder.name = data.name - if data.description is not None: - folder.description = data.description - if data.mount_path is not None: - folder.mount_path = data.mount_path - if data.files is not None: - folder.files = data.files - if data.is_active is not None: - folder.is_active = data.is_active - - await session.commit() - await session.refresh(folder) - - return { - "id": str(folder.id), - "user_id": str(folder.user_id), - "name": folder.name, - "description": folder.description, - "mount_path": folder.mount_path, - "files": folder.files, - "project_overrides": folder.project_overrides, - "is_active": folder.is_active, - "created_at": folder.created_at.isoformat() if folder.created_at else None, - "updated_at": folder.updated_at.isoformat() if folder.updated_at else None, - } - - -@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT) -async def delete_config_folder( - folder_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> None: - """Delete a config folder.""" - folder = await session.get(ConfigFolder, folder_id) - if folder is None or folder.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") - - await session.delete(folder) - await session.commit() - - -class ProjectOverrideWithId(ProjectOverrideCreate): - project_id: uuid.UUID = Field(description="Project ID for the override") - - -@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.") -async def get_config_folder( - folder_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Get a config folder by ID.""" - folder = await session.get(ConfigFolder, folder_id) - if folder is None or folder.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") - - return { - "id": str(folder.id), - "user_id": str(folder.user_id), - "name": folder.name, - "description": folder.description, - "mount_path": folder.mount_path, - "files": folder.files, - "project_overrides": folder.project_overrides, - "is_active": folder.is_active, - "created_at": folder.created_at.isoformat() if folder.created_at else None, - "updated_at": folder.updated_at.isoformat() if folder.updated_at else None, - } - - -@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.") -async def add_project_override( - folder_id: uuid.UUID, - data: ProjectOverrideWithId, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Add a project override to a config folder.""" - folder = await session.get(ConfigFolder, folder_id) - if folder is None or folder.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") - - # Initialize project_overrides if None - if folder.project_overrides is None: - folder.project_overrides = {} - - # Add/update override - override_data = {} - if data.mount_path is not None: - override_data["mount_path"] = data.mount_path - if data.files is not None: - override_data["files"] = data.files - - # Use a copy to trigger SQLAlchemy change detection on JSONB - current_overrides = dict(folder.project_overrides or {}) - current_overrides[str(data.project_id)] = override_data - folder.project_overrides = current_overrides - - await session.commit() - await session.refresh(folder) - - return { - "id": str(folder.id), - "project_overrides": folder.project_overrides, - } - - -@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.") -async def update_project_override( - folder_id: uuid.UUID, - project_id: uuid.UUID, - data: ProjectOverrideCreate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Update a project override.""" - folder = await session.get(ConfigFolder, folder_id) - if folder is None or folder.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") - - # Initialize project_overrides if None - if folder.project_overrides is None: - folder.project_overrides = {} - - # Update override - current_overrides = dict(folder.project_overrides or {}) - override_data = current_overrides.get(str(project_id), {}) - if data.mount_path is not None: - override_data["mount_path"] = data.mount_path - if data.files is not None: - override_data["files"] = data.files - - current_overrides[str(project_id)] = override_data - folder.project_overrides = current_overrides - - # Mark the field as modified to ensure SQLAlchemy detects the change - from sqlalchemy.orm.attributes import flag_modified - flag_modified(folder, "project_overrides") - - await session.commit() - await session.refresh(folder) - - return { - "id": str(folder.id), - "project_overrides": folder.project_overrides, - } - - -@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.") -async def remove_project_override( - folder_id: uuid.UUID, - project_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> None: - """Remove a project override.""" - folder = await session.get(ConfigFolder, folder_id) - if folder is None or folder.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") - - # Remove override if exists - current_overrides = dict(folder.project_overrides or {}) - if str(project_id) in current_overrides: - del current_overrides[str(project_id)] - folder.project_overrides = current_overrides - await session.commit() - await session.refresh(folder) - - return { - "id": str(folder.id), - "project_overrides": folder.project_overrides or {}, - } diff --git a/apps/api/src/api/tool_configs.py b/apps/api/src/api/tool_configs.py deleted file mode 100644 index db8cb48..0000000 --- a/apps/api/src/api/tool_configs.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Tool configuration API endpoints.""" - -import logging -import uuid - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from src.auth.dependencies import get_current_user_id, get_db_session -from src.models.tool_config import ToolConfig -from src.models.tool_type import ToolType -from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) - - -@router.get("", summary="List tool configs", description="Get all tool configs for the current user.") -async def list_configs( - tool_type_id: str | None = None, - project_id: str | None = None, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> list: - """List tool configs for the current user.""" - query = select(ToolConfig).where(ToolConfig.user_id == user_id) - - if tool_type_id: - query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id)) - if project_id: - query = query.where(ToolConfig.project_id == uuid.UUID(project_id)) - else: - # If no project specified, get only global configs (project_id is None) - query = query.where(ToolConfig.project_id.is_(None)) - - result = await session.execute(query) - configs = result.scalars().all() - - return [ - { - "id": str(c.id), - "tool_type_id": str(c.tool_type_id), - "project_id": str(c.project_id) if c.project_id else None, - "key": c.key, - "value": c.value, - "config_type": c.config_type, - "file_path": c.file_path, - "port_override": c.port_override, - "start_command": c.start_command, - "working_directory": c.working_directory, - "environment_variables": c.environment_variables, - "volumes": c.volumes, - } - for c in configs - ] - - -@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED) -async def create_config( - data: ToolConfigCreate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Create a tool config.""" - # Verify tool type exists - tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id)) - if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - - # Check for existing config with same key - query = select(ToolConfig).where( - ToolConfig.user_id == user_id, - ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id), - ToolConfig.key == data.key, - ) - if data.project_id: - query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id)) - else: - query = query.where(ToolConfig.project_id.is_(None)) - - existing = await session.scalar(query) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"config with key '{data.key}' already exists" - ) - - config = ToolConfig( - user_id=user_id, - tool_type_id=uuid.UUID(data.tool_type_id), - project_id=uuid.UUID(data.project_id) if data.project_id else None, - key=data.key, - value=data.value, - config_type=data.config_type, - file_path=data.file_path, - port_override=data.port_override, - start_command=data.start_command, - working_directory=data.working_directory, - environment_variables=data.environment_variables, - volumes=data.volumes, - ) - session.add(config) - await session.commit() - await session.refresh(config) - - return { - "id": str(config.id), - "tool_type_id": str(config.tool_type_id), - "project_id": str(config.project_id) if config.project_id else None, - "key": config.key, - "value": config.value, - "config_type": config.config_type, - "file_path": config.file_path, - "port_override": config.port_override, - "start_command": config.start_command, - "working_directory": config.working_directory, - "environment_variables": config.environment_variables, - "volumes": config.volumes, - } - - -@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.") -async def update_config( - config_id: uuid.UUID, - data: ToolConfigUpdate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Update a tool config.""" - config = await session.get(ToolConfig, config_id) - if config is None or config.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found") - - if data.key is not None: - config.key = data.key - if data.value is not None: - config.value = data.value - if data.config_type is not None: - config.config_type = data.config_type - if data.file_path is not None: - config.file_path = data.file_path - if data.port_override is not None: - config.port_override = data.port_override - if data.start_command is not None: - config.start_command = data.start_command - if data.working_directory is not None: - config.working_directory = data.working_directory - if data.environment_variables is not None: - config.environment_variables = data.environment_variables - if data.volumes is not None: - config.volumes = data.volumes - - await session.commit() - await session.refresh(config) - - return { - "id": str(config.id), - "tool_type_id": str(config.tool_type_id), - "project_id": str(config.project_id) if config.project_id else None, - "key": config.key, - "value": config.value, - "config_type": config.config_type, - "file_path": config.file_path, - "port_override": config.port_override, - "start_command": config.start_command, - "working_directory": config.working_directory, - "environment_variables": config.environment_variables, - "volumes": config.volumes, - } - - -@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.") -async def get_default_configs( - tool_type_id: str, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Get suggested default configs for a tool type.""" - tool_type = await session.get(ToolType, uuid.UUID(tool_type_id)) - if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - - # Return suggested defaults based on required_variables - defaults = [] - for var in tool_type.required_variables: - defaults.append({ - "key": var, - "value": "", - "config_type": "env", - "description": f"Required variable: {var}", - }) - - return { - "tool_type_id": tool_type_id, - "suggested_configs": defaults, - } - - -@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.") -async def delete_config( - config_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> None: - """Delete a tool config.""" - config = await session.get(ToolConfig, config_id) - if config is None or config.user_id != user_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found") - - await session.delete(config) - await session.commit() diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 6cad489..982d4f8 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -1,5 +1,4 @@ import uuid -from datetime import datetime import yaml from fastapi import APIRouter, Depends, HTTPException, status @@ -9,7 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_current_user, get_db_session from src.models.tool_type import ToolType from src.models.user import User -from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest +from src.schemas.tool_type import ( + ToolTypeCreate, + ToolTypeResponse, + ToolTypeUpdate, + ToolTypeValidateRequest, +) router = APIRouter(prefix="/tool-types", tags=["tool-types"]) @@ -48,12 +52,15 @@ async def create_tool_type( The newly created tool type. """ await _require_admin(user) - + # Check for duplicate name existing = await session.scalar(select(ToolType).where(ToolType.name == data.name)) if existing: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists") - + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="tool type with this name already exists", + ) + tool_type = ToolType( name=data.name, display_name=data.display_name, @@ -67,7 +74,6 @@ async def create_tool_type( required_variables=data.required_variables, category=data.category, interfaces=data.interfaces, - is_builtin=False, created_by_id=user.id, ) session.add(tool_type) @@ -122,7 +128,9 @@ async def get_tool_type( """ tool_type = await session.get(ToolType, tool_type_id) if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" + ) return tool_type @@ -150,25 +158,30 @@ async def update_tool_type( The updated tool type. """ await _require_admin(user) - + tool_type = await session.get(ToolType, tool_type_id) if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" + ) + if tool_type.is_builtin: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types") - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="cannot modify built-in tool types", + ) + update_data = data.model_dump(exclude_unset=True) - + # Validate port if being updated if "default_port" in update_data: new_port = update_data["default_port"] if new_port <= 0 or new_port > 65535: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Port must be between 1 and 65535" + detail="Port must be between 1 and 65535", ) - + # Only validate port exposure for compose definitions definition_type = update_data.get("definition_type", tool_type.definition_type) if definition_type == "compose": @@ -178,28 +191,37 @@ async def update_tool_type( parsed = yaml.safe_load(template) except yaml.YAMLError: parsed = None - + if parsed and isinstance(parsed, dict) and "services" in parsed: port_str = str(new_port) port_exposed = False for service_config in parsed["services"].values(): - if isinstance(service_config, dict) and "ports" in service_config: + if ( + isinstance(service_config, dict) + and "ports" in service_config + ): for port_mapping in service_config["ports"]: - if isinstance(port_mapping, str) and port_str in port_mapping: + if ( + isinstance(port_mapping, str) + and port_str in port_mapping + ): port_exposed = True break - elif isinstance(port_mapping, int) and port_mapping == new_port: + elif ( + isinstance(port_mapping, int) + and port_mapping == new_port + ): port_exposed = True break if port_exposed: break - + if not port_exposed: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Port {new_port} is not exposed in the compose template" + detail=f"Port {new_port} is not exposed in the compose template", ) - + # Validate required variables for compose definitions definition_type = update_data.get("definition_type", tool_type.definition_type) if definition_type == "compose": @@ -210,7 +232,7 @@ async def update_tool_type( if placeholder not in template: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Required variable '{var}' not found in compose template" + detail=f"Required variable '{var}' not found in compose template", ) elif "required_variables" in update_data: template = tool_type.compose_template @@ -220,12 +242,12 @@ async def update_tool_type( if placeholder not in template: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Required variable '{var}' not found in compose template" + detail=f"Required variable '{var}' not found in compose template", ) - + for field, value in update_data.items(): setattr(tool_type, field, value) - + await session.commit() await session.refresh(tool_type) return tool_type @@ -306,10 +328,12 @@ async def validate_tool_type( """ tool_type = await session.get(ToolType, tool_type_id) if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" + ) + errors = [] - + if tool_type.definition_type == "compose": if not tool_type.compose_template: errors.append("Compose template is empty") @@ -324,13 +348,13 @@ async def validate_tool_type( errors.append("Compose template must define at least one service") except yaml.YAMLError as e: errors.append(f"Invalid YAML: {e}") - + elif tool_type.definition_type == "dockerfile": if not tool_type.dockerfile_template: errors.append("Dockerfile template is empty") elif not tool_type.dockerfile_template.strip().startswith("FROM"): errors.append("Dockerfile must start with a FROM instruction") - + return { "valid": len(errors) == 0, "errors": errors, @@ -359,13 +383,18 @@ async def delete_tool_type( None with 204 status code. """ await _require_admin(user) - + tool_type = await session.get(ToolType, tool_type_id) if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" + ) + if tool_type.is_builtin: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types") - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="cannot delete built-in tool types", + ) + await session.delete(tool_type) await session.commit() diff --git a/apps/api/src/api/users.py b/apps/api/src/api/users.py index 1955f1b..ec8cf63 100644 --- a/apps/api/src/api/users.py +++ b/apps/api/src/api/users.py @@ -19,7 +19,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"} MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB - @router.get( "/me", response_model=UserProfileResponse, @@ -66,12 +65,16 @@ async def update_profile( if data.name is not None: if len(data.name.strip()) == 0: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty" + ) user.name = data.name.strip() if data.email is not None: if "@" not in data.email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email" + ) user.email = data.email.strip() await session.commit() @@ -159,7 +162,7 @@ async def get_user_sessions( id=str(inst.id), display_name=inst.display_name, tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown", - tool_icon=inst.tool_type.icon if inst.tool_type else None, + tool_icon=None, tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [], repository_name=inst.repository.name if inst.repository else "Unknown", repository_id=str(inst.repository_id), diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 9e2cc4b..4c50a20 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -1,4 +1,3 @@ -import json import logging import os @@ -15,16 +14,13 @@ from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router from src.api.terminal import router as terminal_router from src.api.instance_proxy import router as instance_proxy_router -from src.api.config_folders import router as config_folders_router from src.api.config_profiles import router as config_profiles_router -from src.api.tool_configs import router as tool_configs_router from src.api.tool_instances import router as tool_instances_router -from src.api.tool_instances import sessions_router from src.api.tool_types import router as tool_types_router from src.api.user_config import router as user_config_router from src.api.users import router as users_router from src.config import Settings -from src.database import SessionLocal, init_database +from src.database import init_database from src.logging_config import ( ExceptionLoggingMiddleware, RequestLoggingMiddleware, @@ -67,7 +63,9 @@ def _sanitize_validation_errors(errors): "type": error.get("type"), "loc": error.get("loc"), "msg": error.get("msg"), - "input": str(error.get("input")) if error.get("input") is not None else None, + "input": str(error.get("input")) + if error.get("input") is not None + else None, } # Convert ctx to safe format ctx = error.get("ctx") @@ -111,12 +109,14 @@ async def on_startup(): if not db_ready: logger.error("Database initialization failed. Shutting down.") import sys + sys.exit(1) # Seed built-in data await seed_builtin_tool_types() logger.info("Startup complete.") + app.include_router(health_router) app.include_router(auth_router) app.include_router(dashboard_router) @@ -126,11 +126,8 @@ app.include_router(ssh_keys_router) app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) -app.include_router(config_folders_router) app.include_router(config_profiles_router) app.include_router(tool_instances_router) -app.include_router(tool_configs_router) -app.include_router(sessions_router) app.include_router(instance_proxy_router) app.include_router(terminal_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 3358553..e54cd44 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -1,5 +1,4 @@ from src.models.base import Base -from src.models.config_folder import ConfigFolder from src.models.config_include import ConfigInclude from src.models.config_mount import ConfigMount from src.models.config_profile import ConfigProfile @@ -13,7 +12,6 @@ from src.models.user_config import UserConfig __all__ = [ "Base", - "ConfigFolder", "ConfigInclude", "ConfigMount", "ConfigProfile", diff --git a/apps/api/src/models/config_folder.py b/apps/api/src/models/config_folder.py deleted file mode 100644 index 9c232fe..0000000 --- a/apps/api/src/models/config_folder.py +++ /dev/null @@ -1,33 +0,0 @@ -import uuid -from typing import TYPE_CHECKING - -from sqlalchemy import Boolean, ForeignKey, JSON, String, Text -from sqlalchemy import Uuid as UUID -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin - -if TYPE_CHECKING: - from src.models.user import User - - -class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base): - __tablename__ = "config_folders" - - user_id: Mapped[uuid.UUID] = mapped_column( - UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - name: Mapped[str] = mapped_column(String(255), nullable=False) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - mount_path: Mapped[str] = mapped_column(String(1024), nullable=False) - files: Mapped[dict] = mapped_column( - JSON, default=dict, nullable=False - ) # {"relative/path": "content", ...} - project_overrides: Mapped[dict | None] = mapped_column( - JSON, default=dict, nullable=True - ) # {"project_id": {"mount_path": "...", "files": {...}}} - # DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time - # auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead. - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - - user: Mapped["User"] = relationship() diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index 24b93dc..581228a 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -1,8 +1,7 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import Boolean, ForeignKey, JSON, String, Text -from sqlalchemy import Uuid as UUID +from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin @@ -52,3 +51,21 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): foreign_keys=[manifest_id], ) created_by: Mapped["User | None"] = relationship() + + @property + def interfaces(self) -> list[str]: + """Backward-compatible API view for the single interface type.""" + return [self.interface_type] + + @interfaces.setter + def interfaces(self, value: list[str] | str) -> None: + """Accept legacy interface lists and store the first interface type.""" + if isinstance(value, str): + self.interface_type = value + return + self.interface_type = value[0] if value else "web" + + @property + def is_builtin(self) -> bool: + """Built-in tools are seeded system tools without a creating user.""" + return self.created_by_id is None diff --git a/apps/api/src/schemas/config_folder.py b/apps/api/src/schemas/config_folder.py deleted file mode 100644 index 51d6af5..0000000 --- a/apps/api/src/schemas/config_folder.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Config folder request/response schemas.""" - -import uuid - -from pydantic import BaseModel, Field - - -class ConfigFolderCreate(BaseModel): - name: str = Field(description="Folder name") - description: str | None = Field(default=None, description="Optional description") - mount_path: str = Field(description="Mount path in container") - files: dict[str, str] | None = Field( - default=None, description="Files as {path: content}" - ) - is_active: bool = Field(default=True, description="Whether folder is active") - - -class ConfigFolderUpdate(BaseModel): - name: str | None = None - description: str | None = None - mount_path: str | None = None - files: dict[str, str] | None = None - is_active: bool | None = None - - -class ProjectOverrideCreate(BaseModel): - project_id: str = Field(description="Project ID to override for") - mount_path: str | None = Field(default=None, description="Override mount path") - files: dict[str, str] | None = Field( - default=None, description="Override files" - ) - is_active: bool | None = Field(default=None, description="Override active state") - - -class ConfigFolderResponse(BaseModel): - id: str - user_id: str - name: str - description: str | None - mount_path: str - files: dict[str, str] | None - is_active: bool - created_at: str - updated_at: str diff --git a/apps/api/src/schemas/tool_config.py b/apps/api/src/schemas/tool_config.py deleted file mode 100644 index 3fc2781..0000000 --- a/apps/api/src/schemas/tool_config.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Tool config request/response schemas.""" - -from pydantic import BaseModel, Field - - -class ToolConfigCreate(BaseModel): - tool_type_id: str = Field(description="UUID of the tool type") - key: str = Field(description="Configuration key") - value: str = Field(description="Configuration value") - config_type: str = Field(default="env", description="Config type: env or file") - file_path: str | None = Field(default=None, description="File path for file configs") - port_override: int | None = Field(default=None, description="Port override") - start_command: str | None = Field(default=None, description="Start command override") - working_directory: str | None = Field(default=None, description="Working directory") - environment_variables: dict[str, str] | None = Field( - default=None, description="Additional environment variables" - ) - volumes: list[dict] | None = Field(default=None, description="Volume mounts") - - -class ToolConfigUpdate(BaseModel): - value: str | None = None - config_type: str | None = None - file_path: str | None = None - port_override: int | None = None - start_command: str | None = None - working_directory: str | None = None - environment_variables: dict[str, str] | None = None - volumes: list[dict] | None = None - - -class ToolConfigResponse(BaseModel): - id: str - tool_type_id: str - user_id: str - project_id: str | None - key: str - value: str - config_type: str - file_path: str | None - port_override: int | None - start_command: str | None - working_directory: str | None - environment_variables: dict[str, str] | None - volumes: list[dict] | None - created_at: str - updated_at: str diff --git a/apps/api/src/seeds/builtin_tool_types.py b/apps/api/src/seeds/builtin_tool_types.py index 023b2d1..ab80a31 100644 --- a/apps/api/src/seeds/builtin_tool_types.py +++ b/apps/api/src/seeds/builtin_tool_types.py @@ -46,7 +46,7 @@ async def seed_builtin_tool_types(): "display_name": "VS Code Server", "description": "VS Code running in the browser via code-server", "category": "editor", - "interfaces": ["web"], + "interface_type": "web", "compose_template": """version: "3.8" services: code-server: @@ -69,7 +69,7 @@ services: "display_name": "Jupyter Notebook", "description": "Jupyter Lab for interactive development", "category": "notebook", - "interfaces": ["web"], + "interface_type": "web", "default_port": 8888, "compose_template": """version: "3.8" services: @@ -90,7 +90,7 @@ services: "display_name": "OpenCode", "description": "AI coding assistant - run opencode in terminal", "category": "ai-assistant", - "interfaces": ["terminal"], + "interface_type": "terminal", "default_port": 3000, "compose_template": """version: "3.8" services: @@ -129,19 +129,20 @@ volumes: ] for tool_data in builtin_types: - existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"])) + existing = await session.scalar( + select(ToolType).where(ToolType.name == tool_data["name"]) + ) if not existing: tool_type = ToolType( name=tool_data["name"], display_name=tool_data["display_name"], description=tool_data["description"], category=tool_data["category"], - interfaces=tool_data["interfaces"], + interface_type=tool_data["interface_type"], definition_type="compose", compose_template=tool_data["compose_template"], required_variables=tool_data["required_variables"], - default_port=tool_data.get("default_port"), - is_builtin=True, + default_port=tool_data["default_port"], ) session.add(tool_type) logger.info("Created built-in tool type: %s", tool_data["name"]) @@ -150,11 +151,11 @@ volumes: existing.display_name = tool_data["display_name"] existing.description = tool_data["description"] existing.category = tool_data["category"] - existing.interfaces = tool_data["interfaces"] + existing.interface_type = tool_data["interface_type"] existing.definition_type = "compose" existing.compose_template = tool_data["compose_template"] existing.required_variables = tool_data["required_variables"] - existing.default_port = tool_data.get("default_port") + existing.default_port = tool_data["default_port"] logger.info("Updated built-in tool type: %s", tool_data["name"]) await session.commit() diff --git a/apps/api/src/services/docker/__init__.py b/apps/api/src/services/docker/__init__.py index cd1dc61..a5ea104 100644 --- a/apps/api/src/services/docker/__init__.py +++ b/apps/api/src/services/docker/__init__.py @@ -7,7 +7,7 @@ from .compose import ( write_compose_file, write_env_file, ) -from .config_staging import write_config_files, write_config_folder_files +from .config_staging import write_config_files from .container import ( connect_container_to_network, find_free_port, @@ -30,7 +30,6 @@ __all__ = [ "write_env_file", "execute_compose_command", "write_config_files", - "write_config_folder_files", "get_container_id", "get_container_name", "connect_container_to_network", diff --git a/apps/api/src/services/docker/config_staging.py b/apps/api/src/services/docker/config_staging.py index 817bcbf..5c3746d 100644 --- a/apps/api/src/services/docker/config_staging.py +++ b/apps/api/src/services/docker/config_staging.py @@ -1,11 +1,7 @@ -"""Config folder file staging for Docker instances.""" +"""Config file staging for Docker instances.""" -import logging from pathlib import Path -logger = logging.getLogger(__name__) - - def write_config_files(instance_dir: str, files: dict[str, str]) -> None: """Write config files to the instance directory. @@ -24,56 +20,3 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None: full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content) - - -def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]: - """Write config folder files to the instance directory and return volume mounts. - - Args: - instance_dir: Path to instance directory - folders: List of ConfigFolder objects - project_id: Optional project ID for applying overrides - - Returns: - List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}] - """ - instance_path = Path(instance_dir) - volume_mounts = [] - - for folder in folders: - # Determine mount path (with project override if applicable) - mount_path = folder.mount_path - files = folder.files.copy() - - if project_id and folder.project_overrides: - override = folder.project_overrides.get(str(project_id)) - if override: - if override.get("mount_path"): - mount_path = override["mount_path"] - if override.get("files"): - files.update(override["files"]) - - # Write files to instance directory - folder_dir = instance_path / "volumes" / folder.name - folder_dir.mkdir(parents=True, exist_ok=True) - - for file_path, content in files.items(): - # Security: ensure path doesn't escape folder_dir - full_path = folder_dir / file_path - try: - full_path.resolve().relative_to(folder_dir.resolve()) - except ValueError: - logger.warning("Config folder file path escapes directory: %s", file_path) - continue - - full_path.parent.mkdir(parents=True, exist_ok=True) - full_path.write_text(content) - - # Add volume mount - volume_mounts.append({ - "source": str(folder_dir), - "target": mount_path, - "type": "bind", - }) - - return volume_mounts diff --git a/apps/api/src/services/instance_lifecycle.py b/apps/api/src/services/instance_lifecycle.py index e28a51a..2b57b1e 100644 --- a/apps/api/src/services/instance_lifecycle.py +++ b/apps/api/src/services/instance_lifecycle.py @@ -11,14 +11,11 @@ from datetime import datetime from typing import Any from fastapi import HTTPException, status -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.config_folder import ConfigFolder from src.models.config_profile import ConfigProfile from src.models.git_repository import GitRepository from src.models.project import Project -from src.models.tool_config import ToolConfig from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User @@ -54,7 +51,8 @@ async def create_new_instance( instance = ToolInstance( name=instance_name, - display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}", + display_name=display_name + or f"{project.name} / {repo.name} / {tool_type.display_name}", tool_type_id=tool_type.id, repository_id=repo.id, project_id=project.id, @@ -85,16 +83,27 @@ async def start_existing_instance( instance.status = "building" await session.commit() - env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs( - session, user.id, instance.tool_type_id, project_id - ) + env_vars: dict[str, str] = {} + config_files: dict[str, str] = {} + port_override = None + start_command = None + working_directory = None + extra_volumes: list[dict] = [] selected_profile = None if instance.selected_profile_id: - selected_profile = await session.get(ConfigProfile, instance.selected_profile_id) + selected_profile = await session.get( + ConfigProfile, instance.selected_profile_id + ) if selected_profile and selected_profile.user_id == user.id: instance_dir = os.path.dirname(instance.compose_path) - env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile( + ( + env_vars, + port_override, + start_command, + working_directory, + extra_volumes, + ) = await compose_svc._apply_resolved_profile( selected_profile, instance_dir, env_vars, @@ -104,14 +113,17 @@ async def start_existing_instance( extra_volumes, ) - env_file_path, extra_volumes = await _stage_configs_and_folders( - session, user.id, project_id, os.path.dirname(instance.compose_path), - env_vars, config_files, extra_volumes + env_file_path, extra_volumes = await _stage_configs( + os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes ) if port_override or start_command or working_directory or extra_volumes: compose_svc._modify_compose_file( - instance.compose_path, port_override, start_command, working_directory, extra_volumes + instance.compose_path, + port_override, + start_command, + working_directory, + extra_volumes, ) returncode, _stdout, stderr = compose_svc.execute_compose_command( @@ -138,6 +150,14 @@ async def start_existing_instance( await session.commit() tool_type = await session.get(ToolType, instance.tool_type_id) + if not tool_type: + instance.status = "error" + await session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="tool type not found for instance", + ) + success, probe_logs = await _run_readiness_probe(instance, tool_type) if not success: instance.status = "failed" @@ -175,27 +195,45 @@ async def restart_existing_instance( await session.commit() return {"status": instance.status} - env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs( - session, user.id, instance.tool_type_id, project_id - ) + env_vars: dict[str, str] = {} + config_files: dict[str, str] = {} + port_override = None + start_command = None + working_directory = None + extra_volumes: list[dict] = [] stored_profile = None if instance.selected_profile_id: stored_profile = await session.get(ConfigProfile, instance.selected_profile_id) if stored_profile and stored_profile.user_id == user.id: instance_dir = os.path.dirname(instance.compose_path) - env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile( - stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes + ( + env_vars, + port_override, + start_command, + working_directory, + extra_volumes, + ) = await compose_svc._apply_resolved_profile( + stored_profile, + instance_dir, + env_vars, + port_override, + start_command, + working_directory, + extra_volumes, ) - env_file_path, extra_volumes = await _stage_configs_and_folders( - session, user.id, project_id, os.path.dirname(instance.compose_path), - env_vars, config_files, extra_volumes + env_file_path, extra_volumes = await _stage_configs( + os.path.dirname(instance.compose_path), env_vars, config_files, extra_volumes ) if port_override or start_command or working_directory or extra_volumes: compose_svc._modify_compose_file( - instance.compose_path, port_override, start_command, working_directory, extra_volumes + instance.compose_path, + port_override, + start_command, + working_directory, + extra_volumes, ) returncode, _stdout, _stderr = compose_svc.execute_compose_command( @@ -210,6 +248,11 @@ async def restart_existing_instance( instance.last_started_at = datetime.now() tool_type = await session.get(ToolType, instance.tool_type_id) + if not tool_type: + instance.status = "error" + await session.commit() + return {"status": instance.status} + await _start_tunnel_if_web(instance, tool_type) await session.commit() @@ -235,7 +278,9 @@ async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) await session.commit() -async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None: +async def delete_existing_instance( + session: AsyncSession, instance: ToolInstance +) -> None: """Delete an instance, its containers, and its directory.""" if instance.tunnel_id: try: @@ -255,6 +300,7 @@ async def delete_existing_instance(session: AsyncSession, instance: ToolInstance # ── Internal helpers ─────────────────────────────────────────────────────── + async def _build_or_render_compose( tool_type: ToolType, instance_name: str, @@ -283,11 +329,11 @@ async def _build_or_render_compose( compose_content = ( f'version: "3.8"\nservices:\n app:\n' - f' image: {image_tag}\n' - f' container_name: {instance_name}\n' + f" image: {image_tag}\n" + f" container_name: {instance_name}\n" f' ports:\n - "{tool_port}:{tool_type.default_port}"\n' - f' volumes:\n - {repo.path}:/workspace\n' - f' restart: unless-stopped\n' + f" volumes:\n - {repo.path}:/workspace\n" + f" restart: unless-stopped\n" ) else: variables = { @@ -299,6 +345,11 @@ async def _build_or_render_compose( "USER_ID": str(user.id), "PROJECT_ID": str(project_id), } + if not tool_type.compose_template: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="tool type has no compose template", + ) compose_content = compose_svc.render_compose_template( tool_type.compose_template, variables ) @@ -307,80 +358,25 @@ async def _build_or_render_compose( return os.path.join(instance_dir, "docker-compose.yml") -async def _fetch_tool_configs( - session: AsyncSession, - user_id: Any, - tool_type_id: Any, - project_id: Any, -) -> tuple[dict, dict, Any, Any, Any, dict, list]: - """Fetch tool configs and return parsed values.""" - env_vars: dict[str, str] = {} - config_files: dict[str, str] = {} - port_override = None - start_command = None - working_directory = None - extra_env_vars: dict[str, str] = {} - extra_volumes: list[dict] = [] - - query = ( - select(ToolConfig) - .where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id) - .where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))) - ) - configs = (await session.execute(query)).scalars().all() - - for cfg in configs: - if cfg.config_type == "env": - env_vars[cfg.key] = cfg.value - elif cfg.config_type == "file" and cfg.file_path: - config_files[cfg.file_path] = cfg.value - if cfg.port_override: - port_override = cfg.port_override - if cfg.start_command: - start_command = cfg.start_command - if cfg.working_directory: - working_directory = cfg.working_directory - if cfg.environment_variables: - extra_env_vars.update(cfg.environment_variables) - if cfg.volumes: - extra_volumes.extend(cfg.volumes) - - env_vars.update(extra_env_vars) - return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes - - -async def _stage_configs_and_folders( - session: AsyncSession, - user_id: Any, - project_id: Any, +async def _stage_configs( instance_dir: str, env_vars: dict[str, str], config_files: dict[str, str], extra_volumes: list[dict], ) -> tuple[str | None, list[dict]]: - """Write env/config files and config folders.""" + """Write env/config files for the resolved profile.""" env_file_path: str | None = None if env_vars: env_file_path = compose_svc.write_env_file(instance_dir, env_vars) if config_files: config_staging.write_config_files(instance_dir, config_files) - folder_query = select(ConfigFolder).where( - ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True) - ) - folders = (await session.execute(folder_query)).scalars().all() - if folders: - folder_volumes = config_staging.write_config_folder_files( - instance_dir, folders, str(project_id) - ) - extra_volumes.extend(folder_volumes) - return env_file_path, extra_volumes async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None: """Create Cloudflare tunnel for web-enabled tools.""" - if "web" not in tool_type.interfaces or not tool_type.default_port: + if tool_type.interface_type != "web" or not tool_type.default_port: instance.url = None instance.public_url = None return @@ -393,7 +389,9 @@ async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> N instance.tunnel_id = tunnel_info["pid"] instance.public_url = tunnel_info["url"] instance.url = tunnel_info["url"] - logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"]) + logger.info( + "Created tunnel for instance %s: %s", instance.id, tunnel_info["url"] + ) except Exception as exc: logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc) instance.status = "error" diff --git a/apps/web/scripts/check-structure.js b/apps/web/scripts/check-structure.js index eae68ec..57ce446 100644 --- a/apps/web/scripts/check-structure.js +++ b/apps/web/scripts/check-structure.js @@ -19,7 +19,6 @@ let warnings = 0; const OVERSIZE_ALLOWLIST = [ // Form-heavy admin tabs: 15+ fields each, splitting would create micro-components "components/features/tool-workshop/ToolTypesTab.tsx", - "components/features/tool-workshop/ToolConfigsTab.tsx", // Complex terminal hook: WS lifecycle + ping-pong + echo + resize debouncing "hooks/use-terminal-connection.ts", // Terminal component: xterm lifecycle + resize observer + overlay UI diff --git a/apps/web/src/api/config-folders.test.ts b/apps/web/src/api/config-folders.test.ts deleted file mode 100644 index 7b5645a..0000000 --- a/apps/web/src/api/config-folders.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - createConfigFolder, - deleteConfigFolder, - listConfigFolders, - updateConfigFolder, -} from "../api/config-folders"; - -const mockGet = vi.fn(); -const mockPost = vi.fn(); -const mockPut = vi.fn(); -const mockDelete = vi.fn(); - -vi.mock("../api/client", () => ({ - apiClient: { - get: (...args: unknown[]) => mockGet(...args), - post: (...args: unknown[]) => mockPost(...args), - put: (...args: unknown[]) => mockPut(...args), - delete: (...args: unknown[]) => mockDelete(...args), - interceptors: { - response: { - use: vi.fn(), - }, - }, - }, - shouldSkipAuthRedirect: vi.fn(() => false), -})); - -describe("config_folders API", () => { - describe("listConfigFolders", () => { - it("returns folders with files and overrides", async () => { - const mockResponse = { - data: [ - { - id: "folder-1", - name: "my-dotfiles", - description: "My personal config files", - mount_path: "/home/user", - files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }, - project_overrides: {}, - is_active: true, - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - ], - }; - mockGet.mockResolvedValue(mockResponse); - - const result = await listConfigFolders(); - - expect(result[0].name).toBe("my-dotfiles"); - expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }); - expect(mockGet).toHaveBeenCalledWith("/config-folders"); - }); - }); - - describe("createConfigFolder", () => { - it("creates folder with files", async () => { - const mockResponse = { - data: { - id: "folder-new", - name: "new-folder", - mount_path: "/workspace", - files: { ".env": "API_URL=http://localhost" }, - is_active: true, - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - }; - mockPost.mockResolvedValue(mockResponse); - - const result = await createConfigFolder({ - name: "new-folder", - mount_path: "/workspace", - files: { ".env": "API_URL=http://localhost" }, - }); - - expect(result.name).toBe("new-folder"); - expect(mockPost).toHaveBeenCalledWith( - "/config-folders", - expect.objectContaining({ - name: "new-folder", - mount_path: "/workspace", - }) - ); - }); - }); - - describe("updateConfigFolder", () => { - it("updates folder files", async () => { - const mockResponse = { - data: { - id: "folder-1", - name: "updated-folder", - mount_path: "/home/user", - files: { ".bashrc": "alias ll='ls -la'" }, - is_active: true, - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - }; - mockPut.mockResolvedValue(mockResponse); - - const result = await updateConfigFolder("folder-1", { - files: { ".bashrc": "alias ll='ls -la'" }, - }); - - expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" }); - expect(mockPut).toHaveBeenCalledWith( - "/config-folders/folder-1", - expect.objectContaining({ - files: { ".bashrc": "alias ll='ls -la'" }, - }) - ); - }); - }); - - describe("deleteConfigFolder", () => { - it("deletes folder", async () => { - mockDelete.mockResolvedValue({ data: undefined }); - - await deleteConfigFolder("folder-1"); - - expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1"); - }); - }); -}); diff --git a/apps/web/src/api/config-folders.ts b/apps/web/src/api/config-folders.ts deleted file mode 100644 index 890111a..0000000 --- a/apps/web/src/api/config-folders.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { apiClient } from "./client"; -import type { - ConfigFolder, - CreateConfigFolderRequest, - UpdateConfigFolderRequest, - ProjectOverrideRequest, -} from "../types/config-folder"; - -export type { - ConfigFolder, - CreateConfigFolderRequest, - UpdateConfigFolderRequest, - ProjectOverrideRequest, -} from "../types/config-folder"; - -export const listConfigFolders = async (): Promise => { - const response = await apiClient.get("/config-folders"); - return response.data; -}; - -export const getConfigFolder = async (id: string): Promise => { - const response = await apiClient.get(`/config-folders/${id}`); - return response.data; -}; - -export const createConfigFolder = async ( - data: CreateConfigFolderRequest, -): Promise => { - const response = await apiClient.post("/config-folders", data); - return response.data; -}; - -export const updateConfigFolder = async ( - id: string, - data: UpdateConfigFolderRequest, -): Promise => { - const response = await apiClient.put( - `/config-folders/${id}`, - data, - ); - return response.data; -}; - -export const deleteConfigFolder = async (id: string): Promise => { - await apiClient.delete(`/config-folders/${id}`); -}; - -export const addProjectOverride = async ( - id: string, - projectId: string, - data: ProjectOverrideRequest, -): Promise => { - const response = await apiClient.post( - `/config-folders/${id}/overrides/${projectId}`, - data, - ); - return response.data; -}; - -export const updateProjectOverride = async ( - id: string, - projectId: string, - data: ProjectOverrideRequest, -): Promise => { - const response = await apiClient.put( - `/config-folders/${id}/overrides/${projectId}`, - data, - ); - return response.data; -}; - -export const deleteProjectOverride = async ( - id: string, - projectId: string, -): Promise => { - await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`); -}; diff --git a/apps/web/src/api/tool-configs.ts b/apps/web/src/api/tool-configs.ts deleted file mode 100644 index 3974f30..0000000 --- a/apps/web/src/api/tool-configs.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { apiClient } from "./client"; -import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config"; - -export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config"; - -export const listToolConfigs = async ( - tool_type_id?: string, - project_id?: string, -): Promise => { - const params = new URLSearchParams(); - if (tool_type_id) params.append("tool_type_id", tool_type_id); - if (project_id) params.append("project_id", project_id); - - const response = await apiClient.get<{ configs: ToolConfig[] }>( - `/tool-configs?${params.toString()}`, - ); - return response.data.configs; -}; - -export const createToolConfig = async ( - data: CreateToolConfigRequest, -): Promise => { - const response = await apiClient.post<{ configs: ToolConfig[] }>( - "/tool-configs", - data, - ); - return response.data.configs[0]; -}; - -export const updateToolConfig = async ( - id: string, - data: CreateToolConfigRequest, -): Promise => { - const response = await apiClient.put<{ configs: ToolConfig[] }>( - `/tool-configs/${id}`, - data, - ); - return response.data.configs[0]; -}; - -export const deleteToolConfig = async (id: string): Promise => { - await apiClient.delete(`/tool-configs/${id}`); -}; - -export const getToolConfigDefaults = async ( - toolTypeId: string, -): Promise => { - const response = await apiClient.get( - `/tool-configs/defaults/${toolTypeId}`, - ); - return response.data; -}; diff --git a/apps/web/src/components/features/tool-configs/ToolConfigForm.tsx b/apps/web/src/components/features/tool-configs/ToolConfigForm.tsx deleted file mode 100644 index a0a44b9..0000000 --- a/apps/web/src/components/features/tool-configs/ToolConfigForm.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useState } from "react"; -import type { ToolConfig } from "../../../types/tool-config"; - -interface ToolConfigFormProps { - editingConfig: ToolConfig | null; - onSubmit: (data: { - key: string; - value: string; - config_type: string; - file_path: string; - }) => Promise; - onCancel: () => void; -} - -export const ToolConfigForm = ({ - editingConfig, - onSubmit, - onCancel, -}: ToolConfigFormProps) => { - const [formData, setFormData] = useState({ - key: editingConfig?.key ?? "", - value: editingConfig?.value ?? "", - config_type: editingConfig?.config_type ?? "env", - file_path: editingConfig?.file_path ?? "", - }); - const [saveStatus, setSaveStatus] = useState< - "idle" | "saving" | "saved" | "error" - >("idle"); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSaveStatus("saving"); - try { - await onSubmit(formData); - setSaveStatus("saved"); - } catch { - setSaveStatus("error"); - } - }; - - return ( -
-

{editingConfig ? "Edit Config" : "Add Config"}

-
-
- - setFormData({ ...formData, key: e.target.value })} - placeholder="e.g., OPENAI_API_KEY" - className="form-input" - required - /> -
-
- - -
- {formData.config_type === "file" && ( -
- - - setFormData({ ...formData, file_path: e.target.value }) - } - placeholder="e.g., /app/config.json" - className="form-input" - required - /> -
- )} -
- -