diff --git a/docs/development.md b/docs/development.md index 3d994ed28..d435f11f0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -338,6 +338,7 @@ npm run cli -- ls -a -g --json # Same, as JSON npm run cli -- inspect # Show detailed agent info npm run cli -- logs # View agent timeline npm run cli -- daemon status # Check daemon status +npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register workspace ``` Use `--host ` to point the CLI at a different daemon: diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index ddd192470..9044875ad 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react"; import { Modal, Pressable, @@ -12,18 +21,29 @@ import { } from "react-native"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { Folder } from "lucide-react-native"; +import { Folder, Github } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { getOpenProjectFailureReason, type OpenProjectFailureReason } from "@/hooks/open-project"; -import { useOpenProject } from "@/hooks/use-open-project"; +import { + getOpenProjectFailureReason, + type OpenProjectFailureReason, + type WorkspaceGithubCloneProtocol, +} from "@/hooks/open-project"; +import { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useProjectPickerStore } from "@/stores/project-picker-store"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import { shortenPath } from "@/utils/shorten-path"; import { isNative } from "@/constants/platform"; import { ProjectPickerBrowseButton } from "./project-picker-browse-button"; +import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options"; +type ProjectPickerMode = "local" | "github"; + +const DEFAULT_CLONE_TARGET_DIRECTORY = "~/workspace"; +const CLONE_REPO_ERROR_MESSAGE = "Unable to clone that GitHub repository."; +const CLONE_PROTOCOL_ERROR_MESSAGE = "Choose HTTPS or SSH for owner/repo repository names."; + interface PathRowProps { option: ProjectPickerOption; active: boolean; @@ -133,6 +153,255 @@ function ProjectPickerResults({ ); } +interface CloneProtocolButtonProps { + label: string; + active: boolean; + onPress: () => void; +} + +function CloneProtocolButton({ label, active, onPress }: CloneProtocolButtonProps) { + const { theme } = useUnistyles(); + const buttonStyle = useMemo( + () => [ + styles.protocolButton, + { + borderColor: theme.colors.border, + backgroundColor: active ? theme.colors.surface1 : "transparent", + }, + ], + [active, theme.colors.border, theme.colors.surface1], + ); + const textStyle = useMemo( + () => [ + styles.protocolButtonText, + { color: active ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [active, theme.colors.foreground, theme.colors.foregroundMuted], + ); + return ( + + {label} + + ); +} + +interface GithubRepoFormProps { + repo: string; + cloneProtocol: WorkspaceGithubCloneProtocol | null; + targetDirectory: string; + needsCloneProtocol: boolean; + isSubmitting: boolean; + repoInputRef: RefObject; + onChangeRepo: (text: string) => void; + onChangeTargetDirectory: (text: string) => void; + onSelectHttps: () => void; + onSelectSsh: () => void; + onSubmit: () => void; +} + +function GithubRepoForm({ + repo, + cloneProtocol, + targetDirectory, + needsCloneProtocol, + isSubmitting, + repoInputRef, + onChangeRepo, + onChangeTargetDirectory, + onSelectHttps, + onSelectSsh, + onSubmit, +}: GithubRepoFormProps) { + const { theme } = useUnistyles(); + const inputStyle = useMemo( + () => [styles.input, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const labelStyle = useMemo( + () => [styles.label, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + + return ( + + + GitHub repo + + + {needsCloneProtocol ? ( + + Clone protocol + + + + + + ) : null} + + Checkout directory + + + + ); +} + +interface ProjectPickerKeyboardNavigationInput { + open: boolean; + mode: ProjectPickerMode; + optionsLength: number; + onClose: () => void; + setActiveIndex: Dispatch>; +} + +function useProjectPickerKeyboardNavigation({ + open, + mode, + optionsLength, + onClose, + setActiveIndex, +}: ProjectPickerKeyboardNavigationInput) { + useEffect(() => { + if (!open || isNative) return; + + function handler(event: KeyboardEvent) { + const key = event.key; + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return; + + if (key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (mode !== "local" || optionsLength === 0) return; + event.preventDefault(); + setActiveIndex((current) => { + const delta = key === "ArrowDown" ? 1 : -1; + const next = current + delta; + if (next < 0) return optionsLength - 1; + if (next >= optionsLength) return 0; + return next; + }); + } + + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [mode, onClose, open, optionsLength, setActiveIndex]); +} + +interface GithubCloneModeInput { + client: ReturnType; + serverId: string | null; + openGithubRepo: ReturnType; + close: () => void; +} + +function useGithubCloneMode({ client, serverId, openGithubRepo, close }: GithubCloneModeInput) { + const [repo, setRepo] = useState(""); + const [cloneProtocol, setCloneProtocol] = useState(null); + const [targetDirectory, setTargetDirectory] = useState(DEFAULT_CLONE_TARGET_DIRECTORY); + const [cloneErrorText, setCloneErrorText] = useState(null); + const needsCloneProtocol = repo.trim().length > 0 && !isCompleteGitRemote(repo); + + const reset = useCallback(() => { + setRepo(""); + setCloneProtocol(null); + setTargetDirectory(DEFAULT_CLONE_TARGET_DIRECTORY); + setCloneErrorText(null); + }, []); + + const handleChangeRepo = useCallback((text: string) => { + setRepo(text); + if (isCompleteGitRemote(text)) { + setCloneProtocol(null); + } + setCloneErrorText(null); + }, []); + + const handleChangeTargetDirectory = useCallback((text: string) => { + setTargetDirectory(text); + setCloneErrorText(null); + }, []); + + const handleSetHttpsProtocol = useCallback(() => { + setCloneProtocol("https"); + setCloneErrorText(null); + }, []); + + const handleSetSshProtocol = useCallback(() => { + setCloneProtocol("ssh"); + setCloneErrorText(null); + }, []); + + const handleCloneRepo = useCallback(async () => { + const trimmedRepo = repo.trim(); + const trimmedTargetDirectory = targetDirectory.trim(); + if (!trimmedRepo || !trimmedTargetDirectory || !client || !serverId) return false; + const repoIsCompleteRemote = isCompleteGitRemote(trimmedRepo); + if (!repoIsCompleteRemote && !cloneProtocol) { + setCloneErrorText(CLONE_PROTOCOL_ERROR_MESSAGE); + return false; + } + + setCloneErrorText(null); + const didOpenProject = await openGithubRepo( + trimmedRepo, + trimmedTargetDirectory, + repoIsCompleteRemote ? undefined : (cloneProtocol ?? undefined), + ); + if (!didOpenProject) { + setCloneErrorText(CLONE_REPO_ERROR_MESSAGE); + return false; + } + close(); + return true; + }, [client, cloneProtocol, close, openGithubRepo, repo, serverId, targetDirectory]); + + return { + repo, + cloneProtocol, + targetDirectory, + cloneErrorText, + needsCloneProtocol, + reset, + handleChangeRepo, + handleChangeTargetDirectory, + handleSetHttpsProtocol, + handleSetSshProtocol, + handleCloneRepo, + }; +} + export function ProjectPickerModal() { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -146,12 +415,30 @@ export function ProjectPickerModal() { const recommendedPaths = useRecommendedProjectPaths(serverId); const inputRef = useRef(null); + const repoInputRef = useRef(null); + const [mode, setMode] = useState("local"); const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [openErrorReason, setOpenErrorReason] = useState(null); const openProject = useOpenProject(serverId); + const openGithubRepo = useOpenGithubRepo(serverId); + const supportsGithubClone = + client?.getLastServerInfoMessage()?.features?.workspaceGithubClone === true; + const { + repo, + cloneProtocol, + targetDirectory, + cloneErrorText, + needsCloneProtocol, + reset: resetGithubCloneMode, + handleChangeRepo, + handleChangeTargetDirectory, + handleSetHttpsProtocol, + handleSetSshProtocol, + handleCloneRepo: cloneGithubRepo, + } = useGithubCloneMode({ client, serverId, openGithubRepo, close }); const directorySuggestionsQuery = useQuery({ queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery], @@ -172,7 +459,7 @@ export function ProjectPickerModal() { [], }; }, - enabled: Boolean(client) && isConnected && open, + enabled: Boolean(client) && isConnected && open && mode === "local", staleTime: 15_000, retry: false, }); @@ -228,11 +515,24 @@ export function ProjectPickerModal() { [client, close, openProject, serverId], ); + const handleCloneRepo = useCallback(async () => { + setIsSubmitting(true); + try { + await cloneGithubRepo(); + } finally { + setIsSubmitting(false); + } + }, [cloneGithubRepo]); + const submitActiveOption = useCallback(() => { + if (mode === "github") { + void handleCloneRepo(); + return; + } const option = options[activeIndex]; if (!option) return; void handleSelectPath(option.path); - }, [activeIndex, handleSelectPath, options]); + }, [activeIndex, handleCloneRepo, handleSelectPath, mode, options]); const handleChangeQuery = useCallback((text: string) => { setQuery(text); @@ -244,16 +544,41 @@ export function ProjectPickerModal() { setOpenErrorReason("open_failed"); }, []); + const handleSetLocalMode = useCallback(() => { + setMode("local"); + setActiveIndex(0); + }, []); + + const handleSetGithubMode = useCallback(() => { + setMode("github"); + setActiveIndex(0); + setOpenErrorReason(null); + }, []); + useEffect(() => { if (open) { + setMode("local"); setQuery(""); setDebouncedQuery(""); + resetGithubCloneMode(); setActiveIndex(0); setOpenErrorReason(null); const id = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(id); } - }, [open]); + }, [open, resetGithubCloneMode]); + + useEffect(() => { + if (!open) return; + const id = setTimeout(() => { + if (mode === "github") { + repoInputRef.current?.focus(); + return; + } + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(id); + }, [mode, open]); // Debounce the query that drives the (potentially multi-second) directory // suggestions RPC so fast typing doesn't fire a filesystem scan per keystroke. @@ -263,41 +588,19 @@ export function ProjectPickerModal() { }, [query]); useEffect(() => { - if (!open) return; + if (!open || mode !== "local") return; if (activeIndex >= options.length) { setActiveIndex(options.length > 0 ? options.length - 1 : 0); } - }, [activeIndex, options.length, open]); + }, [activeIndex, mode, options.length, open]); - useEffect(() => { - if (!open || isNative) return; - - function handler(event: KeyboardEvent) { - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return; - - if (key === "Escape") { - event.preventDefault(); - close(); - return; - } - - if (key === "ArrowDown" || key === "ArrowUp") { - if (options.length === 0) return; - event.preventDefault(); - setActiveIndex((current) => { - const delta = key === "ArrowDown" ? 1 : -1; - const next = current + delta; - if (next < 0) return options.length - 1; - if (next >= options.length) return 0; - return next; - }); - } - } - - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [close, open, options.length]); + useProjectPickerKeyboardNavigation({ + open, + mode, + optionsLength: options.length, + onClose: close, + setActiveIndex, + }); const panelStyle = useMemo( () => [ @@ -325,6 +628,27 @@ export function ProjectPickerModal() { () => [styles.emptyText, { color: theme.colors.destructive }], [theme.colors.destructive], ); + const cloneErrorTextStyle = useMemo( + () => [styles.errorText, { color: theme.colors.destructive }], + [theme.colors.destructive], + ); + const modeButtonStyle = useCallback( + (buttonMode: ProjectPickerMode) => [ + styles.modeButton, + { + borderColor: theme.colors.border, + backgroundColor: mode === buttonMode ? theme.colors.surface1 : "transparent", + }, + ], + [mode, theme.colors.border, theme.colors.surface1], + ); + const modeButtonTextStyle = useCallback( + (buttonMode: ProjectPickerMode) => [ + styles.modeButtonText, + { color: mode === buttonMode ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [mode, theme.colors.foreground, theme.colors.foregroundMuted], + ); if (!serverId) return null; @@ -335,40 +659,83 @@ export function ProjectPickerModal() { - - + + + + Local folder + + {supportsGithubClone ? ( + + + GitHub repo + + ) : null} + + + {mode === "local" ? ( + + + + + ) : ( + + )} - + {mode === "local" ? ( + + ) : ( + + {cloneErrorText ? {cloneErrorText} : null} + {isSubmitting ? Cloning repository... : null} + + )} @@ -396,12 +763,58 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.lg, }, header: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderBottomWidth: 1, + gap: theme.spacing[3], + }, + localInputRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + modeRow: { + flexDirection: "row", + gap: theme.spacing[2], + }, + modeButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + }, + modeButtonText: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + }, + githubForm: { + gap: theme.spacing[3], + }, + fieldGroup: { + gap: theme.spacing[1], + }, + label: { + fontSize: theme.fontSize.xs, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0, + }, + protocolRow: { + flexDirection: "row", + gap: theme.spacing[2], + }, + protocolButton: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + }, + protocolButtonText: { + fontSize: theme.fontSize.sm, + fontWeight: "600", }, input: { flex: 1, @@ -446,4 +859,11 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[4], fontSize: theme.fontSize.base, }, + errorText: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[1], + fontSize: theme.fontSize.sm, + fontWeight: "600", + }, })); diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index d83834184..bff4432ba 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -1,12 +1,18 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ProjectAddResponse } from "@getpaseo/protocol/messages"; +import type { ProjectAddResponse, WorkspaceGithubCloneProtocol } from "@getpaseo/protocol/messages"; import { normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor, + normalizeWorkspaceDescriptor, type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, + type WorkspaceDescriptor, } from "@/stores/session-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; type OpenProjectPayload = ProjectAddResponse["payload"]; type OpenProjectErrorCode = NonNullable; +type WorkspaceOpenPayload = + | Awaited> + | Awaited>; export interface OpenProjectSuccess { ok: true; @@ -20,6 +26,7 @@ export interface OpenProjectFailure { export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure; export type OpenProjectFailureReason = "directory_not_found" | "open_failed"; +export type { WorkspaceGithubCloneProtocol }; export function getOpenProjectFailureReason( result: OpenProjectResult, @@ -45,6 +52,22 @@ export interface OpenProjectDirectlyInput { setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; } +interface WorkspaceOpenCallbacks { + serverId: string; + isConnected: boolean; + mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + openDraftTab: (workspaceKey: string) => string | null; + navigateToWorkspace: (serverId: string, workspaceId: string) => void; +} + +export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks { + repo: string; + targetDirectory: string; + cloneProtocol?: WorkspaceGithubCloneProtocol; + client: Pick | null; +} + export async function openProjectDirectly( input: OpenProjectDirectlyInput, ): Promise { @@ -78,3 +101,50 @@ export async function openProjectDirectly( input.setHasHydratedWorkspaces(normalizedServerId, true); return { ok: true }; } + +function finishWorkspaceOpen( + input: WorkspaceOpenCallbacks, + payload: WorkspaceOpenPayload, +): boolean { + const normalizedServerId = input.serverId.trim(); + if (!normalizedServerId || payload.error || !payload.workspace) { + return false; + } + + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: workspace.id, + }); + if (!workspaceKey) { + return false; + } + + input.mergeWorkspaces(normalizedServerId, [workspace]); + input.setHasHydratedWorkspaces(normalizedServerId, true); + input.openDraftTab(workspaceKey); + input.navigateToWorkspace(normalizedServerId, workspace.id); + return true; +} + +export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput): Promise { + const normalizedServerId = input.serverId.trim(); + const trimmedRepo = input.repo.trim(); + const trimmedTargetDirectory = input.targetDirectory.trim(); + if ( + !normalizedServerId || + !trimmedRepo || + !trimmedTargetDirectory || + !input.client || + !input.isConnected + ) { + return false; + } + + const payload = await input.client.cloneGithubWorkspace({ + repo: trimmedRepo, + targetDirectory: trimmedTargetDirectory, + ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), + }); + return finishWorkspaceOpen(input, payload); +} diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 3f04e1a42..16d86c131 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { getOpenProjectFailureReason, openProjectDirectly } from "@/hooks/open-project"; -import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store"; +import { + getOpenProjectFailureReason, + openGithubRepoDirectly, + openProjectDirectly, +} from "@/hooks/open-project"; +import type { + EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, + WorkspaceDescriptor, +} from "@/stores/session-store"; const SERVER_ID = "server-1"; const PROJECT_PATH = "/repo/project"; @@ -14,31 +21,113 @@ function buildProjectPayload() { }; } +function buildWorkspacePayload() { + return { + id: "1", + projectId: "1", + projectDisplayName: "project", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, + projectKind: "git" as const, + workspaceKind: "checkout" as const, + name: "project", + archivingAt: null, + status: "done" as const, + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + }; +} + interface RecordedProject { serverId: string; project: ProjectWithoutWorkspacesDescriptor; } +interface RecordedMerge { + serverId: string; + workspaces: WorkspaceDescriptor[]; +} + interface RecordedHydrated { serverId: string; hydrated: boolean; } +interface RecordedOpenDraftTab { + workspaceKey: string; +} + +interface RecordedNavigate { + serverId: string; + workspaceId: string; +} + +interface RecordedClone { + repo: string; + targetDirectory: string; + cloneProtocol?: "https" | "ssh"; +} + function createFakeSession() { const projects: RecordedProject[] = []; + const merges: RecordedMerge[] = []; const hydrated: RecordedHydrated[] = []; return { projects, + merges, hydrated, addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => { projects.push({ serverId, project }); }, + mergeWorkspaces: (serverId: string, workspaces: Iterable) => { + merges.push({ serverId, workspaces: Array.from(workspaces) }); + }, setHasHydratedWorkspaces: (serverId: string, value: boolean) => { hydrated.push({ serverId, hydrated: value }); }, }; } +function createFakeWorkspaceLayout() { + const openedTabs: RecordedOpenDraftTab[] = []; + return { + openedTabs, + openDraftTab: (workspaceKey: string) => { + openedTabs.push({ workspaceKey }); + return "tab-1"; + }, + }; +} + +function createFakeNavigator() { + const navigations: RecordedNavigate[] = []; + return { + navigations, + navigateToWorkspace: (serverId: string, workspaceId: string) => { + navigations.push({ serverId, workspaceId }); + }, + }; +} + +function createFakeGithubCloneClient(workspace: ReturnType) { + const clones: RecordedClone[] = []; + return { + clones, + cloneGithubWorkspace: async (input: RecordedClone) => { + clones.push(input); + return { + requestId: "request-3", + repo: "owner/project", + checkoutPath: PROJECT_PATH, + error: null, + workspace, + }; + }, + }; +} + describe("openProjectDirectly", () => { it("adds the project and marks workspaces hydrated without opening a workspace", async () => { const session = createFakeSession(); @@ -73,6 +162,7 @@ describe("openProjectDirectly", () => { }, }, ]); + expect(session.merges).toEqual([]); expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); }); @@ -133,6 +223,75 @@ describe("openProjectDirectly", () => { }); }); +describe("openGithubRepoDirectly", () => { + it("opens a cloned GitHub workspace and seeds a draft tab", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const workspacePayload = buildWorkspacePayload(); + const github = createFakeGithubCloneClient(workspacePayload); + + const result = await openGithubRepoDirectly({ + serverId: SERVER_ID, + repo: "owner/project", + targetDirectory: "~/workspace", + cloneProtocol: "https", + isConnected: true, + client: github, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, + }); + + expect(result).toBe(true); + expect(github.clones).toEqual([ + { + repo: "owner/project", + targetDirectory: "~/workspace", + cloneProtocol: "https", + }, + ]); + expect(session.merges).toHaveLength(1); + expect(session.merges[0]?.serverId).toBe(SERVER_ID); + expect(session.merges[0]?.workspaces[0]).toMatchObject({ + id: "1", + projectId: "1", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, + }); + expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); + expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]); + expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]); + }); + + it("rejects a workspace without an identity before changing app state", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const github = createFakeGithubCloneClient({ ...buildWorkspacePayload(), id: " " }); + + const result = await openGithubRepoDirectly({ + serverId: SERVER_ID, + repo: "owner/project", + targetDirectory: "~/workspace", + cloneProtocol: "https", + isConnected: true, + client: github, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, + }); + + expect(result).toBe(false); + expect(session.merges).toEqual([]); + expect(session.hydrated).toEqual([]); + expect(layout.openedTabs).toEqual([]); + expect(navigator.navigations).toEqual([]); + }); +}); + describe("getOpenProjectFailureReason", () => { it("keeps the known directory-not-found failure reason", () => { expect( diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 7dabc0315..97347d38c 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,7 +1,15 @@ import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; -import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { + openGithubRepoDirectly, + openProjectDirectly, + type OpenProjectResult, + type WorkspaceGithubCloneProtocol, +} from "@/hooks/open-project"; export function useOpenProject( serverId: string | null, @@ -40,3 +48,48 @@ export function useOpenProject( ], ); } + +export function useOpenGithubRepo( + serverId: string | null, +): ( + repo: string, + targetDirectory: string, + cloneProtocol?: WorkspaceGithubCloneProtocol, +) => Promise { + const normalizedServerId = serverId?.trim() ?? ""; + const client = useHostRuntimeClient(normalizedServerId); + const isConnected = useHostRuntimeIsConnected(normalizedServerId); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + const openDraftTab = useCallback((workspaceKey: string) => { + return useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { + kind: "draft", + draftId: generateDraftId(), + }); + }, []); + + return useCallback( + async (repo: string, targetDirectory: string, cloneProtocol?: WorkspaceGithubCloneProtocol) => { + return openGithubRepoDirectly({ + serverId: normalizedServerId, + repo, + targetDirectory, + ...(cloneProtocol ? { cloneProtocol } : {}), + isConnected, + client, + mergeWorkspaces, + setHasHydratedWorkspaces, + openDraftTab, + navigateToWorkspace, + }); + }, + [ + client, + isConnected, + mergeWorkspaces, + normalizedServerId, + openDraftTab, + setHasHydratedWorkspaces, + ], + ); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f89004dd5..9c98310ba 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -25,6 +25,7 @@ import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.j import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js"; import { addImportOptions, runImportCommand } from "./commands/agent/import.js"; import { withOutput } from "./output/index.js"; +import { runCloneCommand } from "./commands/clone.js"; import { onboardCommand } from "./commands/onboard.js"; import { addDaemonHostOption, @@ -66,6 +67,20 @@ export function createCli(): Command { withOutput(runImportCommand), ); + addJsonAndDaemonHostOptions( + program + .command("clone") + .description("Clone a GitHub repo and register it as a Paseo workspace") + .argument("", "GitHub repo in owner/repo format or a full git remote URL") + .requiredOption("--dir ", "Parent directory to clone into (for example: ~/workspace)"), + ) + .addOption( + new Option("--protocol ", "Protocol for owner/repo shorthand repositories").choices( + ["https", "ssh"], + ), + ) + .action(withOutput(runCloneCommand)); + addDaemonHostOption(addAttachOptions(program.command("attach"))).action(runAttachCommand); addDaemonHostOption(addLogsOptions(program.command("logs"))).action(runLogsCommand); diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts new file mode 100644 index 000000000..8935c1842 --- /dev/null +++ b/packages/cli/src/commands/clone.ts @@ -0,0 +1,96 @@ +import type { Command } from "commander"; +import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js"; +import type { CommandError, OutputSchema, SingleResult } from "../output/index.js"; +import type { CommandOptions } from "../output/with-output.js"; + +type CloneProtocol = "https" | "ssh"; + +interface CloneCommandOptions extends CommandOptions { + protocol?: CloneProtocol; +} + +export interface CloneResult { + repo: string; + checkoutPath: string; + workspaceId: string; + workspaceName: string; +} + +export const cloneSchema: OutputSchema = { + idField: "workspaceId", + columns: [ + { header: "REPO", field: "repo", width: 28 }, + { header: "WORKSPACE", field: "workspaceName", width: 28 }, + { header: "PATH", field: "checkoutPath", width: 56 }, + ], +}; + +function cmdError(code: string, message: string, details?: string): CommandError { + return details ? { code, message, details } : { code, message }; +} + +export async function runCloneCommand( + repo: string, + options: CloneCommandOptions, + _command: Command, +): Promise> { + const targetDirectory = typeof options.dir === "string" ? options.dir.trim() : ""; + if (!targetDirectory) { + throw cmdError("INVALID_ARGUMENT", "--dir is required"); + } + const repoIsCompleteRemote = isCompleteGitRemote(repo); + if (!repoIsCompleteRemote && !options.protocol) { + throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names"); + } + + let client: DaemonClient; + try { + client = await connectToDaemon({ host: options.host }); + } catch (err) { + throw buildDaemonConnectionCommandError({ host: options.host, error: err }); + } + + if (client.getLastServerInfoMessage()?.features?.workspaceGithubClone !== true) { + await client.close().catch(() => {}); + throw cmdError( + "UNSUPPORTED_BY_HOST", + "This daemon does not support cloning GitHub repos.", + "Update the host to a newer Paseo version.", + ); + } + + try { + const response = await client.cloneGithubWorkspace({ + repo, + targetDirectory, + ...(repoIsCompleteRemote ? {} : { cloneProtocol: options.protocol }), + }); + if (response.error || !response.workspace || !response.checkoutPath) { + throw cmdError( + "CLONE_FAILED", + `Failed to clone GitHub repo: ${response.error ?? "no workspace returned"}`, + ); + } + + return { + type: "single", + data: { + repo: response.repo, + checkoutPath: response.checkoutPath, + workspaceId: response.workspace.id, + workspaceName: response.workspace.name, + }, + schema: cloneSchema, + }; + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw cmdError("CLONE_FAILED", `Failed to clone GitHub repo: ${message}`); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 5f936d91e..697b1b030 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -22,6 +22,12 @@ export interface ConnectOptions { timeout?: number; } +export interface DaemonConnectionCommandError { + code: "DAEMON_NOT_RUNNING"; + message: string; + details: string; +} + const DEFAULT_HOST = "localhost:6767"; const DEFAULT_TIMEOUT = 15000; const PID_FILENAME = "paseo.pid"; @@ -44,6 +50,19 @@ export function getDaemonHost(options?: ConnectOptions): string { return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST; } +export function buildDaemonConnectionCommandError(options: { + host?: string; + error: unknown; +}): DaemonConnectionCommandError { + const host = getDaemonHost({ host: options.host }); + const message = options.error instanceof Error ? options.error.message : String(options.error); + return { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + }; +} + export function normalizeDaemonHost(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a19be362f..1f827a0b3 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -56,6 +56,8 @@ import type { ProjectIconResponse, ProjectAddResponse, OpenProjectResponseMessage, + WorkspaceGithubCloneProtocol, + WorkspaceGithubCloneResponse, ArchiveWorkspaceResponseMessage, WorkspaceSetupStatusResponseMessage, ListCommandsResponse, @@ -148,6 +150,8 @@ const perfNow: () => number = ? () => performance.now() : () => Date.now(); +const WORKSPACE_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000; + interface ImportAgentInputBase { cwd?: string; labels?: Record; @@ -758,6 +762,7 @@ export interface RenameTerminalInput { } type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ProjectAddPayload = ProjectAddResponse["payload"]; +type WorkspaceGithubClonePayload = WorkspaceGithubCloneResponse["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; @@ -1988,6 +1993,23 @@ export class DaemonClient { }); } + async cloneGithubWorkspace( + input: { repo: string; targetDirectory: string; cloneProtocol?: WorkspaceGithubCloneProtocol }, + requestId?: string, + ): Promise { + const message = { + type: "workspace.github.clone.request", + repo: input.repo, + targetDirectory: input.targetDirectory, + ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), + } as const; + return this.sendNamespacedCorrelatedSessionRequest<"workspace.github.clone.response">({ + requestId, + message, + timeout: WORKSPACE_GITHUB_CLONE_TIMEOUT_MS, + }); + } + async startWorkspaceScript( workspaceId: string, scriptName: string, diff --git a/packages/protocol/src/git-remote.test.ts b/packages/protocol/src/git-remote.test.ts new file mode 100644 index 000000000..c098d0340 --- /dev/null +++ b/packages/protocol/src/git-remote.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isCompleteGitRemote, parseGitRemoteLocation } from "./git-remote.js"; + +describe("isCompleteGitRemote", () => { + it("treats supported URLs and scp-like addresses as complete remotes", () => { + expect(isCompleteGitRemote("https://github.com/owner/repo")).toBe(true); + expect(isCompleteGitRemote("http://internal/owner/repo.git")).toBe(true); + expect(isCompleteGitRemote("ssh://git@github.com/owner/repo")).toBe(true); + expect(isCompleteGitRemote("git@github.com:owner/repo.git")).toBe(true); + expect(isCompleteGitRemote(" https://github.com/owner/repo ")).toBe(true); + }); + + it("treats owner/repo shorthand as incomplete (needs a clone protocol)", () => { + expect(isCompleteGitRemote("owner/repo")).toBe(false); + expect(isCompleteGitRemote("owner/repo.git")).toBe(false); + expect(isCompleteGitRemote("")).toBe(false); + }); + + it("rejects schemes the daemon's parser does not accept, so clients agree with the server", () => { + // The old client-side regex matched any `scheme://`, classifying these as + // complete URLs while the daemon (parseGitRemoteLocation) rejected them — + // producing a confusing "use owner/repo format" error. The shared helper + // must classify them identically to the daemon. + for (const repo of ["git://github.com/owner/repo", "ftp://host/repo", "file:///tmp/repo"]) { + expect(isCompleteGitRemote(repo)).toBe(false); + expect(parseGitRemoteLocation(repo)).toBeNull(); + } + }); +}); diff --git a/packages/protocol/src/git-remote.ts b/packages/protocol/src/git-remote.ts index fae141b03..b996c03db 100644 --- a/packages/protocol/src/git-remote.ts +++ b/packages/protocol/src/git-remote.ts @@ -24,6 +24,19 @@ export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | return parseGitHubRemoteIdentity(location.path); } +/** + * Whether `repo` is already a complete git remote (a URL or scp-like address) + * rather than `owner/repo` shorthand that still needs a clone protocol picked. + * + * Clients (app + CLI) and the daemon must agree on this classification: the + * daemon treats `parseGitRemoteLocation(repo) !== null` as "complete remote" + * and everything else as shorthand, so reuse the same parser here instead of a + * separate regex that would drift (e.g. accepting `git://` the parser rejects). + */ +export function isCompleteGitRemote(repo: string): boolean { + return parseGitRemoteLocation(repo) !== null; +} + export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null { const trimmed = remoteUrl.trim(); if (!trimmed) return null; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 74e7c5a3e..ec05be176 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1794,6 +1794,18 @@ export const ProjectAddRequestSchema = z.object({ requestId: z.string(), }); +// Smallest shorthand repo path is "a/b": owner, slash, repository. +const MIN_REPOSITORY_PATH_LENGTH = 3; +export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]); + +export const WorkspaceGithubCloneRequestSchema = z.object({ + type: z.literal("workspace.github.clone.request"), + repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), + cloneProtocol: WorkspaceGithubCloneProtocolSchema.optional(), + targetDirectory: z.string().trim().min(1), + requestId: z.string(), +}); + export const ArchiveWorkspaceRequestSchema = z.object({ type: z.literal("archive_workspace_request"), workspaceId: z.string(), @@ -2178,6 +2190,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ProjectAddRequestSchema, + WorkspaceGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, @@ -2420,6 +2433,8 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), + // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. + workspaceGithubClone: z.boolean().optional(), }) .optional(), }) @@ -2918,6 +2933,17 @@ export const ProjectAddResponseSchema = z.object({ }), }); +export const WorkspaceGithubCloneResponseSchema = z.object({ + type: z.literal("workspace.github.clone.response"), + payload: z.object({ + requestId: z.string(), + repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), + checkoutPath: z.string().nullable(), + workspace: WorkspaceDescriptorPayloadSchema.nullable(), + error: z.string().nullable(), + }), +}); + export const StartWorkspaceScriptResponseMessageSchema = z.object({ type: z.literal("start_workspace_script_response"), payload: z.object({ @@ -4338,6 +4364,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchWorkspacesResponseMessageSchema, ProjectAddResponseSchema, OpenProjectResponseMessageSchema, + WorkspaceGithubCloneResponseSchema, StartWorkspaceScriptResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema, LegacyOpenInEditorResponseMessageSchema, @@ -4497,6 +4524,7 @@ export type FetchWorkspacesResponseMessage = z.infer; export type ScriptStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; +export type WorkspaceGithubCloneResponse = z.infer; export type StartWorkspaceScriptResponseMessage = z.infer< typeof StartWorkspaceScriptResponseMessageSchema >; @@ -4745,6 +4773,8 @@ export type LegacyListAvailableEditorsRequest = z.infer< export type LegacyOpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ProjectAddRequest = z.infer; +export type WorkspaceGithubCloneRequest = z.infer; +export type WorkspaceGithubCloneProtocol = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type WorkspaceClearAttentionRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index c7099db30..d47e46e3c 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -286,6 +286,67 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_project_request"); }); + test("parses workspace GitHub clone request and response repo paths", () => { + const request = SessionInboundMessageSchema.parse({ + type: "workspace.github.clone.request", + repo: "a/b", + cloneProtocol: "https", + targetDirectory: "~/workspace", + requestId: "req-clone", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "workspace.github.clone.response", + payload: { + requestId: "req-clone", + repo: "a/b", + checkoutPath: "/tmp/b", + workspace: null, + error: null, + }, + }); + + expect(request.type).toBe("workspace.github.clone.request"); + if (request.type !== "workspace.github.clone.request") { + throw new Error("expected workspace.github.clone.request"); + } + expect(request.cloneProtocol).toBe("https"); + expect(response.type).toBe("workspace.github.clone.response"); + }); + + test("rejects invalid workspace GitHub clone protocols", () => { + const request = SessionInboundMessageSchema.safeParse({ + type: "workspace.github.clone.request", + repo: "a/b", + cloneProtocol: "ftp", + targetDirectory: "~/workspace", + requestId: "req-clone", + }); + + expect(request.success).toBe(false); + }); + + test("rejects workspace GitHub clone repo paths shorter than owner slash repo", () => { + const request = SessionInboundMessageSchema.safeParse({ + type: "workspace.github.clone.request", + repo: "ab", + targetDirectory: "~/workspace", + requestId: "req-clone", + }); + const response = SessionOutboundMessageSchema.safeParse({ + type: "workspace.github.clone.response", + payload: { + requestId: "req-clone", + repo: "ab", + checkoutPath: null, + workspace: null, + error: "failed", + }, + }); + + expect(request.success).toBe(false); + expect(response.success).toBe(false); + }); + test("parses legacy editor RPC messages for compatibility", () => { const listRequest = SessionInboundMessageSchema.parse({ type: "list_available_editors_request", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1783dcb70..ac2bb1c3f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,6 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; -import { stat } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import { basename, normalize, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; @@ -214,6 +214,7 @@ import { toWorktreeRequestError, toWorktreeWireError, } from "./worktree-errors.js"; +import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { type WorktreeConfig, createWorktree } from "../utils/worktree.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; @@ -1408,6 +1409,7 @@ export class Session { this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ?? + this.dispatchWorkspaceFileMessage(msg) ?? this.dispatchProviderMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? this.dispatchChatScheduleLoopMessage(msg) ?? @@ -1663,6 +1665,8 @@ export class Session { return this.handleOpenProjectRequest(msg); case "project.add.request": return this.handleProjectAddRequest(msg); + case "workspace.github.clone.request": + return this.handleWorkspaceGithubCloneRequest(msg); case "archive_workspace_request": return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": @@ -1675,6 +1679,13 @@ export class Session { return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId); + default: + return undefined; + } + } + + private dispatchWorkspaceFileMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg); case "project_icon_request": @@ -4773,6 +4784,99 @@ export class Session { } } + private async handleWorkspaceGithubCloneRequest( + request: Extract, + ): Promise { + let normalizedRepo = request.repo; + let checkoutPath: string | null = null; + try { + const repo = normalizeCloneRepository({ + repo: request.repo, + cloneProtocol: request.cloneProtocol, + }); + normalizedRepo = repo.displayName; + const targetParent = resolve(expandTilde(request.targetDirectory.trim())); + checkoutPath = resolve(targetParent, repo.name); + if (!this.isPathWithinRoot(targetParent, checkoutPath)) { + throw new Error("Resolved checkout path must stay inside the target directory"); + } + + await mkdir(targetParent, { recursive: true }); + try { + await lstat(checkoutPath); + throw new Error(`Checkout path already exists: ${checkoutPath}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + const cloneStagingPath = await mkdtemp(resolve(targetParent, ".paseo-clone-")); + try { + await runGitCommand(["clone", repo.cloneUrl, cloneStagingPath], { + cwd: targetParent, + timeout: 5 * 60 * 1000, + maxOutputBytes: 1024 * 1024, + logger: this.sessionLogger, + }); + await rename(cloneStagingPath, checkoutPath); + } catch (error) { + await rm(cloneStagingPath, { recursive: true, force: true }).catch((cleanupError) => { + this.sessionLogger.warn( + { err: cleanupError, cloneStagingPath }, + "Failed to clean up partial GitHub clone", + ); + }); + throw error; + } + + const workspace = + await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(checkoutPath); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + const descriptor = await this.describeWorkspaceRecord(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); + void this.workspaceGitService + .getSnapshot(workspace.cwd, { + force: true, + includeGitHub: true, + reason: "open_project", + }) + .catch((error) => { + this.sessionLogger.warn( + { err: error, cwd: workspace.cwd }, + "Background snapshot refresh failed after workspace.github.clone", + ); + }); + + this.emit({ + type: "workspace.github.clone.response", + payload: { + requestId: request.requestId, + repo: repo.displayName, + checkoutPath, + workspace: descriptor, + error: null, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to clone GitHub repo"; + this.sessionLogger.error( + { err: error, repo: request.repo, targetDirectory: request.targetDirectory }, + "Failed to clone GitHub workspace", + ); + this.emit({ + type: "workspace.github.clone.response", + payload: { + requestId: request.requestId, + repo: normalizedRepo, + checkoutPath, + workspace: null, + error: message, + }, + }); + } + } + // Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's // scripts snapshot through here; the workspace-scripts module owns the payload assembly. private buildWorkspaceScriptPayloadSnapshot( @@ -5727,3 +5831,54 @@ export class Session { this.workspaceGitObserver.dispose(); } } + +interface CloneRepositoryInput { + name: string; + displayName: string; + cloneUrl: string; +} + +function normalizeCloneRepository(input: { + repo: string; + cloneProtocol?: "https" | "ssh"; +}): CloneRepositoryInput { + const trimmed = input.repo.trim(); + if (!trimmed) { + throw new Error("Repository is required"); + } + + const remote = parseGitRemoteLocation(trimmed); + if (remote) { + const segments = remote.path.split("/").filter(Boolean); + const name = segments.at(-1); + if (!name || !isValidGitHubRepoSegment(name)) { + throw new Error("Repository name contains invalid characters"); + } + return { name, displayName: remote.path, cloneUrl: trimmed }; + } + + const [owner, rawName, ...extra] = trimmed.split("/"); + if (!owner || !rawName || extra.length > 0) { + throw new Error("Repository must use owner/repo format or a git remote URL"); + } + const name = rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName; + if (!isValidGitHubRepoSegment(owner) || !isValidGitHubRepoSegment(name)) { + throw new Error("Repository contains invalid characters"); + } + if (!input.cloneProtocol) { + throw new Error("Clone protocol is required for owner/repo repository names"); + } + const cloneUrl = + input.cloneProtocol === "ssh" + ? `git@github.com:${owner}/${name}.git` + : `https://github.com/${owner}/${name}.git`; + return { + name, + displayName: `${owner}/${name}`, + cloneUrl, + }; +} + +function isValidGitHubRepoSegment(value: string): boolean { + return /^[A-Za-z0-9._-]+$/u.test(value); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4061fa464..05ce7e886 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1264,6 +1264,8 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, + // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. + workspaceGithubClone: true, }, }; }