diff --git a/apps/api/alembic/versions/0015_single_interface.py b/apps/api/alembic/versions/0015_single_interface.py new file mode 100644 index 0000000..64e0249 --- /dev/null +++ b/apps/api/alembic/versions/0015_single_interface.py @@ -0,0 +1,110 @@ +"""replace interfaces with interface_type and add requires_port + +Revision ID: 0015_single_interface +Revises: 0014_merge_heads +Create Date: 2026-05-22 22:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision: str = "0015_single_interface" +down_revision: Union[str, Sequence[str], None] = "0014_merge_heads" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _get_dialect() -> str: + """Get the current database dialect name.""" + conn = op.get_bind() + return conn.dialect.name + + +def upgrade() -> None: + dialect = _get_dialect() + + # Add new columns + op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True)) + op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true')) + + # Migrate data: take first element from interfaces JSON array + if dialect == 'postgresql': + op.execute(""" + UPDATE tool_types + SET interface_type = COALESCE( + (SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1), + 'web' + ), + requires_port = CASE + WHEN COALESCE( + (SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1), + 'web' + ) = 'web' THEN true + ELSE false + END + """) + else: + # SQLite: interfaces is stored as JSON text, extract first array element + op.execute(""" + UPDATE tool_types + SET interface_type = COALESCE( + (SELECT json_extract(value, '$[0]') + FROM json_each(interfaces) AS value + WHERE json_valid(interfaces) + LIMIT 1), + 'web' + ), + requires_port = CASE + WHEN COALESCE( + (SELECT json_extract(value, '$[0]') + FROM json_each(interfaces) AS value + WHERE json_valid(interfaces) + LIMIT 1), + 'web' + ) = 'web' THEN true + ELSE false + END + """) + + # Make interface_type non-nullable after data migration + op.alter_column('tool_types', 'interface_type', nullable=False) + + # Drop old interfaces column + op.drop_column('tool_types', 'interfaces') + + # Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too) + op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')")) + + +def downgrade() -> None: + dialect = _get_dialect() + + # Drop CHECK constraint + op.drop_constraint('chk_interface_type', 'tool_types', type_='check') + + # Add back interfaces column + if dialect == 'postgresql': + op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]')) + + # Migrate data back: wrap interface_type in array + op.execute(""" + UPDATE tool_types + SET interfaces = jsonb_build_array(interface_type) + """) + else: + op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]')) + + # Migrate data back: wrap interface_type in array for SQLite + op.execute(""" + UPDATE tool_types + SET interfaces = json_array(interface_type) + """) + + # Drop new columns + op.drop_column('tool_types', 'requires_port') + op.drop_column('tool_types', 'interface_type') diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index a41efdc..859c87f 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -390,7 +390,7 @@ async def list_instances( "display_name": i.display_name, "tool_type_id": str(i.tool_type_id), "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_type_interfaces": tool_type.interfaces if tool_type else [], + "tool_type_interface_type": tool_type.interface_type if tool_type else "", "status": i.status, "url": i.url, "port": i.port, @@ -684,7 +684,7 @@ async def start_instance( probe_command = probe_config.get("command", "") probe_timeout = probe_config.get("timeout", 30) probe_interval = probe_config.get("interval", 2) - elif "web" in (tool_type.interfaces or []): + elif tool_type.interface_type == "web": # Default probe for web tools probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" probe_timeout = 30 @@ -747,11 +747,11 @@ async def start_instance( } instance_port = tool_type.default_port - logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s", - instance.id, tool_type.name, instance_port, tool_type.interfaces) + logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", + instance.id, tool_type.name, instance_port, tool_type.interface_type) # Only create Cloudflare tunnel for web-enabled tools - if "web" in tool_type.interfaces: + if tool_type.interface_type == "web": # Create temporary Cloudflare tunnel for public access try: logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)", @@ -916,7 +916,7 @@ async def restart_instance( instance_port = tool_type.default_port # Only create tunnel for web-enabled tools - if "web" in tool_type.interfaces: + if tool_type.interface_type == "web": # Create new temporary tunnel try: tunnel_info = start_cloudflared_tunnel( @@ -1404,7 +1404,7 @@ async def get_user_sessions( "display_name": instance.display_name, "tool_type_name": tool_type.name if tool_type else "unknown", "tool_icon": tool_type.name if tool_type else "code", - "tool_type_interfaces": tool_type.interfaces if tool_type else [], + "tool_type_interface_type": tool_type.interface_type if tool_type else "", "repository_name": repo.name if repo else "unknown", "repository_id": str(instance.repository_id), "project_name": project.name if project else "unknown", diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index d50405c..113c353 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -45,7 +45,8 @@ class ToolTypeCreate(BaseModel): readiness_probe: dict | None = None required_variables: list[str] = [] category: str = "other" - interfaces: list[str] = ["web"] + interface_type: str = "web" + requires_port: bool = True @field_validator("definition_type") @classmethod @@ -95,9 +96,20 @@ class ToolTypeCreate(BaseModel): return v + @field_validator("interface_type") + @classmethod + def validate_interface_type(cls, v: str) -> str: + if v not in ("web", "terminal"): + raise ValueError("interface_type must be 'web' or 'terminal'") + return v + @field_validator("default_port") @classmethod - def validate_default_port(cls, v: int) -> int: + def validate_default_port(cls, v: int, info) -> int: + data = info.data + requires_port = data.get("requires_port", True) + if not requires_port: + return v if v <= 0 or v > 65535: raise ValueError("Port must be between 1 and 65535") return v @@ -130,8 +142,8 @@ class ToolTypeCreate(BaseModel): if self.definition_type == "compose" and self.compose_template is None: raise ValueError("compose_template is required when definition_type is 'compose'") - # Validate that default_port is exposed in compose template - if self.definition_type == "compose" and self.compose_template: + # Validate that default_port is exposed in compose template (only if requires_port) + if self.requires_port and self.definition_type == "compose" and self.compose_template: try: parsed = yaml.safe_load(self.compose_template) except yaml.YAMLError: @@ -171,7 +183,8 @@ class ToolTypeUpdate(BaseModel): readiness_probe: dict | None = None required_variables: list[str] | None = None category: str | None = None - interfaces: list[str] | None = None + interface_type: str | None = None + requires_port: bool | None = None @field_validator("definition_type") @classmethod @@ -182,6 +195,15 @@ class ToolTypeUpdate(BaseModel): raise ValueError("definition_type must be 'compose' or 'dockerfile'") return v + @field_validator("interface_type") + @classmethod + def validate_interface_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("web", "terminal"): + raise ValueError("interface_type must be 'web' or 'terminal'") + return v + @field_validator("compose_template") @classmethod def validate_compose_template(cls, v: str | None, info) -> str | None: @@ -234,7 +256,8 @@ class ToolTypeResponse(BaseModel): display_name: str description: str | None category: str - interfaces: list[str] + interface_type: str + requires_port: bool default_port: int definition_type: str compose_template: str | None @@ -290,7 +313,8 @@ async def create_tool_type( readiness_probe=data.readiness_probe, required_variables=data.required_variables, category=data.category, - interfaces=data.interfaces, + interface_type=data.interface_type, + requires_port=data.requires_port, is_builtin=False, created_by_id=user.id, ) @@ -388,7 +412,8 @@ async def update_tool_type( update_data = data.model_dump(exclude_unset=True) # Validate port if being updated - if "default_port" in update_data: + requires_port = update_data.get("requires_port", tool_type.requires_port) + if "default_port" in update_data and requires_port: new_port = update_data["default_port"] if new_port <= 0 or new_port > 65535: raise HTTPException( diff --git a/apps/api/src/main.py b/apps/api/src/main.py index aabb3f2..9659e5d 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -137,7 +137,8 @@ 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", + "requires_port": True, "compose_template": """version: "3.8" services: code-server: @@ -160,7 +161,8 @@ services: "display_name": "Jupyter Notebook", "description": "Jupyter Lab for interactive development", "category": "notebook", - "interfaces": ["web"], + "interface_type": "web", + "requires_port": True, "default_port": 8888, "compose_template": """version: "3.8" services: @@ -181,7 +183,8 @@ services: "display_name": "OpenCode", "description": "AI coding assistant - run opencode in terminal", "category": "ai-assistant", - "interfaces": ["terminal"], + "interface_type": "terminal", + "requires_port": False, "default_port": 3000, "compose_template": """version: "3.8" services: @@ -227,7 +230,8 @@ volumes: display_name=tool_data["display_name"], description=tool_data["description"], category=tool_data["category"], - interfaces=tool_data["interfaces"], + interface_type=tool_data["interface_type"], + requires_port=tool_data["requires_port"], definition_type="compose", compose_template=tool_data["compose_template"], required_variables=tool_data["required_variables"], @@ -241,7 +245,8 @@ 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.requires_port = tool_data["requires_port"] existing.definition_type = "compose" existing.compose_template = tool_data["compose_template"] existing.required_variables = tool_data["required_variables"] diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index 57e41ec..a8ae655 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): display_name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") - interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web") + requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) default_port: Mapped[int] = mapped_column(nullable=False) definition_type: Mapped[str] = mapped_column( String(20), nullable=False, default="compose" diff --git a/apps/web/src/api/tool_types.ts b/apps/web/src/api/tool_types.ts index b566033..599b646 100644 --- a/apps/web/src/api/tool_types.ts +++ b/apps/web/src/api/tool_types.ts @@ -12,7 +12,8 @@ export interface ToolType { display_name: string; description: string | null; category: string; - interfaces: string[]; + interface_type: string; + requires_port: boolean; default_port: number | null; definition_type: 'compose' | 'dockerfile'; compose_template: string | null; @@ -31,7 +32,8 @@ export interface CreateToolTypeRequest { display_name: string; description?: string; category?: string; - interfaces?: string[]; + interface_type?: string; + requires_port?: boolean; default_port: number; definition_type?: 'compose' | 'dockerfile'; compose_template?: string; @@ -45,7 +47,8 @@ export interface UpdateToolTypeRequest { display_name?: string; description?: string; category?: string; - interfaces?: string[]; + interface_type?: string; + requires_port?: boolean; default_port?: number; definition_type?: 'compose' | 'dockerfile'; compose_template?: string; diff --git a/apps/web/src/components/git-toolbar.tsx b/apps/web/src/components/git-toolbar.tsx index 31a8f07..04556c3 100644 --- a/apps/web/src/components/git-toolbar.tsx +++ b/apps/web/src/components/git-toolbar.tsx @@ -18,6 +18,7 @@ interface GitToolbarProps { currentBranch: string; branches: string[]; hasRemote: boolean; + isMirror: boolean; onBranchChange: (branch: string) => void; onRefresh: () => void; } @@ -28,6 +29,7 @@ export const GitToolbar = ({ currentBranch, branches, hasRemote, + isMirror, onBranchChange, onRefresh, }: GitToolbarProps) => { @@ -136,7 +138,13 @@ export const GitToolbar = ({ return (
- Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")} + Category: {selectedTool.category} · Interface: {selectedTool.interface_type}
)}{toolType.description || "No description"}
+ {toolTypes.length} tool type{toolTypes.length !== 1 ? "s" : ""} +
+Choose a tool from the list to edit, or create a new one.
+ {selectedToolType?.name} · {selectedToolType?.definition_type} · {selectedToolType?.interface_type === "web" ? `Port ${selectedToolType?.default_port}` : "Terminal"} +
+ )} +