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:
co-authored by
Mohamed Boudra
parent
bac302626f
commit
218097b7cc
@@ -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("<repo>", "GitHub repo in owner/repo format or a full git remote URL")
|
||||
.requiredOption("--dir <path>", "Parent directory to clone into (for example: ~/workspace)"),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--protocol <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);
|
||||
|
||||
@@ -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<CloneResult> = {
|
||||
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<SingleResult<CloneResult>> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user