Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 44 additions & 0 deletions .github/workflows/build-project.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ next-env.d.ts
.website-reverse/

# caddy azure deploy spec (contains storage keys / ACR passwords)
caddy/aci.yaml
caddy/aci.yaml

# Browser regression artifacts
/test-results/
/playwright-report/
31 changes: 31 additions & 0 deletions app/api/build-project/github/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
26 changes: 26 additions & 0 deletions app/api/build-project/route.ts
Original file line number Diff line number Diff line change
@@ -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?.();
}
}
236 changes: 236 additions & 0 deletions components/build-project-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BuildProjectFlow
key={`${user?.id ?? "signed-out"}:${prompt}`}
prompt={prompt}
/>
);
}
function BuildProjectFlow({ prompt }: { prompt: string }) {
const { session, signInWithGitHub, signInWithGitHubForExport } = useAuth();
const [project, setProject] = useState<BuiltProject | null>(null);
const [busy, setBusy] = useState(false),
[error, setError] = useState("");
const [repositoryUrl, setRepositoryUrl] = useState("");
const [needsPermission, setNeedsPermission] = useState(false);
const controller = useRef<AbortController | null>(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 (
<div
className="flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2"
aria-busy={busy}
>
{!project ? (
<button
className={style}
disabled={busy || !prompt.trim()}
onClick={() => void build()}
>
{busy ? "Building…" : session ? "Build" : "Sign in to build"}
</button>
) : (
<>
<button className={style} onClick={download} disabled={busy}>
Download ZIP
</button>
{!repositoryUrl && (
<button
className={style}
onClick={() => void publish()}
disabled={busy}
>
{busy ? "Publishing…" : "Create private GitHub repo"}
</button>
)}
{repositoryUrl && (
<a
href={repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs underline"
>
Open repository
</a>
)}
<details className="w-full min-w-0 break-words text-xs text-zinc-600">
<summary className="cursor-pointer">
{project.files.length} generated files · not run or tested
</summary>
<p className="mt-2">
Review the code before installing dependencies or deploying.
Generated code can contain mistakes or unsafe behavior.
</p>
<ul className="mt-2 max-h-24 overflow-auto font-mono">
{project.files.map((f) => (
<li key={f.path}>{f.path}</li>
))}
</ul>
</details>
</>
)}
{busy && !project && (
<button
className="text-xs underline"
onClick={() => {
controller.current?.abort();
setBusy(false);
}}
>
Cancel
</button>
)}
{needsPermission && (
<div className="w-full min-w-0 break-words text-xs text-zinc-600">
<p>
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.
</p>
<button
className="mt-1 underline"
onClick={() =>
void signInWithGitHubForExport().catch(() =>
setError("Could not open GitHub authorization."),
)
}
>
Connect GitHub for private repo export
</button>
</div>
)}
{error && (
<p role="alert" className="w-full text-xs text-red-700">
{error}
</p>
)}
</div>
);
}
4 changes: 3 additions & 1 deletion components/reverse-prompt-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -531,7 +532,8 @@ export function ReversePromptHome({
<h2 className="text-sm font-semibold text-zinc-700">
Reverse engineered prompt
</h2>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<div className="flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
<BuildProjectActions prompt={prompt} />
<div className="group relative">
<div className="absolute inset-0 translate-x-0.5 translate-y-0.5 rounded bg-zinc-900" />
<button
Expand Down
4 changes: 3 additions & 1 deletion components/website-reverse-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { CodeRabbitBanner } from "@/components/coderabbit-banner";
import { BuildProjectActions } from "@/components/build-project-actions";
import { Navbar } from "@/components/navbar";
import { PromptMarkdown } from "@/components/prompt-markdown";
import { WebsiteDesignFlavorText } from "@/components/website-design-flavor-text";
Expand Down Expand Up @@ -296,7 +297,8 @@ export function WebsiteReversePage({
<h2 className="text-sm font-semibold text-zinc-700">
Reverse engineered prompt
</h2>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<div className="flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
<BuildProjectActions prompt={prompt} />
<div className="group relative">
<div className="absolute inset-0 translate-x-0.5 translate-y-0.5 rounded bg-zinc-900" />
<button
Expand Down
Loading
Loading