diff --git a/lib/github/__tests__/createGithubRepo.test.ts b/lib/github/__tests__/createGithubRepo.test.ts new file mode 100644 index 00000000..d210ee05 --- /dev/null +++ b/lib/github/__tests__/createGithubRepo.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createGithubRepo } from "../createGithubRepo"; + +const mockGetExistingGithubRepo = vi.fn(); + +vi.mock("../getExistingGithubRepo", () => ({ + getExistingGithubRepo: (...args: unknown[]) => mockGetExistingGithubRepo(...args), +})); + +describe("createGithubRepo", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + }); + + it("creates a repo and returns html_url", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ html_url: "https://github.com/recoupable/my-account-uuid123" }), + { status: 201 }, + ), + ); + + const result = await createGithubRepo("My Account", "uuid123"); + + expect(result).toBe("https://github.com/recoupable/my-account-uuid123"); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/orgs/recoupable/repos", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "my-account-uuid123", private: true }), + }), + ); + }); + + it("falls back to getExistingGithubRepo on 422", async () => { + vi.mocked(fetch).mockResolvedValue(new Response("Already exists", { status: 422 })); + mockGetExistingGithubRepo.mockResolvedValue("https://github.com/recoupable/my-account-uuid123"); + + const result = await createGithubRepo("My Account", "uuid123"); + + expect(result).toBe("https://github.com/recoupable/my-account-uuid123"); + expect(mockGetExistingGithubRepo).toHaveBeenCalledWith("my-account-uuid123"); + }); + + it("returns undefined when GITHUB_TOKEN is missing", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + delete process.env.GITHUB_TOKEN; + + const result = await createGithubRepo("My Account", "uuid123"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined on non-422 error", async () => { + vi.mocked(fetch).mockResolvedValue(new Response("Server error", { status: 500 })); + + const result = await createGithubRepo("My Account", "uuid123"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined on fetch error", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + + const result = await createGithubRepo("My Account", "uuid123"); + + expect(result).toBeUndefined(); + }); + + it("sanitizes account name for repo name", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ html_url: "https://github.com/recoupable/my-account-uuid123" }), + { status: 201 }, + ), + ); + + await createGithubRepo("My @Account!", "uuid123"); + + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/orgs/recoupable/repos", + expect.objectContaining({ + body: JSON.stringify({ name: "my-account-uuid123", private: true }), + }), + ); + }); +}); diff --git a/lib/github/__tests__/createOrgGithubRepo.test.ts b/lib/github/__tests__/createOrgGithubRepo.test.ts new file mode 100644 index 00000000..a77b93de --- /dev/null +++ b/lib/github/__tests__/createOrgGithubRepo.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createOrgGithubRepo } from "../createOrgGithubRepo"; + +const mockGetExistingGithubRepo = vi.fn(); + +vi.mock("../getExistingGithubRepo", () => ({ + getExistingGithubRepo: (...args: unknown[]) => mockGetExistingGithubRepo(...args), +})); + +describe("createOrgGithubRepo", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + }); + + it("creates a repo with auto_init and org- prefix", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ html_url: "https://github.com/recoupable/org-my-org-org123" }), + { status: 201 }, + ), + ); + + const result = await createOrgGithubRepo("My Org", "org123"); + + expect(result).toBe("https://github.com/recoupable/org-my-org-org123"); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/orgs/recoupable/repos", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "org-my-org-org123", + private: true, + auto_init: true, + }), + }), + ); + }); + + it("falls back to getExistingGithubRepo on 422", async () => { + vi.mocked(fetch).mockResolvedValue(new Response("Already exists", { status: 422 })); + mockGetExistingGithubRepo.mockResolvedValue("https://github.com/recoupable/org-my-org-org123"); + + const result = await createOrgGithubRepo("My Org", "org123"); + + expect(result).toBe("https://github.com/recoupable/org-my-org-org123"); + expect(mockGetExistingGithubRepo).toHaveBeenCalledWith("org-my-org-org123"); + }); + + it("returns undefined when GITHUB_TOKEN is missing", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + delete process.env.GITHUB_TOKEN; + + const result = await createOrgGithubRepo("My Org", "org123"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined on non-422 error", async () => { + vi.mocked(fetch).mockResolvedValue(new Response("Server error", { status: 500 })); + + const result = await createOrgGithubRepo("My Org", "org123"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/lib/github/__tests__/getExistingGithubRepo.test.ts b/lib/github/__tests__/getExistingGithubRepo.test.ts new file mode 100644 index 00000000..7a623fab --- /dev/null +++ b/lib/github/__tests__/getExistingGithubRepo.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getExistingGithubRepo } from "../getExistingGithubRepo"; + +describe("getExistingGithubRepo", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + }); + + it("returns html_url on success", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ html_url: "https://github.com/recoupable/my-repo" }), { + status: 200, + }), + ); + + const result = await getExistingGithubRepo("my-repo"); + + expect(result).toBe("https://github.com/recoupable/my-repo"); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/recoupable/my-repo", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns undefined when GITHUB_TOKEN is missing", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + delete process.env.GITHUB_TOKEN; + + const result = await getExistingGithubRepo("my-repo"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined on non-ok response", async () => { + vi.mocked(fetch).mockResolvedValue(new Response("Not found", { status: 404 })); + + const result = await getExistingGithubRepo("missing-repo"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined on fetch error", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + + const result = await getExistingGithubRepo("my-repo"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/lib/github/__tests__/sanitizeRepoName.test.ts b/lib/github/__tests__/sanitizeRepoName.test.ts new file mode 100644 index 00000000..3b2c8ff8 --- /dev/null +++ b/lib/github/__tests__/sanitizeRepoName.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; + +import { sanitizeRepoName } from "../sanitizeRepoName"; + +describe("sanitizeRepoName", () => { + it("lowercases the name", () => { + expect(sanitizeRepoName("MyRepo")).toBe("myrepo"); + }); + + it("replaces spaces with hyphens", () => { + expect(sanitizeRepoName("my repo name")).toBe("my-repo-name"); + }); + + it("replaces special characters with hyphens", () => { + expect(sanitizeRepoName("my@repo!name")).toBe("my-repo-name"); + }); + + it("collapses multiple hyphens", () => { + expect(sanitizeRepoName("my---repo")).toBe("my-repo"); + }); + + it("trims leading and trailing hyphens", () => { + expect(sanitizeRepoName("-my-repo-")).toBe("my-repo"); + }); + + it("returns 'account' for empty string", () => { + expect(sanitizeRepoName("")).toBe("account"); + }); + + it("returns 'account' for string of only special chars", () => { + expect(sanitizeRepoName("@#$%")).toBe("account"); + }); + + it("handles already valid names", () => { + expect(sanitizeRepoName("valid-name")).toBe("valid-name"); + }); +}); diff --git a/lib/github/createGithubRepo.ts b/lib/github/createGithubRepo.ts new file mode 100644 index 00000000..0ed99a4e --- /dev/null +++ b/lib/github/createGithubRepo.ts @@ -0,0 +1,19 @@ +import { sanitizeRepoName } from "./sanitizeRepoName"; +import { createRepoInOrg } from "./createRepoInOrg"; + +/** + * Creates a private GitHub repository in the recoupable organization. + * + * @param accountName - The account display name + * @param accountId - The account UUID + * @returns The repository HTML URL, or undefined on error + */ +export async function createGithubRepo( + accountName: string, + accountId: string, +): Promise { + const sanitizedName = sanitizeRepoName(accountName); + const repoName = `${sanitizedName}-${accountId}`; + + return createRepoInOrg({ repoName }); +} diff --git a/lib/github/createOrgGithubRepo.ts b/lib/github/createOrgGithubRepo.ts new file mode 100644 index 00000000..9ed69e80 --- /dev/null +++ b/lib/github/createOrgGithubRepo.ts @@ -0,0 +1,22 @@ +import { sanitizeRepoName } from "./sanitizeRepoName"; +import { createRepoInOrg } from "./createRepoInOrg"; + +/** + * Creates a private GitHub repository for an organization in the + * recoupable GitHub organization. + * + * Repo naming: `org-{sanitizedName}-{orgId}` + * + * @param orgName - The organization display name + * @param orgId - The organization UUID + * @returns The repository HTML URL, or undefined on error + */ +export async function createOrgGithubRepo( + orgName: string, + orgId: string, +): Promise { + const sanitizedName = sanitizeRepoName(orgName); + const repoName = `org-${sanitizedName}-${orgId}`; + + return createRepoInOrg({ repoName, autoInit: true }); +} diff --git a/lib/github/createRepoInOrg.ts b/lib/github/createRepoInOrg.ts new file mode 100644 index 00000000..c0fa88ab --- /dev/null +++ b/lib/github/createRepoInOrg.ts @@ -0,0 +1,63 @@ +import { getExistingGithubRepo } from "./getExistingGithubRepo"; + +const GITHUB_ORG = "recoupable"; + +interface CreateRepoInOrgOptions { + repoName: string; + autoInit?: boolean; +} + +/** + * Creates a private GitHub repository in the recoupable organization. + * Falls back to fetching the existing repo URL on 422 (already exists). + * + * @param options - The repo name and optional auto_init flag + * @returns The repository HTML URL, or undefined on error + */ +export async function createRepoInOrg(options: CreateRepoInOrgOptions): Promise { + const { repoName, autoInit } = options; + + const token = process.env.GITHUB_TOKEN; + + if (!token) { + console.error("Missing GITHUB_TOKEN environment variable"); + return undefined; + } + + try { + const response = await fetch(`https://api.github.com/orgs/${GITHUB_ORG}/repos`, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + name: repoName, + private: true, + ...(autoInit ? { auto_init: true } : {}), + }), + }); + + if (!response.ok) { + if (response.status === 422) { + return getExistingGithubRepo(repoName); + } + + console.error("Failed to create GitHub repo", { + repoName, + status: response.status, + }); + return undefined; + } + + const data = (await response.json()) as { html_url: string }; + return data.html_url; + } catch (error) { + console.error("Error creating GitHub repo", { + repoName, + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } +} diff --git a/lib/github/getExistingGithubRepo.ts b/lib/github/getExistingGithubRepo.ts new file mode 100644 index 00000000..964d8ca3 --- /dev/null +++ b/lib/github/getExistingGithubRepo.ts @@ -0,0 +1,42 @@ +const GITHUB_ORG = "recoupable"; + +/** + * Fetches an existing GitHub repository URL from the recoupable organization. + * + * @param repoName - The full repository name (e.g. "account-name-uuid") + * @returns The repository HTML URL, or undefined if not found or on error + */ +export async function getExistingGithubRepo(repoName: string): Promise { + const token = process.env.GITHUB_TOKEN; + + if (!token) { + console.error("Missing GITHUB_TOKEN environment variable"); + return undefined; + } + + try { + const response = await fetch(`https://api.github.com/repos/${GITHUB_ORG}/${repoName}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + console.error("Failed to fetch existing GitHub repo", { + status: response.status, + }); + return undefined; + } + + const data = (await response.json()) as { html_url: string }; + return data.html_url; + } catch (error) { + console.error("Error fetching existing GitHub repo", { + repoName, + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } +} diff --git a/lib/github/sanitizeRepoName.ts b/lib/github/sanitizeRepoName.ts new file mode 100644 index 00000000..b139e6ff --- /dev/null +++ b/lib/github/sanitizeRepoName.ts @@ -0,0 +1,16 @@ +/** + * Sanitizes a string for use as a GitHub repository name. + * Lowercases, replaces spaces and special characters with hyphens, + * collapses multiple hyphens, and trims leading/trailing hyphens. + * + * @param name - The raw name to sanitize + * @returns A valid GitHub repo name + */ +export function sanitizeRepoName(name: string): string { + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + return sanitized || "account"; +} diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index 1530afad..e38bbd9b 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createSandbox } from "../createSandbox"; -import { Sandbox } from "@vercel/sandbox"; +import { Sandbox, APIError } from "@vercel/sandbox"; const mockSandbox = { sandboxId: "sbx_test123", @@ -10,11 +10,15 @@ const mockSandbox = { createdAt: new Date("2024-01-01T00:00:00Z"), }; -vi.mock("@vercel/sandbox", () => ({ - Sandbox: { - create: vi.fn(() => Promise.resolve(mockSandbox)), - }, -})); +vi.mock("@vercel/sandbox", async () => { + const actual = await vi.importActual("@vercel/sandbox"); + return { + ...actual, + Sandbox: { + create: vi.fn(() => Promise.resolve(mockSandbox)), + }, + }; +}); vi.mock("ms", () => ({ default: vi.fn((str: string) => { @@ -79,6 +83,44 @@ describe("createSandbox", () => { }); }); + it("re-throws APIError with detailed message including API response json", async () => { + const fakeResponse = new Response("Bad Request", { status: 400, statusText: "Bad Request" }); + const apiError = new APIError(fakeResponse, { + message: "Status code 400 is not ok", + json: { error: { code: "bad_request", message: "vcpus must be between 0.5 and 2" } }, + text: '{"error":{"code":"bad_request","message":"vcpus must be between 0.5 and 2"}}', + }); + vi.mocked(Sandbox.create).mockRejectedValue(apiError); + + await expect(createSandbox()).rejects.toThrow("vcpus must be between 0.5 and 2"); + }); + + it("logs API error details to console.error", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const fakeResponse = new Response("Bad Request", { status: 400, statusText: "Bad Request" }); + const apiError = new APIError(fakeResponse, { + message: "Status code 400 is not ok", + json: { error: { code: "bad_request", message: "quota exceeded" } }, + text: '{"error":{"code":"bad_request","message":"quota exceeded"}}', + }); + vi.mocked(Sandbox.create).mockRejectedValue(apiError); + + await expect(createSandbox()).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Sandbox.create failed"), + expect.objectContaining({ json: apiError.json }), + ); + consoleSpy.mockRestore(); + }); + + it("re-throws non-APIError errors unchanged", async () => { + const genericError = new Error("Network timeout"); + vi.mocked(Sandbox.create).mockRejectedValue(genericError); + + await expect(createSandbox()).rejects.toThrow("Network timeout"); + }); + it("does not stop sandbox after creation", async () => { const mockSandboxWithStop = { ...mockSandbox, diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index b21b1406..f5406ca5 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -12,13 +12,11 @@ vi.mock("@/lib/sandbox/createSandbox", () => ({ })); vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ - selectAccountSnapshots: (...args: unknown[]) => - mockSelectAccountSnapshots(...args), + selectAccountSnapshots: (...args: unknown[]) => mockSelectAccountSnapshots(...args), })); vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ - insertAccountSandbox: (...args: unknown[]) => - mockInsertAccountSandbox(...args), + insertAccountSandbox: (...args: unknown[]) => mockInsertAccountSandbox(...args), })); describe("createSandboxFromSnapshot", () => { @@ -76,11 +74,27 @@ describe("createSandboxFromSnapshot", () => { }); }); - it("returns Sandbox instance", async () => { + it("returns { sandbox, fromSnapshot: false } when no snapshot exists", async () => { mockSelectAccountSnapshots.mockResolvedValue([]); const result = await createSandboxFromSnapshot("acc_1"); - expect(result).toBe(mockSandbox); + expect(result).toEqual({ + sandbox: mockSandbox, + fromSnapshot: false, + }); + }); + + it("returns { sandbox, fromSnapshot: true } when snapshot exists", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: "snap_abc", account_id: "acc_1" }, + ]); + + const result = await createSandboxFromSnapshot("acc_1"); + + expect(result).toEqual({ + sandbox: mockSandbox, + fromSnapshot: true, + }); }); }); diff --git a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts index 733fdc7a..28736345 100644 --- a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts @@ -11,8 +11,7 @@ vi.mock("../getActiveSandbox", () => ({ })); vi.mock("../createSandboxFromSnapshot", () => ({ - createSandboxFromSnapshot: (...args: unknown[]) => - mockCreateSandboxFromSnapshot(...args), + createSandboxFromSnapshot: (...args: unknown[]) => mockCreateSandboxFromSnapshot(...args), })); describe("getOrCreateSandbox", () => { @@ -20,7 +19,7 @@ describe("getOrCreateSandbox", () => { vi.clearAllMocks(); }); - it("returns existing sandbox with created=false", async () => { + it("returns existing sandbox with created=false and fromSnapshot=true", async () => { const mockSandbox = { sandboxId: "sbx_existing", status: "running", @@ -34,18 +33,22 @@ describe("getOrCreateSandbox", () => { sandbox: mockSandbox, sandboxId: "sbx_existing", created: false, + fromSnapshot: true, }); expect(mockCreateSandboxFromSnapshot).not.toHaveBeenCalled(); }); - it("creates new sandbox when none active with created=true", async () => { + it("creates new sandbox from snapshot with created=true and fromSnapshot=true", async () => { const mockSandbox = { sandboxId: "sbx_new", status: "running", } as unknown as Sandbox; mockGetActiveSandbox.mockResolvedValue(null); - mockCreateSandboxFromSnapshot.mockResolvedValue(mockSandbox); + mockCreateSandboxFromSnapshot.mockResolvedValue({ + sandbox: mockSandbox, + fromSnapshot: true, + }); const result = await getOrCreateSandbox("acc_1"); @@ -53,7 +56,30 @@ describe("getOrCreateSandbox", () => { sandbox: mockSandbox, sandboxId: "sbx_new", created: true, + fromSnapshot: true, }); expect(mockCreateSandboxFromSnapshot).toHaveBeenCalledWith("acc_1"); }); + + it("creates fresh sandbox with created=true and fromSnapshot=false", async () => { + const mockSandbox = { + sandboxId: "sbx_fresh", + status: "running", + } as unknown as Sandbox; + + mockGetActiveSandbox.mockResolvedValue(null); + mockCreateSandboxFromSnapshot.mockResolvedValue({ + sandbox: mockSandbox, + fromSnapshot: false, + }); + + const result = await getOrCreateSandbox("acc_1"); + + expect(result).toEqual({ + sandbox: mockSandbox, + sandboxId: "sbx_fresh", + created: true, + fromSnapshot: false, + }); + }); }); diff --git a/lib/sandbox/__tests__/promptSandboxStreaming.test.ts b/lib/sandbox/__tests__/promptSandboxStreaming.test.ts index ef5e07bb..e7b31e0a 100644 --- a/lib/sandbox/__tests__/promptSandboxStreaming.test.ts +++ b/lib/sandbox/__tests__/promptSandboxStreaming.test.ts @@ -9,223 +9,395 @@ vi.mock("../getOrCreateSandbox", () => ({ getOrCreateSandbox: (...args: unknown[]) => mockGetOrCreateSandbox(...args), })); +const mockSetupFreshSandbox = vi.fn(); + +vi.mock("@/lib/sandbox/setup/setupFreshSandbox", () => ({ + setupFreshSandbox: (...args: unknown[]) => mockSetupFreshSandbox(...args), +})); + +const mockPushSandboxToGithub = vi.fn(); + +vi.mock("@/lib/sandbox/setup/pushSandboxToGithub", () => ({ + pushSandboxToGithub: (...args: unknown[]) => mockPushSandboxToGithub(...args), +})); + +const mockUpsertAccountSnapshot = vi.fn(); + +vi.mock("@/lib/supabase/account_snapshots/upsertAccountSnapshot", () => ({ + upsertAccountSnapshot: (...args: unknown[]) => mockUpsertAccountSnapshot(...args), +})); + describe("promptSandboxStreaming", () => { const mockRunCommand = vi.fn(); + const mockSnapshot = vi.fn(); const mockSandbox = { sandboxId: "sbx_123", status: "running", runCommand: mockRunCommand, + snapshot: mockSnapshot, } as unknown as Sandbox; beforeEach(() => { vi.clearAllMocks(); - }); - - it("yields log chunks in order and returns final result", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, + mockSnapshot.mockResolvedValue({ + snapshotId: "snap_new", + expiresAt: new Date("2025-01-01T00:00:00.000Z"), }); + mockUpsertAccountSnapshot.mockResolvedValue({ data: {}, error: null }); + mockPushSandboxToGithub.mockResolvedValue(true); + }); + /** + * + * @param entries + */ + function makeFakeLogs(entries: Array<{ data: string; stream: "stdout" | "stderr" }>) { + /** + * + */ async function* fakeLogs() { - yield { data: "Hello ", stream: "stdout" as const }; - yield { data: "world", stream: "stdout" as const }; + for (const entry of entries) { + yield entry; + } } - - const mockCmd = { + return { logs: () => fakeLogs(), wait: vi.fn().mockResolvedValue({ exitCode: 0 }), }; - mockRunCommand.mockResolvedValue(mockCmd); - - const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; - let finalResult; - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "say hello", + } + + describe("existing sandbox (fromSnapshot=true)", () => { + beforeEach(() => { + mockGetOrCreateSandbox.mockResolvedValue({ + sandbox: mockSandbox, + sandboxId: "sbx_123", + created: false, + fromSnapshot: true, + }); }); - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; - } - chunks.push( - result.value as { data: string; stream: "stdout" | "stderr" }, + it("yields log chunks in order and returns final result", async () => { + mockRunCommand.mockResolvedValue( + makeFakeLogs([ + { data: "Hello ", stream: "stdout" }, + { data: "world", stream: "stdout" }, + ]), ); - } - expect(chunks).toEqual([ - { data: "Hello ", stream: "stdout" }, - { data: "world", stream: "stdout" }, - ]); - - expect(finalResult).toEqual({ - sandboxId: "sbx_123", - stdout: "Hello world", - stderr: "", - exitCode: 0, - created: false, - }); - }); + const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + let finalResult; + + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "say hello", + }); + + while (true) { + const result = await gen.next(); + if (result.done) { + finalResult = result.value; + break; + } + chunks.push(result.value as { data: string; stream: "stdout" | "stderr" }); + } - it("accumulates stderr separately", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, + expect(chunks).toEqual([ + { data: "Hello ", stream: "stdout" }, + { data: "world", stream: "stdout" }, + ]); + + expect(finalResult).toEqual({ + sandboxId: "sbx_123", + stdout: "Hello world", + stderr: "", + exitCode: 0, + created: false, + }); }); - async function* fakeLogs() { - yield { data: "output", stream: "stdout" as const }; - yield { data: "warn: something", stream: "stderr" as const }; - } + it("does NOT run setup for snapshot-based sandboxes", async () => { + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "done", stream: "stdout" }])); - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 0 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); - let finalResult; + for await (const _ of gen) { + // consume + } - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "test", + expect(mockSetupFreshSandbox).not.toHaveBeenCalled(); }); - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; + it("does NOT push to GitHub or snapshot for existing sandboxes", async () => { + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "done", stream: "stdout" }])); + + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); + + for await (const _ of gen) { + // consume } - } - expect(finalResult).toEqual({ - sandboxId: "sbx_123", - stdout: "output", - stderr: "warn: something", - exitCode: 0, - created: false, + expect(mockPushSandboxToGithub).not.toHaveBeenCalled(); + expect(mockSnapshot).not.toHaveBeenCalled(); }); - }); - it("uses detached mode with runCommand", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, + it("accumulates stderr separately", async () => { + mockRunCommand.mockResolvedValue( + makeFakeLogs([ + { data: "output", stream: "stdout" }, + { data: "warn: something", stream: "stderr" }, + ]), + ); + + let finalResult; + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); + + while (true) { + const result = await gen.next(); + if (result.done) { + finalResult = result.value; + break; + } + } + + expect(finalResult).toEqual({ + sandboxId: "sbx_123", + stdout: "output", + stderr: "warn: something", + exitCode: 0, + created: false, + }); }); - async function* fakeLogs() { - yield { data: "done", stream: "stdout" as const }; - } + it("uses detached mode with runCommand", async () => { + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "done", stream: "stdout" }])); - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 0 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "run", + }); - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "run", + for await (const _ of gen) { + // consume + } + + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "openclaw", + args: ["agent", "--agent", "main", "--message", "run"], + env: { RECOUP_API_KEY: "key_abc" }, + detached: true, + }); }); - // Drain the generator - for await (const _ of gen) { - // consume - } + it("handles non-zero exit code", async () => { + const mockCmd = { + logs: () => + (async function* () { + yield { data: "error output", stream: "stderr" as const }; + })(), + wait: vi.fn().mockResolvedValue({ exitCode: 1 }), + }; + mockRunCommand.mockResolvedValue(mockCmd); + + let finalResult; + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "bad command", + }); + + while (true) { + const result = await gen.next(); + if (result.done) { + finalResult = result.value; + break; + } + } - expect(mockRunCommand).toHaveBeenCalledWith({ - cmd: "openclaw", - args: ["agent", "--agent", "main", "--message", "run"], - env: { RECOUP_API_KEY: "key_abc" }, - detached: true, + expect(finalResult).toEqual({ + sandboxId: "sbx_123", + stdout: "", + stderr: "error output", + exitCode: 1, + created: false, + }); }); }); - it("reports created=true when sandbox was newly created", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_new", - created: true, + describe("fresh sandbox (created=true, fromSnapshot=false)", () => { + beforeEach(() => { + mockGetOrCreateSandbox.mockResolvedValue({ + sandbox: mockSandbox, + sandboxId: "sbx_123", + created: true, + fromSnapshot: false, + }); }); - async function* fakeLogs() { - yield { data: "setup done", stream: "stdout" as const }; - } + it("runs setupFreshSandbox before the prompt", async () => { + /** + * + */ + async function* fakeSetup() { + yield { data: "[Setup] Installing...\n", stream: "stderr" as const }; + yield { data: "[Setup] Done!\n", stream: "stderr" as const }; + return "https://github.com/recoupable/repo"; + } + mockSetupFreshSandbox.mockReturnValue(fakeSetup()); - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 0 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "prompt result", stream: "stdout" }])); + + const chunks: Array<{ data: string; stream: string }> = []; + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); + + while (true) { + const result = await gen.next(); + if (result.done) break; + chunks.push(result.value as { data: string; stream: string }); + } - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "setup", + // Setup messages come first, then prompt output + expect(chunks[0].data).toContain("[Setup]"); + expect(chunks[chunks.length - 1].data).toBe("prompt result"); }); - let finalResult; - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; + it("pushes to GitHub and snapshots after prompt completes", async () => { + /** + * + */ + async function* fakeSetup() { + yield { data: "[Setup] Done!\n", stream: "stderr" as const }; + return "https://github.com/recoupable/repo"; } - } + mockSetupFreshSandbox.mockReturnValue(fakeSetup()); - expect(finalResult!.created).toBe(true); - expect(finalResult!.sandboxId).toBe("sbx_new"); - }); + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "done", stream: "stdout" }])); - it("handles non-zero exit code", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); + + for await (const _ of gen) { + // consume + } + + expect(mockPushSandboxToGithub).toHaveBeenCalledWith( + mockSandbox, + expect.objectContaining({ log: expect.any(Function) }), + ); + expect(mockSnapshot).toHaveBeenCalled(); + expect(mockUpsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_1", + snapshot_id: "snap_new", + expires_at: "2025-01-01T00:00:00.000Z", + github_repo: "https://github.com/recoupable/repo", + }), + ); }); - async function* fakeLogs() { - yield { data: "error output", stream: "stderr" as const }; - } + it("still returns final result with created=true", async () => { + /** + * + */ + async function* fakeSetup() { + yield { data: "[Setup] Done!\n", stream: "stderr" as const }; + return undefined; + } + mockSetupFreshSandbox.mockReturnValue(fakeSetup()); + + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "output", stream: "stdout" }])); + + let finalResult; + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); + + while (true) { + const result = await gen.next(); + if (result.done) { + finalResult = result.value; + break; + } + } - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 1 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); + expect(finalResult!.created).toBe(true); + expect(finalResult!.sandboxId).toBe("sbx_123"); + }); + + it("snapshots even when push fails", async () => { + /** + * + */ + async function* fakeSetup() { + yield { data: "[Setup] Done!\n", stream: "stderr" as const }; + return "https://github.com/recoupable/repo"; + } + mockSetupFreshSandbox.mockReturnValue(fakeSetup()); + mockPushSandboxToGithub.mockResolvedValue(false); + + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "done", stream: "stdout" }])); + + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "bad command", + for await (const _ of gen) { + // consume + } + + // Should still snapshot even if push failed + expect(mockSnapshot).toHaveBeenCalled(); + expect(mockUpsertAccountSnapshot).toHaveBeenCalled(); }); + }); - let finalResult; - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; + describe("created from snapshot (created=true, fromSnapshot=true)", () => { + it("does NOT run setup", async () => { + mockGetOrCreateSandbox.mockResolvedValue({ + sandbox: mockSandbox, + sandboxId: "sbx_snap", + created: true, + fromSnapshot: true, + }); + + mockRunCommand.mockResolvedValue(makeFakeLogs([{ data: "done", stream: "stdout" }])); + + const gen = promptSandboxStreaming({ + accountId: "acc_1", + apiKey: "key_abc", + prompt: "test", + }); + + for await (const _ of gen) { + // consume } - } - expect(finalResult).toEqual({ - sandboxId: "sbx_123", - stdout: "", - stderr: "error output", - exitCode: 1, - created: false, + expect(mockSetupFreshSandbox).not.toHaveBeenCalled(); + expect(mockPushSandboxToGithub).not.toHaveBeenCalled(); }); }); }); diff --git a/lib/sandbox/createSandbox.ts b/lib/sandbox/createSandbox.ts index b26e4472..b703ec52 100644 --- a/lib/sandbox/createSandbox.ts +++ b/lib/sandbox/createSandbox.ts @@ -1,5 +1,5 @@ import ms from "ms"; -import { Sandbox } from "@vercel/sandbox"; +import { Sandbox, APIError } from "@vercel/sandbox"; export interface SandboxCreatedResponse { sandboxId: Sandbox["sandboxId"]; @@ -37,19 +37,40 @@ export async function createSandbox( params.source && "type" in params.source && params.source.type === "snapshot"; // Pass params directly to SDK - it handles all the type variants - const sandbox = await Sandbox.create( - hasSnapshotSource - ? { - ...params, - timeout: params.timeout ?? DEFAULT_TIMEOUT, - } - : { - resources: { vcpus: DEFAULT_VCPUS }, - timeout: params.timeout ?? DEFAULT_TIMEOUT, - runtime: DEFAULT_RUNTIME, - ...params, - }, - ); + const createParams = hasSnapshotSource + ? { + ...params, + timeout: params.timeout ?? DEFAULT_TIMEOUT, + } + : { + resources: { vcpus: DEFAULT_VCPUS }, + timeout: params.timeout ?? DEFAULT_TIMEOUT, + runtime: DEFAULT_RUNTIME, + ...params, + }; + + let sandbox: Sandbox; + try { + sandbox = await Sandbox.create(createParams); + } catch (error) { + if (error instanceof APIError) { + const apiJson = error.json as Record | undefined; + const detail = + (apiJson?.error as Record)?.message ?? + error.text ?? + error.message; + + console.error("Sandbox.create failed", { + status: error.response?.status, + json: apiJson, + text: error.text, + params: createParams, + }); + + throw new Error(`Sandbox creation failed: ${detail}`); + } + throw error; + } return { sandbox, diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index bf9cd61c..900d2c8c 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -3,16 +3,21 @@ import { createSandbox } from "@/lib/sandbox/createSandbox"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; +export interface CreateSandboxFromSnapshotResult { + sandbox: Sandbox; + fromSnapshot: boolean; +} + /** * Creates a new sandbox from the account's latest snapshot (or fresh if none) * and records it in the database. * * @param accountId - The account ID to create a sandbox for - * @returns The created Sandbox instance + * @returns The created Sandbox instance and whether it was created from a snapshot */ export async function createSandboxFromSnapshot( accountId: string, -): Promise { +): Promise { const snapshots = await selectAccountSnapshots(accountId); const snapshotId = snapshots[0]?.snapshot_id; @@ -25,5 +30,8 @@ export async function createSandboxFromSnapshot( sandbox_id: response.sandboxId, }); - return sandbox; + return { + sandbox, + fromSnapshot: !!snapshotId, + }; } diff --git a/lib/sandbox/getOrCreateSandbox.ts b/lib/sandbox/getOrCreateSandbox.ts index f5068c15..de9366e8 100644 --- a/lib/sandbox/getOrCreateSandbox.ts +++ b/lib/sandbox/getOrCreateSandbox.ts @@ -6,17 +6,16 @@ export interface GetOrCreateSandboxResult { sandbox: Sandbox; sandboxId: string; created: boolean; + fromSnapshot: boolean; } /** * Returns an active sandbox for the account, creating one if none exists. * * @param accountId - The account ID to get or create a sandbox for - * @returns The sandbox instance, its ID, and whether it was newly created + * @returns The sandbox instance, its ID, whether it was newly created, and whether it came from a snapshot */ -export async function getOrCreateSandbox( - accountId: string, -): Promise { +export async function getOrCreateSandbox(accountId: string): Promise { const existing = await getActiveSandbox(accountId); if (existing) { @@ -24,14 +23,16 @@ export async function getOrCreateSandbox( sandbox: existing, sandboxId: existing.sandboxId, created: false, + fromSnapshot: true, }; } - const sandbox = await createSandboxFromSnapshot(accountId); + const { sandbox, fromSnapshot } = await createSandboxFromSnapshot(accountId); return { sandbox, sandboxId: sandbox.sandboxId, created: true, + fromSnapshot, }; } diff --git a/lib/sandbox/promptSandboxStreaming.ts b/lib/sandbox/promptSandboxStreaming.ts index 1b9ec52d..9a00b1be 100644 --- a/lib/sandbox/promptSandboxStreaming.ts +++ b/lib/sandbox/promptSandboxStreaming.ts @@ -1,4 +1,8 @@ import { getOrCreateSandbox } from "./getOrCreateSandbox"; +import { setupFreshSandbox } from "@/lib/sandbox/setup/setupFreshSandbox"; +import { pushSandboxToGithub } from "@/lib/sandbox/setup/pushSandboxToGithub"; +import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; +import type { SetupDeps } from "@/lib/sandbox/setup/types"; interface PromptSandboxStreamingInput { accountId: string; @@ -17,7 +21,8 @@ interface PromptSandboxStreamingResult { /** * Streams output from OpenClaw running inside a persistent per-account sandbox. - * Yields log chunks as they arrive, then returns the full result. + * For fresh sandboxes (no snapshot), runs the full setup pipeline inline first. + * After prompt completion on fresh sandboxes, pushes to GitHub and snapshots. * * @param input - The account ID, API key, prompt, and optional abort signal * @yields Log chunks with data and stream type (stdout/stderr) @@ -32,9 +37,26 @@ export async function* promptSandboxStreaming( > { const { accountId, apiKey, prompt, abortSignal } = input; - const { sandbox, sandboxId, created } = - await getOrCreateSandbox(accountId); + const { sandbox, sandboxId, created, fromSnapshot } = await getOrCreateSandbox(accountId); + const isFreshSandbox = created && !fromSnapshot; + let githubRepo: string | undefined; + + // Run inline setup for fresh sandboxes + if (isFreshSandbox) { + const setupGen = setupFreshSandbox({ sandbox, accountId, apiKey }); + + while (true) { + const next = await setupGen.next(); + if (next.done) { + githubRepo = next.value; + break; + } + yield next.value as { data: string; stream: "stderr" }; + } + } + + // Execute the user's prompt const cmd = await sandbox.runCommand({ cmd: "openclaw", args: ["agent", "--agent", "main", "--message", prompt], @@ -58,6 +80,26 @@ export async function* promptSandboxStreaming( const { exitCode } = await cmd.wait(); + // Post-prompt: push to GitHub and snapshot for fresh sandboxes + if (isFreshSandbox) { + const deps: SetupDeps = { + log: msg => console.log(`[PostSetup] ${msg}`), + error: (msg, data) => console.error(`[PostSetup] ${msg}`, data), + }; + + await pushSandboxToGithub(sandbox, deps); + + const snapshotResult = await sandbox.snapshot(); + await upsertAccountSnapshot({ + account_id: accountId, + snapshot_id: snapshotResult.snapshotId, + expires_at: ( + snapshotResult.expiresAt ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + ).toISOString(), + github_repo: githubRepo, + }); + } + return { sandboxId, stdout, diff --git a/lib/sandbox/setup/__tests__/ensureGithubRepo.test.ts b/lib/sandbox/setup/__tests__/ensureGithubRepo.test.ts new file mode 100644 index 00000000..3a84ad18 --- /dev/null +++ b/lib/sandbox/setup/__tests__/ensureGithubRepo.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { ensureGithubRepo } from "../ensureGithubRepo"; +import type { SetupDeps } from "../types"; + +const mockSelectAccountSnapshots = vi.fn(); +const mockSelectAccounts = vi.fn(); +const mockCreateGithubRepo = vi.fn(); +const mockUpsertAccountSnapshot = vi.fn(); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: (...args: unknown[]) => mockSelectAccountSnapshots(...args), +})); + +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: (...args: unknown[]) => mockSelectAccounts(...args), +})); + +vi.mock("@/lib/github/createGithubRepo", () => ({ + createGithubRepo: (...args: unknown[]) => mockCreateGithubRepo(...args), +})); + +vi.mock("@/lib/supabase/account_snapshots/upsertAccountSnapshot", () => ({ + upsertAccountSnapshot: (...args: unknown[]) => mockUpsertAccountSnapshot(...args), +})); + +describe("ensureGithubRepo", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + }); + + it("returns undefined when GITHUB_TOKEN is missing", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + delete process.env.GITHUB_TOKEN; + + const result = await ensureGithubRepo(mockSandbox, "acc_1", deps); + + expect(result).toBeUndefined(); + }); + + it("uses existing github_repo from snapshot", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { + account_id: "acc_1", + github_repo: "https://github.com/recoupable/my-repo", + snapshot_id: "snap_1", + }, + ]); + + // .git exists + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + const result = await ensureGithubRepo(mockSandbox, "acc_1", deps); + + expect(result).toBe("https://github.com/recoupable/my-repo"); + expect(mockCreateGithubRepo).not.toHaveBeenCalled(); + }); + + it("creates repo when none exists", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { account_id: "acc_1", github_repo: null, snapshot_id: "snap_1" }, + ]); + mockSelectAccounts.mockResolvedValue([{ id: "acc_1", name: "Test Account" }]); + mockCreateGithubRepo.mockResolvedValue("https://github.com/recoupable/test-account-acc_1"); + mockUpsertAccountSnapshot.mockResolvedValue({ data: {}, error: null }); + + // .git check returns exists (after clone succeeds) + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + const result = await ensureGithubRepo(mockSandbox, "acc_1", deps); + + expect(result).toBe("https://github.com/recoupable/test-account-acc_1"); + expect(mockCreateGithubRepo).toHaveBeenCalledWith("Test Account", "acc_1"); + expect(mockUpsertAccountSnapshot).toHaveBeenCalled(); + }); + + it("clones repo when .git not present", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { + account_id: "acc_1", + github_repo: "https://github.com/recoupable/my-repo", + snapshot_id: "snap_1", + }, + ]); + + // First call: .git check (not present) + // Subsequent calls: git init, remote add, fetch, rev-parse, checkout, config, submodule + mockRunCommand + .mockResolvedValueOnce({ exitCode: 1 }) // test -d .git fails + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }) // git init + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }) // git remote add + .mockResolvedValueOnce({ exitCode: 0 }) // git fetch + .mockResolvedValueOnce({ exitCode: 0 }) // rev-parse origin/main + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }) // checkout + .mockResolvedValueOnce({ exitCode: 0 }) // git config url + .mockResolvedValueOnce({ exitCode: 0 }); // submodule update + + const result = await ensureGithubRepo(mockSandbox, "acc_1", deps); + + expect(result).toBe("https://github.com/recoupable/my-repo"); + // Verify git init was called + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "git", + args: ["init"], + }); + }); + + it("returns undefined when account not found for repo creation", async () => { + mockSelectAccountSnapshots.mockResolvedValue([]); + mockSelectAccounts.mockResolvedValue([]); + + const result = await ensureGithubRepo(mockSandbox, "acc_1", deps); + + expect(result).toBeUndefined(); + }); +}); diff --git a/lib/sandbox/setup/__tests__/ensureOrgRepos.test.ts b/lib/sandbox/setup/__tests__/ensureOrgRepos.test.ts new file mode 100644 index 00000000..516de3db --- /dev/null +++ b/lib/sandbox/setup/__tests__/ensureOrgRepos.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { ensureOrgRepos } from "../ensureOrgRepos"; +import type { SetupDeps } from "../types"; + +const mockGetAccountOrganizations = vi.fn(); +const mockCreateOrgGithubRepo = vi.fn(); + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: (...args: unknown[]) => mockGetAccountOrganizations(...args), +})); + +vi.mock("@/lib/github/createOrgGithubRepo", () => ({ + createOrgGithubRepo: (...args: unknown[]) => mockCreateOrgGithubRepo(...args), +})); + +vi.mock("../helpers", () => ({ + runOpenClawAgent: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }), +})); + +describe("ensureOrgRepos", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + }); + + it("returns early when GITHUB_TOKEN missing", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + delete process.env.GITHUB_TOKEN; + + await ensureOrgRepos(mockSandbox, "acc_1", deps); + + expect(mockGetAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("returns early when no orgs found", async () => { + mockGetAccountOrganizations.mockResolvedValue([]); + + await ensureOrgRepos(mockSandbox, "acc_1", deps); + + expect(mockCreateOrgGithubRepo).not.toHaveBeenCalled(); + }); + + it("creates repos for each org", async () => { + mockGetAccountOrganizations.mockResolvedValue([ + { + account_id: "acc_1", + organization_id: "org_1", + organization: { id: "org_1", name: "My Org" }, + }, + ]); + mockCreateOrgGithubRepo.mockResolvedValue("https://github.com/recoupable/org-my-org-org_1"); + + await ensureOrgRepos(mockSandbox, "acc_1", deps); + + expect(mockCreateOrgGithubRepo).toHaveBeenCalledWith("My Org", "org_1"); + }); +}); diff --git a/lib/sandbox/setup/__tests__/ensureSetupSandbox.test.ts b/lib/sandbox/setup/__tests__/ensureSetupSandbox.test.ts new file mode 100644 index 00000000..73cd30a3 --- /dev/null +++ b/lib/sandbox/setup/__tests__/ensureSetupSandbox.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { ensureSetupSandbox } from "../ensureSetupSandbox"; +import type { SetupDeps } from "../types"; + +const mockInstallSkill = vi.fn(); +const mockRunOpenClawAgent = vi.fn(); + +vi.mock("../helpers", () => ({ + installSkill: (...args: unknown[]) => mockInstallSkill(...args), + runOpenClawAgent: (...args: unknown[]) => mockRunOpenClawAgent(...args), +})); + +describe("ensureSetupSandbox", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("RECOUP_API_KEY", "test-api-key"); + mockRunOpenClawAgent.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + it("skips when orgs/ directory exists", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); // test -d orgs/ succeeds + + await ensureSetupSandbox(mockSandbox, "acc_1", "api-key-123", deps); + + expect(mockInstallSkill).not.toHaveBeenCalled(); + }); + + it("installs all three skills when orgs/ missing", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); // test -d orgs/ fails + + await ensureSetupSandbox(mockSandbox, "acc_1", "api-key-123", deps); + + expect(mockInstallSkill).toHaveBeenCalledWith(mockSandbox, "recoupable/setup-sandbox", deps); + expect(mockInstallSkill).toHaveBeenCalledWith(mockSandbox, "recoupable/setup-artist", deps); + expect(mockInstallSkill).toHaveBeenCalledWith( + mockSandbox, + "recoupable/release-management", + deps, + ); + }); + + it("runs setup-sandbox and setup-artist skills", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); + + await ensureSetupSandbox(mockSandbox, "acc_1", "api-key-123", deps); + + // setup-sandbox call + expect(mockRunOpenClawAgent).toHaveBeenCalledWith( + mockSandbox, + expect.objectContaining({ + label: expect.stringContaining("setup-sandbox"), + message: expect.stringContaining("setup-sandbox"), + env: expect.objectContaining({ + RECOUP_API_KEY: "api-key-123", + RECOUP_ACCOUNT_ID: "acc_1", + }), + }), + deps, + ); + + // setup-artist call + expect(mockRunOpenClawAgent).toHaveBeenCalledWith( + mockSandbox, + expect.objectContaining({ + label: expect.stringContaining("setup-artist"), + message: expect.stringContaining("setup-artist"), + }), + deps, + ); + }); + + it("throws when setup-sandbox fails", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); + mockRunOpenClawAgent.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "error" }); + + await expect(ensureSetupSandbox(mockSandbox, "acc_1", "api-key-123", deps)).rejects.toThrow( + "Failed to set up sandbox", + ); + }); +}); diff --git a/lib/sandbox/setup/__tests__/helpers.test.ts b/lib/sandbox/setup/__tests__/helpers.test.ts new file mode 100644 index 00000000..ff7a462f --- /dev/null +++ b/lib/sandbox/setup/__tests__/helpers.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { runGitCommand, runOpenClawAgent, installSkill } from "../helpers"; +import type { SetupDeps } from "../types"; + +describe("helpers", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("runGitCommand", () => { + it("returns true on success", async () => { + mockRunCommand.mockResolvedValue({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); + + const result = await runGitCommand(mockSandbox, ["status"], "check status", deps); + + expect(result).toBe(true); + expect(mockRunCommand).toHaveBeenCalledWith({ cmd: "git", args: ["status"] }); + }); + + it("returns false and logs on failure", async () => { + mockRunCommand.mockResolvedValue({ + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("fatal: not a git repo"), + }); + + const result = await runGitCommand(mockSandbox, ["status"], "check status", deps); + + expect(result).toBe(false); + expect(deps.error).toHaveBeenCalledWith( + "Failed to check status", + expect.objectContaining({ exitCode: 1 }), + ); + }); + }); + + describe("runOpenClawAgent", () => { + it("runs openclaw agent with correct args", async () => { + mockRunCommand.mockResolvedValue({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue("done"), + stderr: vi.fn().mockResolvedValue(""), + }); + + const result = await runOpenClawAgent( + mockSandbox, + { + label: "Test run", + message: "do something", + }, + deps, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("done"); + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "openclaw", + args: ["agent", "--agent", "main", "--message", "do something"], + }); + }); + + it("passes env vars when provided", async () => { + mockRunCommand.mockResolvedValue({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); + + await runOpenClawAgent( + mockSandbox, + { + label: "Test", + message: "test", + env: { MY_VAR: "value" }, + }, + deps, + ); + + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "openclaw", + args: ["agent", "--agent", "main", "--message", "test"], + env: { MY_VAR: "value" }, + }); + }); + + it("logs error on non-zero exit", async () => { + mockRunCommand.mockResolvedValue({ + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("error msg"), + }); + + const result = await runOpenClawAgent( + mockSandbox, + { + label: "Failing", + message: "bad", + }, + deps, + ); + + expect(result.exitCode).toBe(1); + expect(deps.error).toHaveBeenCalledWith( + "Failing failed", + expect.objectContaining({ stderr: "error msg" }), + ); + }); + }); + + describe("installSkill", () => { + it("installs via npx and copies to openclaw workspace", async () => { + mockRunCommand + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue("installed"), + stderr: vi.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + exitCode: 0, + stderr: vi.fn().mockResolvedValue(""), + }); + + await installSkill(mockSandbox, "recoupable/setup-sandbox", deps); + + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "npx", + args: ["skills", "add", "recoupable/setup-sandbox", "-y"], + }); + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "sh", + args: [ + "-c", + "mkdir -p ~/.openclaw/workspace/skills && rm -rf ~/.openclaw/workspace/skills/setup-sandbox && cp -r .agents/skills/setup-sandbox ~/.openclaw/workspace/skills/setup-sandbox", + ], + }); + }); + + it("throws on install failure", async () => { + mockRunCommand.mockResolvedValueOnce({ + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("install failed"), + }); + + await expect(installSkill(mockSandbox, "recoupable/setup-sandbox", deps)).rejects.toThrow( + "Failed to install skill recoupable/setup-sandbox", + ); + }); + + it("throws on copy failure", async () => { + mockRunCommand + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + exitCode: 1, + stderr: vi.fn().mockResolvedValue("cp failed"), + }); + + await expect(installSkill(mockSandbox, "recoupable/setup-sandbox", deps)).rejects.toThrow( + "Failed to copy skill setup-sandbox", + ); + }); + }); +}); diff --git a/lib/sandbox/setup/__tests__/installOpenClaw.test.ts b/lib/sandbox/setup/__tests__/installOpenClaw.test.ts new file mode 100644 index 00000000..3f124128 --- /dev/null +++ b/lib/sandbox/setup/__tests__/installOpenClaw.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { installOpenClaw } from "../installOpenClaw"; +import type { SetupDeps } from "../types"; + +describe("installOpenClaw", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips installation when openclaw is already installed", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); + + await installOpenClaw(mockSandbox, deps); + + expect(mockRunCommand).toHaveBeenCalledTimes(1); + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "which", + args: ["openclaw"], + }); + expect(deps.log).toHaveBeenCalledWith(expect.stringContaining("already installed")); + }); + + it("installs openclaw when not present", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 1 }) // which openclaw fails + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); // npm install succeeds + + await installOpenClaw(mockSandbox, deps); + + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "npm", + args: ["install", "-g", "openclaw@latest"], + sudo: true, + }); + }); + + it("throws when installation fails", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 1 }) // which openclaw fails + .mockResolvedValueOnce({ + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("install error"), + }); // npm install fails + + await expect(installOpenClaw(mockSandbox, deps)).rejects.toThrow( + "Failed to install OpenClaw CLI", + ); + }); +}); diff --git a/lib/sandbox/setup/__tests__/pushSandboxToGithub.test.ts b/lib/sandbox/setup/__tests__/pushSandboxToGithub.test.ts new file mode 100644 index 00000000..2527889a --- /dev/null +++ b/lib/sandbox/setup/__tests__/pushSandboxToGithub.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { pushSandboxToGithub } from "../pushSandboxToGithub"; +import type { SetupDeps } from "../types"; + +const mockRunGitCommand = vi.fn(); +const mockRunOpenClawAgent = vi.fn(); + +vi.mock("../helpers", () => ({ + runGitCommand: (...args: unknown[]) => mockRunGitCommand(...args), + runOpenClawAgent: (...args: unknown[]) => mockRunOpenClawAgent(...args), +})); + +describe("pushSandboxToGithub", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + mockRunGitCommand.mockResolvedValue(true); + mockRunOpenClawAgent.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + it("configures git user, copies openclaw, stages, commits, and pushes", async () => { + // diff --cached --quiet returns non-zero (changes exist) + mockRunCommand + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue("/root"), + }) // pushOrgRepos: echo ~ + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + }) // pushOrgRepos: find org repos + .mockResolvedValueOnce({ exitCode: 0 }) // copyOpenClawToRepo + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue("/root"), + }) // addOrgSubmodules: echo ~ + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + }) // addOrgSubmodules: find orgs + .mockResolvedValueOnce({ exitCode: 0 }) // stripGitmodulesTokens + .mockResolvedValueOnce({ exitCode: 1 }) // diff --cached (has changes) + .mockResolvedValueOnce({ exitCode: 0 }); // rebase --abort + + const result = await pushSandboxToGithub(mockSandbox, deps); + + expect(result).toBe(true); + + // git config user.email + expect(mockRunGitCommand).toHaveBeenCalledWith( + mockSandbox, + ["config", "user.email", "agent@recoupable.com"], + expect.any(String), + deps, + ); + + // git config user.name + expect(mockRunGitCommand).toHaveBeenCalledWith( + mockSandbox, + ["config", "user.name", "Recoup Agent"], + expect.any(String), + deps, + ); + + // git add -A + expect(mockRunGitCommand).toHaveBeenCalledWith( + mockSandbox, + ["add", "-A"], + expect.any(String), + deps, + ); + + // git commit + expect(mockRunGitCommand).toHaveBeenCalledWith( + mockSandbox, + ["commit", "-m", "Update sandbox files"], + expect.any(String), + deps, + ); + + // git push --force + expect(mockRunGitCommand).toHaveBeenCalledWith( + mockSandbox, + ["push", "--force", "origin", "HEAD:main"], + expect.any(String), + deps, + ); + }); + + it("skips commit when no changes", async () => { + // diff --cached --quiet returns 0 (no changes) + mockRunCommand + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue("/root"), + }) // pushOrgRepos: echo ~ + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + }) // pushOrgRepos: find + .mockResolvedValueOnce({ exitCode: 0 }) // copyOpenClawToRepo + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue("/root"), + }) // addOrgSubmodules: echo ~ + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + }) // addOrgSubmodules: find orgs + .mockResolvedValueOnce({ exitCode: 0 }) // stripGitmodulesTokens + .mockResolvedValueOnce({ exitCode: 0 }) // diff --cached (no changes) + .mockResolvedValueOnce({ exitCode: 0 }); // rebase --abort + + const result = await pushSandboxToGithub(mockSandbox, deps); + + expect(result).toBe(true); + expect(mockRunGitCommand).not.toHaveBeenCalledWith( + mockSandbox, + ["commit", "-m", "Update sandbox files"], + expect.any(String), + deps, + ); + }); + + it("returns false when git config fails", async () => { + mockRunGitCommand.mockResolvedValueOnce(false); // config email fails + + const result = await pushSandboxToGithub(mockSandbox, deps); + + expect(result).toBe(false); + }); +}); diff --git a/lib/sandbox/setup/__tests__/setupFreshSandbox.test.ts b/lib/sandbox/setup/__tests__/setupFreshSandbox.test.ts new file mode 100644 index 00000000..090d2564 --- /dev/null +++ b/lib/sandbox/setup/__tests__/setupFreshSandbox.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { setupFreshSandbox } from "../setupFreshSandbox"; + +const mockInstallOpenClaw = vi.fn(); +const mockSetupOpenClaw = vi.fn(); +const mockEnsureGithubRepo = vi.fn(); +const mockWriteReadme = vi.fn(); +const mockEnsureOrgRepos = vi.fn(); +const mockEnsureSetupSandbox = vi.fn(); + +vi.mock("../installOpenClaw", () => ({ + installOpenClaw: (...args: unknown[]) => mockInstallOpenClaw(...args), +})); + +vi.mock("../setupOpenClaw", () => ({ + setupOpenClaw: (...args: unknown[]) => mockSetupOpenClaw(...args), +})); + +vi.mock("../ensureGithubRepo", () => ({ + ensureGithubRepo: (...args: unknown[]) => mockEnsureGithubRepo(...args), +})); + +vi.mock("../writeReadme", () => ({ + writeReadme: (...args: unknown[]) => mockWriteReadme(...args), +})); + +vi.mock("../ensureOrgRepos", () => ({ + ensureOrgRepos: (...args: unknown[]) => mockEnsureOrgRepos(...args), +})); + +vi.mock("../ensureSetupSandbox", () => ({ + ensureSetupSandbox: (...args: unknown[]) => mockEnsureSetupSandbox(...args), +})); + +describe("setupFreshSandbox", () => { + const mockSandbox = { + sandboxId: "sbx_123", + } as unknown as Sandbox; + + beforeEach(() => { + vi.clearAllMocks(); + mockEnsureGithubRepo.mockResolvedValue("https://github.com/recoupable/test-repo"); + }); + + it("yields progress messages for each setup step", async () => { + const messages: string[] = []; + + const gen = setupFreshSandbox({ + sandbox: mockSandbox, + accountId: "acc_1", + apiKey: "key_123", + }); + + for await (const chunk of gen) { + messages.push(chunk.data); + } + + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining("Installing OpenClaw"), + expect.stringContaining("Configuring OpenClaw"), + expect.stringContaining("GitHub repository"), + expect.stringContaining("README"), + expect.stringContaining("organization repos"), + expect.stringContaining("skills"), + expect.stringContaining("complete"), + ]), + ); + }); + + it("calls all setup functions in order", async () => { + const callOrder: string[] = []; + + mockInstallOpenClaw.mockImplementation(() => { + callOrder.push("installOpenClaw"); + }); + mockSetupOpenClaw.mockImplementation(() => { + callOrder.push("setupOpenClaw"); + }); + mockEnsureGithubRepo.mockImplementation(() => { + callOrder.push("ensureGithubRepo"); + return "https://github.com/recoupable/test-repo"; + }); + mockWriteReadme.mockImplementation(() => { + callOrder.push("writeReadme"); + }); + mockEnsureOrgRepos.mockImplementation(() => { + callOrder.push("ensureOrgRepos"); + }); + mockEnsureSetupSandbox.mockImplementation(() => { + callOrder.push("ensureSetupSandbox"); + }); + + const gen = setupFreshSandbox({ + sandbox: mockSandbox, + accountId: "acc_1", + apiKey: "key_123", + }); + + // Consume the generator + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of gen) { + // consume + } + + expect(callOrder).toEqual([ + "installOpenClaw", + "setupOpenClaw", + "ensureGithubRepo", + "writeReadme", + "ensureOrgRepos", + "ensureSetupSandbox", + ]); + }); + + it("passes sandbox and deps to each function", async () => { + const gen = setupFreshSandbox({ + sandbox: mockSandbox, + accountId: "acc_1", + apiKey: "key_123", + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of gen) { + // consume + } + + expect(mockInstallOpenClaw).toHaveBeenCalledWith( + mockSandbox, + expect.objectContaining({ log: expect.any(Function) }), + ); + expect(mockSetupOpenClaw).toHaveBeenCalledWith( + mockSandbox, + "acc_1", + "key_123", + expect.objectContaining({ log: expect.any(Function) }), + ); + expect(mockEnsureGithubRepo).toHaveBeenCalledWith( + mockSandbox, + "acc_1", + expect.objectContaining({ log: expect.any(Function) }), + ); + expect(mockEnsureSetupSandbox).toHaveBeenCalledWith( + mockSandbox, + "acc_1", + "key_123", + expect.objectContaining({ log: expect.any(Function) }), + ); + }); + + it("returns githubRepo from the generator", async () => { + mockEnsureGithubRepo.mockResolvedValue("https://github.com/recoupable/my-repo"); + + const gen = setupFreshSandbox({ + sandbox: mockSandbox, + accountId: "acc_1", + apiKey: "key_123", + }); + + let result; + while (true) { + const next = await gen.next(); + if (next.done) { + result = next.value; + break; + } + } + + expect(result).toBe("https://github.com/recoupable/my-repo"); + }); + + it("yields stderr chunks for each step", async () => { + const chunks: Array<{ data: string; stream: string }> = []; + + const gen = setupFreshSandbox({ + sandbox: mockSandbox, + accountId: "acc_1", + apiKey: "key_123", + }); + + for await (const chunk of gen) { + chunks.push(chunk); + } + + // All progress messages should be stderr + expect(chunks.every(c => c.stream === "stderr")).toBe(true); + }); +}); diff --git a/lib/sandbox/setup/__tests__/setupOpenClaw.test.ts b/lib/sandbox/setup/__tests__/setupOpenClaw.test.ts new file mode 100644 index 00000000..4c514ef4 --- /dev/null +++ b/lib/sandbox/setup/__tests__/setupOpenClaw.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { setupOpenClaw } from "../setupOpenClaw"; +import type { SetupDeps } from "../types"; + +describe("setupOpenClaw", () => { + const mockRunCommand = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("AI_GATEWAY_API_KEY", "test-gateway-key"); + vi.stubEnv("RECOUP_API_KEY", "test-api-key"); + vi.stubEnv("GITHUB_TOKEN", "test-github-token"); + }); + + it("skips onboard when config exists, still injects env and starts gateway", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 0 }) // config check - exists + .mockResolvedValueOnce({ exitCode: 0, stderr: vi.fn().mockResolvedValue("") }) // inject env + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); // gateway + + await setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps); + + // Should check config, skip onboard, inject env, start gateway + expect(mockRunCommand).toHaveBeenCalledTimes(3); + }); + + it("runs onboard when config does not exist", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 1 }) // config check - missing + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }) // onboard + .mockResolvedValueOnce({ exitCode: 0, stderr: vi.fn().mockResolvedValue("") }) // inject env + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); // gateway + + await setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps); + + expect(mockRunCommand).toHaveBeenCalledWith({ + cmd: "openclaw", + args: expect.arrayContaining(["onboard", "--non-interactive"]), + }); + }); + + it("injects RECOUP_API_KEY and RECOUP_ACCOUNT_ID into openclaw.json", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 0 }) // config check - exists + .mockResolvedValueOnce({ exitCode: 0, stderr: vi.fn().mockResolvedValue("") }) // inject env + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); // gateway + + await setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps); + + const injectCall = mockRunCommand.mock.calls[1]; + const shellScript = injectCall[0].args[1]; + expect(shellScript).toContain("api-key-123"); + expect(shellScript).toContain("acc_1"); + }); + + it("starts gateway in background", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 0 }) // config check + .mockResolvedValueOnce({ exitCode: 0, stderr: vi.fn().mockResolvedValue("") }) // inject env + .mockResolvedValueOnce({ + exitCode: 0, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue(""), + }); // gateway + + await setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps); + + const gatewayCall = mockRunCommand.mock.calls[2]; + expect(gatewayCall[0].args[1]).toContain("openclaw gateway run"); + }); + + it("throws when AI_GATEWAY_API_KEY is missing", async () => { + vi.stubEnv("AI_GATEWAY_API_KEY", ""); + delete process.env.AI_GATEWAY_API_KEY; + + mockRunCommand.mockResolvedValueOnce({ exitCode: 1 }); // config check - missing + + await expect(setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps)).rejects.toThrow( + "Missing AI_GATEWAY_API_KEY environment variable", + ); + }); + + it("throws when env injection fails", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 0 }) // config check + .mockResolvedValueOnce({ + exitCode: 1, + stderr: vi.fn().mockResolvedValue("node error"), + }); // inject env fails + + await expect(setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps)).rejects.toThrow( + "Failed to inject env vars", + ); + }); + + it("throws when gateway start fails", async () => { + mockRunCommand + .mockResolvedValueOnce({ exitCode: 0 }) // config check + .mockResolvedValueOnce({ exitCode: 0, stderr: vi.fn().mockResolvedValue("") }) // inject env + .mockResolvedValueOnce({ + exitCode: 1, + stdout: vi.fn().mockResolvedValue(""), + stderr: vi.fn().mockResolvedValue("gateway error"), + }); // gateway fails + + await expect(setupOpenClaw(mockSandbox, "acc_1", "api-key-123", deps)).rejects.toThrow( + "Failed to start OpenClaw gateway", + ); + }); +}); diff --git a/lib/sandbox/setup/__tests__/writeReadme.test.ts b/lib/sandbox/setup/__tests__/writeReadme.test.ts new file mode 100644 index 00000000..fa57eb3e --- /dev/null +++ b/lib/sandbox/setup/__tests__/writeReadme.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; + +import { writeReadme } from "../writeReadme"; +import type { SetupDeps } from "../types"; + +describe("writeReadme", () => { + const mockRunCommand = vi.fn(); + const mockWriteFiles = vi.fn(); + const mockSandbox = { + sandboxId: "sbx_123", + runCommand: mockRunCommand, + writeFiles: mockWriteFiles, + } as unknown as Sandbox; + const deps: SetupDeps = { + log: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips when README already has sandbox details", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 0 }); // grep finds "Recoup Sandbox" + + await writeReadme(mockSandbox, "sbx_123", "acc_1", undefined, deps); + + expect(mockWriteFiles).not.toHaveBeenCalled(); + }); + + it("writes README with sandbox details", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); // grep fails - no existing content + + await writeReadme(mockSandbox, "sbx_123", "acc_1", "https://github.com/recoupable/repo", deps); + + expect(mockWriteFiles).toHaveBeenCalledWith([ + expect.objectContaining({ + path: "/vercel/sandbox/README.md", + }), + ]); + + const content = mockWriteFiles.mock.calls[0][0][0].content.toString(); + expect(content).toContain("Recoup Sandbox"); + expect(content).toContain("sbx_123"); + expect(content).toContain("acc_1"); + expect(content).toContain("https://github.com/recoupable/repo"); + }); + + it("shows 'Not configured' when no github repo", async () => { + mockRunCommand.mockResolvedValue({ exitCode: 1 }); + + await writeReadme(mockSandbox, "sbx_123", "acc_1", undefined, deps); + + const content = mockWriteFiles.mock.calls[0][0][0].content.toString(); + expect(content).toContain("Not configured"); + }); +}); diff --git a/lib/sandbox/setup/ensureGithubRepo.ts b/lib/sandbox/setup/ensureGithubRepo.ts new file mode 100644 index 00000000..ffd5473a --- /dev/null +++ b/lib/sandbox/setup/ensureGithubRepo.ts @@ -0,0 +1,144 @@ +import type { Sandbox } from "@vercel/sandbox"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { createGithubRepo } from "@/lib/github/createGithubRepo"; +import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; +import { runGitCommand } from "./helpers"; +import type { SetupDeps } from "./types"; + +/** + * Ensures a GitHub repository exists for the account, is persisted, and + * is cloned into the sandbox. + * + * @param sandbox - The Vercel Sandbox instance + * @param accountId - The account ID + * @param deps - Logging dependencies + * @returns The github repo URL, or undefined if not configured + */ +export async function ensureGithubRepo( + sandbox: Sandbox, + accountId: string, + deps: SetupDeps, +): Promise { + const githubToken = process.env.GITHUB_TOKEN; + + if (!githubToken) { + deps.error("Missing GITHUB_TOKEN environment variable"); + return undefined; + } + + // Fetch github_repo from snapshot + const snapshots = await selectAccountSnapshots(accountId); + const snapshot = snapshots[0] ?? null; + let githubRepo = snapshot?.github_repo ?? null; + const snapshotId = snapshot?.snapshot_id ?? "pending"; + const expiresAt = + snapshot?.expires_at ?? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + + // If no repo exists, create one + if (!githubRepo) { + deps.log("No GitHub repo found, creating one"); + + const accounts = await selectAccounts(accountId); + const account = accounts[0]; + + if (!account) { + deps.error("Account not found for repo creation", { accountId }); + return undefined; + } + + const repoUrl = await createGithubRepo(account.name, accountId); + + if (!repoUrl) { + deps.error("Failed to create GitHub repo", { accountId }); + return undefined; + } + + // Persist the repo URL + deps.log("Persisting GitHub repo URL", { repoUrl }); + await upsertAccountSnapshot({ + account_id: accountId, + snapshot_id: snapshotId, + expires_at: expiresAt, + github_repo: repoUrl, + }); + + githubRepo = repoUrl; + } + + // Check if repo is already cloned + const gitCheck = await sandbox.runCommand({ + cmd: "test", + args: ["-d", ".git"], + }); + + if (gitCheck.exitCode === 0) { + deps.log("GitHub repo already cloned in sandbox"); + return githubRepo; + } + + // Clone the repo into the sandbox root + deps.log("Cloning GitHub repo into sandbox root"); + + const repoUrl = githubRepo.replace( + "https://github.com/", + `https://x-access-token:${githubToken}@github.com/`, + ); + + if (!(await runGitCommand(sandbox, ["init"], "initialize git", deps))) { + return undefined; + } + + if (!(await runGitCommand(sandbox, ["remote", "add", "origin", repoUrl], "add remote", deps))) { + return undefined; + } + + // Fetch and checkout only if the remote has content + const fetchResult = await sandbox.runCommand({ + cmd: "git", + args: ["fetch", "origin"], + }); + + if (fetchResult.exitCode === 0) { + const refCheck = await sandbox.runCommand({ + cmd: "git", + args: ["rev-parse", "--verify", "origin/main"], + }); + + if (refCheck.exitCode === 0) { + if ( + !(await runGitCommand( + sandbox, + ["checkout", "-B", "main", "origin/main"], + "checkout main branch", + deps, + )) + ) { + return undefined; + } + + // Set up URL rewriting for submodule clones + await sandbox.runCommand({ + cmd: "git", + args: [ + "config", + `url.https://x-access-token:${githubToken}@github.com/.insteadOf`, + "https://github.com/", + ], + }); + + // Initialize submodules if they exist + await sandbox.runCommand({ + cmd: "git", + args: ["submodule", "update", "--init", "--recursive"], + }); + } else { + deps.log("Empty remote repo, skipping checkout"); + } + } else { + deps.log("Fetch failed or empty remote, skipping checkout"); + } + + deps.log("GitHub repo initialized in sandbox root"); + return githubRepo; +} diff --git a/lib/sandbox/setup/ensureOrgRepos.ts b/lib/sandbox/setup/ensureOrgRepos.ts new file mode 100644 index 00000000..df74f3f4 --- /dev/null +++ b/lib/sandbox/setup/ensureOrgRepos.ts @@ -0,0 +1,93 @@ +import type { Sandbox } from "@vercel/sandbox"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { createOrgGithubRepo } from "@/lib/github/createOrgGithubRepo"; +import { sanitizeRepoName } from "@/lib/github/sanitizeRepoName"; +import { runOpenClawAgent } from "./helpers"; +import type { SetupDeps } from "./types"; + +/** + * Ensures each of the account's organizations has a GitHub repo and + * tells OpenClaw to clone them into `orgs/` in the workspace. + * + * @param sandbox - The Vercel Sandbox instance + * @param accountId - The account ID to look up orgs for + * @param deps - Logging dependencies + */ +export async function ensureOrgRepos( + sandbox: Sandbox, + accountId: string, + deps: SetupDeps, +): Promise { + const githubToken = process.env.GITHUB_TOKEN; + + if (!githubToken) { + deps.error("Missing GITHUB_TOKEN for org repos"); + return; + } + + deps.log("Fetching account organizations"); + const orgs = await getAccountOrganizations({ accountId }); + + if (!orgs || orgs.length === 0) { + deps.log("No organizations found, skipping org repo setup"); + return; + } + + deps.log("Setting up org repos"); + + const orgRepos: Array<{ name: string; url: string }> = []; + + for (const org of orgs) { + const orgName = org.organization?.name ?? "unknown"; + const orgId = org.organization_id; + + const repoUrl = await createOrgGithubRepo(orgName, orgId); + + if (!repoUrl) { + deps.error("Failed to create org GitHub repo, skipping", { + orgId, + orgName, + }); + continue; + } + + orgRepos.push({ + name: sanitizeRepoName(orgName), + url: repoUrl, + }); + } + + if (orgRepos.length === 0) { + deps.log("No org repos created, skipping clone step"); + return; + } + + const repoList = orgRepos.map(r => `- "${r.name}" -> ${r.url}`).join("\n"); + + const message = [ + "Clone the following GitHub repositories into orgs/ in your workspace.", + "Use the GITHUB_TOKEN environment variable for authentication.", + "Replace https://github.com/ with https://x-access-token:$GITHUB_TOKEN@github.com/ in the clone URL.", + "", + "For each repo, check orgs/{name}:", + "- If it has a .git directory OR a .git file (submodule gitlink), it's already a git repo -- run: git -C orgs/{name} pull origin main", + "- If it exists but has neither a .git directory nor a .git file, remove it and clone fresh.", + "- If it does not exist, clone the repo.", + "", + "IMPORTANT: Submodules use a .git file (gitlink), not a .git directory.", + "Always check for BOTH: [ -d orgs/{name}/.git ] || [ -f orgs/{name}/.git ]", + "", + repoList, + ].join("\n"); + + await runOpenClawAgent( + sandbox, + { + label: "Cloning org repos", + message, + }, + deps, + ); + + deps.log("Org repo setup complete"); +} diff --git a/lib/sandbox/setup/ensureSetupSandbox.ts b/lib/sandbox/setup/ensureSetupSandbox.ts new file mode 100644 index 00000000..9c02e812 --- /dev/null +++ b/lib/sandbox/setup/ensureSetupSandbox.ts @@ -0,0 +1,73 @@ +import type { Sandbox } from "@vercel/sandbox"; +import { installSkill, runOpenClawAgent } from "./helpers"; +import type { SetupDeps } from "./types"; + +/** + * Ensures the sandbox has the org/artist folder structure set up. + * Installs skills, runs setup-sandbox, then setup-artist for each artist. + * Idempotent -- skips if `orgs/` directory already exists. + * + * @param sandbox - The Vercel Sandbox instance + * @param accountId - The account ID for the sandbox owner + * @param apiKey - The RECOUP_API_KEY + * @param deps - Logging dependencies + */ +export async function ensureSetupSandbox( + sandbox: Sandbox, + accountId: string, + apiKey: string, + deps: SetupDeps, +): Promise { + const check = await sandbox.runCommand({ + cmd: "test", + args: ["-d", "orgs/"], + }); + + if (check.exitCode === 0) { + deps.log("Sandbox already set up, skipping"); + return; + } + + deps.log("Installing skills"); + + await installSkill(sandbox, "recoupable/setup-sandbox", deps); + await installSkill(sandbox, "recoupable/setup-artist", deps); + await installSkill(sandbox, "recoupable/release-management", deps); + + const env = { + RECOUP_API_KEY: apiKey, + RECOUP_ACCOUNT_ID: accountId, + }; + + deps.log("Running setup-sandbox skill"); + const setupResult = await runOpenClawAgent( + sandbox, + { + label: "Running setup-sandbox skill", + message: + "Install the Recoup CLI globally: npm install -g @recoupable/cli\n\nThen run the /setup-sandbox skill to create the org and artist folder structure.\n\nRECOUP_API_KEY and RECOUP_ACCOUNT_ID are available as environment variables.", + env, + }, + deps, + ); + + if (setupResult.exitCode !== 0) { + throw new Error("Failed to set up sandbox via OpenClaw"); + } + + deps.log("Running setup-artist skill"); + const artistResult = await runOpenClawAgent( + sandbox, + { + label: "Running setup-artist skill", + message: + "Run the /setup-artist skill for EACH artist folder that exists under orgs/.\n\nRECOUP_API_KEY and RECOUP_ACCOUNT_ID are available as environment variables.", + env, + }, + deps, + ); + + if (artistResult.exitCode !== 0) { + throw new Error("Failed to set up artists via OpenClaw"); + } +} diff --git a/lib/sandbox/setup/helpers.ts b/lib/sandbox/setup/helpers.ts new file mode 100644 index 00000000..02478413 --- /dev/null +++ b/lib/sandbox/setup/helpers.ts @@ -0,0 +1,129 @@ +import type { Sandbox } from "@vercel/sandbox"; +import type { SetupDeps } from "./types"; + +/** + * Runs a git command in the sandbox and logs on failure. + * + * @param sandbox - The Vercel Sandbox instance + * @param args - Git command arguments + * @param description - Human-readable description for error logging + * @param deps - Logging dependencies + * @returns true if the command succeeded, false otherwise + */ +export async function runGitCommand( + sandbox: Sandbox, + args: string[], + description: string, + deps: SetupDeps, +): Promise { + const result = await sandbox.runCommand({ cmd: "git", args }); + + if (result.exitCode !== 0) { + const stderr = (await result.stderr()) || ""; + const stdout = (await result.stdout()) || ""; + deps.error(`Failed to ${description}`, { + exitCode: result.exitCode, + stderr, + stdout, + }); + return false; + } + + return true; +} + +interface RunOpenClawAgentOptions { + label: string; + message: string; + env?: Record; +} + +interface RunOpenClawAgentResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Runs an OpenClaw agent command with standardized logging. + * + * @param sandbox - The Vercel Sandbox instance + * @param options - Label for logging, message prompt, optional env vars + * @param deps - Logging dependencies + * @returns exitCode, stdout, and stderr from the command + */ +export async function runOpenClawAgent( + sandbox: Sandbox, + options: RunOpenClawAgentOptions, + deps: SetupDeps, +): Promise { + const { label, message, env } = options; + + deps.log(label); + + const commandOpts: Parameters[0] = { + cmd: "openclaw", + args: ["agent", "--agent", "main", "--message", message], + ...(env ? { env } : {}), + }; + + const result = await sandbox.runCommand(commandOpts); + + const stdout = (await result.stdout()) || ""; + const stderr = (await result.stderr()) || ""; + + if (result.exitCode !== 0) { + deps.error(`${label} failed`, { stderr }); + } + + return { + exitCode: result.exitCode, + stdout, + stderr, + }; +} + +/** + * Installs a skills.sh skill into the OpenClaw workspace skills directory. + * + * @param sandbox - The Vercel Sandbox instance + * @param skill - The skills.sh skill identifier (e.g. "recoupable/setup-sandbox") + * @param deps - Logging dependencies + */ +export async function installSkill( + sandbox: Sandbox, + skill: string, + deps: SetupDeps, +): Promise { + const skillName = skill.split("/").pop()!; + + deps.log(`Installing skill: ${skill}`); + + const install = await sandbox.runCommand({ + cmd: "npx", + args: ["skills", "add", skill, "-y"], + }); + + if (install.exitCode !== 0) { + const stderr = (await install.stderr()) || ""; + deps.error(`Failed to install skill ${skill}`, { stderr }); + throw new Error(`Failed to install skill ${skill} via skills.sh`); + } + + const copy = await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + `mkdir -p ~/.openclaw/workspace/skills && rm -rf ~/.openclaw/workspace/skills/${skillName} && cp -r .agents/skills/${skillName} ~/.openclaw/workspace/skills/${skillName}`, + ], + }); + + const copyStderr = (await copy.stderr()) || ""; + + if (copy.exitCode !== 0) { + deps.error(`Failed to copy skill ${skillName}`, { stderr: copyStderr }); + throw new Error(`Failed to copy skill ${skillName} to OpenClaw workspace`); + } + + deps.log(`Skill installed: ${skillName}`); +} diff --git a/lib/sandbox/setup/installOpenClaw.ts b/lib/sandbox/setup/installOpenClaw.ts new file mode 100644 index 00000000..581d5aa7 --- /dev/null +++ b/lib/sandbox/setup/installOpenClaw.ts @@ -0,0 +1,42 @@ +import type { Sandbox } from "@vercel/sandbox"; +import type { SetupDeps } from "./types"; + +/** + * Installs OpenClaw CLI globally in the sandbox. + * Idempotent -- skips if already installed. + * + * @param sandbox - The Vercel Sandbox instance + * @param deps - Logging dependencies + */ +export async function installOpenClaw(sandbox: Sandbox, deps: SetupDeps): Promise { + const check = await sandbox.runCommand({ + cmd: "which", + args: ["openclaw"], + }); + + if (check.exitCode === 0) { + deps.log("OpenClaw CLI already installed, skipping"); + return; + } + + deps.log("Installing OpenClaw CLI globally"); + + const installCLI = await sandbox.runCommand({ + cmd: "npm", + args: ["install", "-g", "openclaw@latest"], + sudo: true, + }); + + if (installCLI.exitCode !== 0) { + const stdout = (await installCLI.stdout()) || ""; + const stderr = (await installCLI.stderr()) || ""; + deps.error("Failed to install OpenClaw CLI", { + exitCode: installCLI.exitCode, + stdout, + stderr, + }); + throw new Error("Failed to install OpenClaw CLI"); + } + + deps.log("OpenClaw installation complete"); +} diff --git a/lib/sandbox/setup/pushSandboxToGithub.ts b/lib/sandbox/setup/pushSandboxToGithub.ts new file mode 100644 index 00000000..b8ced799 --- /dev/null +++ b/lib/sandbox/setup/pushSandboxToGithub.ts @@ -0,0 +1,237 @@ +import type { Sandbox } from "@vercel/sandbox"; +import { runGitCommand, runOpenClawAgent } from "./helpers"; +import type { SetupDeps } from "./types"; + +/** + * Copies ~/.openclaw into the repo, strips tokens from .gitmodules, + * and pushes org repos via OpenClaw. + * + * @param sandbox - The Vercel Sandbox instance + */ +async function copyOpenClawToRepo(sandbox: Sandbox): Promise { + await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + "rm -rf /vercel/sandbox/.openclaw && " + + "cp -r ~/.openclaw /vercel/sandbox/.openclaw && " + + "rm -rf /vercel/sandbox/.openclaw/workspace/orgs && " + + "find /vercel/sandbox/.openclaw -name .git -type d -exec rm -rf {} + 2>/dev/null || true", + ], + }); +} + +/** + * Registers org repos as git submodules in the sandbox working directory. + * + * @param sandbox - The Vercel Sandbox instance + */ +async function addOrgSubmodules(sandbox: Sandbox): Promise { + if (!process.env.GITHUB_TOKEN) return; + + const homeResult = await sandbox.runCommand({ + cmd: "sh", + args: ["-c", "echo ~"], + }); + const homeDir = ((await homeResult.stdout()) || "").trim() || "/root"; + const workspaceOrgs = `${homeDir}/.openclaw/workspace/orgs`; + + const findResult = await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + `find ${workspaceOrgs} -mindepth 1 -maxdepth 1 -type d '(' -exec test -d {}/.git ';' -o -exec test -f {}/.git ';' ')' -print 2>/dev/null | xargs -I{} basename {}`, + ], + }); + + const stdout = (await findResult.stdout()) || ""; + const orgNames = stdout + .split("\n") + .map(s => s.trim()) + .filter(Boolean); + + if (orgNames.length === 0) return; + + for (const name of orgNames) { + const orgPath = `.openclaw/workspace/orgs/${name}`; + + const remoteResult = await sandbox.runCommand({ + cmd: "git", + args: ["-C", `${workspaceOrgs}/${name}`, "remote", "get-url", "origin"], + }); + const remoteUrl = ((await remoteResult.stdout()) || "").trim(); + if (!remoteUrl) continue; + + const checkResult = await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + `test -f ${orgPath}/.git && git config --file .gitmodules --get submodule.${orgPath}.url 2>/dev/null`, + ], + }); + if (checkResult.exitCode === 0) continue; + + await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + `git rm -r --cached ${orgPath} 2>/dev/null || true; ` + + `git config --remove-section submodule.${orgPath} 2>/dev/null || true; ` + + `rm -rf .git/modules/${orgPath} ${orgPath} 2>/dev/null || true`, + ], + }); + + const authedUrl = remoteUrl.replace( + "https://github.com/", + `https://x-access-token:${process.env.GITHUB_TOKEN}@github.com/`, + ); + await sandbox.runCommand({ + cmd: "git", + args: ["submodule", "add", authedUrl, orgPath], + }); + } +} + +/** + * Strips x-access-token auth from .gitmodules. + * + * @param sandbox - The Vercel Sandbox instance + */ +async function stripGitmodulesTokens(sandbox: Sandbox): Promise { + await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + "sed -i 's|https://x-access-token:[^@]*@github.com/|https://github.com/|g' .gitmodules 2>/dev/null || true; " + + "git add .gitmodules 2>/dev/null || true", + ], + }); +} + +/** + * Pushes org repo changes via OpenClaw. + * + * @param sandbox - The Vercel Sandbox instance + * @param deps - Logging dependencies + */ +async function pushOrgRepos(sandbox: Sandbox, deps: SetupDeps): Promise { + if (!process.env.GITHUB_TOKEN) return; + + const homeResult = await sandbox.runCommand({ + cmd: "sh", + args: ["-c", "echo ~"], + }); + const homeDir = ((await homeResult.stdout()) || "").trim() || "/root"; + const workspaceOrgs = `${homeDir}/.openclaw/workspace/orgs`; + + const findResult = await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + `find ${workspaceOrgs} -mindepth 1 -maxdepth 1 -type d -exec test -d {}/.git \\; -print 2>/dev/null | xargs -I{} basename {}`, + ], + }); + + const stdout = (await findResult.stdout()) || ""; + const orgNames = stdout + .split("\n") + .map(s => s.trim()) + .filter(Boolean); + + if (orgNames.length === 0) return; + + const message = [ + "Commit and push changes for each org repo.", + "Org repos are at ~/.openclaw/workspace/orgs/", + "", + "For each org directory that is a git repo:", + "1. git add -A", + "2. git commit -m 'Update org files' (skip if nothing to commit)", + "3. git push origin HEAD:main --force (always push if there are unpushed commits)", + ].join("\n"); + + await runOpenClawAgent( + sandbox, + { + label: "Pushing org repo changes", + message, + }, + deps, + ); +} + +/** + * Commits and pushes all local sandbox files to the GitHub repository. + * + * @param sandbox - The Vercel Sandbox instance + * @param deps - Logging dependencies + * @returns true if push succeeded or there were no changes, false on error + */ +export async function pushSandboxToGithub(sandbox: Sandbox, deps: SetupDeps): Promise { + deps.log("Pushing sandbox files to GitHub"); + + if ( + !(await runGitCommand( + sandbox, + ["config", "user.email", "agent@recoupable.com"], + "configure git email", + deps, + )) + ) { + return false; + } + + if ( + !(await runGitCommand( + sandbox, + ["config", "user.name", "Recoup Agent"], + "configure git name", + deps, + )) + ) { + return false; + } + + await pushOrgRepos(sandbox, deps); + await copyOpenClawToRepo(sandbox); + await addOrgSubmodules(sandbox); + await stripGitmodulesTokens(sandbox); + + if (!(await runGitCommand(sandbox, ["add", "-A"], "stage files", deps))) { + return false; + } + + const diffResult = await sandbox.runCommand({ + cmd: "git", + args: ["diff", "--cached", "--quiet"], + }); + + if (diffResult.exitCode !== 0) { + if ( + !(await runGitCommand( + sandbox, + ["commit", "-m", "Update sandbox files"], + "commit changes", + deps, + )) + ) { + return false; + } + } + + await sandbox.runCommand({ cmd: "git", args: ["rebase", "--abort"] }); + + if ( + !(await runGitCommand( + sandbox, + ["push", "--force", "origin", "HEAD:main"], + "push to remote", + deps, + )) + ) { + return false; + } + + deps.log("Sandbox files pushed to GitHub successfully"); + return true; +} diff --git a/lib/sandbox/setup/setupFreshSandbox.ts b/lib/sandbox/setup/setupFreshSandbox.ts new file mode 100644 index 00000000..7aa89dcb --- /dev/null +++ b/lib/sandbox/setup/setupFreshSandbox.ts @@ -0,0 +1,65 @@ +import type { Sandbox } from "@vercel/sandbox"; +import { installOpenClaw } from "./installOpenClaw"; +import { setupOpenClaw } from "./setupOpenClaw"; +import { ensureGithubRepo } from "./ensureGithubRepo"; +import { writeReadme } from "./writeReadme"; +import { ensureOrgRepos } from "./ensureOrgRepos"; +import { ensureSetupSandbox } from "./ensureSetupSandbox"; +import type { SetupDeps } from "./types"; + +interface SetupFreshSandboxInput { + sandbox: Sandbox; + accountId: string; + apiKey: string; +} + +/** + * Runs the full setup pipeline for a fresh sandbox (no snapshot). + * Yields progress messages as stderr chunks compatible with promptSandboxStreaming. + * Returns the GitHub repo URL (or undefined) when complete. + * + * @param input - The sandbox, account ID, and API key + * @yields Progress messages as { data, stream: "stderr" } chunks + * @returns The GitHub repo URL, or undefined + */ +export async function* setupFreshSandbox( + input: SetupFreshSandboxInput, +): AsyncGenerator<{ data: string; stream: "stderr" }, string | undefined, undefined> { + const { sandbox, accountId, apiKey } = input; + + const deps: SetupDeps = { + log: msg => console.log(`[Setup] ${msg}`), + error: (msg, data) => console.error(`[Setup] ${msg}`, data), + }; + + yield { data: "[Setup] Installing OpenClaw...\n", stream: "stderr" }; + await installOpenClaw(sandbox, deps); + + yield { data: "[Setup] Configuring OpenClaw...\n", stream: "stderr" }; + await setupOpenClaw(sandbox, accountId, apiKey, deps); + + yield { + data: "[Setup] Setting up GitHub repository...\n", + stream: "stderr", + }; + const githubRepo = await ensureGithubRepo(sandbox, accountId, deps); + + yield { data: "[Setup] Writing README...\n", stream: "stderr" }; + await writeReadme(sandbox, sandbox.sandboxId, accountId, githubRepo, deps); + + yield { + data: "[Setup] Setting up organization repos...\n", + stream: "stderr", + }; + await ensureOrgRepos(sandbox, accountId, deps); + + yield { + data: "[Setup] Installing skills and running setup...\n", + stream: "stderr", + }; + await ensureSetupSandbox(sandbox, accountId, apiKey, deps); + + yield { data: "[Setup] Setup complete!\n", stream: "stderr" }; + + return githubRepo; +} diff --git a/lib/sandbox/setup/setupOpenClaw.ts b/lib/sandbox/setup/setupOpenClaw.ts new file mode 100644 index 00000000..2cb473c9 --- /dev/null +++ b/lib/sandbox/setup/setupOpenClaw.ts @@ -0,0 +1,119 @@ +import type { Sandbox } from "@vercel/sandbox"; +import type { SetupDeps } from "./types"; + +/** + * Ensures OpenClaw is onboarded, seeds env vars into the config, + * and starts the gateway process in the background. + * + * @param sandbox - The Vercel Sandbox instance + * @param accountId - The account ID for the sandbox owner + * @param apiKey - The RECOUP_API_KEY to inject + * @param deps - Logging dependencies + */ +export async function setupOpenClaw( + sandbox: Sandbox, + accountId: string, + apiKey: string, + deps: SetupDeps, +): Promise { + // Check if already onboarded + const configCheck = await sandbox.runCommand({ + cmd: "sh", + args: ["-c", "test -f ~/.openclaw/openclaw.json"], + }); + + if (configCheck.exitCode !== 0) { + const gatewayApiKey = process.env.AI_GATEWAY_API_KEY; + + if (!gatewayApiKey) { + throw new Error("Missing AI_GATEWAY_API_KEY environment variable"); + } + + // Run onboard + const onboardArgs = [ + "onboard", + "--non-interactive", + "--mode", + "local", + "--auth-choice", + "ai-gateway-api-key", + "--ai-gateway-api-key", + gatewayApiKey, + "--gateway-port", + "18789", + "--gateway-bind", + "loopback", + "--accept-risk", + ]; + + deps.log("Running OpenClaw onboard"); + + const onboard = await sandbox.runCommand({ + cmd: "openclaw", + args: onboardArgs, + }); + + if (onboard.exitCode !== 0) { + const stdout = (await onboard.stdout()) || ""; + const stderr = (await onboard.stderr()) || ""; + // Onboard writes config but may exit non-zero when it can't verify gateway + deps.log("OpenClaw onboard exited with non-zero code (may be expected)", { + exitCode: onboard.exitCode, + stdout, + stderr, + }); + } + } else { + deps.log("OpenClaw already onboarded, skipping"); + } + + // Inject env vars into openclaw.json + const githubToken = process.env.GITHUB_TOKEN; + + const injectEnv = await sandbox.runCommand({ + cmd: "sh", + args: [ + "-c", + `node -e " + const fs = require('fs'); + const p = require('os').homedir() + '/.openclaw/openclaw.json'; + const c = JSON.parse(fs.readFileSync(p, 'utf8')); + c.env = c.env || {}; + c.env.RECOUP_API_KEY = '${apiKey}'; + c.env.RECOUP_ACCOUNT_ID = '${accountId}'; + ${githubToken ? `c.env.GITHUB_TOKEN = '${githubToken}';` : ""} + fs.writeFileSync(p, JSON.stringify(c, null, 2)); + "`, + ], + }); + + if (injectEnv.exitCode !== 0) { + const stderr = (await injectEnv.stderr()) || ""; + deps.error("Failed to inject env vars into openclaw.json", { + exitCode: injectEnv.exitCode, + stderr, + }); + throw new Error("Failed to inject env vars into openclaw.json"); + } + + deps.log("OpenClaw onboard complete, starting gateway"); + + // Start gateway in background + const gateway = await sandbox.runCommand({ + cmd: "sh", + args: ["-c", "nohup openclaw gateway run > /tmp/gateway.log 2>&1 &"], + }); + + if (gateway.exitCode !== 0) { + const stdout = (await gateway.stdout()) || ""; + const stderr = (await gateway.stderr()) || ""; + deps.error("Failed to start OpenClaw gateway", { + exitCode: gateway.exitCode, + stdout, + stderr, + }); + throw new Error("Failed to start OpenClaw gateway"); + } + + deps.log("OpenClaw gateway started"); +} diff --git a/lib/sandbox/setup/types.ts b/lib/sandbox/setup/types.ts new file mode 100644 index 00000000..667c4dcc --- /dev/null +++ b/lib/sandbox/setup/types.ts @@ -0,0 +1,13 @@ +import type { Sandbox } from "@vercel/sandbox"; + +export interface SetupDeps { + log: (msg: string, data?: Record) => void; + error: (msg: string, data?: Record) => void; +} + +export interface SetupContext { + sandbox: Sandbox; + accountId: string; + apiKey: string; + deps: SetupDeps; +} diff --git a/lib/sandbox/setup/writeReadme.ts b/lib/sandbox/setup/writeReadme.ts new file mode 100644 index 00000000..0bdb9018 --- /dev/null +++ b/lib/sandbox/setup/writeReadme.ts @@ -0,0 +1,102 @@ +import type { Sandbox } from "@vercel/sandbox"; +import type { SetupDeps } from "./types"; + +/** + * Writes a README.md to the sandbox root with details about the sandbox environment. + * Idempotent -- skips if the README already contains sandbox details. + * + * @param sandbox - The Vercel Sandbox instance + * @param sandboxId - The sandbox identifier + * @param accountId - The account ID that owns the sandbox + * @param githubRepo - The GitHub repo URL associated with the sandbox, if any + * @param deps - Logging dependencies + */ +export async function writeReadme( + sandbox: Sandbox, + sandboxId: string, + accountId: string, + githubRepo: string | undefined, + deps: SetupDeps, +): Promise { + const check = await sandbox.runCommand({ + cmd: "grep", + args: ["-q", "Recoup Sandbox", "README.md"], + }); + + if (check.exitCode === 0) { + deps.log("README.md already has sandbox details, skipping"); + return; + } + + const repoLine = githubRepo + ? `- **GitHub Repo**: ${githubRepo}` + : "- **GitHub Repo**: Not configured"; + + const content = `# Recoup Sandbox + +This is an isolated Linux microVM sandbox environment managed by [Recoup](https://recoupable.com). + +## Sandbox Details + +- **Sandbox ID**: \`${sandboxId}\` +- **Account ID**: \`${accountId}\` +${repoLine} + +## What is a Sandbox? + +Sandboxes are ephemeral, isolated Linux microVM environments created via the [Recoup API](https://developers.recoupable.com/api-reference/sandboxes/create). They provide a safe space to execute code, run AI agent tasks, and evaluate generated output. + +### Key Features + +- **Isolated execution** -- each sandbox runs in its own microVM +- **Snapshot support** -- sandbox state can be saved and restored from snapshots +- **GitHub integration** -- sandboxes can be linked to a GitHub repo for persistent file storage +- **Command execution** -- run any command with optional arguments and working directory +- **Automatic timeout** -- sandboxes stop automatically after a configurable timeout period + +### API + +Create a sandbox via \`POST /api/sandboxes\`: + +\`\`\`json +{ + "command": "ls", + "args": ["-la", "/home"], + "cwd": "/home/user" +} +\`\`\` + +The response includes sandbox status, snapshot ID, and GitHub repo if configured: + +\`\`\`json +{ + "status": "success", + "sandboxes": [ + { + "sandboxId": "sbx_abc123", + "sandboxStatus": "running", + "timeout": 300000, + "createdAt": "2024-01-15T10:30:00.000Z" + } + ], + "snapshot_id": "snap_abc123", + "github_repo": "https://github.com/org/repo" +} +\`\`\` + +### Docs + +Full API documentation: https://developers.recoupable.com +`; + + deps.log("Writing README.md to sandbox"); + + await sandbox.writeFiles([ + { + path: "/vercel/sandbox/README.md", + content: Buffer.from(content), + }, + ]); + + deps.log("README.md written successfully"); +} diff --git a/package.json b/package.json index c53bb782..4b6abcb4 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@privy-io/node": "^0.6.2", "@supabase/supabase-js": "^2.86.0", "@trigger.dev/sdk": "^4.2.0", - "@vercel/sandbox": "^1.3.1", + "@vercel/sandbox": "^1.8.0", "ai": "6.0.0-beta.122", "apify-client": "^2.20.0", "arweave": "^1.15.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0b0d314..7e39edc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^4.2.0 version: 4.2.0(ai@6.0.0-beta.122(zod@4.1.13))(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) '@vercel/sandbox': - specifier: ^1.3.1 - version: 1.3.1 + specifier: ^1.8.0 + version: 1.8.0 ai: specifier: 6.0.0-beta.122 version: 6.0.0-beta.122(zod@4.1.13) @@ -2355,8 +2355,12 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} - '@vercel/sandbox@1.3.1': - resolution: {integrity: sha512-FFeeJgb4mcm89LSS2ta5kUlAbJ4FsjBbi6otK6Au50GJh9ubzdZuaxiVhWas9NlQ9EnE3FZsIslLLLvjN7Onlw==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/sandbox@1.8.0': + resolution: {integrity: sha512-SbXkg8Fmp8i+I9IdyD4PAAVtxM/KS4ULV4eiEfY/9tab1AF1MPvmEA8/ebvCn7QTWQQ7twwtpJNSPlUVmOBp3w==} '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -8961,9 +8965,11 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vercel/sandbox@1.3.1': + '@vercel/oidc@3.2.0': {} + + '@vercel/sandbox@1.8.0': dependencies: - '@vercel/oidc': 3.1.0 + '@vercel/oidc': 3.2.0 async-retry: 1.3.3 jsonlines: 0.1.1 ms: 2.1.3