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:
parent
e72593d4f8
commit
b5128cf69f
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue