From b5128cf69fa5f4b7d969833fc41cd503c084dd0f Mon Sep 17 00:00:00 2001 From: prateek Date: Tue, 3 Mar 2026 14:42:35 +0530 Subject: [PATCH] fix: use gh repo clone for private repo auth in ao start (#273) HTTPS clone fails on private repos without credentials. Now tries three strategies in order: 1. gh repo clone (handles GitHub auth via gh auth token) 2. git clone with SSH URL (works with SSH keys) 3. git clone with HTTPS URL (public repos fallback) Co-authored-by: Claude Opus 4.6 --- packages/cli/__tests__/commands/start.test.ts | 60 ++++++++++++++++++- packages/cli/src/commands/start.ts | 46 +++++++++++++- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 02ecf6526..ffebcc5b6 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -15,9 +15,10 @@ import type { SessionManager } from "@composio/ao-core"; // Hoisted mocks // --------------------------------------------------------------------------- -const { mockExec, mockConfigRef, mockSessionManager, mockWaitForPortAndOpen, mockSpawn } = +const { mockExec, mockExecSilent, mockConfigRef, mockSessionManager, mockWaitForPortAndOpen, mockSpawn } = vi.hoisted(() => ({ mockExec: vi.fn(), + mockExecSilent: vi.fn(), mockConfigRef: { current: null as Record | null }, mockSessionManager: { list: vi.fn(), @@ -36,7 +37,7 @@ const { mockExec, mockConfigRef, mockSessionManager, mockWaitForPortAndOpen, moc vi.mock("../../src/lib/shell.js", () => ({ tmux: vi.fn(), exec: mockExec, - execSilent: vi.fn(), + execSilent: mockExecSilent, git: vi.fn(), gh: vi.fn(), getTmuxSessions: vi.fn().mockResolvedValue([]), @@ -132,6 +133,9 @@ beforeEach(() => { mockSessionManager.spawnOrchestrator.mockReset(); mockSessionManager.kill.mockReset(); mockExec.mockReset(); + mockExecSilent.mockReset(); + // Default: execSilent returns null (gh not available), so clone falls through to git SSH/HTTPS + mockExecSilent.mockResolvedValue(null); mockWaitForPortAndOpen.mockReset(); mockWaitForPortAndOpen.mockResolvedValue(undefined); mockSpawn.mockClear(); @@ -304,11 +308,55 @@ describe("start command — URL argument", () => { expect(output).toContain("Startup complete"); }); - it("clones repo when not already present", async () => { + it("clones repo via gh when gh auth is available", async () => { const repoDir = join(tmpDir, "my-app"); mockCwd(tmpDir); + // gh auth status succeeds + mockExecSilent.mockResolvedValue("Logged in"); + mockExec.mockImplementation(async (cmd: string, args: string[]) => { + if (cmd === "gh" && args[0] === "repo" && args[1] === "clone") { + createFakeRepo(repoDir, "https://github.com/owner/my-app.git", { + "Cargo.toml": "", + }); + return { stdout: "", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + await program.parseAsync([ + "node", + "test", + "start", + "https://github.com/owner/my-app", + "--no-dashboard", + "--no-orchestrator", + ]); + + expect(mockExec).toHaveBeenCalledWith( + "gh", + ["repo", "clone", "owner/my-app", repoDir, "--", "--depth", "1"], + expect.anything(), + ); + + const output = vi.mocked(console.log).mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("Startup complete"); + }); + + it("falls back to git clone when gh is unavailable", async () => { + const repoDir = join(tmpDir, "my-app"); + mockCwd(tmpDir); + + // gh auth status fails (not installed or not logged in) + mockExecSilent.mockResolvedValue(null); + + mockExec.mockImplementation(async (cmd: string, args: string[]) => { + // SSH attempt fails + if (cmd === "git" && args[0] === "clone" && args[3]?.startsWith("git@")) { + throw new Error("Permission denied (publickey)"); + } + // HTTPS fallback succeeds if (cmd === "git" && args[0] === "clone") { createFakeRepo(repoDir, "https://github.com/owner/my-app.git", { "Cargo.toml": "", @@ -327,6 +375,12 @@ describe("start command — URL argument", () => { "--no-orchestrator", ]); + // Should have tried SSH first, then HTTPS + expect(mockExec).toHaveBeenCalledWith( + "git", + ["clone", "--depth", "1", "git@github.com:owner/my-app.git", repoDir], + expect.anything(), + ); expect(mockExec).toHaveBeenCalledWith( "git", ["clone", "--depth", "1", "https://github.com/owner/my-app.git", repoDir], diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index d3afdb4d5..35b54d257 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -28,7 +28,7 @@ import { type ProjectConfig, type ParsedRepoUrl, } from "@composio/ao-core"; -import { exec } from "../lib/shell.js"; +import { exec, execSilent } from "../lib/shell.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js"; import { cleanNextCache } from "../lib/dashboard-rebuild.js"; @@ -102,6 +102,46 @@ function resolveProjectByRepo( return resolveProject(config); } +/** + * Clone a repo with authentication support. + * + * Strategy: + * 1. Try `gh repo clone owner/repo target -- --depth 1` — handles GitHub auth + * for private repos via the user's `gh auth` token. + * 2. Fall back to `git clone --depth 1` with SSH URL — works for users with + * SSH keys configured (common for private repos without gh). + * 3. Final fallback to `git clone --depth 1` with HTTPS URL — works for + * public repos without any auth setup. + */ +async function cloneRepo(parsed: ParsedRepoUrl, targetDir: string, cwd: string): Promise { + // 1. Try gh repo clone (handles GitHub auth automatically) + if (parsed.host === "github.com") { + const ghAvailable = (await execSilent("gh", ["auth", "status"])) !== null; + if (ghAvailable) { + try { + await exec("gh", ["repo", "clone", parsed.ownerRepo, targetDir, "--", "--depth", "1"], { + cwd, + }); + return; + } catch { + // gh clone failed — fall through to git clone with SSH + } + } + } + + // 2. Try git clone with SSH URL (works with SSH keys for private repos) + const sshUrl = `git@${parsed.host}:${parsed.ownerRepo}.git`; + try { + await exec("git", ["clone", "--depth", "1", sshUrl, targetDir], { cwd }); + return; + } catch { + // SSH failed — fall through to HTTPS + } + + // 3. Final fallback: HTTPS (works for public repos) + await exec("git", ["clone", "--depth", "1", parsed.cloneUrl, targetDir], { cwd }); +} + /** * Handle `ao start ` — clone repo, generate config, return loaded config. * Also returns the parsed URL so the caller can match by repo when the config @@ -126,12 +166,12 @@ async function handleUrlStart(url: string): Promise<{ config: OrchestratorConfig } else { spinner.start(`Cloning ${parsed.ownerRepo}`); try { - await exec("git", ["clone", "--depth", "1", parsed.cloneUrl, targetDir], { cwd }); + await cloneRepo(parsed, targetDir, cwd); spinner.succeed(`Cloned to ${targetDir}`); } catch (err) { spinner.fail("Clone failed"); throw new Error( - `Failed to clone ${parsed.cloneUrl}: ${err instanceof Error ? err.message : String(err)}`, + `Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); }