Merge remote-tracking branch 'origin/main' into paseo-cloud-worktree-plan

This commit is contained in:
Mohamed Boudra
2026-07-13 13:30:10 +02:00
18 changed files with 1265 additions and 108 deletions
+15
View File
@@ -26,6 +26,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,
@@ -67,6 +68,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);
+96
View File
@@ -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(() => {});
}
}
+19
View File
@@ -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) {