diff --git a/.env.example b/.env.example index c56c146..466e67d 100644 --- a/.env.example +++ b/.env.example @@ -74,3 +74,10 @@ VIEWS_IP_SALT= # STRIPE_PRICE_CREDIT_ID=price_1Tq9A8IBG5KwEK8aYIUW1fVp # Fallback if /api/create-checkout fails (optional): # NEXT_PUBLIC_STRIPE_PAYMENT_LINK=https://buy.stripe.com/... + +# --- Build-to-GitHub experiment (private preview only; see docs/build-project-experiment.md) --- +# NEXT_PUBLIC_GITREVERSE_BUILD_ENABLED=false +# GITREVERSE_BUILD_ENABLED=false +# GITREVERSE_BUILD_ALLOWED_USER_IDS= +# GITREVERSE_BUILD_PROVIDER=azure +# GITREVERSE_BUILD_MODEL=gpt-5.4 diff --git a/.github/workflows/build-project.yml b/.github/workflows/build-project.yml new file mode 100644 index 0000000..25aa4ba --- /dev/null +++ b/.github/workflows/build-project.yml @@ -0,0 +1,44 @@ +name: Build project experiment regressions + +on: + pull_request: + paths: + - 'lib/build-project/**' + - 'app/api/build-project/**' + - 'components/build-project-actions.tsx' + - 'components/reverse-prompt-home.tsx' + - 'components/website-reverse-page.tsx' + - 'contexts/AuthContext.tsx' + - 'tests/build-project*.test.ts' + - 'tests/browser/build-project.spec.ts' + - 'playwright.build.config.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/build-project.yml' + +permissions: + contents: read + +jobs: + build-project: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - run: pnpm install --frozen-lockfile + - run: pnpm test:build-project + - run: pnpm test:cache-security + - run: pnpm exec tsc --noEmit + - run: pnpm exec eslint components/build-project-actions.tsx lib/build-project app/api/build-project tests/build-project.test.ts tests/browser/build-project.spec.ts playwright.build.config.ts + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:build-browser diff --git a/.gitignore b/.gitignore index 8e0afe3..1fe3c04 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,8 @@ next-env.d.ts .website-reverse/ # caddy azure deploy spec (contains storage keys / ACR passwords) -caddy/aci.yaml \ No newline at end of file +caddy/aci.yaml + +# Browser regression artifacts +/test-results/ +/playwright-report/ diff --git a/app/api/build-project/github/route.ts b/app/api/build-project/github/route.ts new file mode 100644 index 0000000..2166c26 --- /dev/null +++ b/app/api/build-project/github/route.ts @@ -0,0 +1,31 @@ +import { + requireBuildUser, + readJsonLimited, + buildFailure, + BuildError, +} from "@/lib/build-project/server"; +import { exportProject } from "@/lib/build-project/github"; +export const runtime = "nodejs"; +export const maxDuration = 120; +export async function POST(req: Request) { + try { + const user = await requireBuildUser(req); + const body = (await readJsonLimited(req, 800000)) as { + project?: unknown; + githubToken?: unknown; + name?: unknown; + confirmPrivateRepository?: unknown; + }; + if (body?.confirmPrivateRepository !== true) + throw new BuildError("Confirm creating a new private repository first."); + const result = await exportProject( + user, + body.project, + body.githubToken, + body.name, + ); + return Response.json(result, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return buildFailure(error); + } +} diff --git a/app/api/build-project/route.ts b/app/api/build-project/route.ts new file mode 100644 index 0000000..5874abc --- /dev/null +++ b/app/api/build-project/route.ts @@ -0,0 +1,26 @@ +import { + requireBuildUser, + readJsonLimited, + reserveBuild, + generateProject, + buildFailure, +} from "@/lib/build-project/server"; +export const runtime = "nodejs"; +export const maxDuration = 120; +export async function POST(req: Request) { + let release: (() => void) | undefined; + try { + const user = await requireBuildUser(req); + const body = (await readJsonLimited(req, 100000)) as { prompt?: unknown }; + release = reserveBuild(user.id); + const project = await generateProject(body?.prompt, req.signal); + return Response.json( + { project }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + return buildFailure(error); + } finally { + release?.(); + } +} diff --git a/components/build-project-actions.tsx b/components/build-project-actions.tsx new file mode 100644 index 0000000..090bbd6 --- /dev/null +++ b/components/build-project-actions.tsx @@ -0,0 +1,236 @@ +"use client"; +import { useRef, useState, useEffect } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { projectZip, type BuiltProject } from "@/lib/build-project/manifest"; + +const style = + "rounded border-[3px] border-zinc-900 bg-[#ffc480] px-3 py-1.5 text-xs font-medium text-zinc-900 disabled:opacity-50"; +/** A small action inside the existing prompt card, not a new editor or page. */ +export function BuildProjectActions({ prompt }: { prompt: string }) { + const { user } = useAuth(); + if (process.env.NEXT_PUBLIC_GITREVERSE_BUILD_ENABLED !== "true") return null; + // Remount when prompt changes so older results can never attach to a newer prompt. + return ( + + ); +} +function BuildProjectFlow({ prompt }: { prompt: string }) { + const { session, signInWithGitHub, signInWithGitHubForExport } = useAuth(); + const [project, setProject] = useState(null); + const [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const [repositoryUrl, setRepositoryUrl] = useState(""); + const [needsPermission, setNeedsPermission] = useState(false); + const controller = useRef(null), + inFlight = useRef(false), + alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + controller.current?.abort(); + }; + }, []); + async function build() { + if (inFlight.current) return; + if (!session?.access_token) { + await signInWithGitHub().catch(() => setError("Could not open sign-in.")); + return; + } + inFlight.current = true; + setBusy(true); + setError(""); + setRepositoryUrl(""); + const abort = new AbortController(); + controller.current = abort; + try { + const response = await fetch("/api/build-project", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ prompt }), + signal: abort.signal, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Build failed."); + if (alive.current) setProject(data.project); + } catch (e) { + if (alive.current && !abort.signal.aborted) + setError(e instanceof Error ? e.message : "Build failed."); + } finally { + inFlight.current = false; + if (alive.current) setBusy(false); + } + } + function download() { + if (!project) return; + const bytes = projectZip(project), + url = URL.createObjectURL( + new Blob([bytes as BlobPart], { type: "application/zip" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = `${project.name}.zip`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + async function publish() { + if (inFlight.current || !project || !session?.access_token) return; + const name = window.prompt( + "New private GitHub repository name (existing repositories will never be overwritten):", + project.name, + ); + if (!name) return; + if ( + !window.confirm( + `Create private repository “${name}” in your signed-in GitHub account and upload these ${project.files.length} generated files? Review the ZIP before running it. No code is run or deployed by GitReverse.`, + ) + ) + return; + if (!session.provider_token) { + setNeedsPermission(true); + setError( + "Connect GitHub below, then click Create private GitHub repo again.", + ); + return; + } + inFlight.current = true; + setBusy(true); + setError(""); + // Export is not aborted/retried automatically: the repo creation may already have happened. + try { + const response = await fetch("/api/build-project/github", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ + project, + githubToken: session.provider_token, + name, + confirmPrivateRepository: true, + }), + }); + const data = await response.json(); + if (!alive.current) return; + if ( + typeof data.repositoryUrl === "string" && + /^https:\/\/github\.com\/[a-zA-Z0-9-]+\/[a-zA-Z0-9_-]+$/.test( + data.repositoryUrl, + ) + ) + setRepositoryUrl(data.repositoryUrl); + if (data.code === "github_permission") setNeedsPermission(true); + if (!response.ok) + throw new Error( + data.error || "Export failed. Check GitHub before retrying.", + ); + } catch (e) { + if (alive.current) + setError( + e instanceof Error + ? e.message + : "Export uncertain. Check GitHub before retrying.", + ); + } finally { + inFlight.current = false; + if (alive.current) setBusy(false); + } + } + return ( +
+ {!project ? ( + + ) : ( + <> + + {!repositoryUrl && ( + + )} + {repositoryUrl && ( + + Open repository + + )} +
+ + {project.files.length} generated files · not run or tested + +

+ Review the code before installing dependencies or deploying. + Generated code can contain mistakes or unsafe behavior. +

+
    + {project.files.map((f) => ( +
  • {f.path}
  • + ))} +
+
+ + )} + {busy && !project && ( + + )} + {needsPermission && ( +
+

+ GitHub’s repo permission can access your repositories. Connect only + if you accept that scope. GitReverse uses it here to create a new + private repository; ZIP download needs no repository access. +

+ +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/reverse-prompt-home.tsx b/components/reverse-prompt-home.tsx index 869ac81..618bc3d 100644 --- a/components/reverse-prompt-home.tsx +++ b/components/reverse-prompt-home.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import ReactMarkdown from "react-markdown"; import { CodeRabbitBanner } from "@/components/coderabbit-banner"; +import { BuildProjectActions } from "@/components/build-project-actions"; import { Navbar } from "@/components/navbar"; import { ReverseGenerationFlavorText } from "@/components/reverse-generation-flavor-text"; import { useAuth } from "@/contexts/AuthContext"; @@ -531,7 +532,8 @@ export function ReversePromptHome({

Reverse engineered prompt

-
+
+