feat: implement repository clone mode with SSH key support

- Add clone_mode and branch fields to tool_instances
- Add ssh_key_id to git_repositories for per-repo SSH key assignment
- Implement host-side git cloning with branch selection (default: main)
- Mount SSH keys into containers for git operations in clone mode
- Add dirty state check on clone-mode instance deletion with confirmation
- Update SessionsPage with mount/clone selector, branch input, SSH key display
- Add SSH key selector to repository creation form
- Add dirty delete confirmation modal with changed files list
- Update API schemas and endpoints for new fields
- Sync delta specs to main specs (git-repo, tool-instances, repo-clone-mode)
- Archive completed OpenSpec change: repo-clone-mode-with-ssh
- Document git requirement for custom tool types

Quality gates: Frontend typecheck and build passed
OpenSpec: repo-clone-mode-with-ssh archived with all tasks complete
This commit is contained in:
Fusion
2026-05-22 22:56:35 +02:00
parent 952a9f3234
commit 063a839790
95 changed files with 2002 additions and 392 deletions
+14
View File
@@ -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<GitRepository> {
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;
+11 -3
View File
@@ -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<ToolInstance> {
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<void> {
await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ params: { force } }
);
}
@@ -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<SSHKey[]>([]);
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | 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"
/>
</label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<button
type="button"
@@ -223,45 +257,61 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
</>
)}
{createMode === "clone" && useAdvancedUrl && (
<label className="form-field">
Remote URL
<input
type="text"
value={advancedUrl}
onChange={(event) => setAdvancedUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
<>
<label className="form-field">
Remote URL
<input
type="text"
value={advancedUrl}
onChange={(event) => setAdvancedUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<button
type="button"
className="secondary-button small"
@@ -269,7 +319,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
>
Use owner/repo instead
</button>
</label>
</>
)}
{formError && (
<div className="error-message">
+163 -6
View File
@@ -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<CreateStatus>("idle");
const [createError, setCreateError] = useState<string | null>(null);
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
@@ -96,6 +104,18 @@ export const SessionsPage = () => {
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 = () => {
</label>
</div>
<div className="form-row">
<label className="form-field">
Repository Access
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="mount"
checked={cloneMode === "mount"}
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
/>
Mount (live sync)
</label>
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="clone"
checked={cloneMode === "clone"}
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
/>
Clone fresh copy
</label>
</div>
</label>
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
/>
</label>
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
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 (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode requires an SSH key.
</span>
);
})()}
</div>
)}
</>
)}
</div>
<label className="form-field">
Display Name (optional)
<input
@@ -641,6 +753,51 @@ export const SessionsPage = () => {
</div>
</form>
</div>
{/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && (
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Uncommitted Changes</h3>
<p>
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
uncommitted changes. Deleting this session will permanently lose these
changes.
</p>
<div className="changed-files-list">
<h4>Changed files:</h4>
<ul>
{dirtyDeleteFiles.map((file, idx) => (
<li key={idx}>{file}</li>
))}
</ul>
</div>
<div className="modal-actions">
<button
className="secondary-button"
onClick={() => setDirtyDeleteSession(null)}
type="button"
>
Cancel
</button>
<button
className="danger-button"
onClick={() =>
void handleDelete(
dirtyDeleteSession.id,
dirtyDeleteSession.project_id,
dirtyDeleteSession.repository_id,
true
)
}
type="button"
>
Force Delete
</button>
</div>
</div>
</div>
)}
</>
)}
</section>
+1 -3
View File
@@ -9,8 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const;
const THEME_OPTIONS = [
@@ -106,7 +104,7 @@ export const SettingsPage = () => {
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
<p className="muted">General preferences and SSH keys.</p>
</header>
<nav className="settings-tabs" aria-label="Settings sections">
-6
View File
@@ -14,8 +14,6 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ToolConfigsPage } from "./pages/tool-configs";
import { ToolTypesPage } from "./pages/tool-types";
import { SessionsPage } from "./pages/sessions";
export const AppRouter = () => {
@@ -23,8 +21,6 @@ export const AppRouter = () => {
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
<Route
path="/"
element={
@@ -44,8 +40,6 @@ export const AppRouter = () => {
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="sessions" element={<SessionsPage />} />