feat: clone GitHub repo into a workspace (#1331)

* feat: clone GitHub repo into a workspace

Add an end-to-end "clone a GitHub repo and register it as a Paseo
workspace" flow: a new workspace.github.clone RPC, daemon handler,
client method, CLI `paseo clone` command, and a GitHub-repo mode in
the project picker modal. Gated behind the workspaceGithubClone
server capability flag.

- protocol: workspace.github.clone request/response schemas + feature flag
- server: handleWorkspaceGithubCloneRequest, normalizeCloneRepository
- client: DaemonClient.cloneGithubWorkspace
- cli: `paseo clone <repo> --dir <path> [--protocol https|ssh]`
- app: GitHub-repo mode, clone-protocol picker, error surfacing

Review fixes:
- CLI clone now checks the workspaceGithubClone capability and fails
  fast with a clear "update the host" error instead of hanging for the
  full request timeout against an older daemon.
- Replace the duplicated client-side URL-detection regex (app + CLI)
  with a shared isCompleteGitRemote() in @getpaseo/protocol/git-remote,
  backed by parseGitRemoteLocation so clients classify remotes
  identically to the daemon (fixes confusing errors for git://, ftp://,
  file:// inputs).
- Add git-remote.test.ts covering the shared classifier.

* Fix GitHub clone failure handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Matt Cowger
2026-07-13 11:15:40 +00:00
committed by GitHub
co-authored by Mohamed Boudra
parent bac302626f
commit 218097b7cc
15 changed files with 1223 additions and 78 deletions
+71 -1
View File
@@ -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<OpenProjectPayload["errorCode"]>;
type WorkspaceOpenPayload =
| Awaited<ReturnType<DaemonClient["openProject"]>>
| Awaited<ReturnType<DaemonClient["cloneGithubWorkspace"]>>;
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<WorkspaceDescriptor>) => 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<DaemonClient, "cloneGithubWorkspace"> | null;
}
export async function openProjectDirectly(
input: OpenProjectDirectlyInput,
): Promise<OpenProjectResult> {
@@ -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<boolean> {
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);
}