diff --git a/apps/api/alembic/versions/2026_05_22_add_clone_mode.py b/apps/api/alembic/versions/2026_05_22_add_clone_mode.py new file mode 100644 index 0000000..eca0b6d --- /dev/null +++ b/apps/api/alembic/versions/2026_05_22_add_clone_mode.py @@ -0,0 +1,36 @@ +"""add_clone_mode_and_ssh_key_id + +Revision ID: 2026_05_22_add_clone_mode +Revises: 0014_merge_heads +Create Date: 2026-05-22 20:30:00.000000 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2026_05_22_add_clone_mode' +down_revision = '0014_merge_heads' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ssh_key_id to git_repositories + op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True)) + op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id']) + + # Add clone_mode and branch to tool_instances + op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount')) + op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main')) + + +def downgrade() -> None: + # Drop columns from tool_instances + op.drop_column('tool_instances', 'branch') + op.drop_column('tool_instances', 'clone_mode') + + # Drop ssh_key_id from git_repositories + op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey') + op.drop_column('git_repositories', 'ssh_key_id') diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 8c2bb0e..e5045fb 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -14,6 +14,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session from src.config import Settings from src.models.git_repository import GitRepository from src.models.project import Project +from src.models.ssh_key import SSHKey from src.models.user import User from src.utils.git_files import ( commit_file, @@ -175,6 +176,7 @@ class GitRepositoryCreate(BaseModel): name: str remote_url: str | None = None force_original_url: bool = False + ssh_key_id: str | None = None class URLParseRequest(BaseModel): @@ -202,6 +204,7 @@ class GitRepositoryResponse(BaseModel): is_mirror: bool remote_url: str | None last_push: datetime | None + ssh_key_id: uuid.UUID | None created_at: datetime updated_at: datetime @@ -352,6 +355,20 @@ async def create_repository( if remote_url: _preflight_remote_repository(remote_url) + # Validate SSH key if provided + ssh_key_id = None + if data.ssh_key_id: + try: + ssh_key_id = uuid.UUID(data.ssh_key_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") + + ssh_key = await session.get(SSHKey, ssh_key_id) + if ssh_key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + if ssh_key.user_id != user_id and ssh_key.project_id != project_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") + repo_path = _get_repo_path(user_id, project_id, data.name) # Ensure parent directory exists @@ -369,6 +386,7 @@ async def create_repository( owner_id=user_id, is_mirror=False, remote_url=remote_url, + ssh_key_id=ssh_key_id, ) session.add(repo) await session.commit() @@ -376,6 +394,64 @@ async def create_repository( return repo +class UpdateSSHKeyRequest(BaseModel): + ssh_key_id: str | None = None + + +@router.patch( + "/{project_id}/repositories/{repo_id}/ssh-key", + response_model=GitRepositoryResponse, + summary="Update repository SSH key", + description="Update the SSH key associated with a repository.", +) +async def update_repository_ssh_key( + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: UpdateSSHKeyRequest, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> GitRepository: + """Update the SSH key for a repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: Update data containing the new SSH key ID. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated repository. + """ + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + # Validate SSH key if provided + if data.ssh_key_id: + try: + ssh_key_id = uuid.UUID(data.ssh_key_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") + + ssh_key = await session.get(SSHKey, ssh_key_id) + if ssh_key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + if ssh_key.user_id != user_id and ssh_key.project_id != project_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") + + repo.ssh_key_id = ssh_key_id + else: + repo.ssh_key_id = None + + await session.commit() + await session.refresh(repo) + return repo + + @router.get( "/{project_id}/repositories/{repo_id}/history", summary="Get repository history", diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index e8805b4..a41efdc 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id from src.auth.dependencies import get_db_session 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 @@ -43,8 +44,10 @@ from src.services.docker import ( write_env_file, write_config_folder_files, ) +from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory from src.services.docker_build import build_image from src.services.readiness_probe import execute_probe +from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files router = APIRouter(prefix="/projects", tags=["tool-instances"]) @@ -56,6 +59,8 @@ class CreateInstanceRequest(BaseModel): tool_type_id: str = Field(description="UUID of the tool type to instantiate") display_name: str | None = Field(default=None, description="Optional display name for the instance") + clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") + branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") def _modify_compose_file( @@ -193,6 +198,19 @@ async def create_instance( ) try: + # Validate clone mode requirements + if data.clone_mode == "clone": + if not repo.remote_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository does not have a remote URL for cloning" + ) + if not repo.ssh_key_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository must have an SSH key assigned for clone mode" + ) + # Generate unique name instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" @@ -204,6 +222,40 @@ async def create_instance( # Find free port tool_port = find_free_port() + # Determine repo path based on clone mode + if data.clone_mode == "clone": + # Get SSH key for cloning + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + if ssh_key is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="repository SSH key not found" + ) + + # Prepare SSH key for clone operation + ssh_key_path = None + try: + ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) + ssh_key_path = os.path.join(ssh_dir, "id_ed25519") + + # Clone repository + clone_path = clone_repository( + remote_url=repo.remote_url, + ssh_key_path=ssh_key_path, + instance_dir=instance_dir, + branch=data.branch or "main", + ) + repo_path = clone_path + except Exception as exc: + logger.exception("Failed to clone repository: %s", exc) + cleanup_ssh_key_files(instance_dir) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to clone repository: {exc}" + ) + else: + repo_path = repo.path + # Handle based on definition type if tool_type.definition_type == "dockerfile": # Build image from Dockerfile @@ -235,7 +287,7 @@ services: ports: - "{tool_port}:{tool_type.default_port}" volumes: - - {repo.path}:/workspace + - {repo_path}:/workspace restart: unless-stopped """ write_compose_file(instance_dir, compose_content) @@ -243,7 +295,7 @@ services: else: # Render compose template variables = { - "REPO_PATH": repo.path, + "REPO_PATH": repo_path, "INSTANCE_NAME": instance_name, "INSTANCE_ID": instance_name, "TOOL_NAME": instance_name, @@ -265,6 +317,8 @@ services: status="pending", compose_path=compose_path, port=tool_port, + clone_mode=data.clone_mode, + branch=data.branch if data.clone_mode == "clone" else None, ) session.add(instance) await session.commit() @@ -276,6 +330,8 @@ services: "display_name": instance.display_name, "tool_type_id": str(instance.tool_type_id), "status": instance.status, + "clone_mode": instance.clone_mode, + "branch": instance.branch, "created_at": instance.created_at.isoformat(), } except Exception as exc: @@ -338,6 +394,8 @@ async def list_instances( "status": i.status, "url": i.url, "port": i.port, + "clone_mode": i.clone_mode, + "branch": i.branch, "created_at": i.created_at.isoformat(), }) @@ -398,6 +456,8 @@ async def get_instance( "compose_path": instance.compose_path, "url": instance.url, "port": instance.port, + "clone_mode": instance.clone_mode, + "branch": instance.branch, "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, "created_at": instance.created_at.isoformat(), @@ -514,6 +574,23 @@ async def start_instance( extra_volumes.extend(folder_volumes) logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id) + # Mount SSH key for clone-mode instances + if instance.clone_mode == "clone": + repo = await session.get(GitRepository, instance.repository_id) + if repo and repo.ssh_key_id: + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + if ssh_key: + try: + ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) + extra_volumes.append({ + "source": ssh_dir, + "target": "/root/.ssh", + "type": "ro", + }) + logger.info("Mounted SSH key for clone-mode instance %s", instance.id) + except Exception as exc: + logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc) + # Modify compose file if needed (port override, start command, working dir, volumes) if port_override or start_command or working_directory or extra_volumes: _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) @@ -889,6 +966,7 @@ async def delete_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, + force: bool = False, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: @@ -913,6 +991,23 @@ async def delete_instance( status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" ) + # Check dirty state for clone-mode instances + if instance.clone_mode == "clone" and not force: + instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None + if instance_dir: + clone_path = os.path.join(instance_dir, "repo-clone") + if os.path.exists(clone_path): + is_dirty, changed_files = check_dirty_state(clone_path) + if is_dirty: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Repository has uncommitted changes", + "changed_files": changed_files, + "force_required": True, + }, + ) + # Stop Cloudflare tunnel if exists if instance.tunnel_id: try: @@ -925,7 +1020,7 @@ async def delete_instance( if instance.compose_path and os.path.exists(instance.compose_path): execute_compose_command(instance.compose_path, "down") - # Remove instance directory + # Remove instance directory (includes clone and SSH keys) if instance.compose_path: instance_dir = os.path.dirname(instance.compose_path) if os.path.exists(instance_dir): diff --git a/apps/api/src/models/git_repository.py b/apps/api/src/models/git_repository.py index 5e8aa74..69dfd8a 100644 --- a/apps/api/src/models/git_repository.py +++ b/apps/api/src/models/git_repository.py @@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from src.models.project import Project + from src.models.ssh_key import SSHKey from src.models.user import User @@ -23,6 +24,10 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base): is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ssh_key_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("ssh_keys.id"), nullable=True + ) project: Mapped["Project"] = relationship(back_populates="repositories") owner: Mapped["User"] = relationship() + ssh_key: Mapped["SSHKey | None"] = relationship() diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index 162144e..8715558 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -65,6 +65,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): probe_result: Mapped[dict | None] = mapped_column( JSON, nullable=True ) + clone_mode: Mapped[str] = mapped_column( + String(20), nullable=False, default="mount" + ) + branch: Mapped[str | None] = mapped_column( + String(255), nullable=True, default="main" + ) tool_type: Mapped["ToolType"] = relationship() repository: Mapped["GitRepository"] = relationship() diff --git a/apps/api/src/services/clone.py b/apps/api/src/services/clone.py new file mode 100644 index 0000000..743054b --- /dev/null +++ b/apps/api/src/services/clone.py @@ -0,0 +1,97 @@ +"""Clone service for repository cloning and dirty state checking.""" + +import logging +import os +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def clone_repository( + remote_url: str, + ssh_key_path: str | None, + instance_dir: str, + branch: str = "main", +) -> str: + """Clone a git repository into the instance directory. + + Args: + remote_url: Git remote URL (SSH or HTTPS) + ssh_key_path: Path to SSH private key for authentication (optional) + instance_dir: Path to instance directory + branch: Branch to clone (default: main) + + Returns: + Path to the cloned repository + """ + clone_path = Path(instance_dir) / "repo-clone" + clone_path.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + if ssh_key_path: + # Use SSH key for cloning + env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + cmd = [ + "git", + "clone", + "--branch", branch, + "--single-branch", + remote_url, + str(clone_path), + ] + + logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=env, + timeout=300, + ) + + if result.returncode != 0: + logger.error("Git clone failed: %s", result.stderr) + raise RuntimeError(f"Failed to clone repository: {result.stderr}") + + logger.info("Successfully cloned repository into %s", clone_path) + return str(clone_path) + + +def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]: + """Check for uncommitted changes in a cloned repository. + + Args: + clone_path: Path to the cloned repository + + Returns: + Tuple of (is_dirty, list_of_changed_files) + """ + result = subprocess.run( + ["git", "-C", clone_path, "status", "--short"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.warning("Failed to check git status: %s", result.stderr) + return False, [] + + changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()] + is_dirty = len(changed_files) > 0 + + return is_dirty, changed_files + + +def remove_clone_directory(instance_dir: str) -> None: + """Remove the cloned repository from the instance directory. + + Args: + instance_dir: Path to instance directory + """ + clone_path = Path(instance_dir) / "repo-clone" + if clone_path.exists(): + import shutil + shutil.rmtree(clone_path) + logger.info("Removed clone directory: %s", clone_path) diff --git a/apps/api/src/services/ssh_keys.py b/apps/api/src/services/ssh_keys.py new file mode 100644 index 0000000..6f05bdc --- /dev/null +++ b/apps/api/src/services/ssh_keys.py @@ -0,0 +1,73 @@ +"""SSH key service utilities for preparing keys for container use.""" + +import os +from pathlib import Path + +from cryptography.fernet import Fernet + +from src.config import Settings + + +def _get_fernet() -> Fernet: + """Generate a valid Fernet key from the session secret.""" + import base64 + import hashlib + + settings = Settings() + key_bytes = hashlib.sha256(settings.session_secret.encode()).digest() + key = base64.urlsafe_b64encode(key_bytes) + return Fernet(key) + + +def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str: + """Decrypt and write SSH key files to instance directory for container mounting. + + Args: + instance_dir: Path to instance directory + ssh_key: SSHKey model instance with encrypted private key + + Returns: + Path to the .ssh directory + """ + ssh_dir = Path(instance_dir) / ".ssh" + ssh_dir.mkdir(parents=True, exist_ok=True) + + # Decrypt private key + fernet = _get_fernet() + private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() + + # Write private key with restricted permissions + private_key_path = ssh_dir / "id_ed25519" + private_key_path.write_text(private_key) + os.chmod(private_key_path, 0o600) + + # Write public key + public_key_path = ssh_dir / "id_ed25519.pub" + public_key_path.write_text(ssh_key.public_key) + os.chmod(public_key_path, 0o644) + + # Write SSH config + config_path = ssh_dir / "config" + config_content = """Host * + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +""" + config_path.write_text(config_content) + os.chmod(config_path, 0o644) + + return str(ssh_dir) + + +def cleanup_ssh_key_files(instance_dir: str) -> None: + """Remove temporary SSH key files from instance directory. + + Args: + instance_dir: Path to instance directory + """ + ssh_dir = Path(instance_dir) / ".ssh" + if ssh_dir.exists(): + for file_path in ssh_dir.iterdir(): + file_path.unlink() + ssh_dir.rmdir() diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts index d2a3262..1b07c3e 100644 --- a/apps/web/src/api/git_repositories.ts +++ b/apps/web/src/api/git_repositories.ts @@ -8,6 +8,7 @@ export interface GitRepository { owner_id: string; is_mirror: boolean; remote_url: string | null; + ssh_key_id: string | null; last_push: string | null; created_at: string | null; } @@ -16,6 +17,7 @@ export interface GitRepositoryCreate { name: string; remote_url?: string; force_original_url?: boolean; + ssh_key_id?: string; } export interface URLParseResult { @@ -50,6 +52,18 @@ export async function deleteRepository(projectId: string, repoId: string): Promi await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); } +export async function updateRepositorySshKey( + projectId: string, + repoId: string, + sshKeyId: string | null +): Promise { + const response = await apiClient.patch( + `/projects/${projectId}/repositories/${repoId}/ssh-key`, + { ssh_key_id: sshKeyId } + ); + return response.data; +} + export interface CommitHistoryEntry { hash: string; short_hash: string; diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 05da0cc..a4e33a9 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -27,6 +27,8 @@ export interface Session { url: string | null; container_status?: string; probe_status?: string; + clone_mode?: string; + branch?: string | null; } export async function listInstances( @@ -43,13 +45,17 @@ export async function createInstance( projectId: string, repoId: string, toolTypeId: string, - displayName?: string + displayName?: string, + cloneMode?: string, + branch?: string ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, { tool_type_id: toolTypeId, display_name: displayName, + clone_mode: cloneMode || "mount", + branch: branch || undefined, } ); return response.data; @@ -91,10 +97,12 @@ export async function restartInstance( export async function deleteInstance( projectId: string, repoId: string, - instanceId: string + instanceId: string, + force?: boolean ): Promise { await apiClient.delete( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}` + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, + { params: { force } } ); } diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx index dda7269..bf4cdc0 100644 --- a/apps/web/src/components/repository-create-dialog.tsx +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories"; +import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { Icon } from "./icon"; type CreateMode = "clone" | "blank"; @@ -26,6 +27,8 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea status: UrlValidationStatus; result: URLParseResult | null; }>({ status: "idle", result: null }); + const [sshKeys, setSshKeys] = useState([]); + const [selectedSshKey, setSelectedSshKey] = useState(""); const debounceTimer = useRef | null>(null); useEffect(() => { @@ -35,6 +38,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea } }, [open]); + useEffect(() => { + if (!open) return; + const loadKeys = async () => { + try { + const data = await listSSHKeys(); + setSshKeys(data); + } catch { + // ignore + } + }; + void loadKeys(); + }, [open]); + useEffect(() => { if (!open) return; if (!useAdvancedUrl) { @@ -84,6 +100,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea setUseAdvancedUrl(false); setFormError(null); setUrlValidation({ status: "idle", result: null }); + setSelectedSshKey(""); }; const handleClose = () => { @@ -120,6 +137,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea } input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; } + if (selectedSshKey) { + input.ssh_key_id = selectedSshKey; + } } await createRepository(projectId, input); @@ -212,6 +232,20 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea placeholder="repo-name" /> +

SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git

+ )} + {urlValidation.status === "needs-parsing" && urlValidation.result && ( +
+ + This looks like a browser URL + +
+ Suggested: {urlValidation.result.base_url} + +
- - )} - {urlValidation.status === "invalid" && ( - - Invalid URL - - )} + )} + {urlValidation.status === "invalid" && ( + + Invalid URL + + )} + + - + )} {formError && (
diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index c58d49f..bf74bad 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -16,6 +16,7 @@ import { import { listToolTypes, type ToolType } from "../api/tool_types"; import { createInstance } from "../api/sessions"; import { getUserConfig, updateUserConfig } from "../api/settings"; +import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { Icon } from "../components/icon"; type SessionsStatus = "loading" | "ready" | "error"; @@ -38,6 +39,13 @@ export const SessionsPage = () => { const [createStatus, setCreateStatus] = useState("idle"); const [createError, setCreateError] = useState(null); + const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); + const [branch, setBranch] = useState("main"); + const [sshKeys, setSshKeys] = useState([]); + + const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); + const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [stopConfirmId, setStopConfirmId] = useState(null); const [tunnelHealth, setTunnelHealth] = useState { void loadToolTypes(); }, []); + useEffect(() => { + const loadSshKeys = async () => { + try { + const data = await listSSHKeys(); + setSshKeys(data); + } catch { + // ignore + } + }; + void loadSshKeys(); + }, []); + // Poll health every 30 seconds for active instances useEffect(() => { const checkHealth = async () => { @@ -177,13 +197,23 @@ export const SessionsPage = () => { return; } + if (cloneMode === "clone") { + const repo = repositories.find((r) => r.id === selectedRepo); + if (!repo?.ssh_key_id) { + setCreateError("Repository must have an SSH key assigned for clone mode"); + return; + } + } + setCreateStatus("creating"); try { const instance = await createInstance( selectedProject, selectedRepo, selectedToolType, - displayName || undefined + displayName || undefined, + cloneMode, + cloneMode === "clone" ? branch : undefined ); // Auto-start the instance @@ -195,10 +225,16 @@ export const SessionsPage = () => { setSelectedRepo(""); setSelectedToolType(""); setDisplayName(""); + setCloneMode("mount"); + setBranch("main"); await loadSessions(); - } catch { + } catch (error) { setCreateStatus("error"); - setCreateError("Failed to create session"); + const axiosError = error as { response?: { data?: { detail?: string } } }; + const message = axiosError.response?.data?.detail; + setCreateError( + typeof message === "string" ? message : "Failed to create session" + ); } }; @@ -212,13 +248,25 @@ export const SessionsPage = () => { } }; - const handleDelete = async (sessionId: string, projectId: string, repoId: string) => { + const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => { try { - await deleteInstance(projectId, repoId, sessionId); + await deleteInstance(projectId, repoId, sessionId, force); setDeleteConfirmId(null); + setDirtyDeleteSession(null); + setDirtyDeleteFiles([]); // Remove from local state immediately setSessions((prev) => prev.filter((s) => s.id !== sessionId)); - } catch { + } catch (error) { + const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } }; + if (axiosError.response?.status === 409) { + const detail = axiosError.response.data?.detail; + if (detail?.changed_files) { + setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null); + setDirtyDeleteFiles(detail.changed_files); + setDeleteConfirmId(null); + return; + } + } setDeleteConfirmId(null); } }; @@ -608,6 +656,70 @@ export const SessionsPage = () => {
+
+ + + {cloneMode === "clone" && ( + <> + + + {selectedRepo && ( +
+ {(() => { + const repo = repositories.find((r) => r.id === selectedRepo); + if (!repo) return null; + if (repo.ssh_key_id) { + const key = sshKeys.find((k) => k.id === repo.ssh_key_id); + return ( + + SSH key: {key?.name || "Assigned"} + + ); + } + return ( + + No SSH key assigned to this repository. Clone mode requires an SSH key. + + ); + })()} +
+ )} + + )} +
+