fix: use gh repo clone for private repo auth in ao start <url> (#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 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-03-03 14:42:35 +05:30 committed by GitHub
parent e72593d4f8
commit b5128cf69f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 100 additions and 6 deletions

View File

@ -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<string, unknown> | 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],

View File

@ -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<void> {
// 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 <url>` 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 },
);
}