From 9bd5fc5c687e7e7ff2203950ef5080dcfd19f7f8 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 28 May 2026 20:08:48 +0200 Subject: [PATCH] refactor: remove Tool Configs and Config Folders These features are fully superseded by Config Profiles which provide: - Env vars, file mounts, port overrides, start commands, working dirs - Git mounts, profile composition, cycle detection - Default selection, project/tool-type scoping Changes: - Delete backend models: ToolConfig, ConfigFolder - Delete backend APIs: tool_configs.py, config_folders.py - Delete frontend API clients: tool_configs.ts, config_folders.ts - Remove Tool Config fetching from start_instance, use ConfigProfile only - Simplify merge_with_config to accept only profile (no tool_configs) - Remove configs/folders tabs from Tool Workshop page - Delete associated integration and unit tests - Add Alembic migration to drop tool_configs and config_folders tables Quality gates: backend tests 59 passed, frontend typecheck clean --- ...28_drop_tool_configs_and_config_folders.py | 89 ++ apps/api/src/api/config_folders.py | 337 -------- apps/api/src/api/tool_configs.py | 290 ------- apps/api/src/api/tool_instances.py | 59 +- apps/api/src/main.py | 4 - apps/api/src/models/__init__.py | 2 - apps/api/src/models/config_folder.py | 31 - apps/api/src/models/tool_config.py | 48 -- apps/api/src/services/manifest_compiler.py | 25 +- .../integration/test_config_folders_api.py | 255 ------ .../test_tool_configs_api_extended.py | 255 ------ apps/api/tests/unit/test_manifest_compiler.py | 41 +- apps/web/src/api/config_folders.test.ts | 131 --- apps/web/src/api/config_folders.ts | 95 --- apps/web/src/api/tool_configs.ts | 75 -- apps/web/src/pages/tool-workshop.test.tsx | 399 --------- apps/web/src/pages/tool-workshop.tsx | 780 +----------------- 17 files changed, 124 insertions(+), 2792 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_28_drop_tool_configs_and_config_folders.py 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/models/tool_config.py delete mode 100644 apps/api/tests/integration/test_config_folders_api.py delete mode 100644 apps/api/tests/integration/test_tool_configs_api_extended.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/pages/tool-workshop.test.tsx diff --git a/apps/api/alembic/versions/2026_05_28_drop_tool_configs_and_config_folders.py b/apps/api/alembic/versions/2026_05_28_drop_tool_configs_and_config_folders.py new file mode 100644 index 0000000..22a0475 --- /dev/null +++ b/apps/api/alembic/versions/2026_05_28_drop_tool_configs_and_config_folders.py @@ -0,0 +1,89 @@ +"""drop tool_configs and config_folders tables + +Revision ID: 2026_05_28_drop_tool_configs_and_config_folders +Revises: 2026_05_28_add_tool_definition_manifests +Create Date: 2026-05-28 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2026_05_28_drop_tool_configs_and_config_folders" +down_revision: Union[str, None] = "2026_05_28_add_tool_definition_manifests" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Drop tool_configs table if it exists + result = conn.execute( + sa.text(""" + SELECT table_name FROM information_schema.tables + WHERE table_name = 'tool_configs' + """) + ) + if result.fetchone(): + op.drop_table("tool_configs") + + # Drop config_folders table if it exists + result = conn.execute( + sa.text(""" + SELECT table_name FROM information_schema.tables + WHERE table_name = 'config_folders' + """) + ) + if result.fetchone(): + op.drop_table("config_folders") + + +def downgrade() -> None: + # Recreate config_folders table + op.create_table( + "config_folders", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("mount_path", sa.String(1024), nullable=False), + sa.Column("files", sa.JSON(), default=dict, nullable=False), + sa.Column("project_overrides", sa.JSON(), default=dict, nullable=True), + sa.Column("is_active", sa.Boolean(), default=True, nullable=False), + sa.Column( + "created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now() + ), + sa.PrimaryKeyConstraint("id"), + ) + + # Recreate tool_configs table + op.create_table( + "tool_configs", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("tool_type_id", sa.UUID(), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=True), + sa.Column("key", sa.String(255), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("config_type", sa.String(20), default="env", nullable=False), + sa.Column("file_path", sa.String(1024), nullable=True), + sa.Column("port_override", sa.Integer(), nullable=True), + sa.Column("start_command", sa.Text(), nullable=True), + sa.Column("working_directory", sa.Text(), nullable=True), + sa.Column("environment_variables", sa.JSON(), default=dict, nullable=True), + sa.Column("volumes", sa.JSON(), default=list, nullable=True), + sa.Column( + "created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now() + ), + sa.PrimaryKeyConstraint("id"), + ) diff --git a/apps/api/src/api/config_folders.py b/apps/api/src/api/config_folders.py deleted file mode 100644 index 0bfdc3e..0000000 --- a/apps/api/src/api/config_folders.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Config folder API endpoints.""" - -import logging -import uuid - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field, field_validator -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path -from src.auth.dependencies import get_current_user_id, get_db_session -from src.models.config_folder import ConfigFolder - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/config-folders", tags=["config-folders"]) - - -class ConfigFolderCreate(BaseModel): - name: str = Field(description="Folder name (unique per user)") - description: str | None = Field(default=None, description="Optional description") - mount_path: str = Field(description="Default mount path in container") - files: dict = Field(default_factory=dict, description="Files as {path: content}") - - @field_validator("mount_path") - @classmethod - def validate_mount_path(cls, v: str) -> str: - return _validate_mount_path(v) - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict) -> dict: - return _validate_files(v) - - -class ConfigFolderUpdate(BaseModel): - name: str | None = Field(default=None, description="Folder name") - description: str | None = Field(default=None, description="Optional description") - mount_path: str | None = Field(default=None, description="Default mount path") - files: dict | None = Field(default=None, description="Files as {path: content}") - is_active: bool | None = Field(default=None, description="Active/inactive toggle") - - @field_validator("mount_path") - @classmethod - def validate_mount_path(cls, v: str | None) -> str | None: - return _validate_mount_path(v) - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict | None) -> dict | None: - return _validate_files(v) - - -class ProjectOverrideCreate(BaseModel): - mount_path: str | None = Field(default=None, description="Override mount path") - files: dict = Field(default_factory=dict, description="Override files") - - @field_validator("mount_path") - @classmethod - def validate_mount_path(cls, v: str | None) -> str | None: - return _validate_mount_path(v) - - -class ConfigFolderResponse(BaseModel): - id: str - user_id: str - name: str - description: str | None - mount_path: str - files: dict - project_overrides: dict | None - is_active: bool - created_at: str - updated_at: str - - -@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 2f38913..0000000 --- a/apps/api/src/api/tool_configs.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Tool configuration API endpoints.""" - -import uuid - -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field, field_validator -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes -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 - -router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) - - -class ToolConfigCreate(BaseModel): - tool_type_id: str = Field(description="UUID of the tool type") - project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config") - key: str = Field(description="Config key name") - value: str = Field(description="Config value") - config_type: str = Field(default="env", description="Type: env or file") - file_path: str | None = Field(default=None, description="File path for file-type configs") - port_override: int | None = Field(default=None, description="Port override (1-65535)") - start_command: str | None = Field(default=None, description="Override container start command") - working_directory: str | None = Field(default=None, description="Working directory inside container") - environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object") - volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array") - - @field_validator("port_override") - @classmethod - def validate_port(cls, v: int | None) -> int | None: - if v is None: - return v - if v < 1 or v > 65535: - raise ValueError("Port must be between 1 and 65535") - return v - - @field_validator("environment_variables") - @classmethod - def validate_env_vars(cls, v: dict | None) -> dict | None: - return _validate_env_vars(v) - - @field_validator("volumes") - @classmethod - def validate_volumes(cls, v: list | None) -> list | None: - return _validate_volumes(v) - - -class ToolConfigUpdate(BaseModel): - key: str | None = Field(default=None, description="Config key name") - value: str | None = Field(default=None, description="Config value") - config_type: str | None = Field(default=None, description="Type: env or file") - file_path: str | None = Field(default=None, description="File path for file-type configs") - port_override: int | None = Field(default=None, description="Port override (1-65535)") - start_command: str | None = Field(default=None, description="Override container start command") - working_directory: str | None = Field(default=None, description="Working directory inside container") - environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object") - volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array") - - @field_validator("port_override") - @classmethod - def validate_port(cls, v: int | None) -> int | None: - if v is None: - return v - if v < 1 or v > 65535: - raise ValueError("Port must be between 1 and 65535") - return v - - @field_validator("environment_variables") - @classmethod - def validate_env_vars(cls, v: dict | None) -> dict | None: - return _validate_env_vars(v) - - @field_validator("volumes") - @classmethod - def validate_volumes(cls, v: list | None) -> list | None: - return _validate_volumes(v) - - -class ToolConfigResponse(BaseModel): - id: str - tool_type_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 | None - volumes: list[dict] | None - - -@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_instances.py b/apps/api/src/api/tool_instances.py index e709858..109dfa3 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -32,7 +32,6 @@ from src.models.config_profile import ConfigProfile from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey -from src.models.tool_config import ToolConfig from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.services.clone import check_dirty_state, clone_repository @@ -976,7 +975,6 @@ async def _prepare_manifest_instance( instance: ToolInstance, instance_dir: str, repo_path: str, - configs: list, env_vars: dict, extra_volumes: list, working_directory: str | None, @@ -1011,22 +1009,7 @@ async def _prepare_manifest_instance( manifest_def.id, ) - # Merge tool configs - tool_config_dicts = [ - { - "config_type": c.config_type, - "key": c.key, - "value": c.value, - "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 - ] - manifest = merge_with_config(manifest, tool_config_dicts) + manifest = merge_with_config(manifest) # Resolve extra env and volumes from merge_with_config extra_env = manifest.pop("_extra_env", {}) @@ -1159,51 +1142,14 @@ async def start_instance( await session.commit() logger.info("Starting instance %s (name=%s)", instance.id, instance.name) - # Fetch tool configs for this tool type + # Runtime overrides populated by config profiles env_vars = {} config_files = {} port_override = None start_command = None working_directory = None - extra_env_vars = {} extra_volumes = [] - config_query = ( - select(ToolConfig) - .where( - ToolConfig.user_id == user_id, - ToolConfig.tool_type_id == instance.tool_type_id, - ) - .where( - (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) - ) - ) - - config_result = await session.execute(config_query) - configs = config_result.scalars().all() - logger.debug("Found %d tool configs for instance %s", len(configs), instance.id) - - for config in configs: - if config.config_type == "env": - env_vars[config.key] = config.value - elif config.config_type == "file" and config.file_path: - config_files[config.file_path] = config.value - - # Handle new config fields - if config.port_override: - port_override = config.port_override - if config.start_command: - start_command = config.start_command - if config.working_directory: - working_directory = config.working_directory - if config.environment_variables: - extra_env_vars.update(config.environment_variables) - if config.volumes: - extra_volumes.extend(config.volumes) - - # Merge extra env vars - env_vars.update(extra_env_vars) - # Apply selected config profile if any instance_dir = os.path.dirname(instance.compose_path) if instance.selected_config_profile_id is not None: @@ -1288,7 +1234,6 @@ async def start_instance( instance=instance, instance_dir=instance_dir, repo_path=repo_path, - configs=configs, env_vars=env_vars, extra_volumes=extra_volumes, working_directory=working_directory, diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 3b19ed2..e185066 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -15,9 +15,7 @@ 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_definitions import router as tool_definitions_router from src.api.tool_instances import router as tool_instances_router from src.api.tool_instances import sessions_router @@ -130,10 +128,8 @@ app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) app.include_router(tool_definitions_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) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 79e88ff..20b81a5 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_profile import ConfigProfile, ConfigProfileInclude from src.models.git_repository import GitRepository from src.models.project import Project @@ -13,7 +12,6 @@ from src.models.user_config import UserConfig __all__ = [ "Base", - "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", diff --git a/apps/api/src/models/config_folder.py b/apps/api/src/models/config_folder.py deleted file mode 100644 index 0ca2499..0000000 --- a/apps/api/src/models/config_folder.py +++ /dev/null @@ -1,31 +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": {...}}} - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - - user: Mapped["User"] = relationship() diff --git a/apps/api/src/models/tool_config.py b/apps/api/src/models/tool_config.py deleted file mode 100644 index 87e043b..0000000 --- a/apps/api/src/models/tool_config.py +++ /dev/null @@ -1,48 +0,0 @@ -import uuid -from typing import TYPE_CHECKING - -from sqlalchemy import 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.project import Project - from src.models.tool_type import ToolType - from src.models.user import User - - -class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): - __tablename__ = "tool_configs" - - user_id: Mapped[uuid.UUID] = mapped_column( - UUID(), ForeignKey("users.id"), nullable=False - ) - tool_type_id: Mapped[uuid.UUID] = mapped_column( - UUID(), ForeignKey("tool_types.id"), nullable=False - ) - project_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(), ForeignKey("projects.id"), nullable=True - ) - key: Mapped[str] = mapped_column(String(255), nullable=False) - value: Mapped[str] = mapped_column(Text, nullable=False) - config_type: Mapped[str] = mapped_column( - String(20), nullable=False, default="env" - ) # "env" or "file" - file_path: Mapped[str | None] = mapped_column( - String(1024), nullable=True - ) # Only for file type - port_override: Mapped[int | None] = mapped_column(nullable=True) - start_command: Mapped[str | None] = mapped_column(Text, nullable=True) - working_directory: Mapped[str | None] = mapped_column(Text, nullable=True) - environment_variables: Mapped[dict | None] = mapped_column( - JSON, default=dict, nullable=True - ) - volumes: Mapped[list[dict] | None] = mapped_column( - JSON, default=list, nullable=True - ) - - user: Mapped["User"] = relationship() - tool_type: Mapped["ToolType"] = relationship() - project: Mapped["Project | None"] = relationship() diff --git a/apps/api/src/services/manifest_compiler.py b/apps/api/src/services/manifest_compiler.py index af76233..94f790d 100644 --- a/apps/api/src/services/manifest_compiler.py +++ b/apps/api/src/services/manifest_compiler.py @@ -351,13 +351,12 @@ def compute_image_tag(tool_name: str, manifest: dict) -> str: def merge_with_config( - manifest: dict, tool_configs: list[dict], profile: dict | None = None + manifest: dict, profile: dict | None = None ) -> dict: - """Merge ToolConfig and ConfigProfile overrides into a manifest. + """Merge ConfigProfile overrides into a manifest. Args: manifest: Base manifest from tool definition. - tool_configs: List of ToolConfig records. profile: Resolved ConfigProfile (optional). Returns: @@ -365,29 +364,9 @@ def merge_with_config( """ result = deepcopy(manifest) - # Apply ToolConfigs extra_env: dict[str, str] = {} extra_volumes: list[dict] = [] - for config in tool_configs: - if config.get("config_type") == "env": - extra_env[config["key"]] = config["value"] - elif config.get("config_type") == "file" and config.get("file_path"): - # Files are handled outside the manifest (written to instance dir) - pass - if config.get("port_override"): - result["default_port"] = config["port_override"] - if config.get("start_command"): - result["runtime"] = result.get("runtime", {}) - result["runtime"]["command"] = config["start_command"].split() - if config.get("working_directory"): - result["runtime"] = result.get("runtime", {}) - result["runtime"]["working_dir"] = config["working_directory"] - if config.get("environment_variables"): - extra_env.update(config["environment_variables"]) - if config.get("volumes"): - extra_volumes.extend(config["volumes"]) - # Apply ConfigProfile if profile: if profile.get("environment_variables"): diff --git a/apps/api/tests/integration/test_config_folders_api.py b/apps/api/tests/integration/test_config_folders_api.py deleted file mode 100644 index d8b0b66..0000000 --- a/apps/api/tests/integration/test_config_folders_api.py +++ /dev/null @@ -1,255 +0,0 @@ -import uuid -import pytest -from fastapi.testclient import TestClient - - -@pytest.mark.integration -class TestConfigFoldersAPI: - """Integration tests for config folders API.""" - - def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None: - """Test that listing config folders requires authentication.""" - response = test_client.get("/config-folders") - assert response.status_code == 401 - - def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None: - """Test that authenticated users can list their folders.""" - response = authenticated_client.get("/config-folders") - assert response.status_code == 200 - data = response.json() - assert isinstance(data, dict) - assert "folders" in data - assert isinstance(data["folders"], list) - - def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None: - """Test creating a config folder.""" - response = authenticated_client.post( - "/config-folders", - json={ - "name": "test-folder", - "description": "Test folder", - "mount_path": "/home/user", - "files": {"test.txt": "hello world"}, - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["name"] == "test-folder" - assert data["mount_path"] == "/home/user" - assert data["files"] == {"test.txt": "hello world"} - - def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None: - """Test that duplicate folder names are rejected.""" - # Create first folder - response = authenticated_client.post( - "/config-folders", - json={ - "name": "duplicate-folder", - "mount_path": "/home/user", - "files": {}, - }, - ) - assert response.status_code == 201 - - # Try to create second with same name - response = authenticated_client.post( - "/config-folders", - json={ - "name": "duplicate-folder", - "mount_path": "/home/user", - "files": {}, - }, - ) - assert response.status_code == 409 - - def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None: - """Test that folders exceeding 10MB are rejected.""" - large_content = "x" * (11 * 1024 * 1024) # 11MB - response = authenticated_client.post( - "/config-folders", - json={ - "name": "large-folder", - "mount_path": "/home/user", - "files": {"large.txt": large_content}, - }, - ) - assert response.status_code == 422 - - def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None: - """Test that path traversal in file paths is prevented.""" - response = authenticated_client.post( - "/config-folders", - json={ - "name": "bad-folder", - "mount_path": "/home/user", - "files": {"../../../etc/passwd": "malicious"}, - }, - ) - assert response.status_code == 422 - - def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None: - """Test getting a config folder by ID.""" - # Create folder first - create_response = authenticated_client.post( - "/config-folders", - json={ - "name": "get-test", - "mount_path": "/home/user", - "files": {}, - }, - ) - folder_id = create_response.json()["id"] - - # Get it back - response = authenticated_client.get(f"/config-folders/{folder_id}") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "get-test" - - def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None: - """Test getting a non-existent folder.""" - response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}") - assert response.status_code == 404 - - def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None: - """Test updating a config folder.""" - # Create folder first - create_response = authenticated_client.post( - "/config-folders", - json={ - "name": "update-test", - "mount_path": "/home/user", - "files": {}, - }, - ) - folder_id = create_response.json()["id"] - - # Update it - response = authenticated_client.put( - f"/config-folders/{folder_id}", - json={ - "name": "updated-name", - "mount_path": "/workspace", - "files": {"new.txt": "content"}, - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["name"] == "updated-name" - assert data["mount_path"] == "/workspace" - - def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None: - """Test deleting a config folder.""" - # Create folder first - create_response = authenticated_client.post( - "/config-folders", - json={ - "name": "delete-test", - "mount_path": "/home/user", - "files": {}, - }, - ) - folder_id = create_response.json()["id"] - - # Delete it - response = authenticated_client.delete(f"/config-folders/{folder_id}") - assert response.status_code == 204 - - # Verify it's gone - get_response = authenticated_client.get(f"/config-folders/{folder_id}") - assert get_response.status_code == 404 - - def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None: - """Test adding a project override.""" - # Create folder first - create_response = authenticated_client.post( - "/config-folders", - json={ - "name": "override-test", - "mount_path": "/home/user", - "files": {"global.txt": "global"}, - }, - ) - folder_id = create_response.json()["id"] - project_id = str(uuid.uuid4()) - - # Add override - response = authenticated_client.post( - f"/config-folders/{folder_id}/overrides", - json={ - "project_id": project_id, - "mount_path": "/workspace", - "files": {"project.txt": "project"}, - }, - ) - assert response.status_code == 200 - data = response.json() - assert project_id in data["project_overrides"] - - def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None: - """Test updating a project override.""" - # Create folder with override - create_response = authenticated_client.post( - "/config-folders", - json={ - "name": "update-override-test", - "mount_path": "/home/user", - "files": {}, - }, - ) - folder_id = create_response.json()["id"] - project_id = str(uuid.uuid4()) - - # Add override - authenticated_client.post( - f"/config-folders/{folder_id}/overrides", - json={ - "project_id": project_id, - "mount_path": "/workspace", - "files": {"old.txt": "old"}, - }, - ) - - # Update override - response = authenticated_client.put( - f"/config-folders/{folder_id}/overrides/{project_id}", - json={ - "mount_path": "/app", - "files": {"new.txt": "new"}, - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["project_overrides"][project_id]["mount_path"] == "/app" - - def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None: - """Test deleting a project override.""" - # Create folder with override - create_response = authenticated_client.post( - "/config-folders", - json={ - "name": "delete-override-test", - "mount_path": "/home/user", - "files": {}, - }, - ) - folder_id = create_response.json()["id"] - project_id = str(uuid.uuid4()) - - # Add override - authenticated_client.post( - f"/config-folders/{folder_id}/overrides", - json={ - "project_id": project_id, - "mount_path": "/workspace", - "files": {}, - }, - ) - - # Delete override - response = authenticated_client.delete( - f"/config-folders/{folder_id}/overrides/{project_id}" - ) - assert response.status_code == 200 - data = response.json() - assert project_id not in data["project_overrides"] diff --git a/apps/api/tests/integration/test_tool_configs_api_extended.py b/apps/api/tests/integration/test_tool_configs_api_extended.py deleted file mode 100644 index 1f2e4f1..0000000 --- a/apps/api/tests/integration/test_tool_configs_api_extended.py +++ /dev/null @@ -1,255 +0,0 @@ -import pytest -from fastapi.testclient import TestClient - - -@pytest.mark.integration -class TestToolConfigsAPIExtended: - """Integration tests for tool configs API with new fields.""" - - def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None: - """Test creating a tool config with all new fields.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "config-test-tool", - "display_name": "Config Test Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", - "required_variables": [], - }, - ) - tool_id = tool_response.json()["id"] - - # Create config with new fields - response = authenticated_client.post( - "/tool-configs", - json={ - "tool_type_id": tool_id, - "key": "ADVANCED_CONFIG", - "value": "test-value", - "config_type": "env", - "port_override": 9090, - "start_command": "python app.py", - "working_directory": "/app", - "environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"}, - "volumes": [ - {"source": "data", "target": "/data", "type": "bind"} - ], - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["key"] == "ADVANCED_CONFIG" - assert data["port_override"] == 9090 - assert data["start_command"] == "python app.py" - assert data["working_directory"] == "/app" - assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"} - assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}] - - def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None: - """Test that invalid port numbers are rejected.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "port-test-tool", - "display_name": "Port Test Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", - "required_variables": [], - }, - ) - tool_id = tool_response.json()["id"] - - # Try to create config with invalid port - response = authenticated_client.post( - "/tool-configs", - json={ - "tool_type_id": tool_id, - "key": "BAD_PORT", - "value": "test", - "config_type": "env", - "port_override": 99999, - }, - ) - assert response.status_code == 422 - - def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None: - """Test that invalid volume structures are rejected.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "volume-test-tool", - "display_name": "Volume Test Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", - "required_variables": [], - }, - ) - tool_id = tool_response.json()["id"] - - # Try to create config with invalid volume - response = authenticated_client.post( - "/tool-configs", - json={ - "tool_type_id": tool_id, - "key": "BAD_VOLUME", - "value": "test", - "config_type": "env", - "volumes": [{"invalid": "structure"}], - }, - ) - assert response.status_code == 422 - - def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None: - """Test updating a tool config with new fields.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "update-config-tool", - "display_name": "Update Config Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", - "required_variables": [], - }, - ) - tool_id = tool_response.json()["id"] - - # Create config - create_response = authenticated_client.post( - "/tool-configs", - json={ - "tool_type_id": tool_id, - "key": "UPDATE_TEST", - "value": "original", - "config_type": "env", - }, - ) - config_id = create_response.json()["id"] - - # Update with new fields - response = authenticated_client.put( - f"/tool-configs/{config_id}", - json={ - "value": "updated", - "port_override": 3000, - "start_command": "npm start", - "working_directory": "/workspace", - "environment_variables": {"NODE_ENV": "production"}, - "volumes": [{"source": "src", "target": "/app/src", "type": "bind"}], - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["value"] == "updated" - assert data["port_override"] == 3000 - assert data["start_command"] == "npm start" - assert data["working_directory"] == "/workspace" - assert data["environment_variables"] == {"NODE_ENV": "production"} - - def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None: - """Test that listing configs returns new fields.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "list-config-tool", - "display_name": "List Config Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", - "required_variables": [], - }, - ) - tool_id = tool_response.json()["id"] - - # Create config with new fields - authenticated_client.post( - "/tool-configs", - json={ - "tool_type_id": tool_id, - "key": "LIST_TEST", - "value": "test", - "config_type": "env", - "port_override": 5000, - "environment_variables": {"TEST": "true"}, - }, - ) - - # List configs - response = authenticated_client.get("/tool-configs") - assert response.status_code == 200 - data = response.json() - assert len(data) > 0 - config = data[0] - assert "port_override" in config - assert "start_command" in config - assert "working_directory" in config - assert "environment_variables" in config - assert "volumes" in config - - def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None: - """Test getting tool config defaults.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "defaults-tool", - "display_name": "Defaults Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n", - "required_variables": ["REPO_PATH"], - }, - ) - tool_id = tool_response.json()["id"] - - # Get defaults - response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}") - assert response.status_code == 200 - data = response.json() - assert data["tool_type_id"] == tool_id - assert "suggested_configs" in data - - def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None: - """Test that old configs without new fields still work.""" - # Create a tool type first - tool_response = authenticated_client.post( - "/tool-types", - json={ - "name": "backward-compat-tool", - "display_name": "Backward Compat Tool", - "default_port": 8080, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", - "required_variables": [], - }, - ) - tool_id = tool_response.json()["id"] - - # Create config without new fields (simulating old client) - response = authenticated_client.post( - "/tool-configs", - json={ - "tool_type_id": tool_id, - "key": "OLD_STYLE", - "value": "value", - "config_type": "env", - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["key"] == "OLD_STYLE" - # New fields should have default values - assert data["port_override"] is None - assert data["start_command"] is None - assert data["working_directory"] is None - assert data["environment_variables"] is None - assert data["volumes"] is None diff --git a/apps/api/tests/unit/test_manifest_compiler.py b/apps/api/tests/unit/test_manifest_compiler.py index 12600ec..f95df02 100644 --- a/apps/api/tests/unit/test_manifest_compiler.py +++ b/apps/api/tests/unit/test_manifest_compiler.py @@ -292,24 +292,41 @@ class TestComputeImageTag: class TestMergeWithConfig: - """Tests for merge_with_config.""" + """Tests for merge_with_config (ConfigProfile only).""" - def test_applies_tool_config_env(self) -> None: + def test_no_profile_returns_manifest_unchanged(self) -> None: manifest = {"name": "test"} - configs = [ - {"config_type": "env", "key": "FOO", "value": "bar"}, - ] - result = merge_with_config(manifest, configs) + result = merge_with_config(manifest) + assert result["name"] == "test" + assert result["_extra_env"] == {} + assert result["_extra_volumes"] == [] + + def test_profile_env_vars(self) -> None: + manifest = {"name": "test"} + profile = {"environment_variables": {"FOO": "bar"}} + result = merge_with_config(manifest, profile) assert result["_extra_env"]["FOO"] == "bar" - def test_applies_port_override(self) -> None: + def test_profile_mounts(self) -> None: + manifest = {"name": "test"} + profile = {"mounts": [{"source": "/host", "target": "/container"}]} + result = merge_with_config(manifest, profile) + assert len(result["_extra_volumes"]) == 1 + + def test_profile_port_override(self) -> None: manifest = {"name": "test", "default_port": 8080} - configs = [{"port_override": 3000}] - result = merge_with_config(manifest, configs) + profile = {"hints": {"port_override": 3000}} + result = merge_with_config(manifest, profile) assert result["default_port"] == 3000 - def test_applies_start_command(self) -> None: + def test_profile_start_command(self) -> None: manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}} - configs = [{"start_command": "/bin/sh"}] - result = merge_with_config(manifest, configs) + profile = {"hints": {"start_command": "/bin/sh"}} + result = merge_with_config(manifest, profile) assert result["runtime"]["command"] == ["/bin/sh"] + + def test_profile_working_directory(self) -> None: + manifest = {"name": "test"} + profile = {"hints": {"working_directory": "/workspace"}} + result = merge_with_config(manifest, profile) + assert result["runtime"]["working_dir"] == "/workspace" 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 9f32449..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 0fd7245..0000000 --- a/apps/web/src/api/config_folders.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { apiClient } from "./client"; - -export interface ConfigFolder { - id: string; - user_id: string; - name: string; - description: string | null; - mount_path: string; - files: Record; - project_overrides: Record }> | null; - is_active: boolean; - created_at: string; - updated_at: string; -} - -export interface CreateConfigFolderRequest { - name: string; - description?: string; - mount_path: string; - files?: Record; - is_active?: boolean; -} - -export interface UpdateConfigFolderRequest { - name?: string; - description?: string; - mount_path?: string; - files?: Record; - is_active?: boolean; -} - -export interface ProjectOverrideRequest { - mount_path?: string; - files?: Record; -} - -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 5d1d2c7..0000000 --- a/apps/web/src/api/tool_configs.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { apiClient } from "./client"; - -export interface ToolConfig { - id: string; - tool_type_id: string; - project_id: string | null; - key: string; - value: string; - config_type: string; - file_path: string | null; - port_override: number | null; - start_command: string | null; - working_directory: string | null; - environment_variables: Record | null; - volumes: Array<{ source: string; target: string; type?: string }> | null; -} - -export interface CreateToolConfigRequest { - tool_type_id: string; - project_id?: string; - key: string; - value: string; - config_type?: string; - file_path?: string; - port_override?: number; - start_command?: string; - working_directory?: string; - environment_variables?: Record; - volumes?: Array<{ source: string; target: string; type?: string }>; -} - -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; -}; \ No newline at end of file diff --git a/apps/web/src/pages/tool-workshop.test.tsx b/apps/web/src/pages/tool-workshop.test.tsx deleted file mode 100644 index e0937f0..0000000 --- a/apps/web/src/pages/tool-workshop.test.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { ToolWorkshopPage } from "./tool-workshop"; -import * as toolTypesApi from "../api/tool_types"; -import * as toolConfigsApi from "../api/tool_configs"; -import * as configFoldersApi from "../api/config_folders"; - -const mockToolTypes = [ - { - id: "type-1", - name: "code-server", - display_name: "VS Code Server", - description: "VS Code in browser", - category: "editor", - interface_type: "web", - requires_port: true, - default_port: 8443, - definition_type: "compose", - compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", - dockerfile_template: null, - build_context: null, - readiness_probe: null, - required_variables: ["REPO_PATH"], - is_builtin: true, - created_by_id: null, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - { - id: "type-2", - name: "custom-tool", - display_name: "Custom Tool", - description: "My custom tool", - category: "utility", - interface_type: "terminal", - requires_port: false, - default_port: 8080, - definition_type: "dockerfile", - compose_template: null, - dockerfile_template: "FROM python:3.11", - build_context: null, - readiness_probe: { - command: "python --version", - timeout: 30, - interval: 2, - }, - required_variables: [], - is_builtin: false, - created_by_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, -]; - -const mockConfigs = [ - { - id: "config-1", - tool_type_id: "type-1", - project_id: null, - key: "OPENAI_API_KEY", - value: "sk-test123", - config_type: "env", - file_path: null, - port_override: null, - start_command: null, - working_directory: null, - environment_variables: {}, - volumes: [], - }, - { - id: "config-2", - tool_type_id: "type-2", - project_id: null, - key: "advanced-config", - value: "test-value", - config_type: "env", - file_path: null, - port_override: 9090, - start_command: "python app.py", - working_directory: "/app", - environment_variables: { DEBUG: "true" }, - volumes: [{ source: "data", target: "/data", type: "bind" }], - }, -]; - -const mockFolders = [ - { - id: "folder-1", - user_id: "user-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, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - { - id: "folder-2", - user_id: "user-1", - name: "project-configs", - description: "Project specific configs", - mount_path: "/workspace", - files: { ".env": "API_URL=http://localhost:8080" }, - project_overrides: { - "proj-1": { - mount_path: "/app", - files: { ".env": "API_URL=http://prod.api" }, - }, - }, - is_active: false, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, -]; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -describe("ToolWorkshopPage", () => { - it("renders loading state initially", () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {})); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {})); - vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {})); - - render(); - expect(screen.getByText(/loading/i)).toBeInTheDocument(); - }); - - it("renders tool types tab by default", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - expect(screen.getByText("Custom Tool")).toBeInTheDocument(); - }); - - it("switches to configs tab", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("VS Code Server")); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /configs/i })); - - await waitFor(() => { - expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /add config/i })); - - expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument(); - expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument(); - }); - - it("creates config with advanced fields", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("VS Code Server")); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /configs/i })); - - await waitFor(() => { - expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /add config/i })); - - fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), { - target: { value: "MY_CONFIG" }, - }); - fireEvent.change(screen.getByPlaceholderText(/Enter value/i), { - target: { value: "my-value" }, - }); - fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), { - target: { value: "9090" }, - }); - fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), { - target: { value: "python app.py" }, - }); - - fireEvent.click(screen.getByRole("button", { name: /add$/i })); - - await waitFor(() => { - expect(createMock).toHaveBeenCalledWith( - expect.objectContaining({ - key: "MY_CONFIG", - value: "my-value", - port_override: 9090, - start_command: "python app.py", - }) - ); - }); - expect(configsListMock).toHaveBeenCalledTimes(2); - }); - - it("opens folder creation form", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("VS Code Server")); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /folders/i })); - - await waitFor(() => { - expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /create folder/i })); - - expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument(); - }); - - it("creates config folder successfully", async () => { - const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder); - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("VS Code Server")); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /folders/i })); - - await waitFor(() => { - expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /create folder/i })); - - fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), { - target: { value: "new-folder" }, - }); - fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), { - target: { value: "/home/dev" }, - }); - - fireEvent.click(screen.getByRole("button", { name: /create$/i })); - - await waitFor(() => { - expect(createMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "new-folder", - mount_path: "/home/dev", - }) - ); - }); - expect(foldersListMock).toHaveBeenCalledTimes(2); - }); - - it("shows folder active/inactive status", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("VS Code Server")); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /folders/i })); - - await waitFor(() => { - expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); - }); - - // Check that active folder shows Active badge - expect(screen.getByText("Active")).toBeInTheDocument(); - }); - - it("handles error state gracefully", async () => { - vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error")); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error")); - vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error")); - - render(); - - await waitFor(() => { - expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); - }); - - expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); - }); - - it("retries loading after error", async () => { - const listMock = vi.spyOn(toolTypesApi, "listToolTypes") - .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]); - vi.spyOn(toolConfigsApi, "listToolConfigs") - .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders") - .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole("button", { name: /retry/i })); - - await waitFor(() => { - expect(screen.getByText("VS Code Server")).toBeInTheDocument(); - }); - expect(listMock).toHaveBeenCalledTimes(2); - }); - - it("deletes tool type successfully", async () => { - const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); - const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined); - vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); - vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); - - render(); - - await waitFor(() => { - expect(screen.getByText("Custom Tool")).toBeInTheDocument(); - }); - - // Find and click delete button for custom tool (not built-in) - const customToolCard = screen.getByText("Custom Tool").closest(".card") || - screen.getByText("Custom Tool").parentElement; - if (customToolCard) { - const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i }); - if (deleteButton) { - vi.spyOn(window, "confirm").mockReturnValue(true); - fireEvent.click(deleteButton); - - await waitFor(() => { - expect(deleteMock).toHaveBeenCalledWith("type-2"); - }); - expect(listMock).toHaveBeenCalledTimes(2); - } - } - }); -}); diff --git a/apps/web/src/pages/tool-workshop.tsx b/apps/web/src/pages/tool-workshop.tsx index 9ed11b6..57003f0 100644 --- a/apps/web/src/pages/tool-workshop.tsx +++ b/apps/web/src/pages/tool-workshop.tsx @@ -26,25 +26,6 @@ import { type ToolDefinitionManifest, } from "../api/tool_definitions"; import { ManifestEditor } from "../components/manifest-editor"; -import { - createToolConfig, - deleteToolConfig, - listToolConfigs, - updateToolConfig, - type CreateToolConfigRequest, - type ToolConfig, -} from "../api/tool_configs"; -import { - createConfigFolder, - deleteConfigFolder, - listConfigFolders, - updateConfigFolder, - type ConfigFolder, - type CreateConfigFolderRequest, - type UpdateConfigFolderRequest, -} from "../api/config_folders"; - -type RightPanelTab = "details" | "configs" | "folders"; type Status = "loading" | "ready" | "error"; type MobileView = "list" | "detail" | "edit"; @@ -54,8 +35,6 @@ export const ToolWorkshopPage = () => { const [mobileView, setMobileView] = useState("list"); const [status, setStatus] = useState("loading"); const [toolTypes, setToolTypes] = useState([]); - const [configs, setConfigs] = useState([]); - const [folders, setFolders] = useState([]); const [baseDefinitions, setBaseDefinitions] = useState< ToolDefinitionManifest[] >([]); @@ -72,7 +51,6 @@ export const ToolWorkshopPage = () => { null, ); const [isCreating, setIsCreating] = useState(false); - const [rightPanelTab, setRightPanelTab] = useState("details"); // Tool Type form state const [toolTypeForm, setToolTypeForm] = useState({ @@ -95,55 +73,18 @@ export const ToolWorkshopPage = () => { const [toolTypeError, setToolTypeError] = useState(null); const [toolTypeDirty, setToolTypeDirty] = useState(false); - // Config form state - const [configForm, setConfigForm] = useState({ - key: "", - value: "", - config_type: "env", - file_path: "", - port_override: "", - start_command: "", - working_directory: "", - env_vars_json: "{}", - volumes_json: "[]", - }); - const [configError, setConfigError] = useState(null); - const [showConfigForm, setShowConfigForm] = useState(false); - const [selectedConfig, setSelectedConfig] = useState(null); - - // Folder form state - const [folderForm, setFolderForm] = useState({ - name: "", - description: "", - mount_path: "/home/user", - files_json: "{}", - is_active: true, - }); - const [folderError, setFolderError] = useState(null); - const [showFolderForm, setShowFolderForm] = useState(false); - const [selectedFolder, setSelectedFolder] = useState( - null, - ); const selectedToolType = (toolTypes || []).find((t) => t.id === selectedToolTypeId) || null; - const toolConfigs = (configs || []).filter( - (c) => c.tool_type_id === selectedToolTypeId, - ); - const toolFolders = folders || []; // Config folders are global, not per-tool-type in current API const loadData = useCallback(async () => { setStatus("loading"); try { - const [types, cfgs, fldrs, defs] = await Promise.all([ + const [types, defs] = await Promise.all([ listToolTypes(), - listToolConfigs(), - listConfigFolders(), listToolDefinitions(), ]); setToolTypes(types || []); - setConfigs(cfgs || []); - setFolders(fldrs || []); setBaseDefinitions((defs || []).filter((d) => d.is_base)); setStatus("ready"); } catch { @@ -219,9 +160,6 @@ export const ToolWorkshopPage = () => { } else { setSelectedToolTypeId(null); } - setRightPanelTab("details"); - setShowConfigForm(false); - setShowFolderForm(false); }; const handleCreateNew = () => { @@ -233,9 +171,6 @@ export const ToolWorkshopPage = () => { setSelectedToolTypeId(null); setIsCreating(true); resetToolTypeForm(); - setRightPanelTab("details"); - setShowConfigForm(false); - setShowFolderForm(false); }; const handleToolTypeSubmit = async (e: React.FormEvent) => { @@ -366,213 +301,6 @@ export const ToolWorkshopPage = () => { } }; - // Config handlers - const resetConfigForm = () => { - setConfigForm({ - key: "", - value: "", - config_type: "env", - file_path: "", - port_override: "", - start_command: "", - working_directory: "", - env_vars_json: "{}", - volumes_json: "[]", - }); - setConfigError(null); - setSelectedConfig(null); - }; - - const openCreateConfig = () => { - resetConfigForm(); - setShowConfigForm(true); - }; - - const openEditConfig = (config: ToolConfig) => { - setConfigForm({ - key: config.key, - value: config.value, - config_type: config.config_type, - file_path: config.file_path || "", - port_override: config.port_override?.toString() || "", - start_command: config.start_command || "", - working_directory: config.working_directory || "", - env_vars_json: config.environment_variables - ? JSON.stringify(config.environment_variables, null, 2) - : "{}", - volumes_json: config.volumes - ? JSON.stringify(config.volumes, null, 2) - : "[]", - }); - setConfigError(null); - setShowConfigForm(true); - setSelectedConfig(config); - }; - - const handleConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setConfigError(null); - - if (!selectedToolTypeId || !configForm.key.trim()) { - setConfigError("Tool type and key are required"); - return; - } - - let envVars: Record | undefined; - let volumes: - | Array<{ source: string; target: string; type?: string }> - | undefined; - - try { - if ( - configForm.env_vars_json.trim() && - configForm.env_vars_json.trim() !== "{}" - ) { - envVars = JSON.parse(configForm.env_vars_json); - } - } catch { - setConfigError("Environment variables must be valid JSON"); - return; - } - - try { - if ( - configForm.volumes_json.trim() && - configForm.volumes_json.trim() !== "[]" - ) { - volumes = JSON.parse(configForm.volumes_json); - } - } catch { - setConfigError("Volumes must be valid JSON array"); - return; - } - - const data: CreateToolConfigRequest = { - tool_type_id: selectedToolTypeId, - key: configForm.key.trim(), - value: configForm.value, - config_type: configForm.config_type, - file_path: - configForm.config_type === "file" ? configForm.file_path : undefined, - port_override: configForm.port_override - ? Number(configForm.port_override) - : undefined, - start_command: configForm.start_command.trim() || undefined, - working_directory: configForm.working_directory.trim() || undefined, - environment_variables: envVars, - volumes, - }; - - try { - if (selectedConfig) { - await updateToolConfig(selectedConfig.id, data); - } else { - await createToolConfig(data); - } - setShowConfigForm(false); - setSelectedConfig(null); - resetConfigForm(); - await loadData(); - } catch (err) { - setConfigError(extractErrorMessage(err)); - } - }; - - const handleDeleteConfig = async (id: string) => { - if (!window.confirm("Delete this config?")) return; - try { - await deleteToolConfig(id); - await loadData(); - } catch { - alert("Failed to delete config"); - } - }; - - // Folder handlers - const resetFolderForm = () => { - setFolderForm({ - name: "", - description: "", - mount_path: "/home/user", - files_json: "{}", - is_active: true, - }); - setFolderError(null); - setSelectedFolder(null); - }; - - const openCreateFolder = () => { - resetFolderForm(); - setShowFolderForm(true); - }; - - const openEditFolder = (folder: ConfigFolder) => { - setFolderForm({ - name: folder.name, - description: folder.description || "", - mount_path: folder.mount_path, - files_json: JSON.stringify(folder.files, null, 2), - is_active: folder.is_active, - }); - setFolderError(null); - setShowFolderForm(true); - setSelectedFolder(folder); - }; - - const handleFolderSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setFolderError(null); - - if (!folderForm.name.trim() || !folderForm.mount_path.trim()) { - setFolderError("Name and mount path are required"); - return; - } - - let files: Record | undefined; - try { - if ( - folderForm.files_json.trim() && - folderForm.files_json.trim() !== "{}" - ) { - files = JSON.parse(folderForm.files_json); - } - } catch { - setFolderError("Files must be valid JSON object"); - return; - } - - const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = { - name: folderForm.name.trim(), - description: folderForm.description.trim() || undefined, - mount_path: folderForm.mount_path.trim(), - files, - is_active: folderForm.is_active, - }; - - try { - if (selectedFolder) { - await updateConfigFolder(selectedFolder.id, data); - } else { - await createConfigFolder(data as CreateConfigFolderRequest); - } - setShowFolderForm(false); - setSelectedFolder(null); - resetFolderForm(); - await loadData(); - } catch (err) { - setFolderError(extractErrorMessage(err)); - } - }; - - const handleDeleteFolder = async (id: string) => { - if (!window.confirm("Delete this config folder?")) return; - try { - await deleteConfigFolder(id); - await loadData(); - } catch { - alert("Failed to delete folder"); - } - }; if (status === "loading") { return ( @@ -1143,45 +871,7 @@ export const ToolWorkshopPage = () => { )} - {/* Right Panel Tabs */} -
- {(["details", "configs", "folders"] as RightPanelTab[]).map( - (tab) => ( - - ), - )} -
- - {/* Details Tab */} - {rightPanelTab === "details" && ( + {(
{
)} - {/* Configs Tab */} - {rightPanelTab === "configs" && selectedToolTypeId && ( -
-
-

- Configurations for {selectedToolType?.display_name} -

- -
- - {showConfigForm && ( -
-

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

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