Implement Netlify deployment integration and payment templates#215
Implement Netlify deployment integration and payment templates#215Jackson57279 wants to merge 34 commits intomasterfrom
Conversation
- Added support for deploying projects to Netlify, including authentication and deployment status tracking. - Introduced new API routes for managing Netlify domains, environment variables, and deployment logs. - Created payment integration templates for various frameworks (Angular, React, Next.js, Svelte, Vue) using Autumn and Stripe. - Updated environment example and README to include new Netlify and payment integration configurations. - Enhanced project UI with deployment dashboard and export options to GitHub. This commit significantly expands the deployment capabilities and payment processing features of the application.
|
You have run out of free Bugbot PR reviews for this billing cycle. This will reset on February 17. To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎Codebase SummaryZapDev is an AI-powered development platform that lets users create web applications using real-time sandboxes, chat with AI agents to generate code, and manage projects via a rich UI built on Next.js, React, and other modern tools. The UI allows a split-pane view for file browsing, code preview, and interactive project development with deployment and payment functionalities integrated. PR ChangesThis PR implements Netlify deployment integration and adds support for payment integration templates using Stripe via Autumn. New API routes have been introduced for managing Netlify deployments, domains, environment variables, and deployment logs. The project UI now includes a deployment dashboard with export options to GitHub, and GitHub export functionality via OAuth. Additionally, payment integration templates for Angular, React, Next.js, Svelte, and Vue have been added along with corresponding prompt enhancements for payment integration. Setup Instructions
Generated Test Cases1: Netlify Deployment Flow in Deployment Dashboard ❗️❗️❗️Description: Verifies that users can trigger a deployment from the project view. This ensures that the new Netlify deployment integration (deploy button, deployment status, and dashboard updates) works correctly. Prerequisites:
Steps:
Expected Result: A new deployment is initiated on Netlify with updated status shown on the dashboard. Success messages (toasts) appear and actionable buttons such as 'Env Vars' and 'Custom Domains' are displayed. 2: GitHub Export Modal Functionality ❗️❗️❗️Description: Checks the GitHub export workflow from the project header. Ensures users can connect GitHub, open the export modal, enter new repository details or select an existing repository, and complete the export process. Prerequisites:
Steps:
Expected Result: The GitHub export modal functions correctly with toggling between new and existing repository options. Upon successful export, the user sees repository export details and can navigate to GitHub. 3: Payment Templates Tool Functionality ❗️❗️Description: Tests the new payment integration templates feature which provides billing templates for various frameworks. It verifies that the tool returns the proper configuration details (including autumnConfigTemplate and paymentEnvExample) for the selected payment framework. Prerequisites:
Steps:
Expected Result: The paymentTemplates tool returns a JSON object containing the payment integration template, autumn configuration template, and a sample environment variables configuration snippet. The content should match the selected framework's expected template structure. 4: Project View Tabs Navigation and Layout Verification ❗️Description: Ensures that the project view renders all tabs (Preview, Code, Deploy) correctly and that switching between them displays the appropriate content sections. Prerequisites:
Steps:
Expected Result: All tabs are accessible, and the correct UI components for each section are displayed without layout issues. Visual indicators, such as active tab highlighting, should be consistent and user-friendly. 5: Environment Variables Dialog Functionality ❗️❗️Description: Verifies that users can manage Netlify environment variables through the Env Vars Dialog in the deployment dashboard. This test ensures that users can add and remove environment variables. Prerequisites:
Steps:
Expected Result: The Env Vars Dialog lets the user add a new environment variable and subsequently remove it. The UI updates to reflect the current list of environment variables, and toasts confirm actions. 6: Custom Domain Dialog Functionality ❗️❗️Description: Checks the workflow for adding and removing custom domains via the Custom Domain Dialog in the deployment dashboard. Prerequisites:
Steps:
Expected Result: Users are able to add a custom domain which appears in the list, and once removed, the domain is no longer displayed. All actions show proper success or error toasts. 7: Deployment History Logs and Rollback Actions ❗️Description: Tests the Deployment History section in the deployment dashboard, ensuring that users can view build logs and initiate a rollback for a previous deployment. Prerequisites:
Steps:
Expected Result: The deployment history displays previous deployments. Upon clicking 'Logs', the build logs are shown in a dialog. The rollback action triggers a deployment rollback and confirms the action via a toast message. 8: Deployment Status Polling and View Site Action ❗️❗️Description: Ensures that the Deployment Status component periodically polls for status updates and updates the UI accordingly, allowing the user to view the site once ready. Prerequisites:
Steps:
Expected Result: The Deployment Status component polls every 10 seconds and updates the displayed status. Once the deployment is ready, the UI shows a clickable link to view the deployed site. Raw Changes AnalyzedFile: README.md
Changes:
@@ -14,6 +14,7 @@ AI-powered development platform that lets you create web applications by chattin
- 🔐 Authentication with Clerk
- ⚙️ Background job processing with Inngest
- 🗃️ Project management and persistence
+- 💰 Generated app billing templates (Stripe via Autumn)
## Tech Stack
@@ -200,6 +201,10 @@ npm run lint # Run ESLint
5. **File Management**: Users can browse generated files with syntax highlighting
6. **Iteration**: Conversational development allows for refinements and additions
+## Generated App Payments
+
+ZapDev can generate payment-ready apps using Stripe through Autumn. Templates live in `src/lib/payment-templates/` and include checkout flows, billing portal endpoints, feature gates, and usage tracking helpers. Configure with environment variables from `paymentEnvExample` in the same folder.
+
---
Created by [CodeWithAntonio](https://codewithantonio.com)
File: ROADMAP.md
Changes:
@@ -4,7 +4,7 @@
### Payments Integration
-**Status**: In Progress
+**Status**: Finished
**Priority**: High
Currently, ZapDev uses Polar.sh for subscription billing. This roadmap item focuses on:
@@ -176,3 +176,52 @@ Allow users to choose their preferred database provider:
- Cost optimization options
- Regional data residency compliance
+
+### GitHub Export
+
+**Status**: Planned
+**Priority**: High
+
+Enable users to export their generated projects directly to GitHub repositories for version control, collaboration, and deployment:
+
+- **Repository Creation**:
+ - One-click export to new GitHub repository
+ - Automatic repository initialization with generated code
+ - Support for public, private, and organization repositories
+ - Custom repository name and description
+ - Optional README generation with project details
+
+- **Export Features**:
+ - Full project structure export (all files and directories)
+ - Preserve file permissions and structure
+ - Include `.gitignore` and other configuration files
+ - Export project metadata and documentation
+ - Incremental updates to existing repositories
+
+- **GitHub Integration**:
+ - OAuth authentication with GitHub
+ - Secure token storage and management
+ - Support for GitHub App authentication
+ - Branch creation for project versions
+ - Commit history tracking
+
+- **Advanced Features**:
+ - Export to existing repositories (push to specific branch)
+ - Multiple repository export (fork to multiple locations)
+ - Automated initial commit with descriptive messages
+ - Tag creation for project versions
+ - GitHub Actions workflow templates inclusion
+
+- **User Experience**:
+ - Export progress indicator
+ - Error handling and retry logic
+ - Export history tracking
+ - Quick access to exported repositories
+ - One-click repository opening in GitHub
+
+- **Technical Implementation**:
+ - GitHub REST API integration
+ - File tree generation and upload
+ - Large file handling (GitHub LFS support)
+ - Rate limit management
+ - Background job processing for large exports
File: convex/_generated/api.d.ts
Changes:
@@ -10,6 +10,8 @@
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
+import type * as deployments from "../deployments.js";
+import type * as githubExports from "../githubExports.js";
import type * as importData from "../importData.js";
import type * as imports from "../imports.js";
import type * as messages from "../messages.js";
@@ -31,6 +33,8 @@ import type {
declare const fullApi: ApiFromModules<{
helpers: typeof helpers;
http: typeof http;
+ deployments: typeof deployments;
+ githubExports: typeof githubExports;
importData: typeof importData;
imports: typeof imports;
messages: typeof messages;
File: convex/deployments.ts
Changes:
@@ -0,0 +1,176 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+import { requireAuth } from "./helpers";
+
+const deploymentStatusEnum = v.union(
+ v.literal("pending"),
+ v.literal("building"),
+ v.literal("ready"),
+ v.literal("error")
+);
+
+export const createDeployment = mutation({
+ args: {
+ projectId: v.id("projects"),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ status: deploymentStatusEnum,
+ isPreview: v.optional(v.boolean()),
+ branch: v.optional(v.string()),
+ commitRef: v.optional(v.string()),
+ },
+ returns: v.id("deployments"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ const latest = await ctx.db
+ .query("deployments")
+ .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .first();
+
+ const nextDeployNumber = (latest?.deployNumber ?? 0) + 1;
+ const now = Date.now();
+
+ return await ctx.db.insert("deployments", {
+ projectId: args.projectId,
+ userId,
+ platform: args.platform,
+ siteId: args.siteId,
+ siteUrl: args.siteUrl,
+ deployId: args.deployId,
+ deployNumber: nextDeployNumber,
+ commitRef: args.commitRef,
+ branch: args.branch,
+ isPreview: args.isPreview ?? false,
+ status: args.status,
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+export const updateDeployment = mutation({
+ args: {
+ deploymentId: v.id("deployments"),
+ status: v.optional(deploymentStatusEnum),
+ deployId: v.optional(v.string()),
+ error: v.optional(v.string()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ },
+ returns: v.id("deployments"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const deployment = await ctx.db.get(args.deploymentId);
+ if (!deployment || deployment.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ await ctx.db.patch(args.deploymentId, {
+ ...(args.status ? { status: args.status } : {}),
+ ...(args.deployId ? { deployId: args.deployId } : {}),
+ ...(args.error ? { error: args.error } : {}),
+ ...(args.buildLog ? { buildLog: args.buildLog } : {}),
+ ...(args.buildTime ? { buildTime: args.buildTime } : {}),
+ updatedAt: Date.now(),
+ });
+
+ return args.deploymentId;
+ },
+});
+
+export const getDeployment = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.union(
+ v.null(),
+ v.object({
+ _id: v.id("deployments"),
+ _creationTime: v.number(),
+ projectId: v.id("projects"),
+ userId: v.string(),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ deployNumber: v.optional(v.number()),
+ commitRef: v.optional(v.string()),
+ branch: v.optional(v.string()),
+ isPreview: v.optional(v.boolean()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ previousDeployId: v.optional(v.id("deployments")),
+ status: deploymentStatusEnum,
+ error: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ ),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("deployments")
+ .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .first();
+ },
+});
+
+export const listDeployments = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id("deployments"),
+ _creationTime: v.number(),
+ projectId: v.id("projects"),
+ userId: v.string(),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ deployNumber: v.optional(v.number()),
+ commitRef: v.optional(v.string()),
+ branch: v.optional(v.string()),
+ isPreview: v.optional(v.boolean()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ previousDeployId: v.optional(v.id("deployments")),
+ status: deploymentStatusEnum,
+ error: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ ),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("deployments")
+ .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .collect();
+ },
+});
File: convex/githubExports.ts
Changes:
@@ -0,0 +1,392 @@
+import { v } from "convex/values";
+import { action, mutation, query } from "./_generated/server";
+import { requireAuth } from "./helpers";
+import { githubExportStatusEnum } from "./schema";
+import { api } from "./_generated/api";
+import type { Doc, Id } from "./_generated/dataModel";
+import {
+ buildTreeEntries,
+ createBranchRef,
+ createCommit,
+ createTree,
+ getBranchRef,
+ getCommitTreeSha,
+ getRepository,
+ updateBranchRef,
+ withDefaultFiles,
+ type ProjectFramework,
+} from "../src/lib/github-api";
+import { filterFilesForDownload } from "../src/lib/filter-ai-files";
+
+const githubExportRecord = v.object({
+ _id: v.id("githubExports"),
+ _creationTime: v.number(),
+ projectId: v.id("projects"),
+ userId: v.string(),
+ repositoryName: v.string(),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.optional(v.string()),
+ commitSha: v.optional(v.string()),
+ status: githubExportStatusEnum,
+ error: v.optional(v.string()),
+ fileCount: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+ if (!isRecord(value)) {
+ return {};
+ }
+
+ const files: Record<string, string> = {};
+ for (const [path, content] of Object.entries(value)) {
+ if (typeof content === "string") {
+ files[path] = content;
+ }
+ }
+
+ return files;
+};
+
+type MessageWithFragment = {
+ _id: Id<"messages">;
+ _creationTime: number;
+ Fragment: {
+ _id: Id<"fragments">;
+ files?: unknown;
+ framework: ProjectFramework;
+ } | null;
+};
+
+export const list = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.array(githubExportRecord),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("githubExports")
+ .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .collect();
+ },
+});
+
+export const get = query({
+ args: {
+ exportId: v.id("githubExports"),
+ },
+ returns: githubExportRecord,
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const exportRecord = await ctx.db.get(args.exportId);
+ if (!exportRecord) {
+ throw new Error("Export not found");
+ }
+ if (exportRecord.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return exportRecord;
+ },
+});
+
+export const getLatest = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.union(githubExportRecord, v.null()),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("githubExports")
+ .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .first();
+ },
+});
+
+export const create = mutation({
+ args: {
+ projectId: v.id("projects"),
+ repositoryName: v.string(),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.optional(v.string()),
+ },
+ returns: v.id("githubExports"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ const now = Date.now();
+ return await ctx.db.insert("githubExports", {
+ projectId: args.projectId,
+ userId,
+ repositoryName: args.repositoryName,
+ repositoryUrl: args.repositoryUrl,
+ repositoryFullName: args.repositoryFullName,
+ branch: args.branch,
+ status: "pending",
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+export const updateStatus = mutation({
+ args: {
+ exportId: v.id("githubExports"),
+ status: githubExportStatusEnum,
+ error: v.optional(v.string()),
+ },
+ returns: v.id("githubExports"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const exportRecord = await ctx.db.get(args.exportId);
+ if (!exportRecord) {
+ throw new Error("Export not found");
+ }
+ if (exportRecord.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ await ctx.db.patch(args.exportId, {
+ status: args.status,
+ ...(args.error !== undefined && { error: args.error }),
+ updatedAt: Date.now(),
+ });
+
+ return args.exportId;
+ },
+});
+
+export const complete = mutation({
+ args: {
+ exportId: v.id("githubExports"),
+ commitSha: v.string(),
+ branch: v.string(),
+ fileCount: v.number(),
+ },
+ returns: v.id("githubExports"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const exportRecord = await ctx.db.get(args.exportId);
+ if (!exportRecord) {
+ throw new Error("Export not found");
+ }
+ if (exportRecord.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ await ctx.db.patch(args.exportId, {
+ commitSha: args.commitSha,
+ branch: args.branch,
+ fileCount: args.fileCount,
+ status: "complete",
+ updatedAt: Date.now(),
+ });
+
+ return args.exportId;
+ },
+});
+
+export const exportToGitHub = action({
+ args: {
+ exportId: v.id("githubExports"),
+ branch: v.optional(v.string()),
+ includeReadme: v.optional(v.boolean()),
+ includeGitignore: v.optional(v.boolean()),
+ commitMessage: v.optional(v.string()),
+ },
+ returns: v.object({
+ exportId: v.id("githubExports"),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.string(),
+ commitSha: v.string(),
+ fileCount: v.number(),
+ }),
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.subject) {
+ throw new Error("Unauthorized");
+ }
+
+ const exportRecord: Doc<"githubExports"> = await ctx.runQuery(
+ api.githubExports.get,
+ { exportId: args.exportId },
+ );
+
+ await ctx.runMutation(api.githubExports.updateStatus, {
+ exportId: args.exportId,
+ status: "processing",
+ });
+
+ try {
+ const project: Doc<"projects"> = await ctx.runQuery(api.projects.get, {
+ projectId: exportRecord.projectId,
+ });
+
+ const messages: Array<MessageWithFragment> = await ctx.runQuery(
+ api.messages.list,
+ { projectId: exportRecord.projectId },
+ );
+
+ const latestWithFragment = [...messages]
+ .reverse()
+ .find((message) => message.Fragment);
+
+ const fragment = latestWithFragment?.Fragment;
+ if (!fragment) {
+ throw new Error("No AI-generated files are ready to export.");
+ }
+
+ const normalized = normalizeFiles(fragment.files);
+ const filtered = filterFilesForDownload(normalized);
+ if (Object.keys(filtered).length === 0) {
+ throw new Error("No AI-generated files are ready to export.");
+ }
+
+ const includeReadme = args.includeReadme ?? true;
+ const includeGitignore = args.includeGitignore ?? true;
+ const files = withDefaultFiles(
+ filtered,
+ {
+ projectName: project.name,
+ framework: fragment.framework,
+ },
+ includeReadme,
+ includeGitignore,
+ );
+
+ const treeEntries = buildTreeEntries(files);
+ const accessToken = await ctx.runQuery(api.oauth.getGithubAccessToken, {});
+ if (!accessToken) {
+ throw new Error("GitHub connection not found. Please connect GitHub.");
+ }
+
+ const repository = await getRepository(
+ accessToken,
+ exportRecord.repositoryFullName,
+ );
+ const defaultBranch = repository.default_branch ?? "main";
+ const targetBranch = args.branch ?? exportRecord.branch ?? defaultBranch;
+
+ let baseCommitSha: string | null = null;
+ let baseTreeSha: string | undefined;
+ let needsCreateBranch = false;
+
+ try {
+ baseCommitSha = await getBranchRef(
+ accessToken,
+ repository.full_name,
+ targetBranch,
+ );
+ baseTreeSha = await getCommitTreeSha(
+ accessToken,
+ repository.full_name,
+ baseCommitSha,
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "GitHub error";
+ if (
+ targetBranch !== defaultBranch &&
+ message.toLowerCase().includes("not found")
+ ) {
+ baseCommitSha = await getBranchRef(
+ accessToken,
+ repository.full_name,
+ defaultBranch,
+ );
+ baseTreeSha = await getCommitTreeSha(
+ accessToken,
+ repository.full_name,
+ baseCommitSha,
+ );
+ needsCreateBranch = true;
+ } else {
+ throw error;
+ }
+ }
+
+ if (!baseCommitSha) {
+ throw new Error("Unable to resolve base branch for export.");
+ }
+
+ const treeSha = await createTree(
+ accessToken,
+ repository.full_name,
+ treeEntries,
+ baseTreeSha,
+ );
+ const commitSha = await createCommit(
+ accessToken,
+ repository.full_name,
+ args.commitMessage ?? "Export project from ZapDev",
+ treeSha,
+ baseCommitSha ? [baseCommitSha] : [],
+ );
+
+ if (needsCreateBranch) {
+ await createBranchRef(
+ accessToken,
+ repository.full_name,
+ targetBranch,
+ commitSha,
+ );
+ } else {
+ await updateBranchRef(
+ accessToken,
+ repository.full_name,
+ targetBranch,
+ commitSha,
+ );
+ }
+
+ await ctx.runMutation(api.githubExports.complete, {
+ exportId: args.exportId,
+ commitSha,
+ branch: targetBranch,
+ fileCount: treeEntries.length,
+ });
+
+ return {
+ exportId: args.exportId,
+ repositoryUrl: exportRecord.repositoryUrl,
+ repositoryFullName: exportRecord.repositoryFullName,
+ branch: targetBranch,
+ commitSha,
+ fileCount: treeEntries.length,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Export failed";
+ await ctx.runMutation(api.githubExports.updateStatus, {
+ exportId: args.exportId,
+ status: "failed",
+ error: message,
+ });
+ throw error;
+ }
+ },
+});
File: convex/oauth.ts
Changes:
@@ -70,6 +70,23 @@ export const getConnection = query({
},
});
+export const getGithubAccessToken = query({
+ args: {},
+ returns: v.union(v.string(), v.null()),
+ handler: async (ctx) => {
+ const userId = await requireAuth(ctx);
+
+ const connection = await ctx.db
+ .query("oauthConnections")
+ .withIndex("by_userId_provider", (q) =>
+ q.eq("userId", userId).eq("provider", "github"),
+ )
+ .first();
+
+ return connection?.accessToken ?? null;
+ },
+});
+
// List all OAuth connections for user
export const listConnections = query({
handler: async (ctx) => {
File: convex/schema.ts
Changes:
@@ -39,7 +39,8 @@ export const importSourceEnum = v.union(
export const oauthProviderEnum = v.union(
v.literal("figma"),
- v.literal("github")
+ v.literal("github"),
+ v.literal("netlify")
);
export const importStatusEnum = v.union(
@@ -49,6 +50,13 @@ export const importStatusEnum = v.union(
v.literal("FAILED")
);
+export const githubExportStatusEnum = v.union(
+ v.literal("pending"),
+ v.literal("processing"),
+ v.literal("complete"),
+ v.literal("failed")
+);
+
export const sandboxStateEnum = v.union(
v.literal("RUNNING"),
v.literal("PAUSED"),
@@ -159,6 +167,35 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_userId_provider", ["userId", "provider"]),
+ deployments: defineTable({
+ projectId: v.id("projects"),
+ userId: v.string(),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ deployNumber: v.optional(v.number()),
+ commitRef: v.optional(v.string()),
+ branch: v.optional(v.string()),
+ isPreview: v.optional(v.boolean()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ previousDeployId: v.optional(v.id("deployments")),
+ status: v.union(
+ v.literal("pending"),
+ v.literal("building"),
+ v.literal("ready"),
+ v.literal("error")
+ ),
+ error: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_projectId", ["projectId"])
+ .index("by_projectId_deployNumber", ["projectId", "deployNumber"])
+ .index("by_userId", ["userId"])
+ .index("by_siteId", ["siteId"]),
+
imports: defineTable({
userId: v.string(),
projectId: v.id("projects"),
@@ -177,6 +214,24 @@ export default defineSchema({
.index("by_projectId", ["projectId"])
.index("by_status", ["status"]),
+ githubExports: defineTable({
+ projectId: v.id("projects"),
+ userId: v.string(),
+ repositoryName: v.string(),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.optional(v.string()),
+ commitSha: v.optional(v.string()),
+ status: githubExportStatusEnum,
+ error: v.optional(v.string()),
+ fileCount: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_projectId", ["projectId"])
+ .index("by_userId", ["userId"])
+ .index("by_status", ["status"]),
+
usage: defineTable({
userId: v.string(),
points: v.number(),
File: env.example
Changes:
@@ -27,6 +27,10 @@ CEREBRAS_API_KEY="" # Get from https://cloud.cerebras.ai
# Vercel AI Gateway (fallback for Cerebras rate limits)
VERCEL_AI_GATEWAY_API_KEY="" # Get from https://vercel.com/dashboard/ai-gateway
+# Netlify Deployment
+NETLIFY_CLIENT_ID=""
+NETLIFY_CLIENT_SECRET=""
+
# Brave Search API (web search for subagent research - optional)
BRAVE_SEARCH_API_KEY="" # Get from https://api-dashboard.search.brave.com/app/keys
File: src/agents/tools.ts
Changes:
@@ -1,6 +1,11 @@
import { tool } from "ai";
import { z } from "zod";
import { getSandbox, writeFilesBatch, readFileFast } from "./sandbox-utils";
+import {
+ autumnConfigTemplate,
+ getPaymentTemplate,
+ paymentEnvExample,
+} from "@/lib/payment-templates";
import type { AgentState } from "./types";
export interface ToolContext {
@@ -138,5 +143,20 @@ export function createAgentTools(context: ToolContext) {
}
},
}),
+ paymentTemplates: tool({
+ description:
+ "Get Stripe + Autumn payment integration templates for a framework",
+ inputSchema: z.object({
+ framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]),
+ }),
+ execute: async ({ framework }) => {
+ const template = getPaymentTemplate(framework);
+ return JSON.stringify({
+ ...template,
+ autumnConfigTemplate,
+ paymentEnvExample,
+ });
+ },
+ }),
};
}
File: src/app/api/deploy/netlify/auth/route.ts
Changes:
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+
+export async function GET() {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!NETLIFY_CLIENT_ID) {
+ return NextResponse.json(
+ { error: "Netlify OAuth not configured" },
+ { status: 500 }
+ );
+ }
+
+ const state = Buffer.from(
+ JSON.stringify({ userId: user.id, timestamp: Date.now() })
+ ).toString("base64");
+
+ const params = new URLSearchParams({
+ client_id: NETLIFY_CLIENT_ID,
+ redirect_uri: NETLIFY_REDIRECT_URI,
+ response_type: "code",
+ state,
+ });
+
+ const netlifyAuthUrl = `https://app.netlify.com/authorize?${params.toString()}`;
+ return NextResponse.redirect(netlifyAuthUrl);
+}
File: src/app/api/deploy/netlify/callback/route.ts
Changes:
@@ -0,0 +1,141 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+import { fetchMutation } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_CLIENT_SECRET = process.env.NETLIFY_CLIENT_SECRET;
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+
+type NetlifyTokenResponse = {
+ access_token?: string;
+ token_type?: string;
+ scope?: string;
+};
+
+type NetlifyUserResponse = {
+ id?: string;
+ email?: string;
+ full_name?: string;
+ avatar_url?: string;
+};
+
+const parseTokenResponse = (value: unknown): NetlifyTokenResponse => {
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ const record = value as Record<string, unknown>;
+ return {
+ access_token: typeof record.access_token === "string" ? record.access_token : undefined,
+ token_type: typeof record.token_type === "string" ? record.token_type : undefined,
+ scope: typeof record.scope === "string" ? record.scope : undefined,
+ };
+};
+
+const parseUserResponse = (value: unknown): NetlifyUserResponse => {
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ const record = value as Record<string, unknown>;
+ return {
+ id: typeof record.id === "string" ? record.id : undefined,
+ email: typeof record.email === "string" ? record.email : undefined,
+ full_name: typeof record.full_name === "string" ? record.full_name : undefined,
+ avatar_url: typeof record.avatar_url === "string" ? record.avatar_url : undefined,
+ };
+};
+
+export async function GET(request: Request) {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const code = searchParams.get("code");
+ const state = searchParams.get("state");
+ const error = searchParams.get("error");
+
+ if (error) {
+ return NextResponse.redirect(
+ new URL(`/projects?error=${encodeURIComponent(error)}`, request.url)
+ );
+ }
+
+ if (!code || !state) {
+ return NextResponse.redirect(
+ new URL("/projects?error=Missing+authorization+code", request.url)
+ );
+ }
+
+ if (!NETLIFY_CLIENT_ID || !NETLIFY_CLIENT_SECRET) {
+ return NextResponse.json(
+ { error: "Netlify OAuth not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const decodedState = JSON.parse(Buffer.from(state, "base64").toString());
+ if (decodedState.userId !== user.id) {
+ throw new Error("State token mismatch");
+ }
+
+ const tokenParams = new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: NETLIFY_CLIENT_ID,
+ client_secret: NETLIFY_CLIENT_SECRET,
+ redirect_uri: NETLIFY_REDIRECT_URI,
+ code,
+ });
+
+ const tokenResponse = await fetch("https://api.netlify.com/oauth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: tokenParams.toString(),
+ });
+
+ if (!tokenResponse.ok) {
+ const errorText = await tokenResponse.text();
+ throw new Error(errorText || "Failed to exchange authorization code");
+ }
+
+ const tokenData = parseTokenResponse(await tokenResponse.json());
+ if (!tokenData.access_token) {
+ throw new Error("Missing Netlify access token");
+ }
+
+ const userResponse = await fetch("https://api.netlify.com/api/v1/user", {
+ headers: {
+ Authorization: `Bearer ${tokenData.access_token}`,
+ },
+ });
+
+ const userData = userResponse.ok
+ ? parseUserResponse(await userResponse.json())
+ : {};
+
+ await fetchMutation(api.oauth.storeConnection, {
+ provider: "netlify",
+ accessToken: tokenData.access_token,
+ scope: tokenData.scope || tokenData.token_type || "netlify",
+ metadata: {
+ netlifyId: userData.id,
+ netlifyEmail: userData.email,
+ netlifyName: userData.full_name,
+ netlifyAvatarUrl: userData.avatar_url,
+ },
+ });
+
+ return NextResponse.redirect(
+ new URL("/projects?netlify=connected", request.url)
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "OAuth failed";
+ return NextResponse.redirect(
+ new URL(`/projects?error=${encodeURIComponent(message)}`, request.url)
+ );
+ }
+}
File: src/app/api/deploy/netlify/deploy/route.ts
Changes:
@@ -0,0 +1,137 @@
+import JSZip from "jszip";
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { getUser, getConvexClientWithAuth } from "@/lib/auth-server";
+import { filterFilesForDownload } from "@/lib/filter-ai-files";
+import { getNetlifyToml } from "@/lib/netlify-config";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type DeployRequest = {
+ projectId: string;
+ siteId?: string;
+ deployType?: "preview" | "production";
+ branch?: string;
+ commitRef?: string;
+};
+
+type MessageWithFragment = {
+ _id: Id<"messages">;
+ _creationTime: number;
+ Fragment: {
+ _id: Id<"fragments">;
+ files?: unknown;
+ framework: "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+ } | null;
+};
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ const files: Record<string, string> = {};
+ for (const [path, content] of Object.entries(value)) {
+ if (typeof content === "string") {
+ files[path] = content;
+ }
+ }
+ return files;
+};
+
+const getLatestFragmentFiles = async (projectId: Id<"projects">) => {
+ const messages = await fetchQuery(api.messages.list, { projectId }) as MessageWithFragment[];
+ const latestWithFragment = [...messages].reverse().find((message) => message.Fragment);
+ const fragment = latestWithFragment?.Fragment;
+
+ if (!fragment) {
+ throw new Error("No AI-generated files are ready to deploy.");
+ }
+
+ const normalized = normalizeFiles(fragment.files);
+ const filtered = filterFilesForDownload(normalized);
+
+ if (Object.keys(filtered).length === 0) {
+ throw new Error("No AI-generated files are ready to deploy.");
+ }
+
+ return { files: filtered, framework: fragment.framework };
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found. Please connect your Netlify account.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as DeployRequest;
+ if (!body.projectId) {
+ return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
+ }
+
+ const projectId = body.projectId as Id<"projects">;
+ const convex = await getConvexClientWithAuth();
+ const project = await convex.query(api.projects.get, { projectId });
+
+ const { files, framework } = await getLatestFragmentFiles(projectId);
+ const netlifyToml = getNetlifyToml(framework);
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+
+ const zip = new JSZip();
+ for (const [filename, content] of Object.entries(files)) {
+ zip.file(filename, content);
+ }
+ zip.file("netlify.toml", netlifyToml);
+
+ const archive = await zip.generateAsync({ type: "arraybuffer" });
+ const archiveBlob = new Blob([archive], { type: "application/zip" });
+
+ const site =
+ body.siteId ? await netlifyClient.getSite(body.siteId) : await netlifyClient.createSite(project.name);
+
+ const deploy =
+ body.deployType === "preview"
+ ? await netlifyClient.createPreviewDeployment(site.id, archiveBlob)
+ : await netlifyClient.deploySite(site.id, archiveBlob);
+
+ await fetchMutation(api.deployments.createDeployment, {
+ projectId,
+ platform: "netlify",
+ siteId: site.id,
+ siteUrl: site.site_url || site.url,
+ deployId: deploy.id,
+ status: deploy.state || "pending",
+ isPreview: body.deployType === "preview",
+ branch: body.branch,
+ commitRef: body.commitRef,
+ });
+
+ return NextResponse.json({
+ siteId: site.id,
+ siteUrl: site.site_url || site.url,
+ deployId: deploy.id,
+ deployState: deploy.state,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Deployment failed";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/domains/route.ts
Changes:
@@ -0,0 +1,100 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+type DomainPayload = {
+ siteId: string;
+ domain: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ const domainId = searchParams.get("domainId");
+ if (!siteId) {
+ return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ if (domainId) {
+ const domain = await netlifyClient.verifyDomain(siteId, domainId);
+ return NextResponse.json(domain);
+ }
+
+ const domains = await netlifyClient.listDomains(siteId);
+ return NextResponse.json(domains);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch domains";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as DomainPayload;
+ if (!body.siteId || !body.domain) {
+ return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const domain = await netlifyClient.addDomain(body.siteId, body.domain);
+
+ return NextResponse.json(domain);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to add domain";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ const domainId = searchParams.get("domainId");
+ if (!siteId || !domainId) {
+ return NextResponse.json({ error: "Missing siteId or domainId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ await netlifyClient.deleteDomain(siteId, domainId);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to delete domain";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/env-vars/route.ts
Changes:
@@ -0,0 +1,129 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+type EnvVarPayload = {
+ siteId: string;
+ key: string;
+ value?: string;
+ context?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ if (!siteId) {
+ return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const envVars = await netlifyClient.getEnvVars(siteId);
+
+ return NextResponse.json(envVars);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch env vars";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as EnvVarPayload;
+ if (!body.siteId || !body.key || !body.value) {
+ return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const envVar = await netlifyClient.setEnvVar(
+ body.siteId,
+ body.key,
+ body.value,
+ body.context
+ );
+
+ return NextResponse.json(envVar);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to set env var";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function PUT(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as EnvVarPayload;
+ if (!body.siteId || !body.key || !body.value) {
+ return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const envVar = await netlifyClient.updateEnvVar(
+ body.siteId,
+ body.key,
+ body.value,
+ body.context
+ );
+
+ return NextResponse.json(envVar);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to update env var";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ const key = searchParams.get("key");
+ if (!siteId || !key) {
+ return NextResponse.json({ error: "Missing siteId or key" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ await netlifyClient.deleteEnvVar(siteId, key);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to delete env var";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/logs/route.ts
Changes:
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const deployId = searchParams.get("deployId");
+ if (!deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const logs = await netlifyClient.getBuildLog(deployId);
+
+ return NextResponse.json({ logs });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch logs";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/preview/route.ts
Changes:
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function DELETE(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const deployId = searchParams.get("deployId");
+ if (!deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ await netlifyClient.deletePreviewDeployment(deployId);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to delete preview";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/rollback/route.ts
Changes:
@@ -0,0 +1,47 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+type RollbackPayload = {
+ deployId: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as RollbackPayload;
+ if (!body.deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const rollback = await netlifyClient.rollbackDeployment(body.deployId);
+
+ return NextResponse.json(rollback);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Rollback failed";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/sites/route.ts
Changes:
@@ -0,0 +1,38 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET() {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const sites = await netlifyClient.listSites();
+
+ return NextResponse.json(sites);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch sites";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/status/route.ts
Changes:
@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const deployId = searchParams.get("deployId");
+ if (!deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const status = await netlifyClient.getDeploymentStatus(deployId);
+
+ return NextResponse.json(status);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch status";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/github/repositories/route.ts
Changes:
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { listRepositories } from "@/lib/github-api";
+
+export async function GET() {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+ if (!accessToken) {
+ return NextResponse.json(
+ { error: "GitHub connection not found. Please connect GitHub." },
+ { status: 400 },
+ );
+ }
+
+ const repositories = await listRepositories(accessToken);
+
+ return NextResponse.json({
+ repositories: repositories.map((repo) => ({
+ id: repo.id,
+ name: repo.name,
+ fullName: repo.full_name,
+ url: repo.html_url,
+ isPrivate: repo.private,
+ defaultBranch: repo.default_branch ?? "main",
+ })),
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load repositories";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/projects/[projectId]/export/github/route.ts
Changes:
@@ -0,0 +1,127 @@
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { z } from "zod";
+
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { getConvexClientWithAuth, getUser } from "@/lib/auth-server";
+import {
+ createRepository,
+ getRepository,
+ type CreateRepositoryInput,
+} from "@/lib/github-api";
+
+const exportRequestSchema = z
+ .object({
+ repositoryName: z.string().trim().min(1).optional(),
+ repositoryFullName: z.string().trim().min(1).optional(),
+ description: z.string().trim().optional(),
+ isPrivate: z.boolean().optional(),
+ branch: z.string().trim().optional(),
+ includeReadme: z.boolean().optional(),
+ includeGitignore: z.boolean().optional(),
+ commitMessage: z.string().trim().optional(),
+ })
+ .refine((data) => data.repositoryFullName || data.repositoryName, {
+ message: "Repository name is required.",
+ });
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ projectId: string }> },
+) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+ const body = exportRequestSchema.parse(await request.json());
+ const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+
+ if (!accessToken) {
+ return NextResponse.json(
+ { error: "GitHub connection not found. Please connect GitHub." },
+ { status: 400 },
+ );
+ }
+
+ let repository;
+ if (body.repositoryFullName) {
+ repository = await getRepository(accessToken, body.repositoryFullName);
+ } else {
+ if (!body.repositoryName) {
+ return NextResponse.json(
+ { error: "Repository name is required." },
+ { status: 400 },
+ );
+ }
+
+ const input: CreateRepositoryInput = {
+ name: body.repositoryName,
+ description: body.description,
+ isPrivate: body.isPrivate ?? false,
+ };
+ repository = await createRepository(accessToken, input);
+ }
+
+ const branch = body.branch ?? repository.default_branch ?? "main";
+
+ const exportId = await fetchMutation(api.githubExports.create, {
+ projectId: projectId as Id<"projects">,
+ repositoryName: repository.name,
+ repositoryUrl: repository.html_url,
+ repositoryFullName: repository.full_name,
+ branch,
+ });
+
+ const convex = await getConvexClientWithAuth();
+ const result = await convex.action(api.githubExports.exportToGitHub, {
+ exportId,
+ branch,
+ includeReadme: body.includeReadme,
+ includeGitignore: body.includeGitignore,
+ commitMessage: body.commitMessage,
+ });
+
+ return NextResponse.json(result);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Export failed";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ projectId: string }> },
+) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+ const { searchParams } = new URL(request.url);
+ const exportId = searchParams.get("exportId");
+
+ if (!exportId) {
+ return NextResponse.json({ error: "Missing exportId" }, { status: 400 });
+ }
+
+ const exportsList = await fetchQuery(api.githubExports.list, {
+ projectId: projectId as Id<"projects">,
+ });
+ const record = exportsList.find((item) => item._id === exportId);
+
+ if (!record) {
+ return NextResponse.json({ error: "Export not found" }, { status: 404 });
+ }
+
+ return NextResponse.json(record);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load export";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/lib/github-api.ts
Changes:
@@ -0,0 +1,350 @@
+import { z } from "zod";
+
+const GITHUB_API_BASE_URL = "https://api.github.com";
+const GITHUB_API_VERSION = "2022-11-28";
+const MAX_TREE_CONTENT_BYTES = 100000;
+
+const githubErrorSchema = z.object({
+ message: z.string().optional(),
+});
+
+const githubUserSchema = z.object({
+ id: z.number(),
+ login: z.string(),
+ name: z.string().nullable().optional(),
+ email: z.string().nullable().optional(),
+ avatar_url: z.string().optional(),
+});
+
+const githubRepositorySchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ full_name: z.string(),
+ html_url: z.string(),
+ private: z.boolean(),
+ default_branch: z.string().optional(),
+});
+
+const githubRefSchema = z.object({
+ object: z.object({
+ sha: z.string(),
+ }),
+});
+
+const githubTreeSchema = z.object({
+ sha: z.string(),
+});
+
+const githubCommitSchema = z.object({
+ sha: z.string(),
+ tree: z.object({
+ sha: z.string(),
+ }),
+});
+
+type GitHubRequestOptions = {
+ method?: "GET" | "POST" | "PATCH" | "PUT";
+ body?: unknown;
+ headers?: Record<string, string>;
+};
+
+export type GitHubUser = z.infer<typeof githubUserSchema>;
+export type GitHubRepository = z.infer<typeof githubRepositorySchema>;
+
+export type GitHubTreeEntry = {
+ path: string;
+ mode: "100644";
+ type: "blob";
+ content: string;
+};
+
+export type ProjectFramework = "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE";
+
+export type CreateRepositoryInput = {
+ name: string;
+ description?: string;
+ isPrivate: boolean;
+};
+
+export type ExportReadmeInput = {
+ projectName: string;
+ framework: ProjectFramework;
+ description?: string;
+};
+
+const parseGitHubError = (payload: unknown, status: number): string => {
+ const parsed = githubErrorSchema.safeParse(payload);
+ if (parsed.success && parsed.data.message) {
+ return parsed.data.message;
+ }
+
+ return `GitHub API error (${status})`;
+};
+
+const githubRequest = async (
+ path: string,
+ accessToken: string,
+ options: GitHubRequestOptions = {},
+): Promise<unknown> => {
+ const response = await fetch(`${GITHUB_API_BASE_URL}${path}`, {
+ method: options.method ?? "GET",
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${accessToken}`,
+ "User-Agent": "ZapDev",
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ const payload = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ throw new Error(parseGitHubError(payload, response.status));
+ }
+
+ return payload;
+};
+
+export const getAuthenticatedUser = async (
+ accessToken: string,
+): Promise<GitHubUser> => {
+ const payload = await githubRequest("/user", accessToken);
+ return githubUserSchema.parse(payload);
+};
+
+export const listRepositories = async (
+ accessToken: string,
+): Promise<Array<GitHubRepository>> => {
+ const payload = await githubRequest("/user/repos?per_page=100&sort=updated", accessToken);
+ return z.array(githubRepositorySchema).parse(payload);
+};
+
+export const getRepository = async (
+ accessToken: string,
+ fullName: string,
+): Promise<GitHubRepository> => {
+ const payload = await githubRequest(`/repos/${fullName}`, accessToken);
+ return githubRepositorySchema.parse(payload);
+};
+
+export const createRepository = async (
+ accessToken: string,
+ input: CreateRepositoryInput,
+): Promise<GitHubRepository> => {
+ const payload = await githubRequest("/user/repos", accessToken, {
+ method: "POST",
+ body: {
+ name: input.name,
+ description: input.description ?? "",
+ private: input.isPrivate,
+ auto_init: true,
+ },
+ });
+ return githubRepositorySchema.parse(payload);
+};
+
+export const getBranchRef = async (
+ accessToken: string,
+ fullName: string,
+ branch: string,
+): Promise<string> => {
+ const payload = await githubRequest(
+ `/repos/${fullName}/git/ref/heads/${branch}`,
+ accessToken,
+ );
+ return githubRefSchema.parse(payload).object.sha;
+};
+
+export const getCommitTreeSha = async (
+ accessToken: string,
+ fullName: string,
+ commitSha: string,
+): Promise<string> => {
+ const payload = await githubRequest(
+ `/repos/${fullName}/git/commits/${commitSha}`,
+ accessToken,
+ );
+ return githubCommitSchema.parse(payload).tree.sha;
+};
+
+export const createTree = async (
+ accessToken: string,
+ fullName: string,
+ tree: Array<GitHubTreeEntry>,
+ baseTreeSha?: string,
+): Promise<string> => {
+ const payload = await githubRequest(`/repos/${fullName}/git/trees`, accessToken, {
+ method: "POST",
+ body: {
+ base_tree: baseTreeSha,
+ tree,
+ },
+ });
+ return githubTreeSchema.parse(payload).sha;
+};
+
+export const createCommit = async (
+ accessToken: string,
+ fullName: string,
+ message: string,
+ treeSha: string,
+ parents: Array<string>,
+): Promise<string> => {
+ const payload = await githubRequest(`/repos/${fullName}/git/commits`, accessToken, {
+ method: "POST",
+ body: {
+ message,
+ tree: treeSha,
+ parents,
+ },
+ });
+ return githubCommitSchema.parse(payload).sha;
+};
+
+export const createBranchRef = async (
+ accessToken: string,
+ fullName: string,
+ branch: string,
+ commitSha: string,
+): Promise<void> => {
+ await githubRequest(`/repos/${fullName}/git/refs`, accessToken, {
+ method: "POST",
+ body: {
+ ref: `refs/heads/${branch}`,
+ sha: commitSha,
+ },
+ });
+};
+
+export const updateBranchRef = async (
+ accessToken: string,
+ fullName: string,
+ branch: string,
+ commitSha: string,
+): Promise<void> => {
+ await githubRequest(`/repos/${fullName}/git/refs/heads/${branch}`, accessToken, {
+ method: "PATCH",
+ body: {
+ sha: commitSha,
+ force: false,
+ },
+ });
+};
+
+const sanitizePath = (value: string): string => {
+ return value.replace(/^\/+/, "").replace(/\\/g, "/");
+};
+
+export const buildTreeEntries = (
+ files: Record<string, string>,
+): Array<GitHubTreeEntry> => {
+ const entries: Array<GitHubTreeEntry> = [];
+ const encoder = new TextEncoder();
+
+ for (const [rawPath, content] of Object.entries(files)) {
+ const path = sanitizePath(rawPath);
+ if (!path) {
+ continue;
+ }
+
+ const byteLength = encoder.encode(content).length;
+ if (byteLength > MAX_TREE_CONTENT_BYTES) {
+ throw new Error(`File too large for GitHub export: ${path}`);
+ }
+
+ entries.push({
+ path,
+ mode: "100644",
+ type: "blob",
+ content,
+ });
+ }
+
+ return entries;
+};
+
+const getFrameworkLabel = (framework: ProjectFramework): string => {
+ switch (framework) {
+ case "NEXTJS":
+ return "Next.js";
+ case "ANGULAR":
+ return "Angular";
+ case "REACT":
+ return "React";
+ case "VUE":
+ return "Vue";
+ case "SVELTE":
+ return "Svelte";
+ default:
+ return framework;
+ }
+};
+
+export const generateReadme = (input: ExportReadmeInput): string => {
+ const frameworkLabel = getFrameworkLabel(input.framework);
+
+ const lines: Array<string> = [`# ${input.projectName}`, ""];
+
+ if (input.description) {
+ lines.push(input.description, "");
+ }
+
+ lines.push(
+ "Exported from ZapDev.",
+ "",
+ `Framework: ${frameworkLabel}`,
+ "",
+ "## Getting Started",
+ "",
+ "1. Install dependencies with `bun install`.",
+ "2. Start the dev server with `bun run dev`.",
+ "3. Build for production with `bun run build`.",
+ );
+
+ return lines.join("\n");
+};
+
+export const generateGitignore = (framework: ProjectFramework): string => {
+ const base = [
+ "node_modules",
+ ".env",
+ ".env.local",
+ ".env.*.local",
+ "dist",
+ "build",
+ ".cache",
+ ".DS_Store",
+ ];
+
+ const frameworkSpecific: Record<ProjectFramework, Array<string>> = {
+ NEXTJS: [".next", "out", "next-env.d.ts"],
+ REACT: ["coverage"],
+ VUE: ["dist", ".vite"],
+ ANGULAR: [".angular", "dist"],
+ SVELTE: [".svelte-kit"],
+ };
+
+ const entries = [...base, ...frameworkSpecific[framework]];
+ return entries.join("\n");
+};
+
+export const withDefaultFiles = (
+ files: Record<string, string>,
+ input: ExportReadmeInput,
+ includeReadme: boolean,
+ includeGitignore: boolean,
+): Record<string, string> => {
+ const updated: Record<string, string> = { ...files };
+
+ if (includeReadme && !updated["README.md"]) {
+ updated["README.md"] = generateReadme(input);
+ }
+
+ if (includeGitignore && !updated[".gitignore"]) {
+ updated[".gitignore"] = generateGitignore(input.framework);
+ }
+
+ return updated;
+};
File: src/lib/netlify-client.ts
Changes:
@@ -0,0 +1,238 @@
+type NetlifyRequestOptions = {
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+ headers?: Record<string, string>;
+ body?: BodyInit | null;
+};
+
+type NetlifySite = {
+ id: string;
+ name: string;
+ url: string;
+ site_url: string;
+ admin_url?: string;
+};
+
+type NetlifyDeploy = {
+ id: string;
+ state: string;
+ url?: string;
+ deploy_url?: string;
+ created_at?: string;
+ updated_at?: string;
+};
+
+type NetlifyEnvVar = {
+ key: string;
+ values?: Array<{
+ value: string;
+ context?: string;
+ }>;
+};
+
+type NetlifyDomain = {
+ id: string;
+ name: string;
+ ssl_status?: string;
+ verification?: {
+ status?: string;
+ };
+};
+
+const NETLIFY_API_BASE = "https://api.netlify.com/api/v1";
+
+const parseJson = async <T>(response: Response): Promise<T> => {
+ const text = await response.text();
+ if (!text) {
+ return {} as T;
+ }
+ return JSON.parse(text) as T;
+};
+
+const handleApiError = async (response: Response) => {
+ if (response.status === 429) {
+ const retryAfter = response.headers.get("retry-after");
+ throw new Error(`Netlify rate limit hit. Retry after ${retryAfter ?? "unknown"} seconds.`);
+ }
+
+ const errorBody = await response.text();
+ throw new Error(errorBody || `Netlify API error: ${response.status}`);
+};
+
+export const createNetlifyClient = (accessToken: string) => {
+ const request = async <T>(path: string, options: NetlifyRequestOptions = {}) => {
+ const response = await fetch(`${NETLIFY_API_BASE}${path}`, {
+ method: options.method ?? "GET",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ?? null,
+ });
+
+ if (!response.ok) {
+ await handleApiError(response);
+ }
+
+ return parseJson<T>(response);
+ };
+
+ return {
+ async createSite(name?: string): Promise<NetlifySite> {
+ return request<NetlifySite>("/sites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(name ? { name } : {}),
+ });
+ },
+
+ async getSite(siteId: string): Promise<NetlifySite> {
+ return request<NetlifySite>(`/sites/${siteId}`);
+ },
+
+ async listSites(): Promise<NetlifySite[]> {
+ return request<NetlifySite[]>("/sites");
+ },
+
+ async updateSite(siteId: string, payload: Record<string, unknown>): Promise<NetlifySite> {
+ return request<NetlifySite>(`/sites/${siteId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ },
+
+ async deleteSite(siteId: string): Promise<void> {
+ await request<void>(`/sites/${siteId}`, { method: "DELETE" });
+ },
+
+ async deploySite(siteId: string, zipBody: BodyInit, options?: { draft?: boolean }): Promise<NetlifyDeploy> {
+ const params = new URLSearchParams();
+ if (options?.draft) {
+ params.set("draft", "true");
+ }
+
+ const query = params.toString();
+ const path = query ? `/sites/${siteId}/deploys?${query}` : `/sites/${siteId}/deploys`;
+
+ return request<NetlifyDeploy>(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/zip" },
+ body: zipBody,
+ });
+ },
+
+ async getDeploymentStatus(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}`);
+ },
+
+ async listDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+ return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys`);
+ },
+
+ async getDeployment(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}`);
+ },
+
+ async cancelDeployment(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}/cancel`, { method: "POST" });
+ },
+
+ async rollbackDeployment(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}/rollback`, { method: "POST" });
+ },
+
+ async getBuildLog(deployId: string): Promise<string> {
+ const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!response.ok) {
+ await handleApiError(response);
+ }
+
+ return response.text();
+ },
+
+ async getEnvVars(siteId: string): Promise<NetlifyEnvVar[]> {
+ return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`);
+ },
+
+ async setEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+ return request<NetlifyEnvVar>(`/sites/${siteId}/env`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ key,
+ values: [{ value, context }],
+ }),
+ });
+ },
+
+ async updateEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+ return request<NetlifyEnvVar>(`/sites/${siteId}/env/${key}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ values: [{ value, context }],
+ }),
+ });
+ },
+
+ async deleteEnvVar(siteId: string, key: string): Promise<void> {
+ await request<void>(`/sites/${siteId}/env/${key}`, { method: "DELETE" });
+ },
+
+ async setBulkEnvVars(siteId: string, vars: Array<{ key: string; value: string; context?: string }>): Promise<NetlifyEnvVar[]> {
+ const payload = vars.map((entry) => ({
+ key: entry.key,
+ values: [{ value: entry.value, context: entry.context ?? "all" }],
+ }));
+
+ return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ },
+
+ async listDomains(siteId: string): Promise<NetlifyDomain[]> {
+ return request<NetlifyDomain[]>(`/sites/${siteId}/domains`);
+ },
+
+ async addDomain(siteId: string, domain: string): Promise<NetlifyDomain> {
+ return request<NetlifyDomain>(`/sites/${siteId}/domains`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: domain }),
+ });
+ },
+
+ async deleteDomain(siteId: string, domainId: string): Promise<void> {
+ await request<void>(`/sites/${siteId}/domains/${domainId}`, { method: "DELETE" });
+ },
+
+ async verifyDomain(siteId: string, domainId: string): Promise<NetlifyDomain> {
+ return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+ },
+
+ async getDnsRecords(siteId: string, domainId: string): Promise<NetlifyDomain> {
+ return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+ },
+
+ async createPreviewDeployment(siteId: string, zipBody: BodyInit): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/sites/${siteId}/deploys?draft=true`, {
+ method: "POST",
+ headers: { "Content-Type": "application/zip" },
+ body: zipBody,
+ });
+ },
+
+ async listPreviewDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+ return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys?draft=true`);
+ },
+
+ async deletePreviewDeployment(deployId: string): Promise<void> {
+ await request<void>(`/deploys/${deployId}`, { method: "DELETE" });
+ },
+ };
+};
File: src/lib/netlify-config.ts
Changes:
@@ -0,0 +1,70 @@
+type FrameworkKey = "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+
+type NetlifyConfig = {
+ buildCommand: string;
+ publishDir: string;
+ plugins?: Array<string>;
+ env?: Record<string, string>;
+};
+
+const frameworkConfigMap: Record<FrameworkKey, NetlifyConfig> = {
+ NEXTJS: {
+ buildCommand: "bun run build",
+ publishDir: ".next",
+ plugins: ["@netlify/plugin-nextjs"],
+ },
+ REACT: {
+ buildCommand: "bun run build",
+ publishDir: "dist",
+ },
+ VUE: {
+ buildCommand: "bun run build",
+ publishDir: "dist",
+ },
+ ANGULAR: {
+ buildCommand: "bun run build",
+ publishDir: "dist",
+ },
+ SVELTE: {
+ buildCommand: "bun run build",
+ publishDir: "build",
+ },
+};
+
+const formatEnvBlock = (env?: Record<string, string>) => {
+ if (!env || Object.keys(env).length === 0) {
+ return "";
+ }
+
+ const lines = Object.entries(env).map(([key, value]) => ` ${key} = "${value}"`);
+ return `\n[build.environment]\n${lines.join("\n")}\n`;
+};
+
+export const getNetlifyToml = (framework: FrameworkKey) => {
+ const config = frameworkConfigMap[framework];
+ const pluginsBlock = config.plugins?.length
+ ? `\n[[plugins]]\n package = "${config.plugins[0]}"\n`
+ : "";
+ const envBlock = formatEnvBlock(config.env);
+
+ return [
+ "[build]",
+ ` command = "${config.buildCommand}"`,
+ ` publish = "${config.publishDir}"`,
+ pluginsBlock.trimEnd(),
+ envBlock.trimEnd(),
+ ]
+ .filter((line) => line.length > 0)
+ .join("\n")
+ .trim()
+ .concat("\n");
+};
+
+export const getNetlifyBuildSettings = (framework: FrameworkKey) => {
+ const config = frameworkConfigMap[framework];
+ return {
+ buildCommand: config.buildCommand,
+ publishDir: config.publishDir,
+ plugins: config.plugins ?? [],
+ };
+};
File: src/lib/payment-provider.ts
Changes:
@@ -0,0 +1,211 @@
+export type BillingInterval = "monthly" | "yearly";
+
+export type SubscriptionStatus =
+ | "active"
+ | "trialing"
+ | "past_due"
+ | "canceled"
+ | "unpaid";
+
+export interface CheckoutSessionRequest {
+ customerId: string;
+ productId: string;
+ successUrl: string;
+ cancelUrl: string;
+ metadata?: Record<string, string>;
+}
+
+export interface CheckoutSession {
+ id: string;
+ url: string;
+}
+
+export interface SubscriptionLookup {
+ subscriptionId: string;
+}
+
+export interface SubscriptionSummary {
+ id: string;
+ customerId: string;
+ productId: string;
+ status: SubscriptionStatus;
+ interval: BillingInterval;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+}
+
+export interface CancelSubscriptionRequest {
+ subscriptionId: string;
+ cancelAtPeriodEnd?: boolean;
+}
+
+export interface UpdateSubscriptionRequest {
+ subscriptionId: string;
+ productId: string;
+}
+
+export interface BillingPortalRequest {
+ customerId: string;
+ returnUrl: string;
+}
+
+export interface UsageEvent {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export interface FeatureCheckRequest {
+ customerId: string;
+ featureId: string;
+}
+
+export interface FeatureCheckResult {
+ allowed: boolean;
+ limit?: number;
+ used?: number;
+ remaining?: number;
+}
+
+export interface PaymentProvider {
+ createCheckoutSession(input: CheckoutSessionRequest): Promise<CheckoutSession>;
+ getSubscription(input: SubscriptionLookup): Promise<SubscriptionSummary | null>;
+ updateSubscription(input: UpdateSubscriptionRequest): Promise<SubscriptionSummary>;
+ cancelSubscription(input: CancelSubscriptionRequest): Promise<SubscriptionSummary>;
+ createBillingPortalSession(input: BillingPortalRequest): Promise<{ url: string }>;
+ trackUsage(input: UsageEvent): Promise<void>;
+ checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult>;
+}
+
+interface AutumnConfig {
+ apiKey: string;
+ baseUrl?: string;
+}
+
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export class AutumnStripeProvider implements PaymentProvider {
+ private apiKey: string;
+ private baseUrl: string;
+
+ constructor(config: AutumnConfig) {
+ this.apiKey = config.apiKey;
+ this.baseUrl = config.baseUrl ?? "https://api.useautumn.com";
+ }
+
+ async createCheckoutSession(
+ input: CheckoutSessionRequest
+ ): Promise<CheckoutSession> {
+ return this.request<CheckoutSession>("/v1/checkout", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ productId: input.productId,
+ successUrl: input.successUrl,
+ cancelUrl: input.cancelUrl,
+ metadata: input.metadata,
+ },
+ });
+ }
+
+ async getSubscription(
+ input: SubscriptionLookup
+ ): Promise<SubscriptionSummary | null> {
+ return this.request<SubscriptionSummary | null>(
+ `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`,
+ { method: "GET" }
+ );
+ }
+
+ async updateSubscription(
+ input: UpdateSubscriptionRequest
+ ): Promise<SubscriptionSummary> {
+ return this.request<SubscriptionSummary>(
+ `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`,
+ {
+ method: "PATCH",
+ body: {
+ productId: input.productId,
+ },
+ }
+ );
+ }
+
+ async cancelSubscription(
+ input: CancelSubscriptionRequest
+ ): Promise<SubscriptionSummary> {
+ return this.request<SubscriptionSummary>(
+ `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}/cancel`,
+ {
+ method: "POST",
+ body: {
+ cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? true,
+ },
+ }
+ );
+ }
+
+ async createBillingPortalSession(
+ input: BillingPortalRequest
+ ): Promise<{ url: string }> {
+ return this.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ returnUrl: input.returnUrl,
+ },
+ });
+ }
+
+ async trackUsage(input: UsageEvent): Promise<void> {
+ await this.request<{ ok: boolean }>("/v1/usage", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ meterId: input.meterId,
+ quantity: input.quantity,
+ },
+ });
+ }
+
+ async checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult> {
+ return this.request<FeatureCheckResult>("/v1/features/check", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ featureId: input.featureId,
+ },
+ });
+ }
+
+ private async request<T>(
+ path: string,
+ options: AutumnRequestOptions
+ ): Promise<T> {
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.apiKey}`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
+ );
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ }
+}
File: src/lib/payment-templates/angular.ts
Changes:
@@ -0,0 +1,388 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const angularPaymentTemplate: PaymentTemplateBundle = {
+ framework: "angular",
+ description: "Angular payment integration with Autumn + Stripe",
+ files: {
+ "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+
+ const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ };
+
+ return { request };
+};
+`,
+ "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+ if (!isCheckoutRequest(req.body)) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body: req.body,
+ });
+ res.json(checkout);
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+ const { customerId, returnUrl } = req.body as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!customerId || !returnUrl) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const portal = await autumn.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: { customerId, returnUrl },
+ });
+ res.json(portal);
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, productId } = req.body as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!subscriptionId || !productId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const updated = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId },
+ }
+ );
+ res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!subscriptionId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const canceled = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+ }
+ );
+ res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+ const { customerId, featureId } = req.body as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!customerId || !featureId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const result = await autumn.request<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId, featureId },
+ });
+ res.json(result);
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+ const { customerId, meterId, quantity } = req.body as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!customerId || !meterId || typeof quantity !== "number") {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ await autumn.request("/v1/usage", {
+ method: "POST",
+ body: { customerId, meterId, quantity },
+ });
+ res.json({ ok: true });
+});
+
+export default router;
+`,
+ "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ res.status(500).json({ error: "Missing webhook secret" });
+ return;
+ }
+ const signature = req.headers["autumn-signature"];
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+ const rawBody = req.body as string;
+ if (!verifySignature(signatureValue, rawBody, secret)) {
+ res.status(401).json({ error: "Invalid signature" });
+ return;
+ }
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ res.json({ received: true });
+});
+
+export default router;
+`,
+ "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+ console.log(\`Billing API listening on \${port}\`);
+});
+`,
+ "src/app/services/billing.service.ts": `
+import { Injectable } from "@angular/core";
+
+interface CheckoutPayload {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+}
+
+@Injectable({ providedIn: "root" })
+export class BillingService {
+ async startCheckout(payload: CheckoutPayload): Promise<void> {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ }
+
+ async checkFeature(customerId: string, featureId: string): Promise<boolean> {
+ const response = await fetch("/api/billing/feature-check", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ customerId, featureId }),
+ });
+ const data = (await response.json()) as { allowed?: boolean };
+ return data.allowed === true;
+ }
+
+ async trackUsage(customerId: string, meterId: string, quantity: number): Promise<void> {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ customerId, meterId, quantity }),
+ });
+ }
+}
+`,
+ "src/app/guards/feature.guard.ts": `
+import { Injectable } from "@angular/core";
+import type { CanActivateFn, ActivatedRouteSnapshot } from "@angular/router";
+import { BillingService } from "../services/billing.service";
+
+@Injectable({ providedIn: "root" })
+export class FeatureGuard {
+ constructor(private billingService: BillingService) {}
+
+ canActivate: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
+ const featureId = route.data?.["featureId"];
+ const customerId = route.data?.["customerId"];
+ if (typeof featureId !== "string" || typeof customerId !== "string") {
+ return false;
+ }
+ return this.billingService.checkFeature(customerId, featureId);
+ };
+}
+`,
+ "src/app/components/checkout-button/checkout-button.component.ts": `
+import { Component, Input } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BillingService } from "../../services/billing.service";
+
+@Component({
+ selector: "app-checkout-button",
+ standalone: true,
+ imports: [CommonModule],
+ template: \`
+ <button
+ type="button"
+ class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ [disabled]="loading"
+ (click)="startCheckout()"
+ >
+ {{ loading ? "Redirecting..." : (label ?? "Upgrade") }}
+ </button>
+ \`,
+})
+export class CheckoutButtonComponent {
+ @Input({ required: true }) productId = "";
+ @Input({ required: true }) customerId = "";
+ @Input({ required: true }) successUrl = "";
+ @Input({ required: true }) cancelUrl = "";
+ @Input() label?: string;
+
+ loading = false;
+
+ constructor(private billingService: BillingService) {}
+
+ async startCheckout() {
+ this.loading = true;
+ try {
+ await this.billingService.startCheckout({
+ productId: this.productId,
+ customerId: this.customerId,
+ successUrl: this.successUrl,
+ cancelUrl: this.cancelUrl,
+ });
+ } finally {
+ this.loading = false;
+ }
+ }
+}
+`,
+ "src/app/components/billing-success/billing-success.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+ selector: "app-billing-success",
+ standalone: true,
+ imports: [CommonModule],
+ template: \`
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Payment successful</h1>
+ <p class="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+ Return to app
+ </a>
+ </div>
+ \`,
+})
+export class BillingSuccessComponent {}
+`,
+ "src/app/components/billing-cancel/billing-cancel.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+ selector: "app-billing-cancel",
+ standalone: true,
+ imports: [CommonModule],
+ template: \`
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+ <p class="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+ Return to app
+ </a>
+ </div>
+ \`,
+})
+export class BillingCancelComponent {}
+`,
+ },
+};
File: src/lib/payment-templates/autumn-config.ts
Changes:
@@ -0,0 +1,49 @@
+export const autumnConfigTemplate = `
+export const autumnConfig = {
+ products: [
+ {
+ id: "free",
+ name: "Free",
+ description: "Starter access",
+ prices: [
+ {
+ id: "free-monthly",
+ amount: 0,
+ currency: "usd",
+ interval: "monthly",
+ },
+ ],
+ features: ["basic_generations"],
+ },
+ {
+ id: "pro",
+ name: "Pro",
+ description: "Pro plan with higher limits",
+ prices: [
+ {
+ id: "pro-monthly",
+ amount: 2900,
+ currency: "usd",
+ interval: "monthly",
+ },
+ ],
+ features: ["basic_generations", "priority_generations"],
+ },
+ ],
+ features: {
+ basic_generations: {
+ type: "metered",
+ meterId: "generations",
+ included: 5,
+ },
+ priority_generations: {
+ type: "boolean",
+ },
+ },
+ meters: {
+ generations: {
+ unit: "generation",
+ },
+ },
+} as const;
+`;
File: src/lib/payment-templates/env-example.ts
Changes:
@@ -0,0 +1,9 @@
+export const paymentEnvExample = `
+# Autumn + Stripe (user app billing)
+AUTUMN_API_KEY=""
+AUTUMN_API_BASE_URL="https://api.useautumn.com"
+AUTUMN_WEBHOOK_SECRET=""
+STRIPE_SECRET_KEY=""
+STRIPE_PUBLISHABLE_KEY=""
+NEXT_PUBLIC_APP_URL="http://localhost:3000"
+`;
File: src/lib/payment-templates/index.ts
Changes:
@@ -0,0 +1,24 @@
+import { angularPaymentTemplate } from "./angular";
+import { nextjsPaymentTemplate } from "./nextjs";
+import { reactPaymentTemplate } from "./react";
+import { sveltePaymentTemplate } from "./svelte";
+import { vuePaymentTemplate } from "./vue";
+import type { PaymentFramework, PaymentTemplateBundle } from "./types";
+import { autumnConfigTemplate } from "./autumn-config";
+import { paymentEnvExample } from "./env-example";
+
+const templates: Record<PaymentFramework, PaymentTemplateBundle> = {
+ nextjs: nextjsPaymentTemplate,
+ react: reactPaymentTemplate,
+ vue: vuePaymentTemplate,
+ angular: angularPaymentTemplate,
+ svelte: sveltePaymentTemplate,
+};
+
+export const paymentTemplates = templates;
+export { autumnConfigTemplate, paymentEnvExample };
+export type { PaymentFramework, PaymentTemplateBundle };
+
+export const getPaymentTemplate = (
+ framework: PaymentFramework
+): PaymentTemplateBundle => templates[framework];
File: src/lib/payment-templates/nextjs.ts
Changes:
@@ -0,0 +1,440 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const nextjsPaymentTemplate: PaymentTemplateBundle = {
+ framework: "nextjs",
+ description: "Next.js App Router payment integration with Autumn + Stripe",
+ files: {
+ "lib/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+ return { apiKey, baseUrl };
+};
+
+export async function autumnRequest<T>(
+ path: string,
+ options: AutumnRequestOptions
+): Promise<T> {
+ const { apiKey, baseUrl } = getAutumnConfig();
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+}
+`,
+ "app/api/billing/checkout/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isCheckoutRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const checkout = await autumnRequest<{ url: string; id: string }>(
+ "/v1/checkout",
+ {
+ method: "POST",
+ body: {
+ productId: body.productId,
+ customerId: body.customerId,
+ successUrl: body.successUrl,
+ cancelUrl: body.cancelUrl,
+ },
+ }
+ );
+
+ return NextResponse.json(checkout);
+}
+`,
+ "app/api/billing/portal/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type PortalRequest = {
+ customerId: string;
+ returnUrl: string;
+};
+
+const isPortalRequest = (value: unknown): value is PortalRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return typeof data.customerId === "string" && typeof data.returnUrl === "string";
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isPortalRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ returnUrl: body.returnUrl,
+ },
+ });
+
+ return NextResponse.json(portal);
+}
+`,
+ "app/api/billing/subscription/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UpdateRequest = {
+ subscriptionId: string;
+ productId: string;
+};
+
+type CancelRequest = {
+ subscriptionId: string;
+ cancelAtPeriodEnd?: boolean;
+};
+
+export async function GET(req: Request) {
+ const { searchParams } = new URL(req.url);
+ const subscriptionId = searchParams.get("subscriptionId");
+
+ if (!subscriptionId) {
+ return NextResponse.json({ error: "subscriptionId is required" }, { status: 400 });
+ }
+
+ const subscription = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ { method: "GET" }
+ );
+
+ return NextResponse.json(subscription);
+}
+
+export async function PATCH(req: Request) {
+ const body = (await req.json()) as UpdateRequest;
+ if (!body.subscriptionId || !body.productId) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const updated = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId: body.productId },
+ }
+ );
+
+ return NextResponse.json(updated);
+}
+
+export async function DELETE(req: Request) {
+ const body = (await req.json()) as CancelRequest;
+ if (!body.subscriptionId) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const canceled = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+ }
+ );
+
+ return NextResponse.json(canceled);
+}
+`,
+ "app/api/billing/usage/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UsageRequest = {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+};
+
+const isUsageRequest = (value: unknown): value is UsageRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.customerId === "string" &&
+ typeof data.meterId === "string" &&
+ typeof data.quantity === "number"
+ );
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isUsageRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ await autumnRequest("/v1/usage", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ meterId: body.meterId,
+ quantity: body.quantity,
+ },
+ });
+
+ return NextResponse.json({ ok: true });
+}
+`,
+ "app/api/billing/feature-check/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type FeatureCheckRequest = {
+ customerId: string;
+ featureId: string;
+};
+
+const isFeatureCheckRequest = (
+ value: unknown
+): value is FeatureCheckRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.customerId === "string" && typeof data.featureId === "string"
+ );
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isFeatureCheckRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const result = await autumnRequest<unknown>("/v1/features/check", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ featureId: body.featureId,
+ },
+ });
+
+ return NextResponse.json(result);
+}
+`,
+ "app/api/webhooks/autumn/route.ts": `
+import { NextResponse } from "next/server";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+export const runtime = "nodejs";
+
+const verifySignature = (
+ signature: string,
+ payload: string,
+ secret: string
+): boolean => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export async function POST(req: Request) {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ return NextResponse.json({ error: "Missing webhook secret" }, { status: 500 });
+ }
+
+ const signature = req.headers.get("autumn-signature") ?? "";
+ const rawBody = await req.text();
+
+ if (!verifySignature(signature, rawBody, secret)) {
+ return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
+ }
+
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled": {
+ break;
+ }
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded": {
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ return NextResponse.json({ received: true });
+}
+`,
+ "components/billing/checkout-button.tsx": `
+"use client";
+
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+ label?: string;
+}
+
+export function CheckoutButton({
+ productId,
+ customerId,
+ successUrl,
+ cancelUrl,
+ label = "Upgrade",
+}: CheckoutButtonProps) {
+ const [loading, setLoading] = useState(false);
+
+ const startCheckout = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ productId,
+ customerId,
+ successUrl,
+ cancelUrl,
+ }),
+ });
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <button
+ type="button"
+ onClick={startCheckout}
+ disabled={loading}
+ className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ >
+ {loading ? "Redirecting..." : label}
+ </button>
+ );
+}
+`,
+ "components/billing/feature-gate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+ allowed: boolean;
+ fallback?: ReactNode;
+ children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+ if (!allowed) {
+ return <>{fallback ?? null}</>;
+ }
+ return <>{children}</>;
+}
+`,
+ "lib/usage.ts": `
+interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+}
+`,
+ "app/billing/success/page.tsx": `
+export default function BillingSuccessPage() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Payment successful</h1>
+ <p className="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ "app/billing/cancel/page.tsx": `
+export default function BillingCancelPage() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+ <p className="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ },
+};
File: src/lib/payment-templates/react.ts
Changes:
@@ -0,0 +1,354 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const reactPaymentTemplate: PaymentTemplateBundle = {
+ framework: "react",
+ description: "React (Vite) payment integration with Autumn + Stripe",
+ files: {
+ "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+
+ const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ };
+
+ return { request };
+};
+`,
+ "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+ if (!isCheckoutRequest(req.body)) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+
+ const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body: req.body,
+ });
+
+ res.json(checkout);
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+ const { customerId, returnUrl } = req.body as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!customerId || !returnUrl) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const portal = await autumn.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: { customerId, returnUrl },
+ });
+ res.json(portal);
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, productId } = req.body as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!subscriptionId || !productId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const updated = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId },
+ }
+ );
+ res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!subscriptionId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const canceled = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+ }
+ );
+ res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+ const { customerId, featureId } = req.body as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!customerId || !featureId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const result = await autumn.request<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId, featureId },
+ });
+ res.json(result);
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+ const { customerId, meterId, quantity } = req.body as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!customerId || !meterId || typeof quantity !== "number") {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ await autumn.request("/v1/usage", {
+ method: "POST",
+ body: { customerId, meterId, quantity },
+ });
+ res.json({ ok: true });
+});
+
+export default router;
+`,
+ "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ res.status(500).json({ error: "Missing webhook secret" });
+ return;
+ }
+ const signature = req.headers["autumn-signature"];
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+ const rawBody = req.body as string;
+ if (!verifySignature(signatureValue, rawBody, secret)) {
+ res.status(401).json({ error: "Invalid signature" });
+ return;
+ }
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ res.json({ received: true });
+});
+
+export default router;
+`,
+ "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+ console.log(\`Billing API listening on \${port}\`);
+});
+`,
+ "src/components/CheckoutButton.tsx": `
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+ label?: string;
+}
+
+export function CheckoutButton({
+ productId,
+ customerId,
+ successUrl,
+ cancelUrl,
+ label = "Upgrade",
+}: CheckoutButtonProps) {
+ const [loading, setLoading] = useState(false);
+
+ const startCheckout = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+ });
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <button
+ type="button"
+ onClick={startCheckout}
+ disabled={loading}
+ className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ >
+ {loading ? "Redirecting..." : label}
+ </button>
+ );
+}
+`,
+ "src/components/FeatureGate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+ allowed: boolean;
+ fallback?: ReactNode;
+ children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+ if (!allowed) {
+ return <>{fallback ?? null}</>;
+ }
+ return <>{children}</>;
+}
+`,
+ "src/lib/usage.ts": `
+interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+}
+`,
+ "src/pages/BillingSuccess.tsx": `
+export function BillingSuccess() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Payment successful</h1>
+ <p className="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ "src/pages/BillingCancel.tsx": `
+export function BillingCancel() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+ <p className="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ },
+};
File: src/lib/payment-templates/svelte.ts
Changes:
@@ -0,0 +1,331 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const sveltePaymentTemplate: PaymentTemplateBundle = {
+ framework: "svelte",
+ description: "SvelteKit payment integration with Autumn + Stripe",
+ files: {
+ "src/lib/server/autumn.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+ return { apiKey, baseUrl };
+};
+
+export const autumnRequest = async <T>(
+ path: string,
+ options: AutumnRequestOptions
+): Promise<T> => {
+ const { apiKey, baseUrl } = getAutumnConfig();
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+};
+`,
+ "src/routes/api/billing/checkout/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as unknown;
+ if (!isCheckoutRequest(body)) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const checkout = await autumnRequest<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body,
+ });
+ return json(checkout);
+};
+`,
+ "src/routes/api/billing/portal/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!body.customerId || !body.returnUrl) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ returnUrl: body.returnUrl,
+ },
+ });
+ return json(portal);
+};
+`,
+ "src/routes/api/billing/usage/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!body.customerId || !body.meterId || typeof body.quantity !== "number") {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ await autumnRequest("/v1/usage", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ meterId: body.meterId,
+ quantity: body.quantity,
+ },
+ });
+ return json({ ok: true });
+};
+`,
+ "src/routes/api/billing/subscription/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const GET: RequestHandler = async ({ url }) => {
+ const subscriptionId = url.searchParams.get("subscriptionId");
+ if (!subscriptionId) {
+ return json({ error: "subscriptionId is required" }, { status: 400 });
+ }
+ const subscription = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ { method: "GET" }
+ );
+ return json(subscription);
+};
+
+export const PATCH: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!body.subscriptionId || !body.productId) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const updated = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId: body.productId },
+ }
+ );
+ return json(updated);
+};
+
+export const DELETE: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!body.subscriptionId) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const canceled = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+ }
+ );
+ return json(canceled);
+};
+`,
+ "src/routes/api/billing/feature-check/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!body.customerId || !body.featureId) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const result = await autumnRequest<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId: body.customerId, featureId: body.featureId },
+ });
+ return json(result);
+};
+`,
+ "src/routes/api/webhooks/autumn/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { createHmac, timingSafeEqual } from "node:crypto";
+import type { RequestHandler } from "./$types";
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ return json({ error: "Missing webhook secret" }, { status: 500 });
+ }
+ const signature = request.headers.get("autumn-signature") ?? "";
+ const rawBody = await request.text();
+ if (!verifySignature(signature, rawBody, secret)) {
+ return json({ error: "Invalid signature" }, { status: 401 });
+ }
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ return json({ received: true });
+};
+`,
+ "src/lib/components/CheckoutButton.svelte": `
+<script lang="ts">
+ export let productId: string;
+ export let customerId: string;
+ export let successUrl: string;
+ export let cancelUrl: string;
+ export let label = "Upgrade";
+
+ let loading = false;
+
+ const startCheckout = async () => {
+ loading = true;
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+ });
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } finally {
+ loading = false;
+ }
+ };
+</script>
+
+<button
+ type="button"
+ class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ disabled={loading}
+ on:click={startCheckout}
+>
+ {loading ? "Redirecting..." : label}
+</button>
+`,
+ "src/lib/components/FeatureGate.svelte": `
+<script lang="ts">
+ export let allowed = false;
+ export let fallback = "";
+</script>
+
+{#if allowed}
+ <slot />
+{:else}
+ {fallback}
+{/if}
+`,
+ "src/lib/usage.ts": `
+export interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export const trackUsage = async (payload: UsagePayload): Promise<void> => {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+};
+`,
+ "src/routes/billing/success/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Payment successful</h1>
+ <p class="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+ Return to app
+ </a>
+</div>
+`,
+ "src/routes/billing/cancel/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+ <p class="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+ Return to app
+ </a>
+</div>
+`,
+ },
+};
File: src/lib/payment-templates/types.ts
Changes:
@@ -0,0 +1,7 @@
+export type PaymentFramework = "nextjs" | "react" | "vue" | "angular" | "svelte";
+
+export interface PaymentTemplateBundle {
+ framework: PaymentFramework;
+ description: string;
+ files: Record<string, string>;
+}
File: src/lib/payment-templates/vue.ts
Changes:
@@ -0,0 +1,342 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const vuePaymentTemplate: PaymentTemplateBundle = {
+ framework: "vue",
+ description: "Vue 3 payment integration with Autumn + Stripe",
+ files: {
+ "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+
+ const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ };
+
+ return { request };
+};
+`,
+ "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+ if (!isCheckoutRequest(req.body)) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body: req.body,
+ });
+ res.json(checkout);
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+ const { customerId, returnUrl } = req.body as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!customerId || !returnUrl) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const portal = await autumn.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: { customerId, returnUrl },
+ });
+ res.json(portal);
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, productId } = req.body as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!subscriptionId || !productId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const updated = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId },
+ }
+ );
+ res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!subscriptionId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const canceled = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+ }
+ );
+ res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+ const { customerId, featureId } = req.body as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!customerId || !featureId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const result = await autumn.request<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId, featureId },
+ });
+ res.json(result);
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+ const { customerId, meterId, quantity } = req.body as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!customerId || !meterId || typeof quantity !== "number") {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ await autumn.request("/v1/usage", {
+ method: "POST",
+ body: { customerId, meterId, quantity },
+ });
+ res.json({ ok: true });
+});
+
+export default router;
+`,
+ "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ res.status(500).json({ error: "Missing webhook secret" });
+ return;
+ }
+ const signature = req.headers["autumn-signature"];
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+ const rawBody = req.body as string;
+ if (!verifySignature(signatureValue, rawBody, secret)) {
+ res.status(401).json({ error: "Invalid signature" });
+ return;
+ }
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ res.json({ received: true });
+});
+
+export default router;
+`,
+ "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+ console.log(\`Billing API listening on \${port}\`);
+});
+`,
+ "src/components/CheckoutButton.vue": `
+<script setup lang="ts">
+import { ref } from "vue";
+
+const props = defineProps<{
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+ label?: string;
+}>();
+
+const loading = ref(false);
+
+const startCheckout = async () => {
+ loading.value = true;
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ productId: props.productId,
+ customerId: props.customerId,
+ successUrl: props.successUrl,
+ cancelUrl: props.cancelUrl,
+ }),
+ });
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } finally {
+ loading.value = false;
+ }
+};
+</script>
+
+<template>
+ <button
+ type="button"
+ :disabled="loading"
+ class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ @click="startCheckout"
+ >
+ {{ loading ? "Redirecting..." : props.label ?? "Upgrade" }}
+ </button>
+</template>
+`,
+ "src/components/FeatureGate.vue": `
+<script setup lang="ts">
+defineProps<{
+ allowed: boolean;
+ fallback?: string;
+}>();
+</script>
+
+<template>
+ <slot v-if="allowed" />
+ <span v-else>{{ fallback ?? "" }}</span>
+</template>
+`,
+ "src/composables/useUsage.ts": `
+export interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export const useUsage = () => {
+ const trackUsage = async (payload: UsagePayload): Promise<void> => {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ };
+
+ return { trackUsage };
+};
+`,
+ "src/pages/BillingSuccess.vue": `
+<template>
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Payment successful</h1>
+ <p class="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+ Return to app
+ </a>
+ </div>
+</template>
+`,
+ "src/pages/BillingCancel.vue": `
+<template>
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+ <p class="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+ Return to app
+ </a>
+ </div>
+</template>
+`,
+ },
+};
File: src/modules/projects/ui/components/custom-domain-dialog.tsx
Changes:
@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type NetlifyDomain = {
+ id: string;
+ name: string;
+ ssl_status?: string;
+ verification?: {
+ status?: string;
+ };
+};
+
+type CustomDomainDialogProps = {
+ siteId: string;
+};
+
+export const CustomDomainDialog = ({ siteId }: CustomDomainDialogProps) => {
+ const [domains, setDomains] = useState<NetlifyDomain[]>([]);
+ const [domainInput, setDomainInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadDomains = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/deploy/netlify/domains?siteId=${siteId}`);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to load domains");
+ }
+ setDomains(Array.isArray(data) ? data : []);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to load domains");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAdd = async () => {
+ if (!domainInput) {
+ toast.error("Enter a domain");
+ return;
+ }
+
+ try {
+ const response = await fetch("/api/deploy/netlify/domains", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ siteId, domain: domainInput }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to add domain");
+ }
+ setDomainInput("");
+ await loadDomains();
+ toast.success("Domain added");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to add domain");
+ }
+ };
+
+ const handleDelete = async (domainId: string) => {
+ try {
+ const response = await fetch(
+ `/api/deploy/netlify/domains?siteId=${siteId}&domainId=${domainId}`,
+ { method: "DELETE" }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to remove domain");
+ }
+ await loadDomains();
+ toast.success("Domain removed");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to remove domain");
+ }
+ };
+
+ useEffect(() => {
+ void loadDomains();
+ }, [siteId]);
+
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">Custom Domains</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Custom Domains</DialogTitle>
+ <DialogDescription>Manage domains and DNS verification.</DialogDescription>
+ </DialogHeader>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Input
+ placeholder="yourdomain.com"
+ value={domainInput}
+ onChange={(event) => setDomainInput(event.target.value)}
+ />
+ <Button onClick={handleAdd} disabled={isLoading}>
+ Add Domain
+ </Button>
+ </div>
+ <div className="space-y-2">
+ {domains.length === 0 && !isLoading && (
+ <p className="text-sm text-muted-foreground">No domains configured</p>
+ )}
+ {domains.map((domain) => (
+ <div key={domain.id} className="flex items-center justify-between text-sm">
+ <div className="flex flex-col">
+ <span>{domain.name}</span>
+ <span className="text-xs text-muted-foreground">
+ SSL: {domain.ssl_status ?? "unknown"} • Verification: {domain.verification?.status ?? "unknown"}
+ </span>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDelete(domain.id)}
+ >
+ Remove
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/deploy-button.tsx
Changes:
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import { NetlifyConnectDialog } from "./netlify-connect-dialog";
+
+type DeployButtonProps = {
+ projectId: string;
+};
+
+export const DeployButton = ({ projectId }: DeployButtonProps) => {
+ const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+ const [isDeploying, setIsDeploying] = useState(false);
+
+ const handleDeploy = async () => {
+ if (isDeploying) return;
+ setIsDeploying(true);
+
+ try {
+ const response = await fetch("/api/deploy/netlify/deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId }),
+ });
+
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || "Deployment failed");
+ }
+
+ toast.success(`Deployment started: ${payload.siteUrl}`);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Deployment failed");
+ } finally {
+ setIsDeploying(false);
+ }
+ };
+
+ if (!connection) {
+ return <NetlifyConnectDialog />;
+ }
+
+ return (
+ <Button size="sm" onClick={handleDeploy} disabled={isDeploying}>
+ {isDeploying ? "Deploying..." : "Deploy to Netlify"}
+ </Button>
+ );
+};
File: src/modules/projects/ui/components/deployment-dashboard.tsx
Changes:
@@ -0,0 +1,45 @@
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { EnvVarsDialog } from "./env-vars-dialog";
+import { CustomDomainDialog } from "./custom-domain-dialog";
+import { DeploymentHistory } from "./deployment-history";
+import { PreviewDeployments } from "./preview-deployments";
+
+type DeploymentDashboardProps = {
+ projectId: string;
+};
+
+export const DeploymentDashboard = ({ projectId }: DeploymentDashboardProps) => {
+ const deployment = useQuery(api.deployments.getDeployment, { projectId });
+
+ return (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <h3 className="text-sm font-medium">Netlify Deployment</h3>
+ <DeploymentStatus projectId={projectId} />
+ </div>
+ <DeployButton projectId={projectId} />
+ </div>
+
+ {deployment?.siteId && (
+ <div className="flex flex-wrap items-center gap-2">
+ <EnvVarsDialog siteId={deployment.siteId} />
+ <CustomDomainDialog siteId={deployment.siteId} />
+ </div>
+ )}
+
+ <div>
+ <h4 className="text-sm font-medium mb-2">Preview Deployments</h4>
+ <PreviewDeployments projectId={projectId} />
+ </div>
+
+ <div>
+ <h4 className="text-sm font-medium mb-2">Deployment History</h4>
+ <DeploymentHistory projectId={projectId} />
+ </div>
+ </div>
+ );
+};
File: src/modules/projects/ui/components/deployment-history.tsx
Changes:
@@ -0,0 +1,107 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type DeploymentHistoryProps = {
+ projectId: string;
+};
+
+export const DeploymentHistory = ({ projectId }: DeploymentHistoryProps) => {
+ const deployments = useQuery(api.deployments.listDeployments, { projectId });
+ const [logs, setLogs] = useState<string | null>(null);
+
+ const fetchLogs = async (deployId: string) => {
+ try {
+ const response = await fetch(`/api/deploy/netlify/logs?deployId=${deployId}`);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to fetch logs");
+ }
+ setLogs(data.logs || "");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to fetch logs");
+ }
+ };
+
+ const handleRollback = async (deployId?: string) => {
+ if (!deployId) return;
+ try {
+ const response = await fetch("/api/deploy/netlify/rollback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ deployId }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Rollback failed");
+ }
+ toast.success("Rollback initiated");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Rollback failed");
+ }
+ };
+
+ if (!deployments || deployments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No deployments yet</p>;
+ }
+
+ return (
+ <div className="space-y-2">
+ {deployments.map((deployment) => (
+ <div
+ key={deployment._id}
+ className="flex items-center justify-between rounded-md border p-2 text-sm"
+ >
+ <div className="flex flex-col">
+ <span>Deploy #{deployment.deployNumber ?? "-"} • {deployment.status}</span>
+ <span className="text-xs text-muted-foreground">
+ {deployment.siteUrl}
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ {deployment.deployId && (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => void fetchLogs(deployment.deployId ?? "")}
+ >
+ Logs
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Build Logs</DialogTitle>
+ <DialogDescription>Latest build output from Netlify.</DialogDescription>
+ </DialogHeader>
+ <pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
+ {logs || "No logs available"}
+ </pre>
+ </DialogContent>
+ </Dialog>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRollback(deployment.deployId)}
+ disabled={!deployment.deployId}
+ >
+ Rollback
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+};
File: src/modules/projects/ui/components/deployment-status.tsx
Changes:
@@ -0,0 +1,85 @@
+import { useEffect, useMemo } from "react";
+import Link from "next/link";
+import { useMutation, useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+
+type DeploymentStatusProps = {
+ projectId: string;
+};
+
+type NetlifyStatusResponse = {
+ state?: string;
+};
+
+const statusLabelMap: Record<string, string> = {
+ pending: "Pending",
+ building: "Building",
+ ready: "Ready",
+ error: "Error",
+};
+
+export const DeploymentStatus = ({ projectId }: DeploymentStatusProps) => {
+ const deployment = useQuery(api.deployments.getDeployment, { projectId });
+ const updateDeployment = useMutation(api.deployments.updateDeployment);
+
+ const shouldPoll = useMemo(() => {
+ if (!deployment?.deployId) return false;
+ return deployment.status === "pending" || deployment.status === "building";
+ }, [deployment]);
+
+ useEffect(() => {
+ if (!shouldPoll || !deployment?.deployId) {
+ return;
+ }
+
+ let cancelled = false;
+ const pollStatus = async () => {
+ try {
+ const response = await fetch(`/api/deploy/netlify/status?deployId=${deployment.deployId}`);
+ if (!response.ok) {
+ return;
+ }
+
+ const data = (await response.json()) as NetlifyStatusResponse;
+ if (!data.state || cancelled) {
+ return;
+ }
+
+ await updateDeployment({
+ deploymentId: deployment._id,
+ status: data.state === "ready" ? "ready" : data.state === "error" ? "error" : "building",
+ });
+ } catch {
+ // ignore polling errors
+ }
+ };
+
+ const interval = setInterval(pollStatus, 10000);
+ pollStatus();
+
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [deployment, shouldPoll, updateDeployment]);
+
+ if (!deployment) {
+ return null;
+ }
+
+ const label = statusLabelMap[deployment.status] ?? deployment.status;
+
+ return (
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <span>Netlify: {label}</span>
+ {deployment.siteUrl && deployment.status === "ready" && (
+ <Button asChild variant="link" size="sm" className="h-auto p-0">
+ <Link href={deployment.siteUrl} target="_blank" rel="noreferrer">
+ View site
+ </Link>
+ </Button>
+ )}
+ </div>
+ );
+};
File: src/modules/projects/ui/components/env-vars-dialog.tsx
Changes:
@@ -0,0 +1,137 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type EnvVar = {
+ key: string;
+};
+
+type EnvVarsDialogProps = {
+ siteId: string;
+};
+
+export const EnvVarsDialog = ({ siteId }: EnvVarsDialogProps) => {
+ const [envVars, setEnvVars] = useState<EnvVar[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [newKey, setNewKey] = useState("");
+ const [newValue, setNewValue] = useState("");
+
+ const loadEnvVars = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/deploy/netlify/env-vars?siteId=${siteId}`);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to load env vars");
+ }
+ setEnvVars(Array.isArray(data) ? data : []);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to load env vars");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAdd = async () => {
+ if (!newKey || !newValue) {
+ toast.error("Provide a key and value");
+ return;
+ }
+
+ try {
+ const response = await fetch("/api/deploy/netlify/env-vars", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ siteId, key: newKey, value: newValue }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to set env var");
+ }
+ setNewKey("");
+ setNewValue("");
+ await loadEnvVars();
+ toast.success("Env var saved");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to set env var");
+ }
+ };
+
+ const handleDelete = async (key: string) => {
+ try {
+ const response = await fetch(
+ `/api/deploy/netlify/env-vars?siteId=${siteId}&key=${encodeURIComponent(key)}`,
+ { method: "DELETE" }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to delete env var");
+ }
+ await loadEnvVars();
+ toast.success("Env var deleted");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to delete env var");
+ }
+ };
+
+ useEffect(() => {
+ void loadEnvVars();
+ }, [siteId]);
+
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">Env Vars</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Environment Variables</DialogTitle>
+ <DialogDescription>Manage Netlify environment variables for this site.</DialogDescription>
+ </DialogHeader>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Input
+ placeholder="KEY"
+ value={newKey}
+ onChange={(event) => setNewKey(event.target.value)}
+ />
+ <Input
+ placeholder="VALUE"
+ value={newValue}
+ onChange={(event) => setNewValue(event.target.value)}
+ />
+ <Button onClick={handleAdd} disabled={isLoading}>
+ Save
+ </Button>
+ </div>
+ <div className="space-y-2">
+ {envVars.length === 0 && !isLoading && (
+ <p className="text-sm text-muted-foreground">No variables set</p>
+ )}
+ {envVars.map((envVar) => (
+ <div key={envVar.key} className="flex items-center justify-between text-sm">
+ <span>{envVar.key}</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDelete(envVar.key)}
+ >
+ Remove
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/github-export-button.tsx
Changes:
@@ -0,0 +1,62 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { useQuery } from "convex/react";
+
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { GitHubExportModal } from "./github-export-modal";
+
+type GitHubExportButtonProps = {
+ projectId: string;
+};
+
+export const GitHubExportButton = ({ projectId }: GitHubExportButtonProps) => {
+ const connection = useQuery(api.oauth.getConnection, { provider: "github" });
+ const [open, setOpen] = useState(false);
+
+ if (!connection) {
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button size="sm" variant="outline">
+ Connect GitHub
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Connect GitHub</DialogTitle>
+ <DialogDescription>
+ Connect your GitHub account to export projects.
+ </DialogDescription>
+ </DialogHeader>
+ <Button asChild>
+ <Link href="/api/import/github/auth">Continue to GitHub</Link>
+ </Button>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ return (
+ <>
+ <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
+ Export to GitHub
+ </Button>
+ <GitHubExportModal
+ projectId={projectId}
+ open={open}
+ onOpenChange={setOpen}
+ />
+ </>
+ );
+};
File: src/modules/projects/ui/components/github-export-modal.tsx
Changes:
@@ -0,0 +1,407 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { ExternalLinkIcon, Loader2Icon } from "lucide-react";
+import { z } from "zod";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+
+type GitHubRepoOption = {
+ id: number;
+ name: string;
+ fullName: string;
+ url: string;
+ isPrivate: boolean;
+ defaultBranch: string;
+};
+
+type ExportResult = {
+ exportId: string;
+ repositoryUrl: string;
+ repositoryFullName: string;
+ branch: string;
+ commitSha: string;
+ fileCount: number;
+};
+
+type GitHubExportModalProps = {
+ projectId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+const exportResultSchema = z.object({
+ exportId: z.string(),
+ repositoryUrl: z.string(),
+ repositoryFullName: z.string(),
+ branch: z.string(),
+ commitSha: z.string(),
+ fileCount: z.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+ return typeof value === "object" && value !== null;
+};
+
+const isRepoOption = (value: unknown): value is GitHubRepoOption => {
+ if (!isRecord(value)) {
+ return false;
+ }
+
+ const record = value;
+ return (
+ typeof record.id === "number" &&
+ typeof record.name === "string" &&
+ typeof record.fullName === "string" &&
+ typeof record.url === "string" &&
+ typeof record.isPrivate === "boolean" &&
+ typeof record.defaultBranch === "string"
+ );
+};
+
+const parseRepositories = (value: unknown): Array<GitHubRepoOption> => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const repos: Array<GitHubRepoOption> = [];
+ for (const repo of value) {
+ if (isRepoOption(repo)) {
+ repos.push(repo);
+ }
+ }
+
+ return repos;
+};
+
+export const GitHubExportModal = ({
+ projectId,
+ open,
+ onOpenChange,
+}: GitHubExportModalProps) => {
+ const [mode, setMode] = useState<"new" | "existing">("new");
+ const [repoName, setRepoName] = useState("");
+ const [repoDescription, setRepoDescription] = useState("");
+ const [isPrivate, setIsPrivate] = useState(false);
+ const [repos, setRepos] = useState<Array<GitHubRepoOption>>([]);
+ const [selectedRepo, setSelectedRepo] = useState("");
+ const [branch, setBranch] = useState("");
+ const [includeReadme, setIncludeReadme] = useState(true);
+ const [includeGitignore, setIncludeGitignore] = useState(true);
+ const [commitMessage, setCommitMessage] = useState("");
+ const [isLoadingRepos, setIsLoadingRepos] = useState(false);
+ const [isExporting, setIsExporting] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [result, setResult] = useState<ExportResult | null>(null);
+
+ const selectedRepoOption = useMemo(() => {
+ return repos.find((repo) => repo.fullName === selectedRepo) ?? null;
+ }, [repos, selectedRepo]);
+
+ useEffect(() => {
+ if (!open) {
+ setError(null);
+ setResult(null);
+ setIsExporting(false);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ const loadRepositories = async () => {
+ setIsLoadingRepos(true);
+ setError(null);
+ try {
+ const response = await fetch("/api/github/repositories");
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || "Failed to load repositories");
+ }
+
+ const parsedRepos = parseRepositories(payload.repositories);
+ setRepos(parsedRepos);
+ if (parsedRepos.length === 0) {
+ setError("No repositories found in this GitHub account.");
+ }
+ } catch (loadError) {
+ const message =
+ loadError instanceof Error ? loadError.message : "Failed to load repositories";
+ setError(message);
+ } finally {
+ setIsLoadingRepos(false);
+ }
+ };
+
+ void loadRepositories();
+ }, [open]);
+
+ useEffect(() => {
+ if (mode !== "existing" || !selectedRepoOption || branch) {
+ return;
+ }
+
+ setBranch(selectedRepoOption.defaultBranch);
+ }, [mode, selectedRepoOption, branch]);
+
+ const handleExport = async () => {
+ if (isExporting) {
+ return;
+ }
+
+ setIsExporting(true);
+ setError(null);
+
+ try {
+ const payload: Record<string, unknown> = {
+ branch: branch.trim() || undefined,
+ includeReadme,
+ includeGitignore,
+ commitMessage: commitMessage.trim() || undefined,
+ };
+
+ if (mode === "existing") {
+ if (!selectedRepo) {
+ throw new Error("Select a repository to export to.");
+ }
+ payload.repositoryFullName = selectedRepo;
+ } else {
+ const trimmedName = repoName.trim();
+ if (!trimmedName) {
+ throw new Error("Repository name is required.");
+ }
+ payload.repositoryName = trimmedName;
+ payload.description = repoDescription.trim() || undefined;
+ payload.isPrivate = isPrivate;
+ }
+
+ const response = await fetch(`/api/projects/${projectId}/export/github`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Export failed");
+ }
+
+ const parsedResult = exportResultSchema.safeParse(data);
+ if (!parsedResult.success) {
+ throw new Error("Unexpected export response.");
+ }
+
+ setResult(parsedResult.data);
+ toast.success("GitHub export complete");
+ } catch (exportError) {
+ const message =
+ exportError instanceof Error ? exportError.message : "Export failed";
+ setError(message);
+ toast.error(message);
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ const isReady =
+ mode === "existing" ? selectedRepo.length > 0 : repoName.trim().length > 0;
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-xl">
+ <DialogHeader>
+ <DialogTitle>Export to GitHub</DialogTitle>
+ <DialogDescription>
+ Export your latest AI-generated files to a GitHub repository.
+ </DialogDescription>
+ </DialogHeader>
+
+ {error && (
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
+ {error}
+ </div>
+ )}
+
+ {result ? (
+ <div className="space-y-4">
+ <div className="rounded-md border p-3 text-sm">
+ <div className="font-medium">{result.repositoryFullName}</div>
+ <div className="text-muted-foreground">
+ Branch: {result.branch}
+ </div>
+ <div className="text-muted-foreground">
+ Files exported: {result.fileCount}
+ </div>
+ <div className="text-muted-foreground">
+ Commit: {result.commitSha.slice(0, 10)}
+ </div>
+ </div>
+ <div className="flex justify-end gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ <Button asChild>
+ <a href={result.repositoryUrl} target="_blank" rel="noreferrer">
+ Open GitHub
+ <ExternalLinkIcon className="ml-2 size-4" />
+ </a>
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-5">
+ <div className="space-y-2">
+ <Label>Repository</Label>
+ <RadioGroup
+ value={mode}
+ onValueChange={(value) => {
+ if (value === "new" || value === "existing") {
+ setMode(value);
+ }
+ }}
+ className="flex gap-4"
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="new" id="github-export-new" />
+ <Label htmlFor="github-export-new">New repository</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="existing" id="github-export-existing" />
+ <Label htmlFor="github-export-existing">Existing repository</Label>
+ </div>
+ </RadioGroup>
+ </div>
+
+ {mode === "new" ? (
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="repo-name">Repository name</Label>
+ <Input
+ id="repo-name"
+ placeholder="zapdev-export"
+ value={repoName}
+ onChange={(event) => setRepoName(event.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="repo-description">Description</Label>
+ <Input
+ id="repo-description"
+ placeholder="Optional description"
+ value={repoDescription}
+ onChange={(event) => setRepoDescription(event.target.value)}
+ />
+ </div>
+ <div className="flex items-center justify-between rounded-md border px-3 py-2">
+ <div>
+ <p className="text-sm font-medium">Private repository</p>
+ <p className="text-xs text-muted-foreground">
+ Limit visibility to collaborators.
+ </p>
+ </div>
+ <Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ <Label>Select repository</Label>
+ <Select
+ value={selectedRepo}
+ onValueChange={setSelectedRepo}
+ disabled={isLoadingRepos}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="Choose a repository" />
+ </SelectTrigger>
+ <SelectContent>
+ {repos.map((repo) => (
+ <SelectItem key={repo.fullName} value={repo.fullName}>
+ {repo.fullName}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ <div className="grid gap-3 md:grid-cols-2">
+ <div className="space-y-2">
+ <Label htmlFor="branch">Branch</Label>
+ <Input
+ id="branch"
+ placeholder={selectedRepoOption?.defaultBranch ?? "main"}
+ value={branch}
+ onChange={(event) => setBranch(event.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="commit-message">Commit message</Label>
+ <Input
+ id="commit-message"
+ placeholder="Export project from ZapDev"
+ value={commitMessage}
+ onChange={(event) => setCommitMessage(event.target.value)}
+ />
+ </div>
+ </div>
+
+ <div className="space-y-3">
+ <div className="flex items-center justify-between rounded-md border px-3 py-2">
+ <div>
+ <p className="text-sm font-medium">Include README</p>
+ <p className="text-xs text-muted-foreground">
+ Adds a basic project overview.
+ </p>
+ </div>
+ <Switch checked={includeReadme} onCheckedChange={setIncludeReadme} />
+ </div>
+ <div className="flex items-center justify-between rounded-md border px-3 py-2">
+ <div>
+ <p className="text-sm font-medium">Include .gitignore</p>
+ <p className="text-xs text-muted-foreground">
+ Adds framework defaults.
+ </p>
+ </div>
+ <Switch
+ checked={includeGitignore}
+ onCheckedChange={setIncludeGitignore}
+ />
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ <Button onClick={handleExport} disabled={!isReady || isExporting}>
+ {isExporting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
+ {isExporting ? "Exporting..." : "Export to GitHub"}
+ </Button>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/netlify-connect-dialog.tsx
Changes:
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export const NetlifyConnectDialog = () => {
+ const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+
+ if (connection) {
+ return (
+ <Button variant="secondary" size="sm" disabled>
+ Netlify Connected
+ </Button>
+ );
+ }
+
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button size="sm">Connect Netlify</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Connect Netlify</DialogTitle>
+ <DialogDescription>
+ Connect your Netlify account to deploy projects directly from ZapDev.
+ </DialogDescription>
+ </DialogHeader>
+ <Button asChild>
+ <Link href="/api/deploy/netlify/auth">Continue to Netlify</Link>
+ </Button>
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/preview-deployments.tsx
Changes:
@@ -0,0 +1,91 @@
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+
+type PreviewDeploymentsProps = {
+ projectId: string;
+};
+
+export const PreviewDeployments = ({ projectId }: PreviewDeploymentsProps) => {
+ const deployments = useQuery(api.deployments.listDeployments, { projectId });
+ const [isCreating, setIsCreating] = useState(false);
+
+ const previews = useMemo(
+ () => (deployments ?? []).filter((deployment) => deployment.isPreview),
+ [deployments]
+ );
+
+ const handleCreatePreview = async () => {
+ setIsCreating(true);
+ try {
+ const response = await fetch("/api/deploy/netlify/deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId, deployType: "preview" }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Preview deployment failed");
+ }
+ toast.success("Preview deployment started");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Preview deployment failed");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleDeletePreview = async (deployId?: string) => {
+ if (!deployId) return;
+ try {
+ const response = await fetch(`/api/deploy/netlify/preview?deployId=${deployId}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to delete preview");
+ }
+ toast.success("Preview deleted");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to delete preview");
+ }
+ };
+
+ return (
+ <div className="space-y-3">
+ <Button size="sm" onClick={handleCreatePreview} disabled={isCreating}>
+ {isCreating ? "Creating preview..." : "Create Preview Deployment"}
+ </Button>
+ {previews.length === 0 && (
+ <p className="text-sm text-muted-foreground">No preview deployments yet</p>
+ )}
+ {previews.map((deployment) => (
+ <div key={deployment._id} className="flex items-center justify-between text-sm">
+ <div className="flex flex-col">
+ <span>Preview #{deployment.deployNumber ?? "-"}</span>
+ <span className="text-xs text-muted-foreground">{deployment.status}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ {deployment.siteUrl && (
+ <Button asChild variant="link" size="sm" className="h-auto p-0">
+ <a href={deployment.siteUrl} target="_blank" rel="noreferrer">
+ Open
+ </a>
+ </Button>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeletePreview(deployment.deployId)}
+ disabled={!deployment.deployId}
+ >
+ Delete
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+};
File: src/modules/projects/ui/components/project-header.tsx
Changes:
@@ -13,6 +13,9 @@ import {
import { useState } from "react";
import { Button } from "@/components/ui/button";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { GitHubExportButton } from "./github-export-button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -126,15 +129,20 @@ export const ProjectHeader = ({ projectId }: Props) => {
</DropdownMenu>
</div>
- <Button
- variant="ghost"
- size="sm"
- onClick={handleDownload}
- disabled={isDownloading}
- >
- <DownloadIcon className="size-4 mr-2" />
- {isDownloading ? 'Downloading...' : 'Download Code'}
- </Button>
+ <div className="flex items-center gap-3">
+ <DeploymentStatus projectId={projectId} />
+ <DeployButton projectId={projectId} />
+ <GitHubExportButton projectId={projectId} />
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleDownload}
+ disabled={isDownloading}
+ >
+ <DownloadIcon className="size-4 mr-2" />
+ {isDownloading ? "Downloading..." : "Download Code"}
+ </Button>
+ </div>
</header>
);
};
File: src/modules/projects/ui/views/project-view.tsx
Changes:
@@ -3,7 +3,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { Suspense, useEffect, useMemo, useState } from "react";
-import { EyeIcon, CodeIcon, CrownIcon } from "lucide-react";
+import { EyeIcon, CodeIcon, CrownIcon, RocketIcon } from "lucide-react";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
@@ -18,6 +18,7 @@ import {
import { ProjectHeader } from "../components/project-header";
import { MessagesContainer } from "../components/messages-container";
+import { DeploymentDashboard } from "../components/deployment-dashboard";
import { ErrorBoundary } from "react-error-boundary";
import type { Doc } from "@/convex/_generated/dataModel";
import { filterAIGeneratedFiles } from "@/lib/filter-ai-files";
@@ -42,7 +43,7 @@ export const ProjectView = ({ projectId }: Props) => {
const hasProAccess = usage?.planType === "pro";
const [activeFragment, setActiveFragment] = useState<Doc<"fragments"> | null>(null);
- const [tabState, setTabState] = useState<"preview" | "code">("preview");
+ const [tabState, setTabState] = useState<"preview" | "code" | "deploy">("preview");
const [streamingFiles, setStreamingFiles] = useState<Record<string, string>>({});
const explorerFiles = useMemo(() => {
@@ -119,7 +120,7 @@ export const ProjectView = ({ projectId }: Props) => {
className="h-full gap-y-0"
defaultValue="preview"
value={tabState}
- onValueChange={(value) => setTabState(value as "preview" | "code")}
+ onValueChange={(value) => setTabState(value as "preview" | "code" | "deploy")}
>
<div className="w-full flex items-center p-2 border-b gap-x-2">
<TabsList className="h-8 p-0 border rounded-md">
@@ -129,6 +130,9 @@ export const ProjectView = ({ projectId }: Props) => {
<TabsTrigger value="code" className="rounded-md">
<CodeIcon /> <span>Code</span>
</TabsTrigger>
+ <TabsTrigger value="deploy" className="rounded-md">
+ <RocketIcon /> <span>Deploy</span>
+ </TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center gap-x-2">
{!hasProAccess && (
@@ -149,6 +153,9 @@ export const ProjectView = ({ projectId }: Props) => {
<FileExplorer files={explorerFiles} />
)}
</TabsContent>
+ <TabsContent value="deploy" className="min-h-0 p-4">
+ <DeploymentDashboard projectId={projectId} />
+ </TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
File: src/prompt.ts
Changes:
@@ -5,4 +5,5 @@ export { REACT_PROMPT } from "./prompts/react";
export { VUE_PROMPT } from "./prompts/vue";
export { SVELTE_PROMPT } from "./prompts/svelte";
export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector";
+export { PAYMENT_INTEGRATION_RULES } from "./prompts/payment-integration";
export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs";
File: src/prompts/angular.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const ANGULAR_PROMPT = `
You are a senior software engineer working in a sandboxed Angular 19 environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
Angular Specific Environment:
- Main component: src/app/app.component.ts
File: src/prompts/nextjs.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const NEXTJS_PROMPT = `
You are a senior Next.js engineer in a sandboxed environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
Environment:
- Framework: Next.js 15.3.3
File: src/prompts/payment-integration.ts
Changes:
@@ -0,0 +1,9 @@
+export const PAYMENT_INTEGRATION_RULES = `
+Payment Integration (Stripe via Autumn):
+- If the user asks for payments, billing, subscriptions, or checkout flows, implement Stripe through Autumn.
+- Use server-side routes for checkout, billing portal, usage tracking, and webhook handling.
+- Always validate request payloads and verify webhook signatures.
+- Store API keys and secrets in environment variables only (no hardcoding).
+- You may call external APIs for Autumn/Stripe only when payment features are explicitly requested.
+- Provide a FeatureGate component and a usage tracking helper.
+`;
File: src/prompts/react.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const REACT_PROMPT = `
You are a senior software engineer working in a sandboxed React 18 + Vite environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
React + Vite Specific Environment:
- Main file: src/App.tsx
File: src/prompts/svelte.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const SVELTE_PROMPT = `
You are a senior software engineer working in a sandboxed SvelteKit environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
SvelteKit Specific Environment:
- Main page: src/routes/+page.svelte
File: src/prompts/vue.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const VUE_PROMPT = `
You are a senior software engineer working in a sandboxed Vue 3 + Vite environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
Vue + Vite Specific Environment:
- Main component: src/App.vue
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds Convex-backed data models and OAuth/token encryption, Netlify deployment APIs and UI, Inngest orchestration, WebContainer sandbox adapter, a skills system with seeding/loader, payment & database template tooling, GitHub export client, extensive prompt and prompt-tooling updates, and broad site/content/SEO revisions. Changes
Sequence Diagram(s)sequenceDiagram
participant Browser as Client (Browser)
participant Next as Next.js App Server
participant Convex as Convex (DB/RPC)
participant Netlify as Netlify API
Browser->>Next: POST /api/deploy/netlify/deploy { projectId, deployType, branch... }
Next->>Convex: api.messages.getLatestFragment + api.projects.get(projectId)
Convex-->>Next: fragment files + project metadata (framework)
Next->>Next: generate ZIP (files + netlify.toml)
Next->>Netlify: createOrResolveSite / upload ZIP / create deploy
Netlify-->>Next: { siteId, siteUrl, deployId, initialStatus }
Next->>Convex: api.deployments.create(...) (record deploy metadata)
Convex-->>Next: deploymentId
Next-->>Browser: 200 { siteId, siteUrl, deployId, deployState }
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| const response = await fetch(`${NETLIFY_API_BASE}${path}`, { | ||
| method: options.method ?? "GET", | ||
| headers: { | ||
| Authorization: `Bearer ${accessToken}`, | ||
| ...(options.headers ?? {}), | ||
| }, | ||
| body: options.body ?? null, | ||
| }); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 17 days ago
General approach: Constrain and validate all user-controlled values that end up in the URL path (siteId, domainId, deployId, and domain) before using them to build the Netlify API path. Enforce a strict format (for example, Netlify’s typical ID pattern or at least an allow-list of safe characters) and explicitly reject values containing path traversal (.., /, \) or other disallowed characters. This keeps the origin fixed and prevents abusing the path to reach unintended endpoints, and it addresses all alert variants driven by these parameters.
Best concrete fix without changing functionality:
-
Introduce small validation helpers in
src/lib/netlify-client.ts:- A function to validate opaque Netlify IDs (for
siteId,domainId,deployId), restricting them to a safe pattern (e.g. alphanumeric plus a few punctuation characters) and optionally a max length. - A function to validate domain names to ensure they are legitimate DNS hostnames (letters, digits, hyphens, dots) and reasonable length.
These helpers should throw a clear error when validation fails.
- A function to validate opaque Netlify IDs (for
-
Call these validators at the very start of the relevant Netlify client methods:
listDomains(siteId)addDomain(siteId, domain)deleteDomain(siteId, domainId)verifyDomain(siteId, domainId)getDnsRecords(siteId, domainId)createPreviewDeployment(siteId, ...)listPreviewDeployments(siteId)deletePreviewDeployment(deployId)
This ensures that any tainted data is sanitized before it influences thepathargument passed torequest.
-
Leave the API route files (
domains/route.tsandpreview/route.ts) largely unchanged with respect to this vulnerability; they will continue to pass through user-provided values, but those values are now vetted in the client. No extra imports beyond standard TypeScript/JS built-ins are needed.
File/region specifics:
- File:
src/lib/netlify-client.ts- Add two new helper functions just after the existing utility functions (e.g., after
handleApiErrorand beforecreateNetlifyClient):validateNetlifyId(id: string, fieldName: string): stringvalidateDomainName(domain: string): string
- Update methods around lines 202–239 to call these validators before constructing template literals for the paths:
listDomains(siteId: string)→ validatesiteIdaddDomain(siteId: string, domain: string)→ validatesiteIdanddomaindeleteDomain(siteId: string, domainId: string)→ validate both IDsverifyDomain(siteId: string, domainId: string)→ validate bothgetDnsRecords(siteId: string, domainId: string)→ validate bothcreatePreviewDeployment(siteId: string, ...)andlistPreviewDeployments(siteId: string)→ validatesiteIddeletePreviewDeployment(deployId: string)→ validatedeployId
- Add two new helper functions just after the existing utility functions (e.g., after
No other files need changes for this specific SSRF finding, since the path construction is centralized in netlify-client.ts.
| const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, { | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 17 days ago
In general, to address SSRF-like issues where user input influences an outgoing request path, we should strictly validate and constrain the user input before it is used. In this case, deployId should be validated server-side to ensure it matches the expected format of a Netlify deploy ID (e.g., a 26-character URL-safe string or similar), and that it does not contain path separators or other unexpected characters. Optionally, we could further enforce that the deployment actually belongs to the current user by making an authenticated call (or using existing data) to verify ownership before fetching logs.
The minimal change that preserves existing functionality is to add a helper that validates deployId against a conservative pattern (for example, only allow alphanumeric, hyphen, and underscore, and a reasonable length), and then call this validator at the start of getBuildLog. If the validation fails, we throw an error before performing the fetch. This way the URL we pass to fetch is constrained to a safe, well-formed path segment and cannot contain /, ?, #, or other characters that might alter the request path or introduce query parameters. This change is localized to src/lib/netlify-client.ts and does not require changes to the route handler.
Concretely, in src/lib/netlify-client.ts, inside the factory returned by createNetlifyClient, we will define a small validateDeployId function (or similar) that throws on invalid values. Then we modify getBuildLog to call this validator before constructing the URL. Because we are only adding a small local helper and not using any external library, no new imports are needed.
| @@ -146,6 +146,11 @@ | ||
| }, | ||
|
|
||
| async getBuildLog(deployId: string): Promise<string> { | ||
| // Validate deployId to ensure it is a safe path segment and not attacker-crafted | ||
| if (!/^[A-Za-z0-9_-]{1,64}$/.test(deployId)) { | ||
| throw new Error("Invalid Netlify deploy ID"); | ||
| } | ||
|
|
||
| const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, { | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); |
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
There was a problem hiding this comment.
30 issues found across 53 files
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="src/modules/projects/ui/components/preview-deployments.tsx">
<violation number="1" location="src/modules/projects/ui/components/preview-deployments.tsx:40">
P2: Delete operation lacks loading state protection. Unlike `handleCreatePreview` which disables the button via `isCreating` during the API call, `handleDeletePreview` has no such safeguard. Users can spam-click delete, triggering multiple API requests. Consider tracking which deployment is being deleted (e.g., `const [deletingId, setDeletingId] = useState<string | null>(null)`) and disabling that specific button during the operation.</violation>
</file>
<file name="src/app/api/deploy/netlify/env-vars/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/env-vars/route.ts:61">
P2: The validation `!body.value` rejects empty string values, which may be a legitimate use case for environment variables. Consider using `body.value === undefined` or adding a separate check for the value's type to allow empty strings while still requiring the field to be present.</violation>
</file>
<file name="convex/deployments.ts">
<violation number="1" location="convex/deployments.ts:83">
P2: Using truthy check for `buildTime` will silently ignore valid value `0`. A build time of 0ms is a meaningful value that would be incorrectly skipped. Use `args.buildTime !== undefined` instead.</violation>
</file>
<file name="convex/oauth.ts">
<violation number="1" location="convex/oauth.ts:86">
P2: Exposing OAuth access tokens directly to the client violates documented security conventions. Consider using a Convex action to make GitHub API calls server-side instead of returning the token to the client, which could be vulnerable to XSS attacks or unintentional logging.</violation>
</file>
<file name="src/lib/payment-templates/types.ts">
<violation number="1" location="src/lib/payment-templates/types.ts:1">
P2: Hardcoding framework slugs here duplicates the canonical list in `frameworks.ts`, which can drift over time. Derive the type from the shared frameworks metadata so updates stay centralized.</violation>
</file>
<file name="src/modules/projects/ui/components/github-export-modal.tsx">
<violation number="1" location="src/modules/projects/ui/components/github-export-modal.tsx:137">
P2: Missing AbortController for fetch request in useEffect. If the modal closes or component unmounts while the request is in flight, state updates may be attempted on an unmounted component or stale responses may override newer state. Add an AbortController with cleanup to cancel pending requests.</violation>
</file>
<file name="src/modules/projects/ui/components/deployment-history.tsx">
<violation number="1" location="src/modules/projects/ui/components/deployment-history.tsx:21">
P2: Shared `logs` state causes stale data to display across different deployment dialogs. When viewing logs for one deployment and then opening another, the previous deployment's logs briefly appear. Consider storing logs per deployment ID using a Map or moving the logs state into a dedicated child component for each deployment row.</violation>
<violation number="2" location="src/modules/projects/ui/components/deployment-history.tsx:89">
P2: No loading state when fetching logs. Users see 'No logs available' while logs are loading, which is misleading. Consider adding an `isLoading` state and displaying a loading indicator.</violation>
</file>
<file name="src/app/api/deploy/netlify/auth/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/auth/route.ts:20">
P1: OAuth state parameter lacks cryptographic signature, making it vulnerable to CSRF attacks. Base64 encoding is not encryption - an attacker who knows a user's ID can forge a valid state. Use HMAC signing with a server-side secret to create a tamper-proof state, or use a cryptographically random token stored server-side.</violation>
</file>
<file name="src/lib/netlify-config.ts">
<violation number="1" location="src/lib/netlify-config.ts:45">
P2: Only the first Netlify plugin is written to netlify.toml. If `config.plugins` contains multiple entries, the extra plugins are silently omitted, causing missing plugin configuration in the generated file.</violation>
</file>
<file name="src/modules/projects/ui/components/github-export-button.tsx">
<violation number="1" location="src/modules/projects/ui/components/github-export-button.tsx:27">
P2: `useQuery` returns `undefined` while loading, so the falsy check shows the “Connect GitHub” dialog during the loading state. Add an explicit loading guard before treating the user as disconnected.</violation>
</file>
<file name="src/lib/payment-provider.ts">
<violation number="1" location="src/lib/payment-provider.ts:206">
P2: Unsafe type assertion: `undefined as T` bypasses type safety. When `request<SubscriptionSummary>` receives a 204 response, it returns `undefined` but TypeScript believes it's a valid `SubscriptionSummary`. This can cause runtime errors when callers access properties on the result. Consider using a discriminated return type or throwing for unexpected 204 responses.</violation>
</file>
<file name="src/lib/payment-templates/svelte.ts">
<violation number="1" location="src/lib/payment-templates/svelte.ts:261">
P1: The checkout request doesn't check `response.ok` or provide error feedback to users. If the checkout fails, users see no indication of the failure - the button silently returns to its default state. Payment flows require clear error feedback.</violation>
</file>
<file name="src/app/api/projects/[projectId]/export/github/route.ts">
<violation number="1" location="src/app/api/projects/[projectId]/export/github/route.ts:113">
P2: Inefficient query: fetches all exports and filters client-side instead of using `api.githubExports.get` which directly queries by `exportId`. This unnecessarily transfers and processes more data than needed.</violation>
</file>
<file name="src/modules/projects/ui/components/env-vars-dialog.tsx">
<violation number="1" location="src/modules/projects/ui/components/env-vars-dialog.tsx:86">
P2: Environment variables are fetched on component mount before the dialog is opened, causing unnecessary API calls. Consider using the Dialog's `onOpenChange` callback to fetch data only when the dialog is opened.</violation>
<violation number="2" location="src/modules/projects/ui/components/env-vars-dialog.tsx:88">
P2: Missing dependency in useEffect. The `loadEnvVars` function is used but not included in the dependency array. Either wrap `loadEnvVars` in `useCallback` with `[siteId]` as dependencies, or inline the fetch logic directly in the effect.</violation>
<violation number="3" location="src/modules/projects/ui/components/env-vars-dialog.tsx:123">
P2: The "Remove" button is not disabled during loading, unlike the "Save" button. This could cause race conditions if users click delete while another operation is in progress.</violation>
</file>
<file name="src/modules/projects/ui/components/deployment-status.tsx">
<violation number="1" location="src/modules/projects/ui/components/deployment-status.tsx:65">
P1: The `deployment` object in the dependency array causes the effect to re-run on every Convex query update, resetting the interval and immediately calling `pollStatus()`. This creates rapid polling instead of the intended 10-second intervals. Use specific primitive values as dependencies instead.</violation>
</file>
<file name="src/modules/projects/ui/components/deploy-button.tsx">
<violation number="1" location="src/modules/projects/ui/components/deploy-button.tsx:40">
P2: The `!connection` check doesn't distinguish between loading state (`undefined`) and no connection (`null`). This causes `NetlifyConnectDialog` to flash briefly while the query is loading. Consider handling the loading state explicitly.</violation>
</file>
<file name="src/lib/netlify-client.ts">
<violation number="1" location="src/lib/netlify-client.ts:172">
P1: The `key` parameter should be URL-encoded before being used in the path. Without encoding, keys containing special characters (like `/`, `.`, `%`) will cause incorrect API paths or potential path traversal issues.</violation>
</file>
<file name="src/app/api/deploy/netlify/sites/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/sites/route.ts:14">
P1: Missing authentication token for Convex query. The `fetchQuery` call requires the Clerk token to be passed as a third argument for `requireAuth(ctx)` to work in the Convex query. Without this, the query will fail with an auth error.
Use either `getConvexClientWithAuth()` or pass the token to `fetchQuery`.</violation>
</file>
<file name="src/lib/payment-templates/react.ts">
<violation number="1" location="src/lib/payment-templates/react.ts:204">
P2: `JSON.parse(rawBody)` can throw on malformed JSON, crashing the webhook handler. This should be wrapped in a try-catch block to return a proper error response instead of an unhandled exception.</violation>
<violation number="2" location="src/lib/payment-templates/react.ts:264">
P2: Missing error handling in `startCheckout`: The function doesn't check `response.ok` and lacks a `catch` block. If the API returns an error or the request fails, users get no feedback - the button just stops loading without explanation.</violation>
</file>
<file name="src/lib/payment-templates/nextjs.ts">
<violation number="1" location="src/lib/payment-templates/nextjs.ts:350">
P2: Missing error handling in checkout flow. If the API returns an error (400/500), the user gets no feedback - the button just stops loading. Check `response.ok` and display an error message to the user.</violation>
<violation number="2" location="src/lib/payment-templates/nextjs.ts:394">
P2: The `trackUsage` function silently ignores API errors. Failed usage tracking could result in billing inaccuracies. Either throw on error or return a result indicating success/failure.</violation>
</file>
<file name="src/lib/payment-templates/angular.ts">
<violation number="1" location="src/lib/payment-templates/angular.ts:197">
P0: Webhook signature verification is broken. The `express.json()` middleware parses the body before it reaches this route, so `req.body` is already an object, not a raw string. The signature will be computed on `'[object Object]'` instead of the actual payload, and `JSON.parse(rawBody)` will throw. The webhook route needs raw body access - either exclude it from `express.json()` or use `express.raw()` specifically for webhooks.</violation>
</file>
<file name="src/lib/payment-templates/vue.ts">
<violation number="1" location="src/lib/payment-templates/vue.ts:71">
P1: Async route handlers lack try/catch. If `autumn.request()` throws, Express 4.x won't catch it automatically, resulting in unhandled promise rejections and hanging requests. Wrap async operations in try/catch and return appropriate error responses.</violation>
<violation number="2" location="src/lib/payment-templates/vue.ts:197">
P0: Webhook signature verification is broken. `req.body` is already parsed by `express.json()` middleware, so it's an object, not the raw string needed for HMAC verification. To fix this, webhook routes need raw body access - either exclude them from `express.json()` middleware, or use `express.raw()` for webhook routes specifically, storing the raw buffer for signature verification.</violation>
<violation number="3" location="src/lib/payment-templates/vue.ts:251">
P2: Missing error handling for failed checkout requests. If the API returns an error status, the user sees no feedback - the button just stops loading without explanation.</violation>
</file>
<file name="src/app/api/deploy/netlify/logs/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/logs/route.ts:12">
P1: Missing authentication token in Convex `fetchQuery` call. The OAuth connection query runs without user identity context, which could return incorrect or unauthorized data.
Pass the auth token to scope the query to the authenticated user.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c6aed5763
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Actionable comments posted: 19
🤖 Fix all issues with AI agents
In `@convex/deployments.ts`:
- Around line 33-41: The current code reads latest.deployNumber and computes
nextDeployNumber, which can race under concurrent mutations; instead, update the
mutation to obtain the deploy number atomically by either (A) maintaining a
per-project counter and incrementing it inside the same mutation (e.g., patch or
upsert a project counter document and use the returned incremented value as
deployNumber) or (B) stop assigning a separate deployNumber and derive
ordering/display from the record's _creationTime. Locate the code that queries
"deployments" (the latest variable / nextDeployNumber calculation) and replace
it with an atomic increment of a per-project counter (patched/created inside the
same mutation) or switch consumers to use _creationTime for ordering. Ensure the
counter update and deployment insert happen in the same mutation so duplicates
cannot occur.
- Around line 78-84: The current ctx.db.patch call uses truthy checks which skip
updates for empty strings or zero (e.g., error, buildLog, buildTime), so change
the conditional spread checks in the patch for args.status, args.deployId,
args.error, args.buildLog, and args.buildTime to test for !== undefined instead
of truthiness; keep the same spread pattern and updatedAt: Date.now() so fields
can be cleared by sending "" or 0 and still be persisted when present (refer to
ctx.db.patch and args.deploymentId to locate the code).
In `@convex/oauth.ts`:
- Around line 73-88: The getGithubAccessToken function currently exposes raw
OAuth tokens as a public query; change its registration from query to
internalQuery (preserving the handler name getGithubAccessToken and keeping
requireAuth and the oauthConnections lookup) so it is only callable server-side
(e.g., from exportToGitHub in convex/githubExports.ts) and update that file to
import and call the internalQuery variant; additionally, ensure accessToken
values in oauthConnections are stored encrypted and decrypt them only inside
this server-only handler (use your existing encryption/decryption helpers)
before returning.
In `@src/app/api/deploy/netlify/callback/route.ts`:
- Around line 80-85: The current state handling decodes base64 JSON (using
JSON.parse(Buffer.from(state,...)) and only checks decodedState.userId against
user.id, which is predictable; instead generate a cryptographically secure nonce
when starting the OAuth flow, store that nonce server-side (or in a
signed/Httponly cookie) with a short TTL, and verify the incoming state value
exactly matches the stored nonce and is not expired in the callback (then
delete/expire it). Replace the existing decodedState-based check in route.ts
(the JSON.parse(Buffer.from(state...)) block and the "State token mismatch"
error) with a verification against your server-side nonce store or verified
HMAC-signed token and return a clear error if missing/expired/mismatched.
In `@src/app/api/deploy/netlify/deploy/route.ts`:
- Around line 115-125: The call to
fetchMutation(api.deployments.createDeployment, ...) passes deploy.state (a
free-form string) into the typed status field which expects the union "pending"
| "building" | "ready" | "error"; create a normalizeDeploymentStatus(status:
string) helper that maps known Netlify states to those four values (e.g., map
"idle"/"created"→"pending", "building"→"building", "ready"→"ready", any
unknown/failed values→"error") and use normalizeDeploymentStatus(deploy.state)
for the status property in the fetchMutation call (referencing fetchMutation,
api.deployments.createDeployment, and deploy.state).
- Around line 11-17: The request handler in route.ts currently casts the
incoming body to DeployRequest without runtime checks (DeployRequest), so add a
Zod schema mirroring DeployRequest (required projectId string, optional siteId,
deployType enum "preview"|"production", optional branch and commitRef) and use
it to parse/validate the incoming JSON before using it; import zod, parse the
body with schema.parse or safeParse, return a 400 response on validation failure
with an error message, and then use the validated result for subsequent logic
instead of the unchecked cast.
In `@src/app/api/deploy/netlify/env-vars/route.ts`:
- Around line 43-47: The API currently returns full Netlify env var objects
(from netlifyClient.getEnvVars) including plaintext values, so update the
handler to filter each env var before sending with NextResponse.json: iterate
over the envVars response (the array returned by netlifyClient.getEnvVars) and
return only non-secret fields (e.g., key/name, id, context, created_at,
updated_at, and any metadata) while omitting or redacting the values
array/values field; ensure this transformation occurs server-side in the same
function that calls createNetlifyClient and getNetlifyAccessToken and that
NextResponse.json is passed the sanitized list instead of the raw envVars.
In `@src/lib/payment-provider.ts`:
- Around line 113-120: getSubscription currently calls this.request and promises
SubscriptionSummary | null but lets 404s bubble as exceptions; change
getSubscription (the async function named getSubscription taking
SubscriptionLookup) to catch errors from this.request and return null when the
HTTP status is 404 (or when the underlying error indicates a 404), otherwise
rethrow the error; alternatively, if you prefer, modify the request helper to
surface the response status (e.g., by returning an object with status and body)
and then have getSubscription return null when status === 404 and the
SubscriptionSummary when successful.
In `@src/lib/payment-templates/env-example.ts`:
- Around line 1-9: The exported template string paymentEnvExample should be
sanitized before reuse or persistence to avoid NULL-byte issues; wrap the
template value with sanitizeAnyForDatabase() when assigning/exporting
paymentEnvExample (i.e., replace the raw template string assigned to
paymentEnvExample with the result of sanitizeAnyForDatabase(yourTemplateString))
so all consumers receive a sanitized string; ensure sanitizeAnyForDatabase is
imported where paymentEnvExample is defined and that the exported identifier
remains paymentEnvExample.
In `@src/lib/payment-templates/nextjs.ts`:
- Around line 154-158: The PATCH handler casts req.json() to UpdateRequest
without validation, so add a type guard (e.g., isUpdateRequest) or reuse an
existing guard to validate the parsed body before using it in PATCH; locate the
PATCH function in this file, parse the body into a generic object, run the guard
(isUpdateRequest(body)) and return a 400 NextResponse.json when it fails,
otherwise proceed using the typed UpdateRequest.
- Around line 171-175: The DELETE handler casts req.json() directly to
CancelRequest without validating shape, so add a runtime type guard (e.g.,
isCancelRequest) and use it inside export async function DELETE(req: Request) to
check the parsed body before using it; if the guard fails return
NextResponse.json({ error: "Invalid payload" }, { status: 400 }) and otherwise
proceed with the validated CancelRequest values. Locate the DELETE function and
the CancelRequest type and either implement or reuse an existing type guard
helper (isCancelRequest or similar) to perform the check.
In `@src/lib/payment-templates/vue.ts`:
- Around line 189-214: The webhook handler router.post("/autumn") is verifying
an HMAC over req.body but the app uses express.json() so req.body is already
parsed; update the route to receive the raw request bytes and convert them to a
string before calling verifySignature and JSON.parse: register express.raw({
type: "application/json" }) for the /autumn path (or apply raw body middleware
to this route) so you can read the raw Buffer, set rawBody =
buffer.toString("utf8"), then use signatureValue and
process.env.AUTUMN_WEBHOOK_SECRET with verifySignature(rawBody, secret) and only
after verification call JSON.parse(rawBody) to produce the event object.
In `@src/modules/projects/ui/components/custom-domain-dialog.tsx`:
- Around line 27-31: The dialog allows duplicate add/remove clicks because
isLoading is only used for loadDomains; add a dedicated boolean state (e.g.,
isSubmitting) in CustomDomainDialog and use it in addDomain and removeDomain
handlers: at start of each handler return early if isSubmitting, then set
isSubmitting = true before the network call and reset it in a finally block (or
on both success/error) to ensure it's cleared; also disable the Add and Remove
buttons and any form inputs when isSubmitting is true so the UI cannot issue
concurrent POST/DELETE requests, and keep loadDomains using isLoading as before.
In `@src/modules/projects/ui/components/deployment-history.tsx`:
- Line 21: The shared logs state (logs, setLogs) in the DeploymentHistory
component causes stale logs to flash when opening a different deployment; update
the code so that when opening the logs dialog you either clear logs (call
setLogs(null) or an explicit loading sentinel) before fetching new logs in the
open handler (e.g., openLogs/openDeploymentDialog) or replace the single string
state with a map keyed by deployId (e.g., logsByDeployId:
Record<deployId,string|null>) and read/write via setLogsByDeployId(deployId,
...); ensure the fetch logic (where fetch/deploy logs are requested) uses the
deployId key and the dialog rendering reads the keyed value so previous
deployment logs are never shown while loading.
- Around line 15-17: The prop type for DeploymentHistoryProps is wrong: change
projectId from string to Convex's Id type (Id<"projects">) so it matches backend
expectations; update the DeploymentHistoryProps definition and any component
props or usages that type projectId (including the component signature and any
places passing projectId) to use Id<"projects"> and add the necessary import for
Id (from the Convex types module used in the repo) so the file compiles against
Convex.
In `@src/modules/projects/ui/components/deployment-status.tsx`:
- Around line 7-9: Change the DeploymentStatusProps type so projectId uses the
Convex Id type instead of string: replace the projectId: string declaration with
projectId: Id<"projects"> in the DeploymentStatusProps interface used by the
DeploymentStatus component (and update any other occurrences in this file, e.g.,
the other prop definitions around lines 22-23) so callers provide the correct
Convex project id type.
In `@src/modules/projects/ui/components/env-vars-dialog.tsx`:
- Around line 22-27: The component currently only tracks loading via isLoading
(set in loadEnvVars) so Save/Delete can be clicked multiple times; add a new
boolean state like isSubmitting (with setIsSubmitting) in EnvVarsDialog and use
it for mutation flows (e.g., in the save and remove handlers) by early-returning
if isSubmitting is true, setting isSubmitting = true before the POST/DELETE, and
setting it back to false in finally; also disable the Save/Remove buttons and
relevant inputs when isSubmitting is true. You can alternatively reuse isLoading
but ensure it is set around mutations (save/remove) too; update any handlers
named saveEnvVar/removeEnvVar (or similar) to reference this new state.
In `@src/modules/projects/ui/components/github-export-button.tsx`:
- Around line 23-48: The component GitHubExportButton currently treats
connection from useQuery(api.oauth.getConnection) the same when it's undefined
(loading) and null (no account), causing a flash; update the render logic to
explicitly handle the loading state by checking connection === undefined first
(e.g., return null, a spinner, or disabled Button) and only render the "Connect
GitHub" Dialog when connection === null; adjust any dependent state like
open/setOpen inside GitHubExportButton accordingly so the UI doesn't show the
connect dialog during the query's loading phase.
In `@src/modules/projects/ui/components/preview-deployments.tsx`:
- Around line 7-12: The prop type for PreviewDeployments is incorrect:
PreviewDeploymentsProps currently types projectId as string but
useQuery(api.deployments.listDeployments, { projectId }) requires Convex
Id<"projects">; change the prop type to projectId: Id<"projects"> in
PreviewDeploymentsProps (and update the PreviewDeployments signature if needed)
and then update all callers to pass the Convex Id type (or explicitly
convert/cast to Id<"projects"> where safe) so the call to
useQuery(api.deployments.listDeployments, { projectId }) satisfies the expected
type.
♻️ Duplicate comments (7)
src/prompts/svelte.ts (1)
2-8: Same external-API conflict as noted elsewhere.This prompt also inherits the
SHARED_RULESvsPAYMENT_INTEGRATION_RULESconflict regarding external API usage.src/prompts/react.ts (1)
2-8: Same external-API conflict as noted elsewhere.This prompt also inherits the
SHARED_RULESvsPAYMENT_INTEGRATION_RULESconflict regarding external API usage.src/prompts/vue.ts (1)
2-8: Same external-API conflict as noted elsewhere.This prompt also inherits the
SHARED_RULESvsPAYMENT_INTEGRATION_RULESconflict regarding external API usage.src/prompts/angular.ts (1)
2-8: Same external-API conflict as noted elsewhere.This prompt also inherits the
SHARED_RULESvsPAYMENT_INTEGRATION_RULESconflict regarding external API usage.src/app/api/deploy/netlify/preview/route.ts (1)
7-21: Duplicate code:NetlifyConnectionandgetNetlifyAccessToken.Same duplication as noted in
logs/route.ts. Extract to a shared module.src/app/api/deploy/netlify/rollback/route.ts (1)
7-25: Duplicate code:NetlifyConnectionandgetNetlifyAccessToken.Same duplication as noted in other route files. Extract to a shared module.
src/app/api/deploy/netlify/domains/route.ts (1)
7-26: Duplicate code:NetlifyConnectionandgetNetlifyAccessToken.Same duplication as noted in other route files. Extract to a shared module.
🧹 Nitpick comments (29)
src/prompts/nextjs.ts (1)
2-8: Clarify the external-API exception for payments in the prompt.
SHARED_RULESprohibits external APIs, butPAYMENT_INTEGRATION_RULESinstructs calling Autumn/Stripe. Once injected, the model gets conflicting directives. Consider adding an explicit exception inPAYMENT_INTEGRATION_RULES(or refiningSHARED_RULES) so payment flows are unambiguous.♻️ Suggested tweak in src/prompts/payment-integration.ts
export const PAYMENT_INTEGRATION_RULES = ` Payment Integration (Stripe via Autumn): - If the user asks for payments, billing, subscriptions, or checkout flows, implement Stripe through Autumn. - Use server-side routes for checkout, billing portal, usage tracking, and webhook handling. - Always validate request payloads and verify webhook signatures. - Store API keys and secrets in environment variables only (no hardcoding). - You may call external APIs for Autumn/Stripe only when payment features are explicitly requested. +- This is the only exception to the "no external APIs" rule in SHARED_RULES. - Provide a FeatureGate component and a usage tracking helper. `;ROADMAP.md (1)
179-227: Consider relocating ROADMAP.md to/explanations.Documentation files should live under the
explanations/directory unless explicitly exempted.As per coding guidelines, docs belong in
/explanations.src/lib/payment-templates/angular.ts (2)
1-1: Use@/lib/...import for internal lib types.Keeps lib imports consistent and modular.
As per coding guidelines, internal lib imports should use `@/lib/...`.♻️ Proposed change
-import type { PaymentTemplateBundle } from "./types"; +import type { PaymentTemplateBundle } from "@/lib/payment-templates/types";
306-314: Add focus-visible styles on interactive elements.Buttons/links should include
focus-visible:ring-ring/50 focus-visible:ring-[3px](and border ring) per accessibility guidance.As per coding guidelines, focus-visible styles are required for accessible UI.
Also applies to: 357-380
src/lib/payment-templates/react.ts (2)
1-1: Use@/lib/...import for internal lib types.Keeps lib imports consistent and modular.
As per coding guidelines, internal lib imports should use `@/lib/...`.♻️ Proposed change
-import type { PaymentTemplateBundle } from "./types"; +import type { PaymentTemplateBundle } from "@/lib/payment-templates/types";
274-281: Add focus-visible styles on interactive elements.Buttons/links should include
focus-visible:ring-ring/50 focus-visible:ring-[3px](and border ring) per accessibility guidance.As per coding guidelines, focus-visible styles are required for accessible UI.
Also applies to: 325-346
src/lib/netlify-config.ts (1)
43-61: Render all plugins instead of only the first entry.
pluginsis an array but onlyplugins[0]is emitted, which will silently drop future additions.♻️ Suggested update
- const pluginsBlock = config.plugins?.length - ? `\n[[plugins]]\n package = "${config.plugins[0]}"\n` - : ""; + const pluginsBlock = config.plugins?.length + ? config.plugins + .map((plugin) => `\n[[plugins]]\n package = "${plugin}"\n`) + .join("") + : "";src/app/api/deploy/netlify/logs/route.ts (1)
7-21: Extract sharedNetlifyConnectiontype andgetNetlifyAccessTokenhelper.This type and helper are duplicated across multiple route files (
logs,preview,rollback,domains,sites). Extract them to a shared module (e.g.,src/lib/netlify-auth.ts) to improve maintainability and reduce code duplication.♻️ Suggested shared module
Create
src/lib/netlify-auth.ts:import { fetchQuery } from "convex/nextjs"; import { api } from "@/convex/_generated/api"; type NetlifyConnection = { accessToken?: string; }; export const getNetlifyAccessToken = async (): Promise<string> => { const connection = await fetchQuery(api.oauth.getConnection, { provider: "netlify", }) as NetlifyConnection | null; if (!connection?.accessToken) { throw new Error("Netlify connection not found."); } return connection.accessToken; };Then import from this shared module in all route files.
src/app/api/deploy/netlify/rollback/route.ts (1)
34-37: Handle JSON parse errors explicitly.If
request.json()fails (e.g., invalid JSON body), it throws before reaching the validation check. Consider wrapping in a try-catch or validating more defensively.♻️ Safer body parsing
- const body = (await request.json()) as RollbackPayload; - if (!body.deployId) { + let body: RollbackPayload; + try { + body = (await request.json()) as RollbackPayload; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + if (!body?.deployId) { return NextResponse.json({ error: "Missing deployId" }, { status: 400 }); }src/modules/projects/ui/components/deployment-status.tsx (1)
31-65: OptimizeuseEffectdependencies to avoid unnecessary re-runs.Including the entire
deploymentobject in the dependency array causes the effect to re-run on every status update from the Convex subscription, which can trigger redundant polling cycles. Extract only the needed values.♻️ Suggested fix
+ const deployId = deployment?.deployId; + const deploymentId = deployment?._id; + useEffect(() => { - if (!shouldPoll || !deployment?.deployId) { + if (!shouldPoll || !deployId || !deploymentId) { return; } let cancelled = false; const pollStatus = async () => { try { - const response = await fetch(`/api/deploy/netlify/status?deployId=${deployment.deployId}`); + const response = await fetch(`/api/deploy/netlify/status?deployId=${deployId}`); if (!response.ok) { return; } const data = (await response.json()) as NetlifyStatusResponse; if (!data.state || cancelled) { return; } await updateDeployment({ - deploymentId: deployment._id, + deploymentId, status: data.state === "ready" ? "ready" : data.state === "error" ? "error" : "building", }); } catch { // ignore polling errors } }; const interval = setInterval(pollStatus, 10000); pollStatus(); return () => { cancelled = true; clearInterval(interval); }; - }, [deployment, shouldPoll, updateDeployment]); + }, [deployId, deploymentId, shouldPoll, updateDeployment]);src/modules/projects/ui/components/deployment-history.tsx (1)
94-101: Consider adding a confirmation dialog for rollback.Rollback is a potentially destructive operation that reverts the live deployment. A confirmation step would prevent accidental rollbacks.
src/app/api/deploy/netlify/env-vars/route.ts (1)
19-22: Avoidasassertions; validate payloads instead.
Theas NetlifyConnection | nullandas EnvVarPayloadcasts skip runtime validation and undercut strict typing; consider Zod parsing or explicit type guards to validate both Convex responses and request JSON. As per coding guidelines, please avoidasassertions and validate the shapes explicitly.Also applies to: 60-61, 87-88
src/app/api/deploy/netlify/sites/route.ts (1)
12-15: Drop theasassertion and validate the Convex response.
Theas NetlifyConnection | nullcast bypasses runtime checks; prefer schema validation or a type guard so accessToken presence is verified explicitly. As per coding guidelines, please avoidasassertions and validate the shape.convex/githubExports.ts (1)
300-331: Branch resolution logic handles edge cases well.The fallback to default branch when target branch doesn't exist is a sensible approach. The error message check using
toLowerCase().includes("not found")is reasonable but could be fragile if the GitHub API error format changes.Consider checking for specific HTTP status codes (404) rather than parsing error messages, if the GitHub API client exposes them.
src/modules/projects/ui/components/deploy-button.tsx (1)
13-42: Consider a loading state for the Netlify connection.
useQueryreturnsundefinedwhile loading; currently that path renders the connect dialog, which can flicker. Consider a brief disabled/loading state (e.g.,animate-spinoranimate-pulse).src/agents/tools.ts (1)
146-156: EmitonToolCallfor the new tool for consistent telemetry.Other tools call
onToolCallat the start; adding it here keeps tool logging consistent.♻️ Suggested tweak
paymentTemplates: tool({ description: "Get Stripe + Autumn payment integration templates for a framework", inputSchema: z.object({ framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]), }), execute: async ({ framework }) => { + onToolCall?.("paymentTemplates", { framework }); const template = getPaymentTemplate(framework); return JSON.stringify({ ...template,src/lib/payment-templates/index.ts (1)
1-8: Use@/lib/...imports for lib modules.Switching to alias imports improves consistency and follows the lib import rule.
♻️ Suggested refactor
-import { angularPaymentTemplate } from "./angular"; -import { nextjsPaymentTemplate } from "./nextjs"; -import { reactPaymentTemplate } from "./react"; -import { sveltePaymentTemplate } from "./svelte"; -import { vuePaymentTemplate } from "./vue"; -import type { PaymentFramework, PaymentTemplateBundle } from "./types"; -import { autumnConfigTemplate } from "./autumn-config"; -import { paymentEnvExample } from "./env-example"; +import { angularPaymentTemplate } from "@/lib/payment-templates/angular"; +import { nextjsPaymentTemplate } from "@/lib/payment-templates/nextjs"; +import { reactPaymentTemplate } from "@/lib/payment-templates/react"; +import { sveltePaymentTemplate } from "@/lib/payment-templates/svelte"; +import { vuePaymentTemplate } from "@/lib/payment-templates/vue"; +import type { + PaymentFramework, + PaymentTemplateBundle, +} from "@/lib/payment-templates/types"; +import { autumnConfigTemplate } from "@/lib/payment-templates/autumn-config"; +import { paymentEnvExample } from "@/lib/payment-templates/env-example";As per coding guidelines, use
@/lib/...for internal lib imports.src/modules/projects/ui/components/github-export-modal.tsx (2)
120-126: Effect resets state but doesn't reset form fields.When the modal closes, only
error,result, andisExportingare reset. Form fields likerepoName,selectedRepo,branch, etc., retain their values. If the user reopens the modal, stale data will persist.Consider resetting the form state when the modal closes:
♻️ Suggested fix
useEffect(() => { if (!open) { setError(null); setResult(null); setIsExporting(false); + setRepoName(""); + setRepoDescription(""); + setIsPrivate(false); + setSelectedRepo(""); + setBranch(""); + setCommitMessage(""); + setMode("new"); } }, [open]);
128-158: Missing request cancellation could cause state updates on unmounted component.If the modal is closed before the fetch completes,
setReposorsetErrormay be called on an unmounted component. Consider using an AbortController to cancel the request.♻️ Suggested fix
useEffect(() => { if (!open) { return; } + const controller = new AbortController(); + const loadRepositories = async () => { setIsLoadingRepos(true); setError(null); try { - const response = await fetch("/api/github/repositories"); + const response = await fetch("/api/github/repositories", { + signal: controller.signal, + }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || "Failed to load repositories"); } const parsedRepos = parseRepositories(payload.repositories); setRepos(parsedRepos); if (parsedRepos.length === 0) { setError("No repositories found in this GitHub account."); } } catch (loadError) { + if (loadError instanceof DOMException && loadError.name === "AbortError") { + return; + } const message = loadError instanceof Error ? loadError.message : "Failed to load repositories"; setError(message); } finally { setIsLoadingRepos(false); } }; void loadRepositories(); + + return () => { + controller.abort(); + }; }, [open]);src/modules/projects/ui/views/project-view.tsx (1)
133-135: Consider adding icon sizing class for consistency.Per coding guidelines, icons should use
size-4(16px) as the default size. The other tab icons (EyeIcon,CodeIcon) also don't have explicit sizing, so this is consistent within the file, but adding explicit sizing would align with the guidelines.♻️ Optional fix
<TabsTrigger value="deploy" className="rounded-md"> - <RocketIcon /> <span>Deploy</span> + <RocketIcon className="size-4" /> <span>Deploy</span> </TabsTrigger>Consider applying the same to
EyeIconandCodeIconfor full consistency.src/lib/payment-templates/svelte.ts (1)
243-279: CheckoutButton component lacks error feedback to the user.If the checkout API returns an error or a response without a URL, the user receives no feedback—the button simply stops loading. Consider adding error state handling.
♻️ Suggested improvement
<script lang="ts"> export let productId: string; export let customerId: string; export let successUrl: string; export let cancelUrl: string; export let label = "Upgrade"; let loading = false; + let error = ""; const startCheckout = async () => { loading = true; + error = ""; try { const response = await fetch("/api/billing/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }), }); const data = (await response.json()) as { url?: string }; if (data.url) { window.location.href = data.url; + } else { + error = "Failed to start checkout"; } + } catch { + error = "An error occurred"; } finally { loading = false; } }; </script> + +{`#if` error} + <p class="text-red-600 text-sm">{error}</p> +{/if}convex/schema.ts (2)
184-189: Consider extracting inline status enum to a named constant.The deployment status enum is defined inline. For consistency and reusability (similar to
githubExportStatusEnum), consider extracting it:♻️ Suggested refactor
+export const deploymentStatusEnum = v.union( + v.literal("pending"), + v.literal("building"), + v.literal("ready"), + v.literal("error") +); + // In deployments table definition: - status: v.union( - v.literal("pending"), - v.literal("building"), - v.literal("ready"), - v.literal("error") - ), + status: deploymentStatusEnum,
53-58: Inconsistent enum casing with existing enums.The new
githubExportStatusEnumuses lowercase values ("pending","processing", etc.), while existing enums likeimportStatusEnum(lines 46-51) use uppercase ("PENDING","PROCESSING", etc.). Consider aligning the casing for consistency.This inconsistency may cause confusion when working with both import and export statuses in the same codebase.
#!/bin/bash # Verify existing enum casing patterns in the schema rg -n 'v\.literal\("' convex/schema.ts | head -40src/lib/payment-templates/nextjs.ts (1)
315-369: Consider using Shadcn/ui components for generated Next.js templates.Based on learnings, Next.js code generation should use Shadcn components. The current
CheckoutButtonuses plain HTML with Tailwind. While functional, using Shadcn'sButtoncomponent would provide better accessibility, consistent styling, and align with the project's conventions.This is a template that generates code for users, so using Shadcn would give them a better starting point.
src/app/api/projects/[projectId]/export/github/route.ts (2)
39-48: Authentication context may be missing for the GitHub token query.
fetchQueryat line 41 is called without authentication context, butgetConvexClientWithAuthis only used later at line 79. Ifapi.oauth.getGithubAccessTokenrequires authentication to identify the user, it may return incorrect results or fail.Consider using the authenticated client consistently:
♻️ Suggested fix
const { projectId } = await params; const body = exportRequestSchema.parse(await request.json()); - const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {}); + const convex = await getConvexClientWithAuth(); + const accessToken = await convex.query(api.oauth.getGithubAccessToken, {});Then reuse
convexfor the action call at line 80.
54-59: Redundant validation check.This condition is unreachable. The schema's
refineat line 25 already ensures that ifrepositoryFullNameis absent,repositoryNamemust be present. Consider removing this dead code.♻️ Suggested simplification
let repository; if (body.repositoryFullName) { repository = await getRepository(accessToken, body.repositoryFullName); } else { - if (!body.repositoryName) { - return NextResponse.json( - { error: "Repository name is required." }, - { status: 400 }, - ); - } - const input: CreateRepositoryInput = { - name: body.repositoryName, + name: body.repositoryName!, // Guaranteed by schema refinement description: body.description, isPrivate: body.isPrivate ?? false, };src/app/api/deploy/netlify/deploy/route.ts (1)
107-108: Consider adding explicit error handling for invalid site IDs.If
body.siteIdis provided but doesn't exist or the user lacks access,netlifyClient.getSitewill throw a generic error. Consider catching this specifically to provide a clearer error message like "Site not found or access denied."src/lib/payment-provider.ts (2)
183-210: Add request timeout to prevent indefinite hangs.The
fetchcall has no timeout. If the Autumn API is slow or unresponsive, requests will hang indefinitely. Consider usingAbortControllerwith a timeout.♻️ Suggested improvement
private async request<T>( path: string, - options: AutumnRequestOptions + options: AutumnRequestOptions, + timeoutMs = 30000 ): Promise<T> { const url = `${this.baseUrl}${path}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + - const response = await fetch(url, { - ...options, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - ...(options.headers ?? {}), - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }); + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + ...(options.headers ?? {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + // ... rest of response handling + } finally { + clearTimeout(timeoutId); + }
205-207: Type safety concern withundefined as Tcast.Returning
undefined as Tcan cause runtime issues ifTdoesn't includeundefined. Currently, onlytrackUsageexpects void, but if other methods receive an unexpected 204, they'll getundefinedwhen expecting an object.Consider making the return type explicit for 204-capable endpoints or using a separate method for void responses.
- Refactored deployment creation to utilize a project deployment counter for better tracking of deployment numbers. - Updated OAuth token handling to include encryption for access and refresh tokens, improving security. - Modified GitHub export functionality to check for connection existence without requiring an access token. - Improved error handling and validation in various API routes, ensuring more robust interactions with external services. - Enhanced UI components for deployment history and environment variable management, adding loading states and better user feedback. These changes significantly improve the deployment process and security of OAuth tokens, while also enhancing user experience in the application.
CodeCapy Review ₍ᐢ•(ܫ)•ᐢ₎Codebase SummaryZapDev is an AI-powered development platform that lets users generate, preview and deploy web applications in real time. The application supports code generation, live preview, file management, and now includes integrated support for deploying projects to Netlify along with payment integration templates for multiple frameworks (Angular, React, Next.js, Svelte, Vue) using Autumn and Stripe. It also provides GitHub export options directly from the project UI. PR ChangesThis pull request introduces Netlify deployment integration including OAuth flow, deployment API endpoints (create, update, status, logs, domains, environment variables, preview and rollback), and a new deployment dashboard UI. In addition, new payment integration templates for multiple frameworks have been added, updating the README and code generation prompts to reflect billing templates (Stripe via Autumn). GitHub export functionality (using OAuth and repository export endpoints) has also been added with new modals and controls. Setup Instructions
Generated Test Cases1: Netlify OAuth Redirection Flow ❗️❗️❗️Description: Tests the Netlify OAuth flow from the UI by verifying that clicking the Connect Netlify button redirects the user to the correct Netlify authorization URL with proper state encoding. Prerequisites:
Steps:
Expected Result: The user is redirected to Netlify's OAuth page with parameters corresponding to the configured environment variables and a valid state token. 2: Deployment Dashboard Status and Interaction ❗️❗️❗️Description: Ensures that the new deployment dashboard accurately displays the current Netlify deployment status and provides appropriate interactions for preview deployments, viewing logs, and rollback actions. Prerequisites:
Steps:
Expected Result: The deployment dashboard correctly shows the latest deployment status with interactive buttons that load logs and allow rollback. Any status changes are polled and updated in real time. 3: GitHub Export Modal Workflow ❗️❗️❗️Description: Validates the GitHub export functionality by ensuring the export modal allows the user to create a new repository (or select an existing one), configure branch and commit details, and then successfully export the project with feedback. Prerequisites:
Steps:
Expected Result: The export modal processes the input correctly, displays the export result details and provides a working link to the repository on GitHub. Any misconfigurations or errors are reported to the user. 4: Payment Template Inclusion in README and Prompts ❗️❗️Description: Checks that the README and code generation prompts have been updated to include payment integration information. This ensures that users are informed about the new generated app billing templates via Autumn and Stripe. Prerequisites:
Steps:
Expected Result: The README and prompt files clearly indicate the new payment integration features, instructing users on how to use generated billing templates and configure payment-related environment variables. 5: Payment Templates API Tool Execution ❗️❗️Description: Verifies the functionality of the payment template tool integrated into the agent tools. When invoked, it should return a JSON object containing the actual payment integration templates including autumnConfigTemplate and paymentEnvExample. Prerequisites:
Steps:
Expected Result: The paymentTemplates tool returns a valid JSON response with payment integration template details that include both the Autumn configuration template and the example environment variable configuration for Autumn and Stripe. Raw Changes AnalyzedFile: README.md
Changes:
@@ -14,6 +14,7 @@ AI-powered development platform that lets you create web applications by chattin
- 🔐 Authentication with Clerk
- ⚙️ Background job processing with Inngest
- 🗃️ Project management and persistence
+- 💰 Generated app billing templates (Stripe via Autumn)
## Tech Stack
@@ -200,6 +201,10 @@ npm run lint # Run ESLint
5. **File Management**: Users can browse generated files with syntax highlighting
6. **Iteration**: Conversational development allows for refinements and additions
+## Generated App Payments
+
+ZapDev can generate payment-ready apps using Stripe through Autumn. Templates live in `src/lib/payment-templates/` and include checkout flows, billing portal endpoints, feature gates, and usage tracking helpers. Configure with environment variables from `paymentEnvExample` in the same folder.
+
---
Created by [CodeWithAntonio](https://codewithantonio.com)
File: ROADMAP.md
Changes:
@@ -4,7 +4,7 @@
### Payments Integration
-**Status**: In Progress
+**Status**: Finished
**Priority**: High
Currently, ZapDev uses Polar.sh for subscription billing. This roadmap item focuses on:
@@ -176,3 +176,52 @@ Allow users to choose their preferred database provider:
- Cost optimization options
- Regional data residency compliance
+
+### GitHub Export
+
+**Status**: Planned
+**Priority**: High
+
+Enable users to export their generated projects directly to GitHub repositories for version control, collaboration, and deployment:
+
+- **Repository Creation**:
+ - One-click export to new GitHub repository
+ - Automatic repository initialization with generated code
+ - Support for public, private, and organization repositories
+ - Custom repository name and description
+ - Optional README generation with project details
+
+- **Export Features**:
+ - Full project structure export (all files and directories)
+ - Preserve file permissions and structure
+ - Include `.gitignore` and other configuration files
+ - Export project metadata and documentation
+ - Incremental updates to existing repositories
+
+- **GitHub Integration**:
+ - OAuth authentication with GitHub
+ - Secure token storage and management
+ - Support for GitHub App authentication
+ - Branch creation for project versions
+ - Commit history tracking
+
+- **Advanced Features**:
+ - Export to existing repositories (push to specific branch)
+ - Multiple repository export (fork to multiple locations)
+ - Automated initial commit with descriptive messages
+ - Tag creation for project versions
+ - GitHub Actions workflow templates inclusion
+
+- **User Experience**:
+ - Export progress indicator
+ - Error handling and retry logic
+ - Export history tracking
+ - Quick access to exported repositories
+ - One-click repository opening in GitHub
+
+- **Technical Implementation**:
+ - GitHub REST API integration
+ - File tree generation and upload
+ - Large file handling (GitHub LFS support)
+ - Rate limit management
+ - Background job processing for large exports
File: convex/_generated/api.d.ts
Changes:
@@ -10,6 +10,8 @@
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
+import type * as deployments from "../deployments.js";
+import type * as githubExports from "../githubExports.js";
import type * as importData from "../importData.js";
import type * as imports from "../imports.js";
import type * as messages from "../messages.js";
@@ -31,6 +33,8 @@ import type {
declare const fullApi: ApiFromModules<{
helpers: typeof helpers;
http: typeof http;
+ deployments: typeof deployments;
+ githubExports: typeof githubExports;
importData: typeof importData;
imports: typeof imports;
messages: typeof messages;
File: convex/deployments.ts
Changes:
@@ -0,0 +1,190 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+import { requireAuth } from "./helpers";
+
+const deploymentStatusEnum = v.union(
+ v.literal("pending"),
+ v.literal("building"),
+ v.literal("ready"),
+ v.literal("error")
+);
+
+export const createDeployment = mutation({
+ args: {
+ projectId: v.id("projects"),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ status: deploymentStatusEnum,
+ isPreview: v.optional(v.boolean()),
+ branch: v.optional(v.string()),
+ commitRef: v.optional(v.string()),
+ },
+ returns: v.id("deployments"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ const counter = await ctx.db
+ .query("projectDeploymentCounters")
+ .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+ .first();
+
+ const now = Date.now();
+ let nextDeployNumber: number;
+
+ if (counter) {
+ nextDeployNumber = counter.deployNumber + 1;
+ await ctx.db.patch(counter._id, {
+ deployNumber: nextDeployNumber,
+ updatedAt: now,
+ });
+ } else {
+ nextDeployNumber = 1;
+ await ctx.db.insert("projectDeploymentCounters", {
+ projectId: args.projectId,
+ deployNumber: nextDeployNumber,
+ updatedAt: now,
+ });
+ }
+
+ return await ctx.db.insert("deployments", {
+ projectId: args.projectId,
+ userId,
+ platform: args.platform,
+ siteId: args.siteId,
+ siteUrl: args.siteUrl,
+ deployId: args.deployId,
+ deployNumber: nextDeployNumber,
+ commitRef: args.commitRef,
+ branch: args.branch,
+ isPreview: args.isPreview ?? false,
+ status: args.status,
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+export const updateDeployment = mutation({
+ args: {
+ deploymentId: v.id("deployments"),
+ status: v.optional(deploymentStatusEnum),
+ deployId: v.optional(v.string()),
+ error: v.optional(v.string()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ },
+ returns: v.id("deployments"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const deployment = await ctx.db.get(args.deploymentId);
+ if (!deployment || deployment.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ await ctx.db.patch(args.deploymentId, {
+ ...(args.status !== undefined ? { status: args.status } : {}),
+ ...(args.deployId !== undefined ? { deployId: args.deployId } : {}),
+ ...(args.error !== undefined ? { error: args.error } : {}),
+ ...(args.buildLog !== undefined ? { buildLog: args.buildLog } : {}),
+ ...(args.buildTime !== undefined ? { buildTime: args.buildTime } : {}),
+ updatedAt: Date.now(),
+ });
+
+ return args.deploymentId;
+ },
+});
+
+export const getDeployment = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.union(
+ v.null(),
+ v.object({
+ _id: v.id("deployments"),
+ _creationTime: v.number(),
+ projectId: v.id("projects"),
+ userId: v.string(),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ deployNumber: v.optional(v.number()),
+ commitRef: v.optional(v.string()),
+ branch: v.optional(v.string()),
+ isPreview: v.optional(v.boolean()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ previousDeployId: v.optional(v.id("deployments")),
+ status: deploymentStatusEnum,
+ error: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ ),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("deployments")
+ .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .first();
+ },
+});
+
+export const listDeployments = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id("deployments"),
+ _creationTime: v.number(),
+ projectId: v.id("projects"),
+ userId: v.string(),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ deployNumber: v.optional(v.number()),
+ commitRef: v.optional(v.string()),
+ branch: v.optional(v.string()),
+ isPreview: v.optional(v.boolean()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ previousDeployId: v.optional(v.id("deployments")),
+ status: deploymentStatusEnum,
+ error: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ ),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("deployments")
+ .withIndex("by_projectId_deployNumber", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .collect();
+ },
+});
File: convex/githubExports.ts
Changes:
@@ -0,0 +1,396 @@
+import { v } from "convex/values";
+import { action, mutation, query } from "./_generated/server";
+import { requireAuth } from "./helpers";
+import { githubExportStatusEnum } from "./schema";
+import { api } from "./_generated/api";
+import type { Doc, Id } from "./_generated/dataModel";
+import {
+ buildTreeEntries,
+ createBranchRef,
+ createCommit,
+ createTree,
+ getBranchRef,
+ getCommitTreeSha,
+ getRepository,
+ updateBranchRef,
+ withDefaultFiles,
+ type ProjectFramework,
+} from "../src/lib/github-api";
+import { filterFilesForDownload } from "../src/lib/filter-ai-files";
+
+const githubExportRecord = v.object({
+ _id: v.id("githubExports"),
+ _creationTime: v.number(),
+ projectId: v.id("projects"),
+ userId: v.string(),
+ repositoryName: v.string(),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.optional(v.string()),
+ commitSha: v.optional(v.string()),
+ status: githubExportStatusEnum,
+ error: v.optional(v.string()),
+ fileCount: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+ if (!isRecord(value)) {
+ return {};
+ }
+
+ const files: Record<string, string> = {};
+ for (const [path, content] of Object.entries(value)) {
+ if (typeof content === "string") {
+ files[path] = content;
+ }
+ }
+
+ return files;
+};
+
+type MessageWithFragment = {
+ _id: Id<"messages">;
+ _creationTime: number;
+ Fragment: {
+ _id: Id<"fragments">;
+ files?: unknown;
+ framework: ProjectFramework;
+ } | null;
+};
+
+export const list = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.array(githubExportRecord),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("githubExports")
+ .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .collect();
+ },
+});
+
+export const get = query({
+ args: {
+ exportId: v.id("githubExports"),
+ },
+ returns: githubExportRecord,
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const exportRecord = await ctx.db.get(args.exportId);
+ if (!exportRecord) {
+ throw new Error("Export not found");
+ }
+ if (exportRecord.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return exportRecord;
+ },
+});
+
+export const getLatest = query({
+ args: {
+ projectId: v.id("projects"),
+ },
+ returns: v.union(githubExportRecord, v.null()),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return await ctx.db
+ .query("githubExports")
+ .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .first();
+ },
+});
+
+export const create = mutation({
+ args: {
+ projectId: v.id("projects"),
+ repositoryName: v.string(),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.optional(v.string()),
+ },
+ returns: v.id("githubExports"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const project = await ctx.db.get(args.projectId);
+ if (!project || project.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ const now = Date.now();
+ return await ctx.db.insert("githubExports", {
+ projectId: args.projectId,
+ userId,
+ repositoryName: args.repositoryName,
+ repositoryUrl: args.repositoryUrl,
+ repositoryFullName: args.repositoryFullName,
+ branch: args.branch,
+ status: "pending",
+ createdAt: now,
+ updatedAt: now,
+ });
+ },
+});
+
+export const updateStatus = mutation({
+ args: {
+ exportId: v.id("githubExports"),
+ status: githubExportStatusEnum,
+ error: v.optional(v.string()),
+ },
+ returns: v.id("githubExports"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const exportRecord = await ctx.db.get(args.exportId);
+ if (!exportRecord) {
+ throw new Error("Export not found");
+ }
+ if (exportRecord.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ await ctx.db.patch(args.exportId, {
+ status: args.status,
+ ...(args.error !== undefined && { error: args.error }),
+ updatedAt: Date.now(),
+ });
+
+ return args.exportId;
+ },
+});
+
+export const complete = mutation({
+ args: {
+ exportId: v.id("githubExports"),
+ commitSha: v.string(),
+ branch: v.string(),
+ fileCount: v.number(),
+ },
+ returns: v.id("githubExports"),
+ handler: async (ctx, args) => {
+ const userId = await requireAuth(ctx);
+ const exportRecord = await ctx.db.get(args.exportId);
+ if (!exportRecord) {
+ throw new Error("Export not found");
+ }
+ if (exportRecord.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ await ctx.db.patch(args.exportId, {
+ commitSha: args.commitSha,
+ branch: args.branch,
+ fileCount: args.fileCount,
+ status: "complete",
+ updatedAt: Date.now(),
+ });
+
+ return args.exportId;
+ },
+});
+
+export const exportToGitHub = action({
+ args: {
+ exportId: v.id("githubExports"),
+ branch: v.optional(v.string()),
+ includeReadme: v.optional(v.boolean()),
+ includeGitignore: v.optional(v.boolean()),
+ commitMessage: v.optional(v.string()),
+ },
+ returns: v.object({
+ exportId: v.id("githubExports"),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.string(),
+ commitSha: v.string(),
+ fileCount: v.number(),
+ }),
+ handler: async (ctx, args) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity?.subject) {
+ throw new Error("Unauthorized");
+ }
+
+ const exportRecord: Doc<"githubExports"> = await ctx.runQuery(
+ api.githubExports.get,
+ { exportId: args.exportId },
+ );
+
+ await ctx.runMutation(api.githubExports.updateStatus, {
+ exportId: args.exportId,
+ status: "processing",
+ });
+
+ try {
+ const project: Doc<"projects"> = await ctx.runQuery(api.projects.get, {
+ projectId: exportRecord.projectId,
+ });
+
+ const messages: Array<MessageWithFragment> = await ctx.runQuery(
+ api.messages.list,
+ { projectId: exportRecord.projectId },
+ );
+
+ const latestWithFragment = [...messages]
+ .reverse()
+ .find((message) => message.Fragment);
+
+ const fragment = latestWithFragment?.Fragment;
+ if (!fragment) {
+ throw new Error("No AI-generated files are ready to export.");
+ }
+
+ const normalized = normalizeFiles(fragment.files);
+ const filtered = filterFilesForDownload(normalized);
+ if (Object.keys(filtered).length === 0) {
+ throw new Error("No AI-generated files are ready to export.");
+ }
+
+ const includeReadme = args.includeReadme ?? true;
+ const includeGitignore = args.includeGitignore ?? true;
+ const files = withDefaultFiles(
+ filtered,
+ {
+ projectName: project.name,
+ framework: fragment.framework,
+ },
+ includeReadme,
+ includeGitignore,
+ );
+
+ const treeEntries = buildTreeEntries(files);
+import { internal } from "./_generated/api";
+// ...
+ const accessToken = await ctx.runQuery(internal.oauth.getGithubAccessToken, {
+ userId: identity.subject,
+ });
+ if (!accessToken) {
+ throw new Error("GitHub connection not found. Please connect GitHub.");
+ }
+
+ const repository = await getRepository(
+ accessToken,
+ exportRecord.repositoryFullName,
+ );
+ const defaultBranch = repository.default_branch ?? "main";
+ const targetBranch = args.branch ?? exportRecord.branch ?? defaultBranch;
+
+ let baseCommitSha: string | null = null;
+ let baseTreeSha: string | undefined;
+ let needsCreateBranch = false;
+
+ try {
+ baseCommitSha = await getBranchRef(
+ accessToken,
+ repository.full_name,
+ targetBranch,
+ );
+ baseTreeSha = await getCommitTreeSha(
+ accessToken,
+ repository.full_name,
+ baseCommitSha,
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "GitHub error";
+ if (
+ targetBranch !== defaultBranch &&
+ message.toLowerCase().includes("not found")
+ ) {
+ baseCommitSha = await getBranchRef(
+ accessToken,
+ repository.full_name,
+ defaultBranch,
+ );
+ baseTreeSha = await getCommitTreeSha(
+ accessToken,
+ repository.full_name,
+ baseCommitSha,
+ );
+ needsCreateBranch = true;
+ } else {
+ throw error;
+ }
+ }
+
+ if (!baseCommitSha) {
+ throw new Error("Unable to resolve base branch for export.");
+ }
+
+ const treeSha = await createTree(
+ accessToken,
+ repository.full_name,
+ treeEntries,
+ baseTreeSha,
+ );
+ const commitSha = await createCommit(
+ accessToken,
+ repository.full_name,
+ args.commitMessage ?? "Export project from ZapDev",
+ treeSha,
+ baseCommitSha ? [baseCommitSha] : [],
+ );
+
+ if (needsCreateBranch) {
+ await createBranchRef(
+ accessToken,
+ repository.full_name,
+ targetBranch,
+ commitSha,
+ );
+ } else {
+ await updateBranchRef(
+ accessToken,
+ repository.full_name,
+ targetBranch,
+ commitSha,
+ );
+ }
+
+ await ctx.runMutation(api.githubExports.complete, {
+ exportId: args.exportId,
+ commitSha,
+ branch: targetBranch,
+ fileCount: treeEntries.length,
+ });
+
+ return {
+ exportId: args.exportId,
+ repositoryUrl: exportRecord.repositoryUrl,
+ repositoryFullName: exportRecord.repositoryFullName,
+ branch: targetBranch,
+ commitSha,
+ fileCount: treeEntries.length,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Export failed";
+ await ctx.runMutation(api.githubExports.updateStatus, {
+ exportId: args.exportId,
+ status: "failed",
+ error: message,
+ });
+ throw error;
+ }
+ },
+});
File: convex/oauth.ts
Changes:
@@ -1,7 +1,31 @@
-import { mutation, query } from "./_generated/server";
+import { mutation, query, internalQuery } from "./_generated/server";
import { v } from "convex/values";
import { oauthProviderEnum } from "./schema";
import { requireAuth } from "./helpers";
+import crypto from "crypto";
+
+const ENCRYPTION_KEY = process.env.OAUTH_ENCRYPTION_KEY || "fallback-key-change-me-in-production";
+const ALGORITHM = "aes-256-gcm";
+
+function encryptToken(token: string): string {
+ const iv = crypto.randomBytes(16);
+ const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY.slice(0, 32), "utf8"), iv);
+ let encrypted = cipher.update(token, "utf8", "hex");
+ encrypted += cipher.final("hex");
+ const authTag = cipher.getAuthTag();
+ return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
+}
+
+function decryptToken(encryptedToken: string): string {
+ const [ivHex, authTagHex, encrypted] = encryptedToken.split(":");
+ const iv = Buffer.from(ivHex, "hex");
+ const authTag = Buffer.from(authTagHex, "hex");
+ const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY.slice(0, 32), "utf8"), iv);
+ decipher.setAuthTag(authTag);
+ let decrypted = decipher.update(encrypted, "hex", "utf8");
+ decrypted += decipher.final("utf8");
+ return decrypted;
+}
// Store OAuth connection
export const storeConnection = mutation({
@@ -26,11 +50,14 @@ export const storeConnection = mutation({
const now = Date.now();
+ const encryptedAccessToken = encryptToken(args.accessToken);
+ const encryptedRefreshToken = args.refreshToken ? encryptToken(args.refreshToken) : undefined;
+
if (existing) {
// Update existing connection
return await ctx.db.patch(existing._id, {
- accessToken: args.accessToken,
- refreshToken: args.refreshToken || existing.refreshToken,
+ accessToken: encryptedAccessToken,
+ refreshToken: encryptedRefreshToken || existing.refreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
metadata: args.metadata || existing.metadata,
@@ -42,8 +69,8 @@ export const storeConnection = mutation({
return await ctx.db.insert("oauthConnections", {
userId,
provider: args.provider,
- accessToken: args.accessToken,
- refreshToken: args.refreshToken,
+ accessToken: encryptedAccessToken,
+ refreshToken: encryptedRefreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
metadata: args.metadata,
@@ -70,6 +97,28 @@ export const getConnection = query({
},
});
+export const getGithubAccessToken = internalQuery({
+ args: { userId: v.string() },
+ handler: async (ctx, args) => {
+ const connection = await ctx.db
+ .query("oauthConnections")
+ .withIndex("by_userId_provider", (q) =>
+ q.eq("userId", args.userId).eq("provider", "github"),
+ )
+ .first();
+
+ if (!connection?.accessToken) {
+ return null;
+ }
+
+ try {
+ return decryptToken(connection.accessToken);
+ } catch {
+ return null;
+ }
+ },
+});
+
// List all OAuth connections for user
export const listConnections = query({
handler: async (ctx) => {
File: convex/schema.ts
Changes:
@@ -39,7 +39,8 @@ export const importSourceEnum = v.union(
export const oauthProviderEnum = v.union(
v.literal("figma"),
- v.literal("github")
+ v.literal("github"),
+ v.literal("netlify")
);
export const importStatusEnum = v.union(
@@ -49,6 +50,13 @@ export const importStatusEnum = v.union(
v.literal("FAILED")
);
+export const githubExportStatusEnum = v.union(
+ v.literal("pending"),
+ v.literal("processing"),
+ v.literal("complete"),
+ v.literal("failed")
+);
+
export const sandboxStateEnum = v.union(
v.literal("RUNNING"),
v.literal("PAUSED"),
@@ -159,6 +167,35 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_userId_provider", ["userId", "provider"]),
+ deployments: defineTable({
+ projectId: v.id("projects"),
+ userId: v.string(),
+ platform: v.literal("netlify"),
+ siteId: v.string(),
+ siteUrl: v.string(),
+ deployId: v.optional(v.string()),
+ deployNumber: v.optional(v.number()),
+ commitRef: v.optional(v.string()),
+ branch: v.optional(v.string()),
+ isPreview: v.optional(v.boolean()),
+ buildLog: v.optional(v.string()),
+ buildTime: v.optional(v.number()),
+ previousDeployId: v.optional(v.id("deployments")),
+ status: v.union(
+ v.literal("pending"),
+ v.literal("building"),
+ v.literal("ready"),
+ v.literal("error")
+ ),
+ error: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_projectId", ["projectId"])
+ .index("by_projectId_deployNumber", ["projectId", "deployNumber"])
+ .index("by_userId", ["userId"])
+ .index("by_siteId", ["siteId"]),
+
imports: defineTable({
userId: v.string(),
projectId: v.id("projects"),
@@ -177,6 +214,24 @@ export default defineSchema({
.index("by_projectId", ["projectId"])
.index("by_status", ["status"]),
+ githubExports: defineTable({
+ projectId: v.id("projects"),
+ userId: v.string(),
+ repositoryName: v.string(),
+ repositoryUrl: v.string(),
+ repositoryFullName: v.string(),
+ branch: v.optional(v.string()),
+ commitSha: v.optional(v.string()),
+ status: githubExportStatusEnum,
+ error: v.optional(v.string()),
+ fileCount: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_projectId", ["projectId"])
+ .index("by_userId", ["userId"])
+ .index("by_status", ["status"]),
+
usage: defineTable({
userId: v.string(),
points: v.number(),
@@ -266,4 +321,11 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_state", ["state"])
.index("by_sandboxId", ["sandboxId"]),
+
+ projectDeploymentCounters: defineTable({
+ projectId: v.id("projects"),
+ deployNumber: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_projectId", ["projectId"]),
});
File: env.example
Changes:
@@ -27,6 +27,10 @@ CEREBRAS_API_KEY="" # Get from https://cloud.cerebras.ai
# Vercel AI Gateway (fallback for Cerebras rate limits)
VERCEL_AI_GATEWAY_API_KEY="" # Get from https://vercel.com/dashboard/ai-gateway
+# Netlify Deployment
+NETLIFY_CLIENT_ID=""
+NETLIFY_CLIENT_SECRET=""
+
# Brave Search API (web search for subagent research - optional)
BRAVE_SEARCH_API_KEY="" # Get from https://api-dashboard.search.brave.com/app/keys
File: src/agents/tools.ts
Changes:
@@ -1,6 +1,11 @@
import { tool } from "ai";
import { z } from "zod";
import { getSandbox, writeFilesBatch, readFileFast } from "./sandbox-utils";
+import {
+ autumnConfigTemplate,
+ getPaymentTemplate,
+ paymentEnvExample,
+} from "@/lib/payment-templates";
import type { AgentState } from "./types";
export interface ToolContext {
@@ -138,5 +143,20 @@ export function createAgentTools(context: ToolContext) {
}
},
}),
+ paymentTemplates: tool({
+ description:
+ "Get Stripe + Autumn payment integration templates for a framework",
+ inputSchema: z.object({
+ framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]),
+ }),
+ execute: async ({ framework }) => {
+ const template = getPaymentTemplate(framework);
+ return JSON.stringify({
+ ...template,
+ autumnConfigTemplate,
+ paymentEnvExample,
+ });
+ },
+ }),
};
}
File: src/app/api/deploy/netlify/auth/route.ts
Changes:
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+import crypto from "crypto";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_OAUTH_STATE_SECRET = process.env.NETLIFY_OAUTH_STATE_SECRET || "fallback-secret-change-me";
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+
+export async function GET() {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!NETLIFY_CLIENT_ID) {
+ return NextResponse.json(
+ { error: "Netlify OAuth not configured" },
+ { status: 500 }
+ );
+ }
+
+ const payload = JSON.stringify({ userId: user.id, timestamp: Date.now() });
+ const signature = crypto
+ .createHmac("sha256", NETLIFY_OAUTH_STATE_SECRET)
+ .update(payload)
+ .digest("hex");
+
+ const state = Buffer.from(
+ JSON.stringify({ payload, signature })
+ ).toString("base64");
+
+ const params = new URLSearchParams({
+ client_id: NETLIFY_CLIENT_ID,
+ redirect_uri: NETLIFY_REDIRECT_URI,
+ response_type: "code",
+ state,
+ });
+
+ const netlifyAuthUrl = `https://app.netlify.com/authorize?${params.toString()}`;
+ return NextResponse.redirect(netlifyAuthUrl);
+}
File: src/app/api/deploy/netlify/callback/route.ts
Changes:
@@ -0,0 +1,177 @@
+import { NextResponse } from "next/server";
+import { getUser } from "@/lib/auth-server";
+import { fetchMutation } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import crypto from "crypto";
+
+const NETLIFY_CLIENT_ID = process.env.NETLIFY_CLIENT_ID;
+const NETLIFY_CLIENT_SECRET = process.env.NETLIFY_CLIENT_SECRET;
+const NETLIFY_OAUTH_STATE_SECRET = process.env.NETLIFY_OAUTH_STATE_SECRET || "fallback-secret-change-me";
+const NETLIFY_REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/deploy/netlify/callback`;
+const STATE_TTL_MS = 10 * 60 * 1000;
+
+type NetlifyTokenResponse = {
+ access_token?: string;
+ token_type?: string;
+ scope?: string;
+};
+
+type NetlifyUserResponse = {
+ id?: string;
+ email?: string;
+ full_name?: string;
+ avatar_url?: string;
+};
+
+const parseTokenResponse = (value: unknown): NetlifyTokenResponse => {
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ const record = value as Record<string, unknown>;
+ return {
+ access_token: typeof record.access_token === "string" ? record.access_token : undefined,
+ token_type: typeof record.token_type === "string" ? record.token_type : undefined,
+ scope: typeof record.scope === "string" ? record.scope : undefined,
+ };
+};
+
+const parseUserResponse = (value: unknown): NetlifyUserResponse => {
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ const record = value as Record<string, unknown>;
+ return {
+ id: typeof record.id === "string" ? record.id : undefined,
+ email: typeof record.email === "string" ? record.email : undefined,
+ full_name: typeof record.full_name === "string" ? record.full_name : undefined,
+ avatar_url: typeof record.avatar_url === "string" ? record.avatar_url : undefined,
+ };
+};
+
+export async function GET(request: Request) {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const code = searchParams.get("code");
+ const state = searchParams.get("state");
+ const error = searchParams.get("error");
+
+ if (error) {
+ return NextResponse.redirect(
+ new URL(`/projects?error=${encodeURIComponent(error)}`, request.url)
+ );
+ }
+
+ if (!code || !state) {
+ return NextResponse.redirect(
+ new URL("/projects?error=Missing+authorization+code", request.url)
+ );
+ }
+
+ if (!NETLIFY_CLIENT_ID || !NETLIFY_CLIENT_SECRET) {
+ return NextResponse.json(
+ { error: "Netlify OAuth not configured" },
+ { status: 500 }
+ );
+ }
+
+ try {
+ const decodedStateStr = Buffer.from(state, "base64").toString();
+ let decodedState: { payload?: string; signature?: string };
+ try {
+ decodedState = JSON.parse(decodedStateStr);
+ } catch {
+ throw new Error("Invalid state token format");
+ }
+
+ if (!decodedState.payload || !decodedState.signature) {
+ throw new Error("Invalid state token structure");
+ }
+
+ const expectedSignature = crypto
+ .createHmac("sha256", NETLIFY_OAUTH_STATE_SECRET)
+ .update(decodedState.payload)
+ .digest("hex");
+
+ if (!crypto.timingSafeEqual(
+ Buffer.from(decodedState.signature),
+ Buffer.from(expectedSignature)
+ )) {
+ throw new Error("State token signature mismatch");
+ }
+
+ const payload = JSON.parse(decodedState.payload) as { userId?: string; timestamp?: number };
+ if (!payload.userId || !payload.timestamp) {
+ throw new Error("Invalid state token payload");
+ }
+
+ if (payload.userId !== user.id) {
+ throw new Error("State token user mismatch");
+ }
+
+ const age = Date.now() - payload.timestamp;
+ if (age > STATE_TTL_MS || age < 0) {
+ throw new Error("State token expired");
+ }
+
+ const tokenParams = new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: NETLIFY_CLIENT_ID,
+ client_secret: NETLIFY_CLIENT_SECRET,
+ redirect_uri: NETLIFY_REDIRECT_URI,
+ code,
+ });
+
+ const tokenResponse = await fetch("https://api.netlify.com/oauth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: tokenParams.toString(),
+ });
+
+ if (!tokenResponse.ok) {
+ const errorText = await tokenResponse.text();
+ throw new Error(errorText || "Failed to exchange authorization code");
+ }
+
+ const tokenData = parseTokenResponse(await tokenResponse.json());
+ if (!tokenData.access_token) {
+ throw new Error("Missing Netlify access token");
+ }
+
+ const userResponse = await fetch("https://api.netlify.com/api/v1/user", {
+ headers: {
+ Authorization: `Bearer ${tokenData.access_token}`,
+ },
+ });
+
+ const userData = userResponse.ok
+ ? parseUserResponse(await userResponse.json())
+ : {};
+
+ await fetchMutation(api.oauth.storeConnection, {
+ provider: "netlify",
+ accessToken: tokenData.access_token,
+ scope: tokenData.scope || tokenData.token_type || "netlify",
+ metadata: {
+ netlifyId: userData.id,
+ netlifyEmail: userData.email,
+ netlifyName: userData.full_name,
+ netlifyAvatarUrl: userData.avatar_url,
+ },
+ });
+
+ return NextResponse.redirect(
+ new URL("/projects?netlify=connected", request.url)
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "OAuth failed";
+ return NextResponse.redirect(
+ new URL(`/projects?error=${encodeURIComponent(message)}`, request.url)
+ );
+ }
+}
File: src/app/api/deploy/netlify/deploy/route.ts
Changes:
@@ -0,0 +1,160 @@
+import JSZip from "jszip";
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { getUser, getConvexClientWithAuth, getToken } from "@/lib/auth-server";
+import { filterFilesForDownload } from "@/lib/filter-ai-files";
+import { getNetlifyToml } from "@/lib/netlify-config";
+import { createNetlifyClient } from "@/lib/netlify-client";
+import { z } from "zod";
+
+const deployRequestSchema = z.object({
+ projectId: z.string(),
+ siteId: z.string().optional(),
+ deployType: z.enum(["preview", "production"]).optional(),
+ branch: z.string().optional(),
+ commitRef: z.string().optional(),
+});
+
+type DeployRequest = z.infer<typeof deployRequestSchema>;
+
+function normalizeDeploymentStatus(status: string): "pending" | "building" | "ready" | "error" {
+ const normalized = status.toLowerCase();
+ if (normalized === "idle" || normalized === "created") {
+ return "pending";
+ }
+ if (normalized === "building") {
+ return "building";
+ }
+ if (normalized === "ready" || normalized === "published") {
+ return "ready";
+ }
+ return "error";
+}
+
+type MessageWithFragment = {
+ _id: Id<"messages">;
+ _creationTime: number;
+ Fragment: {
+ _id: Id<"fragments">;
+ files?: unknown;
+ framework: "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+ } | null;
+};
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const normalizeFiles = (value: unknown): Record<string, string> => {
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ const files: Record<string, string> = {};
+ for (const [path, content] of Object.entries(value)) {
+ if (typeof content === "string") {
+ files[path] = content;
+ }
+ }
+ return files;
+};
+
+const getLatestFragmentFiles = async (projectId: Id<"projects">, token?: string) => {
+ const messages = await fetchQuery(api.messages.list, { projectId }, { token }) as MessageWithFragment[];
+ const latestWithFragment = [...messages].reverse().find((message) => message.Fragment);
+ const fragment = latestWithFragment?.Fragment;
+
+ if (!fragment) {
+ throw new Error("No AI-generated files are ready to deploy.");
+ }
+
+ const normalized = normalizeFiles(fragment.files);
+ const filtered = filterFilesForDownload(normalized);
+
+ if (Object.keys(filtered).length === 0) {
+ throw new Error("No AI-generated files are ready to deploy.");
+ }
+
+ return { files: filtered, framework: fragment.framework };
+};
+
+const getNetlifyAccessToken = async (token?: string): Promise<string> => {
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found. Please connect your Netlify account.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const token = (await getToken()) ?? undefined;
+ const bodyUnknown = await request.json();
+ const parseResult = deployRequestSchema.safeParse(bodyUnknown);
+ if (!parseResult.success) {
+ return NextResponse.json(
+ { error: "Invalid request body", details: parseResult.error.errors },
+ { status: 400 }
+ );
+ }
+ const body = parseResult.data;
+
+ const projectId = body.projectId as Id<"projects">;
+ const convex = await getConvexClientWithAuth();
+ const project = await convex.query(api.projects.get, { projectId });
+
+ const { files, framework } = await getLatestFragmentFiles(projectId, token);
+ const netlifyToml = getNetlifyToml(framework);
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken(token));
+
+ const zip = new JSZip();
+ for (const [filename, content] of Object.entries(files)) {
+ zip.file(filename, content);
+ }
+ zip.file("netlify.toml", netlifyToml);
+
+ const archive = await zip.generateAsync({ type: "arraybuffer" });
+ const archiveBlob = new Blob([archive], { type: "application/zip" });
+
+ const site =
+ body.siteId ? await netlifyClient.getSite(body.siteId) : await netlifyClient.createSite(project.name);
+
+ const deploy =
+ body.deployType === "preview"
+ ? await netlifyClient.createPreviewDeployment(site.id, archiveBlob)
+ : await netlifyClient.deploySite(site.id, archiveBlob);
+
+ await fetchMutation(api.deployments.createDeployment, {
+ projectId,
+ platform: "netlify",
+ siteId: site.id,
+ siteUrl: site.site_url || site.url,
+ deployId: deploy.id,
+ status: normalizeDeploymentStatus(deploy.state || "pending"),
+ isPreview: body.deployType === "preview",
+ branch: body.branch,
+ commitRef: body.commitRef,
+ });
+
+ return NextResponse.json({
+ siteId: site.id,
+ siteUrl: site.site_url || site.url,
+ deployId: deploy.id,
+ deployState: deploy.state,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Deployment failed";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/domains/route.ts
Changes:
@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+type DomainPayload = {
+ siteId: string;
+ domain: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ const domainId = searchParams.get("domainId");
+ if (!siteId) {
+ return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ if (domainId) {
+ const domain = await netlifyClient.verifyDomain(siteId, domainId);
+ return NextResponse.json(domain);
+ }
+
+ const domains = await netlifyClient.listDomains(siteId);
+ return NextResponse.json(domains);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch domains";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as DomainPayload;
+ if (!body.siteId || !body.domain) {
+ return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const domain = await netlifyClient.addDomain(body.siteId, body.domain);
+
+ return NextResponse.json(domain);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to add domain";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ const domainId = searchParams.get("domainId");
+ if (!siteId || !domainId) {
+ return NextResponse.json({ error: "Missing siteId or domainId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ await netlifyClient.deleteDomain(siteId, domainId);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to delete domain";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/env-vars/route.ts
Changes:
@@ -0,0 +1,135 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+type EnvVarPayload = {
+ siteId: string;
+ key: string;
+ value?: string;
+ context?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ if (!siteId) {
+ return NextResponse.json({ error: "Missing siteId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const envVars = await netlifyClient.getEnvVars(siteId);
+
+ const sanitizedEnvVars = Array.isArray(envVars) ? envVars.map((envVar) => {
+ const { values, ...rest } = envVar as { values?: unknown; [key: string]: unknown };
+ return rest;
+ }) : [];
+
+ return NextResponse.json(sanitizedEnvVars);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch env vars";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as EnvVarPayload;
+ if (!body.siteId || !body.key || body.value === undefined) {
+ return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const envVar = await netlifyClient.setEnvVar(
+ body.siteId,
+ body.key,
+ body.value,
+ body.context
+ );
+
+ return NextResponse.json(envVar);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to set env var";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function PUT(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as EnvVarPayload;
+ if (!body.siteId || !body.key || body.value === undefined) {
+ return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const envVar = await netlifyClient.updateEnvVar(
+ body.siteId,
+ body.key,
+ body.value,
+ body.context
+ );
+
+ return NextResponse.json(envVar);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to update env var";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const siteId = searchParams.get("siteId");
+ const key = searchParams.get("key");
+ if (!siteId || !key) {
+ return NextResponse.json({ error: "Missing siteId or key" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ await netlifyClient.deleteEnvVar(siteId, key);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to delete env var";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/logs/route.ts
Changes:
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const deployId = searchParams.get("deployId");
+ if (!deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const logs = await netlifyClient.getBuildLog(deployId);
+
+ return NextResponse.json({ logs });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch logs";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/preview/route.ts
Changes:
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function DELETE(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const deployId = searchParams.get("deployId");
+ if (!deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ await netlifyClient.deletePreviewDeployment(deployId);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to delete preview";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/rollback/route.ts
Changes:
@@ -0,0 +1,48 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+type RollbackPayload = {
+ deployId: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function POST(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const body = (await request.json()) as RollbackPayload;
+ if (!body.deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const rollback = await netlifyClient.rollbackDeployment(body.deployId);
+
+ return NextResponse.json(rollback);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Rollback failed";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/sites/route.ts
Changes:
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET() {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const sites = await netlifyClient.listSites();
+
+ return NextResponse.json(sites);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch sites";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/deploy/netlify/status/route.ts
Changes:
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+import { api } from "@/convex/_generated/api";
+import { getUser, getToken } from "@/lib/auth-server";
+import { createNetlifyClient } from "@/lib/netlify-client";
+
+type NetlifyConnection = {
+ accessToken?: string;
+};
+
+const getNetlifyAccessToken = async (): Promise<string> => {
+ const token = await getToken();
+ const connection = await fetchQuery(api.oauth.getConnection, {
+ provider: "netlify",
+ }, { token: token ?? undefined }) as NetlifyConnection | null;
+
+ if (!connection?.accessToken) {
+ throw new Error("Netlify connection not found.");
+ }
+
+ return connection.accessToken;
+};
+
+export async function GET(request: Request) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const deployId = searchParams.get("deployId");
+ if (!deployId) {
+ return NextResponse.json({ error: "Missing deployId" }, { status: 400 });
+ }
+
+ const netlifyClient = createNetlifyClient(await getNetlifyAccessToken());
+ const status = await netlifyClient.getDeploymentStatus(deployId);
+
+ return NextResponse.json(status);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fetch status";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/github/repositories/route.ts
Changes:
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { fetchQuery } from "convex/nextjs";
+
+import { api } from "@/convex/_generated/api";
+import { getUser } from "@/lib/auth-server";
+import { listRepositories } from "@/lib/github-api";
+
+export async function GET() {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const accessToken = await fetchQuery(api.oauth.getGithubAccessToken, {});
+ if (!accessToken) {
+ return NextResponse.json(
+ { error: "GitHub connection not found. Please connect GitHub." },
+ { status: 400 },
+ );
+ }
+
+ const repositories = await listRepositories(accessToken);
+
+ return NextResponse.json({
+ repositories: repositories.map((repo) => ({
+ id: repo.id,
+ name: repo.name,
+ fullName: repo.full_name,
+ url: repo.html_url,
+ isPrivate: repo.private,
+ defaultBranch: repo.default_branch ?? "main",
+ })),
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load repositories";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/app/api/projects/[projectId]/export/github/route.ts
Changes:
@@ -0,0 +1,129 @@
+import { NextResponse } from "next/server";
+import { fetchMutation, fetchQuery } from "convex/nextjs";
+import { z } from "zod";
+
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { getConvexClientWithAuth, getUser } from "@/lib/auth-server";
+import {
+ createRepository,
+ getRepository,
+ type CreateRepositoryInput,
+} from "@/lib/github-api";
+
+const exportRequestSchema = z
+ .object({
+ repositoryName: z.string().trim().min(1).optional(),
+ repositoryFullName: z.string().trim().min(1).optional(),
+ description: z.string().trim().optional(),
+ isPrivate: z.boolean().optional(),
+ branch: z.string().trim().optional(),
+ includeReadme: z.boolean().optional(),
+ includeGitignore: z.boolean().optional(),
+ commitMessage: z.string().trim().optional(),
+ })
+ .refine((data) => data.repositoryFullName || data.repositoryName, {
+ message: "Repository name is required.",
+ });
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ projectId: string }> },
+) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+ const body = exportRequestSchema.parse(await request.json());
+
+ // We don't need the access token here anymore since the export action handles it
+ // But we still need to check if the connection exists
+ const connection = await fetchQuery(api.oauth.getConnection, { provider: "github" }, { token: (await getToken()) ?? undefined });
+
+ if (!connection) {
+ return NextResponse.json(
+ { error: "GitHub connection not found. Please connect GitHub." },
+ { status: 400 },
+ );
+ }
+
+ let repository;
+ if (body.repositoryFullName) {
+ repository = await getRepository(accessToken, body.repositoryFullName);
+ } else {
+ if (!body.repositoryName) {
+ return NextResponse.json(
+ { error: "Repository name is required." },
+ { status: 400 },
+ );
+ }
+
+ const input: CreateRepositoryInput = {
+ name: body.repositoryName,
+ description: body.description,
+ isPrivate: body.isPrivate ?? false,
+ };
+ repository = await createRepository(accessToken, input);
+ }
+
+ const branch = body.branch ?? repository.default_branch ?? "main";
+
+ const exportId = await fetchMutation(api.githubExports.create, {
+ projectId: projectId as Id<"projects">,
+ repositoryName: repository.name,
+ repositoryUrl: repository.html_url,
+ repositoryFullName: repository.full_name,
+ branch,
+ });
+
+ const convex = await getConvexClientWithAuth();
+ const result = await convex.action(api.githubExports.exportToGitHub, {
+ exportId,
+ branch,
+ includeReadme: body.includeReadme,
+ includeGitignore: body.includeGitignore,
+ commitMessage: body.commitMessage,
+ });
+
+ return NextResponse.json(result);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Export failed";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ projectId: string }> },
+) {
+ try {
+ const user = await getUser();
+ if (!user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+ const { searchParams } = new URL(request.url);
+ const exportId = searchParams.get("exportId");
+
+ if (!exportId) {
+ return NextResponse.json({ error: "Missing exportId" }, { status: 400 });
+ }
+
+ const record = await fetchQuery(api.githubExports.get, {
+ exportId: exportId as Id<"githubExports">,
+ });
+
+ if (!record) {
+ return NextResponse.json({ error: "Export not found" }, { status: 404 });
+ }
+
+ return NextResponse.json(record);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load export";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
File: src/lib/github-api.ts
Changes:
@@ -0,0 +1,350 @@
+import { z } from "zod";
+
+const GITHUB_API_BASE_URL = "https://api.github.com";
+const GITHUB_API_VERSION = "2022-11-28";
+const MAX_TREE_CONTENT_BYTES = 100000;
+
+const githubErrorSchema = z.object({
+ message: z.string().optional(),
+});
+
+const githubUserSchema = z.object({
+ id: z.number(),
+ login: z.string(),
+ name: z.string().nullable().optional(),
+ email: z.string().nullable().optional(),
+ avatar_url: z.string().optional(),
+});
+
+const githubRepositorySchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ full_name: z.string(),
+ html_url: z.string(),
+ private: z.boolean(),
+ default_branch: z.string().optional(),
+});
+
+const githubRefSchema = z.object({
+ object: z.object({
+ sha: z.string(),
+ }),
+});
+
+const githubTreeSchema = z.object({
+ sha: z.string(),
+});
+
+const githubCommitSchema = z.object({
+ sha: z.string(),
+ tree: z.object({
+ sha: z.string(),
+ }),
+});
+
+type GitHubRequestOptions = {
+ method?: "GET" | "POST" | "PATCH" | "PUT";
+ body?: unknown;
+ headers?: Record<string, string>;
+};
+
+export type GitHubUser = z.infer<typeof githubUserSchema>;
+export type GitHubRepository = z.infer<typeof githubRepositorySchema>;
+
+export type GitHubTreeEntry = {
+ path: string;
+ mode: "100644";
+ type: "blob";
+ content: string;
+};
+
+export type ProjectFramework = "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE";
+
+export type CreateRepositoryInput = {
+ name: string;
+ description?: string;
+ isPrivate: boolean;
+};
+
+export type ExportReadmeInput = {
+ projectName: string;
+ framework: ProjectFramework;
+ description?: string;
+};
+
+const parseGitHubError = (payload: unknown, status: number): string => {
+ const parsed = githubErrorSchema.safeParse(payload);
+ if (parsed.success && parsed.data.message) {
+ return parsed.data.message;
+ }
+
+ return `GitHub API error (${status})`;
+};
+
+const githubRequest = async (
+ path: string,
+ accessToken: string,
+ options: GitHubRequestOptions = {},
+): Promise<unknown> => {
+ const response = await fetch(`${GITHUB_API_BASE_URL}${path}`, {
+ method: options.method ?? "GET",
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${accessToken}`,
+ "User-Agent": "ZapDev",
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ const payload = await response.json().catch(() => null);
+
+ if (!response.ok) {
+ throw new Error(parseGitHubError(payload, response.status));
+ }
+
+ return payload;
+};
+
+export const getAuthenticatedUser = async (
+ accessToken: string,
+): Promise<GitHubUser> => {
+ const payload = await githubRequest("/user", accessToken);
+ return githubUserSchema.parse(payload);
+};
+
+export const listRepositories = async (
+ accessToken: string,
+): Promise<Array<GitHubRepository>> => {
+ const payload = await githubRequest("/user/repos?per_page=100&sort=updated", accessToken);
+ return z.array(githubRepositorySchema).parse(payload);
+};
+
+export const getRepository = async (
+ accessToken: string,
+ fullName: string,
+): Promise<GitHubRepository> => {
+ const payload = await githubRequest(`/repos/${fullName}`, accessToken);
+ return githubRepositorySchema.parse(payload);
+};
+
+export const createRepository = async (
+ accessToken: string,
+ input: CreateRepositoryInput,
+): Promise<GitHubRepository> => {
+ const payload = await githubRequest("/user/repos", accessToken, {
+ method: "POST",
+ body: {
+ name: input.name,
+ description: input.description ?? "",
+ private: input.isPrivate,
+ auto_init: true,
+ },
+ });
+ return githubRepositorySchema.parse(payload);
+};
+
+export const getBranchRef = async (
+ accessToken: string,
+ fullName: string,
+ branch: string,
+): Promise<string> => {
+ const payload = await githubRequest(
+ `/repos/${fullName}/git/ref/heads/${branch}`,
+ accessToken,
+ );
+ return githubRefSchema.parse(payload).object.sha;
+};
+
+export const getCommitTreeSha = async (
+ accessToken: string,
+ fullName: string,
+ commitSha: string,
+): Promise<string> => {
+ const payload = await githubRequest(
+ `/repos/${fullName}/git/commits/${commitSha}`,
+ accessToken,
+ );
+ return githubCommitSchema.parse(payload).tree.sha;
+};
+
+export const createTree = async (
+ accessToken: string,
+ fullName: string,
+ tree: Array<GitHubTreeEntry>,
+ baseTreeSha?: string,
+): Promise<string> => {
+ const payload = await githubRequest(`/repos/${fullName}/git/trees`, accessToken, {
+ method: "POST",
+ body: {
+ base_tree: baseTreeSha,
+ tree,
+ },
+ });
+ return githubTreeSchema.parse(payload).sha;
+};
+
+export const createCommit = async (
+ accessToken: string,
+ fullName: string,
+ message: string,
+ treeSha: string,
+ parents: Array<string>,
+): Promise<string> => {
+ const payload = await githubRequest(`/repos/${fullName}/git/commits`, accessToken, {
+ method: "POST",
+ body: {
+ message,
+ tree: treeSha,
+ parents,
+ },
+ });
+ return githubCommitSchema.parse(payload).sha;
+};
+
+export const createBranchRef = async (
+ accessToken: string,
+ fullName: string,
+ branch: string,
+ commitSha: string,
+): Promise<void> => {
+ await githubRequest(`/repos/${fullName}/git/refs`, accessToken, {
+ method: "POST",
+ body: {
+ ref: `refs/heads/${branch}`,
+ sha: commitSha,
+ },
+ });
+};
+
+export const updateBranchRef = async (
+ accessToken: string,
+ fullName: string,
+ branch: string,
+ commitSha: string,
+): Promise<void> => {
+ await githubRequest(`/repos/${fullName}/git/refs/heads/${branch}`, accessToken, {
+ method: "PATCH",
+ body: {
+ sha: commitSha,
+ force: false,
+ },
+ });
+};
+
+const sanitizePath = (value: string): string => {
+ return value.replace(/^\/+/, "").replace(/\\/g, "/");
+};
+
+export const buildTreeEntries = (
+ files: Record<string, string>,
+): Array<GitHubTreeEntry> => {
+ const entries: Array<GitHubTreeEntry> = [];
+ const encoder = new TextEncoder();
+
+ for (const [rawPath, content] of Object.entries(files)) {
+ const path = sanitizePath(rawPath);
+ if (!path) {
+ continue;
+ }
+
+ const byteLength = encoder.encode(content).length;
+ if (byteLength > MAX_TREE_CONTENT_BYTES) {
+ throw new Error(`File too large for GitHub export: ${path}`);
+ }
+
+ entries.push({
+ path,
+ mode: "100644",
+ type: "blob",
+ content,
+ });
+ }
+
+ return entries;
+};
+
+const getFrameworkLabel = (framework: ProjectFramework): string => {
+ switch (framework) {
+ case "NEXTJS":
+ return "Next.js";
+ case "ANGULAR":
+ return "Angular";
+ case "REACT":
+ return "React";
+ case "VUE":
+ return "Vue";
+ case "SVELTE":
+ return "Svelte";
+ default:
+ return framework;
+ }
+};
+
+export const generateReadme = (input: ExportReadmeInput): string => {
+ const frameworkLabel = getFrameworkLabel(input.framework);
+
+ const lines: Array<string> = [`# ${input.projectName}`, ""];
+
+ if (input.description) {
+ lines.push(input.description, "");
+ }
+
+ lines.push(
+ "Exported from ZapDev.",
+ "",
+ `Framework: ${frameworkLabel}`,
+ "",
+ "## Getting Started",
+ "",
+ "1. Install dependencies with `bun install`.",
+ "2. Start the dev server with `bun run dev`.",
+ "3. Build for production with `bun run build`.",
+ );
+
+ return lines.join("\n");
+};
+
+export const generateGitignore = (framework: ProjectFramework): string => {
+ const base = [
+ "node_modules",
+ ".env",
+ ".env.local",
+ ".env.*.local",
+ "dist",
+ "build",
+ ".cache",
+ ".DS_Store",
+ ];
+
+ const frameworkSpecific: Record<ProjectFramework, Array<string>> = {
+ NEXTJS: [".next", "out", "next-env.d.ts"],
+ REACT: ["coverage"],
+ VUE: ["dist", ".vite"],
+ ANGULAR: [".angular", "dist"],
+ SVELTE: [".svelte-kit"],
+ };
+
+ const entries = [...base, ...frameworkSpecific[framework]];
+ return entries.join("\n");
+};
+
+export const withDefaultFiles = (
+ files: Record<string, string>,
+ input: ExportReadmeInput,
+ includeReadme: boolean,
+ includeGitignore: boolean,
+): Record<string, string> => {
+ const updated: Record<string, string> = { ...files };
+
+ if (includeReadme && !updated["README.md"]) {
+ updated["README.md"] = generateReadme(input);
+ }
+
+ if (includeGitignore && !updated[".gitignore"]) {
+ updated[".gitignore"] = generateGitignore(input.framework);
+ }
+
+ return updated;
+};
File: src/lib/netlify-client.ts
Changes:
@@ -0,0 +1,238 @@
+type NetlifyRequestOptions = {
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+ headers?: Record<string, string>;
+ body?: BodyInit | null;
+};
+
+type NetlifySite = {
+ id: string;
+ name: string;
+ url: string;
+ site_url: string;
+ admin_url?: string;
+};
+
+type NetlifyDeploy = {
+ id: string;
+ state: string;
+ url?: string;
+ deploy_url?: string;
+ created_at?: string;
+ updated_at?: string;
+};
+
+type NetlifyEnvVar = {
+ key: string;
+ values?: Array<{
+ value: string;
+ context?: string;
+ }>;
+};
+
+type NetlifyDomain = {
+ id: string;
+ name: string;
+ ssl_status?: string;
+ verification?: {
+ status?: string;
+ };
+};
+
+const NETLIFY_API_BASE = "https://api.netlify.com/api/v1";
+
+const parseJson = async <T>(response: Response): Promise<T> => {
+ const text = await response.text();
+ if (!text) {
+ return {} as T;
+ }
+ return JSON.parse(text) as T;
+};
+
+const handleApiError = async (response: Response) => {
+ if (response.status === 429) {
+ const retryAfter = response.headers.get("retry-after");
+ throw new Error(`Netlify rate limit hit. Retry after ${retryAfter ?? "unknown"} seconds.`);
+ }
+
+ const errorBody = await response.text();
+ throw new Error(errorBody || `Netlify API error: ${response.status}`);
+};
+
+export const createNetlifyClient = (accessToken: string) => {
+ const request = async <T>(path: string, options: NetlifyRequestOptions = {}) => {
+ const response = await fetch(`${NETLIFY_API_BASE}${path}`, {
+ method: options.method ?? "GET",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ?? null,
+ });
+
+ if (!response.ok) {
+ await handleApiError(response);
+ }
+
+ return parseJson<T>(response);
+ };
+
+ return {
+ async createSite(name?: string): Promise<NetlifySite> {
+ return request<NetlifySite>("/sites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(name ? { name } : {}),
+ });
+ },
+
+ async getSite(siteId: string): Promise<NetlifySite> {
+ return request<NetlifySite>(`/sites/${siteId}`);
+ },
+
+ async listSites(): Promise<NetlifySite[]> {
+ return request<NetlifySite[]>("/sites");
+ },
+
+ async updateSite(siteId: string, payload: Record<string, unknown>): Promise<NetlifySite> {
+ return request<NetlifySite>(`/sites/${siteId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ },
+
+ async deleteSite(siteId: string): Promise<void> {
+ await request<void>(`/sites/${siteId}`, { method: "DELETE" });
+ },
+
+ async deploySite(siteId: string, zipBody: BodyInit, options?: { draft?: boolean }): Promise<NetlifyDeploy> {
+ const params = new URLSearchParams();
+ if (options?.draft) {
+ params.set("draft", "true");
+ }
+
+ const query = params.toString();
+ const path = query ? `/sites/${siteId}/deploys?${query}` : `/sites/${siteId}/deploys`;
+
+ return request<NetlifyDeploy>(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/zip" },
+ body: zipBody,
+ });
+ },
+
+ async getDeploymentStatus(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}`);
+ },
+
+ async listDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+ return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys`);
+ },
+
+ async getDeployment(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}`);
+ },
+
+ async cancelDeployment(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}/cancel`, { method: "POST" });
+ },
+
+ async rollbackDeployment(deployId: string): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/deploys/${deployId}/rollback`, { method: "POST" });
+ },
+
+ async getBuildLog(deployId: string): Promise<string> {
+ const response = await fetch(`${NETLIFY_API_BASE}/deploys/${deployId}/logs`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!response.ok) {
+ await handleApiError(response);
+ }
+
+ return response.text();
+ },
+
+ async getEnvVars(siteId: string): Promise<NetlifyEnvVar[]> {
+ return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`);
+ },
+
+ async setEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+ return request<NetlifyEnvVar>(`/sites/${siteId}/env`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ key,
+ values: [{ value, context }],
+ }),
+ });
+ },
+
+ async updateEnvVar(siteId: string, key: string, value: string, context = "all"): Promise<NetlifyEnvVar> {
+ return request<NetlifyEnvVar>(`/sites/${siteId}/env/${encodeURIComponent(key)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ values: [{ value, context }],
+ }),
+ });
+ },
+
+ async deleteEnvVar(siteId: string, key: string): Promise<void> {
+ await request<void>(`/sites/${siteId}/env/${encodeURIComponent(key)}`, { method: "DELETE" });
+ },
+
+ async setBulkEnvVars(siteId: string, vars: Array<{ key: string; value: string; context?: string }>): Promise<NetlifyEnvVar[]> {
+ const payload = vars.map((entry) => ({
+ key: entry.key,
+ values: [{ value: entry.value, context: entry.context ?? "all" }],
+ }));
+
+ return request<NetlifyEnvVar[]>(`/sites/${siteId}/env`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ },
+
+ async listDomains(siteId: string): Promise<NetlifyDomain[]> {
+ return request<NetlifyDomain[]>(`/sites/${siteId}/domains`);
+ },
+
+ async addDomain(siteId: string, domain: string): Promise<NetlifyDomain> {
+ return request<NetlifyDomain>(`/sites/${siteId}/domains`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: domain }),
+ });
+ },
+
+ async deleteDomain(siteId: string, domainId: string): Promise<void> {
+ await request<void>(`/sites/${siteId}/domains/${domainId}`, { method: "DELETE" });
+ },
+
+ async verifyDomain(siteId: string, domainId: string): Promise<NetlifyDomain> {
+ return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+ },
+
+ async getDnsRecords(siteId: string, domainId: string): Promise<NetlifyDomain> {
+ return request<NetlifyDomain>(`/sites/${siteId}/domains/${domainId}`);
+ },
+
+ async createPreviewDeployment(siteId: string, zipBody: BodyInit): Promise<NetlifyDeploy> {
+ return request<NetlifyDeploy>(`/sites/${siteId}/deploys?draft=true`, {
+ method: "POST",
+ headers: { "Content-Type": "application/zip" },
+ body: zipBody,
+ });
+ },
+
+ async listPreviewDeployments(siteId: string): Promise<NetlifyDeploy[]> {
+ return request<NetlifyDeploy[]>(`/sites/${siteId}/deploys?draft=true`);
+ },
+
+ async deletePreviewDeployment(deployId: string): Promise<void> {
+ await request<void>(`/deploys/${deployId}`, { method: "DELETE" });
+ },
+ };
+};
File: src/lib/netlify-config.ts
Changes:
@@ -0,0 +1,70 @@
+type FrameworkKey = "NEXTJS" | "REACT" | "VUE" | "ANGULAR" | "SVELTE";
+
+type NetlifyConfig = {
+ buildCommand: string;
+ publishDir: string;
+ plugins?: Array<string>;
+ env?: Record<string, string>;
+};
+
+const frameworkConfigMap: Record<FrameworkKey, NetlifyConfig> = {
+ NEXTJS: {
+ buildCommand: "bun run build",
+ publishDir: ".next",
+ plugins: ["@netlify/plugin-nextjs"],
+ },
+ REACT: {
+ buildCommand: "bun run build",
+ publishDir: "dist",
+ },
+ VUE: {
+ buildCommand: "bun run build",
+ publishDir: "dist",
+ },
+ ANGULAR: {
+ buildCommand: "bun run build",
+ publishDir: "dist",
+ },
+ SVELTE: {
+ buildCommand: "bun run build",
+ publishDir: "build",
+ },
+};
+
+const formatEnvBlock = (env?: Record<string, string>) => {
+ if (!env || Object.keys(env).length === 0) {
+ return "";
+ }
+
+ const lines = Object.entries(env).map(([key, value]) => ` ${key} = "${value}"`);
+ return `\n[build.environment]\n${lines.join("\n")}\n`;
+};
+
+export const getNetlifyToml = (framework: FrameworkKey) => {
+ const config = frameworkConfigMap[framework];
+ const pluginsBlock = (config.plugins ?? [])
+ .map((plugin) => `[[plugins]]\n package = "${plugin}"`)
+ .join("\n\n");
+ const envBlock = formatEnvBlock(config.env);
+
+ return [
+ "[build]",
+ ` command = "${config.buildCommand}"`,
+ ` publish = "${config.publishDir}"`,
+ pluginsBlock.trimEnd(),
+ envBlock.trimEnd(),
+ ]
+ .filter((line) => line.length > 0)
+ .join("\n")
+ .trim()
+ .concat("\n");
+};
+
+export const getNetlifyBuildSettings = (framework: FrameworkKey) => {
+ const config = frameworkConfigMap[framework];
+ return {
+ buildCommand: config.buildCommand,
+ publishDir: config.publishDir,
+ plugins: config.plugins ?? [],
+ };
+};
File: src/lib/payment-provider.ts
Changes:
@@ -0,0 +1,232 @@
+export type BillingInterval = "monthly" | "yearly";
+
+export type SubscriptionStatus =
+ | "active"
+ | "trialing"
+ | "past_due"
+ | "canceled"
+ | "unpaid";
+
+export interface CheckoutSessionRequest {
+ customerId: string;
+ productId: string;
+ successUrl: string;
+ cancelUrl: string;
+ metadata?: Record<string, string>;
+}
+
+export interface CheckoutSession {
+ id: string;
+ url: string;
+}
+
+export interface SubscriptionLookup {
+ subscriptionId: string;
+}
+
+export interface SubscriptionSummary {
+ id: string;
+ customerId: string;
+ productId: string;
+ status: SubscriptionStatus;
+ interval: BillingInterval;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+}
+
+export interface CancelSubscriptionRequest {
+ subscriptionId: string;
+ cancelAtPeriodEnd?: boolean;
+}
+
+export interface UpdateSubscriptionRequest {
+ subscriptionId: string;
+ productId: string;
+}
+
+export interface BillingPortalRequest {
+ customerId: string;
+ returnUrl: string;
+}
+
+export interface UsageEvent {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export interface FeatureCheckRequest {
+ customerId: string;
+ featureId: string;
+}
+
+export interface FeatureCheckResult {
+ allowed: boolean;
+ limit?: number;
+ used?: number;
+ remaining?: number;
+}
+
+export interface PaymentProvider {
+ createCheckoutSession(input: CheckoutSessionRequest): Promise<CheckoutSession>;
+ getSubscription(input: SubscriptionLookup): Promise<SubscriptionSummary | null>;
+ updateSubscription(input: UpdateSubscriptionRequest): Promise<SubscriptionSummary>;
+ cancelSubscription(input: CancelSubscriptionRequest): Promise<SubscriptionSummary>;
+ createBillingPortalSession(input: BillingPortalRequest): Promise<{ url: string }>;
+ trackUsage(input: UsageEvent): Promise<void>;
+ checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult>;
+}
+
+interface AutumnConfig {
+ apiKey: string;
+ baseUrl?: string;
+}
+
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export class AutumnStripeProvider implements PaymentProvider {
+ private apiKey: string;
+ private baseUrl: string;
+
+ constructor(config: AutumnConfig) {
+ this.apiKey = config.apiKey;
+ this.baseUrl = config.baseUrl ?? "https://api.useautumn.com";
+ }
+
+ async createCheckoutSession(
+ input: CheckoutSessionRequest
+ ): Promise<CheckoutSession> {
+ return this.request<CheckoutSession>("/v1/checkout", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ productId: input.productId,
+ successUrl: input.successUrl,
+ cancelUrl: input.cancelUrl,
+ metadata: input.metadata,
+ },
+ });
+ }
+
+ async getSubscription(
+ input: SubscriptionLookup
+ ): Promise<SubscriptionSummary | null> {
+ const url = `${this.baseUrl}/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`;
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.apiKey}`,
+ },
+ });
+
+ if (response.status === 404) {
+ return null;
+ }
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
+ );
+ }
+
+ if (response.status === 204) {
+ return null;
+ }
+
+ return (await response.json()) as SubscriptionSummary;
+ }
+
+ async updateSubscription(
+ input: UpdateSubscriptionRequest
+ ): Promise<SubscriptionSummary> {
+ return this.request<SubscriptionSummary>(
+ `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}`,
+ {
+ method: "PATCH",
+ body: {
+ productId: input.productId,
+ },
+ }
+ );
+ }
+
+ async cancelSubscription(
+ input: CancelSubscriptionRequest
+ ): Promise<SubscriptionSummary> {
+ return this.request<SubscriptionSummary>(
+ `/v1/subscriptions/${encodeURIComponent(input.subscriptionId)}/cancel`,
+ {
+ method: "POST",
+ body: {
+ cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? true,
+ },
+ }
+ );
+ }
+
+ async createBillingPortalSession(
+ input: BillingPortalRequest
+ ): Promise<{ url: string }> {
+ return this.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ returnUrl: input.returnUrl,
+ },
+ });
+ }
+
+ async trackUsage(input: UsageEvent): Promise<void> {
+ await this.request<{ ok: boolean }>("/v1/usage", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ meterId: input.meterId,
+ quantity: input.quantity,
+ },
+ });
+ }
+
+ async checkFeature(input: FeatureCheckRequest): Promise<FeatureCheckResult> {
+ return this.request<FeatureCheckResult>("/v1/features/check", {
+ method: "POST",
+ body: {
+ customerId: input.customerId,
+ featureId: input.featureId,
+ },
+ });
+ }
+
+ private async request<T>(
+ path: string,
+ options: AutumnRequestOptions
+ ): Promise<T> {
+ const url = `${this.baseUrl}${path}`;
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${this.apiKey}`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}`
+ );
+ }
+
+ if (response.status === 204) {
+ return null as T;
+ }
+
+ return (await response.json()) as T;
+ }
+}
File: src/lib/payment-templates/angular.ts
Changes:
@@ -0,0 +1,428 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const angularPaymentTemplate: PaymentTemplateBundle = {
+ framework: "angular",
+ description: "Angular payment integration with Autumn + Stripe",
+ files: {
+ "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+
+ const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ };
+
+ return { request };
+};
+`,
+ "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+ try {
+ if (!isCheckoutRequest(req.body)) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body: req.body,
+ });
+ res.json(checkout);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+ try {
+ const { customerId, returnUrl } = req.body as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!customerId || !returnUrl) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const portal = await autumn.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: { customerId, returnUrl },
+ });
+ res.json(portal);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+ try {
+ const { subscriptionId, productId } = req.body as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!subscriptionId || !productId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const updated = await autumn.request<unknown>(
+ `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
+ {
+ method: "PATCH",
+ body: { productId },
+ }
+ );
+ res.json(updated);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+ try {
+ const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!subscriptionId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const canceled = await autumn.request<unknown>(
+ `/v1/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+ }
+ );
+ res.json(canceled);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+ try {
+ const { customerId, featureId } = req.body as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!customerId || !featureId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const result = await autumn.request<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId, featureId },
+ });
+ res.json(result);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+ try {
+ const { customerId, meterId, quantity } = req.body as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!customerId || !meterId || typeof quantity !== "number") {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ await autumn.request("/v1/usage", {
+ method: "POST",
+ body: { customerId, meterId, quantity },
+ });
+ res.json({ ok: true });
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+export default router;
+`,
+ "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ res.status(500).json({ error: "Missing webhook secret" });
+ return;
+ }
+ const signature = req.headers["autumn-signature"];
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+ const rawBody = (req as any).rawBody;
+ if (!rawBody || !verifySignature(signatureValue, rawBody, secret)) {
+ res.status(401).json({ error: "Invalid signature" });
+ return;
+ }
+ try {
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ res.json({ received: true });
+ } catch (err) {
+ res.status(400).json({ error: "Invalid JSON" });
+ }
+});
+
+export default router;
+`,
+ "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json({
+ verify: (req: any, res, buf) => {
+ req.rawBody = buf.toString();
+ }
+}));
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+ console.log(\`Billing API listening on \${port}\`);
+});
+`,
+ "src/app/services/billing.service.ts": `
+import { Injectable } from "@angular/core";
+
+interface CheckoutPayload {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+}
+
+@Injectable({ providedIn: "root" })
+export class BillingService {
+ async startCheckout(payload: CheckoutPayload): Promise<void> {
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Checkout failed");
+ }
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } catch (error) {
+ alert(error instanceof Error ? error.message : "Checkout failed");
+ }
+ }
+
+ async checkFeature(customerId: string, featureId: string): Promise<boolean> {
+ const response = await fetch("/api/billing/feature-check", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ customerId, featureId }),
+ });
+ const data = (await response.json()) as { allowed?: boolean };
+ return data.allowed === true;
+ }
+
+ async trackUsage(customerId: string, meterId: string, quantity: number): Promise<void> {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ customerId, meterId, quantity }),
+ });
+ }
+}
+`,
+ "src/app/guards/feature.guard.ts": `
+import { Injectable } from "@angular/core";
+import type { CanActivateFn, ActivatedRouteSnapshot } from "@angular/router";
+import { BillingService } from "../services/billing.service";
+
+@Injectable({ providedIn: "root" })
+export class FeatureGuard {
+ constructor(private billingService: BillingService) {}
+
+ canActivate: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
+ const featureId = route.data?.["featureId"];
+ const customerId = route.data?.["customerId"];
+ if (typeof featureId !== "string" || typeof customerId !== "string") {
+ return false;
+ }
+ return this.billingService.checkFeature(customerId, featureId);
+ };
+}
+`,
+ "src/app/components/checkout-button/checkout-button.component.ts": `
+import { Component, Input } from "@angular/core";
+import { CommonModule } from "@angular/common";
+import { BillingService } from "../../services/billing.service";
+
+@Component({
+ selector: "app-checkout-button",
+ standalone: true,
+ imports: [CommonModule],
+ template: \`
+ <button
+ type="button"
+ class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ [disabled]="loading"
+ (click)="startCheckout()"
+ >
+ {{ loading ? "Redirecting..." : (label ?? "Upgrade") }}
+ </button>
+ \`,
+})
+export class CheckoutButtonComponent {
+ @Input({ required: true }) productId = "";
+ @Input({ required: true }) customerId = "";
+ @Input({ required: true }) successUrl = "";
+ @Input({ required: true }) cancelUrl = "";
+ @Input() label?: string;
+
+ loading = false;
+
+ constructor(private billingService: BillingService) {}
+
+ async startCheckout() {
+ this.loading = true;
+ try {
+ await this.billingService.startCheckout({
+ productId: this.productId,
+ customerId: this.customerId,
+ successUrl: this.successUrl,
+ cancelUrl: this.cancelUrl,
+ });
+ } finally {
+ this.loading = false;
+ }
+ }
+}
+`,
+ "src/app/components/billing-success/billing-success.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+ selector: "app-billing-success",
+ standalone: true,
+ imports: [CommonModule],
+ template: \`
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Payment successful</h1>
+ <p class="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+ Return to app
+ </a>
+ </div>
+ \`,
+})
+export class BillingSuccessComponent {}
+`,
+ "src/app/components/billing-cancel/billing-cancel.component.ts": `
+import { Component } from "@angular/core";
+import { CommonModule } from "@angular/common";
+
+@Component({
+ selector: "app-billing-cancel",
+ standalone: true,
+ imports: [CommonModule],
+ template: \`
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+ <p class="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+ Return to app
+ </a>
+ </div>
+ \`,
+})
+export class BillingCancelComponent {}
+`,
+ },
+};
File: src/lib/payment-templates/autumn-config.ts
Changes:
@@ -0,0 +1,49 @@
+export const autumnConfigTemplate = `
+export const autumnConfig = {
+ products: [
+ {
+ id: "free",
+ name: "Free",
+ description: "Starter access",
+ prices: [
+ {
+ id: "free-monthly",
+ amount: 0,
+ currency: "usd",
+ interval: "monthly",
+ },
+ ],
+ features: ["basic_generations"],
+ },
+ {
+ id: "pro",
+ name: "Pro",
+ description: "Pro plan with higher limits",
+ prices: [
+ {
+ id: "pro-monthly",
+ amount: 2900,
+ currency: "usd",
+ interval: "monthly",
+ },
+ ],
+ features: ["basic_generations", "priority_generations"],
+ },
+ ],
+ features: {
+ basic_generations: {
+ type: "metered",
+ meterId: "generations",
+ included: 5,
+ },
+ priority_generations: {
+ type: "boolean",
+ },
+ },
+ meters: {
+ generations: {
+ unit: "generation",
+ },
+ },
+} as const;
+`;
File: src/lib/payment-templates/env-example.ts
Changes:
@@ -0,0 +1,11 @@
+import { sanitizeAnyForDatabase } from "@/lib/utils";
+
+export const paymentEnvExample = sanitizeAnyForDatabase(`
+# Autumn + Stripe (user app billing)
+AUTUMN_API_KEY=""
+AUTUMN_API_BASE_URL="https://api.useautumn.com"
+AUTUMN_WEBHOOK_SECRET=""
+STRIPE_SECRET_KEY=""
+STRIPE_PUBLISHABLE_KEY=""
+NEXT_PUBLIC_APP_URL="http://localhost:3000"
+`);
File: src/lib/payment-templates/index.ts
Changes:
@@ -0,0 +1,24 @@
+import { angularPaymentTemplate } from "./angular";
+import { nextjsPaymentTemplate } from "./nextjs";
+import { reactPaymentTemplate } from "./react";
+import { sveltePaymentTemplate } from "./svelte";
+import { vuePaymentTemplate } from "./vue";
+import type { PaymentFramework, PaymentTemplateBundle } from "./types";
+import { autumnConfigTemplate } from "./autumn-config";
+import { paymentEnvExample } from "./env-example";
+
+const templates: Record<PaymentFramework, PaymentTemplateBundle> = {
+ nextjs: nextjsPaymentTemplate,
+ react: reactPaymentTemplate,
+ vue: vuePaymentTemplate,
+ angular: angularPaymentTemplate,
+ svelte: sveltePaymentTemplate,
+};
+
+export const paymentTemplates = templates;
+export { autumnConfigTemplate, paymentEnvExample };
+export type { PaymentFramework, PaymentTemplateBundle };
+
+export const getPaymentTemplate = (
+ framework: PaymentFramework
+): PaymentTemplateBundle => templates[framework];
File: src/lib/payment-templates/nextjs.ts
Changes:
@@ -0,0 +1,468 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const nextjsPaymentTemplate: PaymentTemplateBundle = {
+ framework: "nextjs",
+ description: "Next.js App Router payment integration with Autumn + Stripe",
+ files: {
+ "lib/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+ return { apiKey, baseUrl };
+};
+
+export async function autumnRequest<T>(
+ path: string,
+ options: AutumnRequestOptions
+): Promise<T> {
+ const { apiKey, baseUrl } = getAutumnConfig();
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+}
+`,
+ "app/api/billing/checkout/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isCheckoutRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const checkout = await autumnRequest<{ url: string; id: string }>(
+ "/v1/checkout",
+ {
+ method: "POST",
+ body: {
+ productId: body.productId,
+ customerId: body.customerId,
+ successUrl: body.successUrl,
+ cancelUrl: body.cancelUrl,
+ },
+ }
+ );
+
+ return NextResponse.json(checkout);
+}
+`,
+ "app/api/billing/portal/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type PortalRequest = {
+ customerId: string;
+ returnUrl: string;
+};
+
+const isPortalRequest = (value: unknown): value is PortalRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return typeof data.customerId === "string" && typeof data.returnUrl === "string";
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isPortalRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ returnUrl: body.returnUrl,
+ },
+ });
+
+ return NextResponse.json(portal);
+}
+`,
+ "app/api/billing/subscription/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UpdateRequest = {
+ subscriptionId: string;
+ productId: string;
+};
+
+type CancelRequest = {
+ subscriptionId: string;
+ cancelAtPeriodEnd?: boolean;
+};
+
+const isUpdateRequest = (value: unknown): value is UpdateRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.subscriptionId === "string" &&
+ typeof data.productId === "string"
+ );
+};
+
+const isCancelRequest = (value: unknown): value is CancelRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.subscriptionId === "string" &&
+ (data.cancelAtPeriodEnd === undefined || typeof data.cancelAtPeriodEnd === "boolean")
+ );
+};
+
+export async function GET(req: Request) {
+ const { searchParams } = new URL(req.url);
+ const subscriptionId = searchParams.get("subscriptionId");
+
+ if (!subscriptionId) {
+ return NextResponse.json({ error: "subscriptionId is required" }, { status: 400 });
+ }
+
+ const subscription = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ { method: "GET" }
+ );
+
+ return NextResponse.json(subscription);
+}
+
+export async function PATCH(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isUpdateRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const updated = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId: body.productId },
+ }
+ );
+
+ return NextResponse.json(updated);
+}
+
+export async function DELETE(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isCancelRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const canceled = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+ }
+ );
+
+ return NextResponse.json(canceled);
+}
+`,
+ "app/api/billing/usage/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type UsageRequest = {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+};
+
+const isUsageRequest = (value: unknown): value is UsageRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.customerId === "string" &&
+ typeof data.meterId === "string" &&
+ typeof data.quantity === "number"
+ );
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isUsageRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ await autumnRequest("/v1/usage", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ meterId: body.meterId,
+ quantity: body.quantity,
+ },
+ });
+
+ return NextResponse.json({ ok: true });
+}
+`,
+ "app/api/billing/feature-check/route.ts": `
+import { NextResponse } from "next/server";
+import { autumnRequest } from "@/lib/autumn-client";
+
+type FeatureCheckRequest = {
+ customerId: string;
+ featureId: string;
+};
+
+const isFeatureCheckRequest = (
+ value: unknown
+): value is FeatureCheckRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.customerId === "string" && typeof data.featureId === "string"
+ );
+};
+
+export async function POST(req: Request) {
+ const body = (await req.json()) as unknown;
+ if (!isFeatureCheckRequest(body)) {
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ const result = await autumnRequest<unknown>("/v1/features/check", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ featureId: body.featureId,
+ },
+ });
+
+ return NextResponse.json(result);
+}
+`,
+ "app/api/webhooks/autumn/route.ts": `
+import { NextResponse } from "next/server";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+export const runtime = "nodejs";
+
+const verifySignature = (
+ signature: string,
+ payload: string,
+ secret: string
+): boolean => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export async function POST(req: Request) {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ return NextResponse.json({ error: "Missing webhook secret" }, { status: 500 });
+ }
+
+ const signature = req.headers.get("autumn-signature") ?? "";
+ const rawBody = await req.text();
+
+ if (!verifySignature(signature, rawBody, secret)) {
+ return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
+ }
+
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled": {
+ break;
+ }
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded": {
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ return NextResponse.json({ received: true });
+}
+`,
+ "components/billing/checkout-button.tsx": `
+"use client";
+
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+ label?: string;
+}
+
+export function CheckoutButton({
+ productId,
+ customerId,
+ successUrl,
+ cancelUrl,
+ label = "Upgrade",
+}: CheckoutButtonProps) {
+ const [loading, setLoading] = useState(false);
+
+ const startCheckout = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ productId,
+ customerId,
+ successUrl,
+ cancelUrl,
+ }),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Checkout failed");
+ }
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } catch (error) {
+ alert(error instanceof Error ? error.message : "Checkout failed");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <button
+ type="button"
+ onClick={startCheckout}
+ disabled={loading}
+ className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ >
+ {loading ? "Redirecting..." : label}
+ </button>
+ );
+}
+`,
+ "components/billing/feature-gate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+ allowed: boolean;
+ fallback?: ReactNode;
+ children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+ if (!allowed) {
+ return <>{fallback ?? null}</>;
+ }
+ return <>{children}</>;
+}
+`,
+ "lib/usage.ts": `
+interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+ const response = await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Failed to track usage");
+ }
+}
+`,
+ "app/billing/success/page.tsx": `
+export default function BillingSuccessPage() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Payment successful</h1>
+ <p className="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ "app/billing/cancel/page.tsx": `
+export default function BillingCancelPage() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+ <p className="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ },
+};
File: src/lib/payment-templates/react.ts
Changes:
@@ -0,0 +1,390 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const reactPaymentTemplate: PaymentTemplateBundle = {
+ framework: "react",
+ description: "React (Vite) payment integration with Autumn + Stripe",
+ files: {
+ "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+
+ const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ };
+
+ return { request };
+};
+`,
+ "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+ try {
+ if (!isCheckoutRequest(req.body)) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body: req.body,
+ });
+ res.json(checkout);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+ try {
+ const { customerId, returnUrl } = req.body as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!customerId || !returnUrl) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const portal = await autumn.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: { customerId, returnUrl },
+ });
+ res.json(portal);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+ try {
+ const { subscriptionId, productId } = req.body as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!subscriptionId || !productId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const updated = await autumn.request<unknown>(
+ `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`,
+ {
+ method: "PATCH",
+ body: { productId },
+ }
+ );
+ res.json(updated);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+ try {
+ const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!subscriptionId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const canceled = await autumn.request<unknown>(
+ `/v1/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+ }
+ );
+ res.json(canceled);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+ try {
+ const { customerId, featureId } = req.body as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!customerId || !featureId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const result = await autumn.request<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId, featureId },
+ });
+ res.json(result);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+ try {
+ const { customerId, meterId, quantity } = req.body as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!customerId || !meterId || typeof quantity !== "number") {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ await autumn.request("/v1/usage", {
+ method: "POST",
+ body: { customerId, meterId, quantity },
+ });
+ res.json({ ok: true });
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+export default router;
+`,
+ "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", async (req: Request, res: Response) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ res.status(500).json({ error: "Missing webhook secret" });
+ return;
+ }
+ const signature = req.headers["autumn-signature"];
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+ const rawBody = (req as any).rawBody;
+ if (!rawBody || !verifySignature(signatureValue, rawBody, secret)) {
+ res.status(401).json({ error: "Invalid signature" });
+ return;
+ }
+ try {
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ res.json({ received: true });
+ } catch (err) {
+ res.status(400).json({ error: "Invalid JSON" });
+ }
+});
+
+export default router;
+`,
+ "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json({
+ verify: (req: any, res, buf) => {
+ req.rawBody = buf.toString();
+ }
+}));
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+ console.log(\`Billing API listening on \${port}\`);
+});
+`,
+ "src/components/CheckoutButton.tsx": `
+import { useState } from "react";
+
+interface CheckoutButtonProps {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+ label?: string;
+}
+
+export function CheckoutButton({
+ productId,
+ customerId,
+ successUrl,
+ cancelUrl,
+ label = "Upgrade",
+}: CheckoutButtonProps) {
+ const [loading, setLoading] = useState(false);
+
+ const startCheckout = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Checkout failed");
+ }
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } catch (error) {
+ alert(error instanceof Error ? error.message : "Checkout failed");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <button
+ type="button"
+ onClick={startCheckout}
+ disabled={loading}
+ className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ >
+ {loading ? "Redirecting..." : label}
+ </button>
+ );
+}
+`,
+ "src/components/FeatureGate.tsx": `
+import type { ReactNode } from "react";
+
+interface FeatureGateProps {
+ allowed: boolean;
+ fallback?: ReactNode;
+ children: ReactNode;
+}
+
+export function FeatureGate({ allowed, fallback, children }: FeatureGateProps) {
+ if (!allowed) {
+ return <>{fallback ?? null}</>;
+ }
+ return <>{children}</>;
+}
+`,
+ "src/lib/usage.ts": `
+interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export async function trackUsage(payload: UsagePayload): Promise<void> {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+}
+`,
+ "src/pages/BillingSuccess.tsx": `
+export function BillingSuccess() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Payment successful</h1>
+ <p className="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ "src/pages/BillingCancel.tsx": `
+export function BillingCancel() {
+ return (
+ <div className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 className="text-3xl font-semibold">Checkout canceled</h1>
+ <p className="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a
+ href="/"
+ className="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2"
+ >
+ Return to app
+ </a>
+ </div>
+ );
+}
+`,
+ },
+};
File: src/lib/payment-templates/svelte.ts
Changes:
@@ -0,0 +1,337 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const sveltePaymentTemplate: PaymentTemplateBundle = {
+ framework: "svelte",
+ description: "SvelteKit payment integration with Autumn + Stripe",
+ files: {
+ "src/lib/server/autumn.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+const getAutumnConfig = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+ return { apiKey, baseUrl };
+};
+
+export const autumnRequest = async <T>(
+ path: string,
+ options: AutumnRequestOptions
+): Promise<T> => {
+ const { apiKey, baseUrl } = getAutumnConfig();
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+};
+`,
+ "src/routes/api/billing/checkout/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as unknown;
+ if (!isCheckoutRequest(body)) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const checkout = await autumnRequest<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body,
+ });
+ return json(checkout);
+};
+`,
+ "src/routes/api/billing/portal/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!body.customerId || !body.returnUrl) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const portal = await autumnRequest<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ returnUrl: body.returnUrl,
+ },
+ });
+ return json(portal);
+};
+`,
+ "src/routes/api/billing/usage/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!body.customerId || !body.meterId || typeof body.quantity !== "number") {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ await autumnRequest("/v1/usage", {
+ method: "POST",
+ body: {
+ customerId: body.customerId,
+ meterId: body.meterId,
+ quantity: body.quantity,
+ },
+ });
+ return json({ ok: true });
+};
+`,
+ "src/routes/api/billing/subscription/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const GET: RequestHandler = async ({ url }) => {
+ const subscriptionId = url.searchParams.get("subscriptionId");
+ if (!subscriptionId) {
+ return json({ error: "subscriptionId is required" }, { status: 400 });
+ }
+ const subscription = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ { method: "GET" }
+ );
+ return json(subscription);
+};
+
+export const PATCH: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!body.subscriptionId || !body.productId) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const updated = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId: body.productId },
+ }
+ );
+ return json(updated);
+};
+
+export const DELETE: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!body.subscriptionId) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const canceled = await autumnRequest<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(body.subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: body.cancelAtPeriodEnd ?? true },
+ }
+ );
+ return json(canceled);
+};
+`,
+ "src/routes/api/billing/feature-check/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { autumnRequest } from "$lib/server/autumn";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!body.customerId || !body.featureId) {
+ return json({ error: "Invalid payload" }, { status: 400 });
+ }
+ const result = await autumnRequest<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId: body.customerId, featureId: body.featureId },
+ });
+ return json(result);
+};
+`,
+ "src/routes/api/webhooks/autumn/+server.ts": `
+import { json } from "@sveltejs/kit";
+import { createHmac, timingSafeEqual } from "node:crypto";
+import type { RequestHandler } from "./$types";
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ return json({ error: "Missing webhook secret" }, { status: 500 });
+ }
+ const signature = request.headers.get("autumn-signature") ?? "";
+ const rawBody = await request.text();
+ if (!verifySignature(signature, rawBody, secret)) {
+ return json({ error: "Invalid signature" }, { status: 401 });
+ }
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ return json({ received: true });
+};
+`,
+ "src/lib/components/CheckoutButton.svelte": `
+<script lang="ts">
+ export let productId: string;
+ export let customerId: string;
+ export let successUrl: string;
+ export let cancelUrl: string;
+ export let label = "Upgrade";
+
+ let loading = false;
+
+ const startCheckout = async () => {
+ loading = true;
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ productId, customerId, successUrl, cancelUrl }),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Checkout failed");
+ }
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } catch (error) {
+ alert(error instanceof Error ? error.message : "Checkout failed");
+ } finally {
+ loading = false;
+ }
+ };
+</script>
+
+<button
+ type="button"
+ class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ disabled={loading}
+ on:click={startCheckout}
+>
+ {loading ? "Redirecting..." : label}
+</button>
+`,
+ "src/lib/components/FeatureGate.svelte": `
+<script lang="ts">
+ export let allowed = false;
+ export let fallback = "";
+</script>
+
+{#if allowed}
+ <slot />
+{:else}
+ {fallback}
+{/if}
+`,
+ "src/lib/usage.ts": `
+export interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export const trackUsage = async (payload: UsagePayload): Promise<void> => {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+};
+`,
+ "src/routes/billing/success/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Payment successful</h1>
+ <p class="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+ Return to app
+ </a>
+</div>
+`,
+ "src/routes/billing/cancel/+page.svelte": `
+<div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+ <p class="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+ Return to app
+ </a>
+</div>
+`,
+ },
+};
File: src/lib/payment-templates/types.ts
Changes:
@@ -0,0 +1,9 @@
+import { frameworks } from "../frameworks";
+
+export type PaymentFramework = keyof typeof frameworks;
+
+export interface PaymentTemplateBundle {
+ framework: PaymentFramework;
+ description: string;
+ files: Record<string, string>;
+}
File: src/lib/payment-templates/vue.ts
Changes:
@@ -0,0 +1,369 @@
+import type { PaymentTemplateBundle } from "./types";
+
+export const vuePaymentTemplate: PaymentTemplateBundle = {
+ framework: "vue",
+ description: "Vue 3 payment integration with Autumn + Stripe",
+ files: {
+ "server/autumn-client.ts": `
+type AutumnRequestOptions = Omit<RequestInit, "body"> & {
+ body?: Record<string, unknown>;
+};
+
+export const createAutumnClient = () => {
+ const apiKey = process.env.AUTUMN_API_KEY;
+ const baseUrl = process.env.AUTUMN_API_BASE_URL ?? "https://api.useautumn.com";
+ if (!apiKey) {
+ throw new Error("AUTUMN_API_KEY is required");
+ }
+
+ const request = async <T>(path: string, options: AutumnRequestOptions): Promise<T> => {
+ const response = await fetch(\`\${baseUrl}\${path}\`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: \`Bearer \${apiKey}\`,
+ ...(options.headers ?? {}),
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(\`Autumn API error: \${response.status} - \${errorText}\`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ };
+
+ return { request };
+};
+`,
+ "server/routes/billing.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import { createAutumnClient } from "../autumn-client";
+
+type CheckoutRequest = {
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+};
+
+const isCheckoutRequest = (value: unknown): value is CheckoutRequest => {
+ if (!value || typeof value !== "object") return false;
+ const data = value as Record<string, unknown>;
+ return (
+ typeof data.productId === "string" &&
+ typeof data.customerId === "string" &&
+ typeof data.successUrl === "string" &&
+ typeof data.cancelUrl === "string"
+ );
+};
+
+const router = Router();
+const autumn = createAutumnClient();
+
+router.post("/checkout", async (req: Request, res: Response) => {
+ try {
+ if (!isCheckoutRequest(req.body)) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const checkout = await autumn.request<{ url: string; id: string }>("/v1/checkout", {
+ method: "POST",
+ body: req.body,
+ });
+ res.json(checkout);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/portal", async (req: Request, res: Response) => {
+ try {
+ const { customerId, returnUrl } = req.body as {
+ customerId?: string;
+ returnUrl?: string;
+ };
+ if (!customerId || !returnUrl) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const portal = await autumn.request<{ url: string }>("/v1/portal", {
+ method: "POST",
+ body: { customerId, returnUrl },
+ });
+ res.json(portal);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.patch("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, productId } = req.body as {
+ subscriptionId?: string;
+ productId?: string;
+ };
+ if (!subscriptionId || !productId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const updated = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`,
+ {
+ method: "PATCH",
+ body: { productId },
+ }
+ );
+ res.json(updated);
+});
+
+router.delete("/subscription", async (req: Request, res: Response) => {
+ const { subscriptionId, cancelAtPeriodEnd } = req.body as {
+ subscriptionId?: string;
+ cancelAtPeriodEnd?: boolean;
+ };
+ if (!subscriptionId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const canceled = await autumn.request<unknown>(
+ \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`,
+ {
+ method: "POST",
+ body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true },
+ }
+ );
+ res.json(canceled);
+});
+
+router.post("/feature-check", async (req: Request, res: Response) => {
+ try {
+ const { customerId, featureId } = req.body as {
+ customerId?: string;
+ featureId?: string;
+ };
+ if (!customerId || !featureId) {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ const result = await autumn.request<unknown>("/v1/features/check", {
+ method: "POST",
+ body: { customerId, featureId },
+ });
+ res.json(result);
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+router.post("/usage", async (req: Request, res: Response) => {
+ try {
+ const { customerId, meterId, quantity } = req.body as {
+ customerId?: string;
+ meterId?: string;
+ quantity?: number;
+ };
+ if (!customerId || !meterId || typeof quantity !== "number") {
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ await autumn.request("/v1/usage", {
+ method: "POST",
+ body: { customerId, meterId, quantity },
+ });
+ res.json({ ok: true });
+ } catch (error) {
+ res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" });
+ }
+});
+
+export default router;
+`,
+ "server/routes/webhooks.ts": `
+import type { Request, Response } from "express";
+import { Router } from "express";
+import express from "express";
+import { createHmac, timingSafeEqual } from "node:crypto";
+
+const router = Router();
+
+const verifySignature = (signature: string, payload: string, secret: string) => {
+ const digest = createHmac("sha256", secret).update(payload).digest("hex");
+ const signatureBuffer = Buffer.from(signature);
+ const digestBuffer = Buffer.from(digest);
+ if (signatureBuffer.length !== digestBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(signatureBuffer, digestBuffer);
+};
+
+router.post("/autumn", express.raw({ type: "application/json" }), async (req: Request, res: Response) => {
+ const secret = process.env.AUTUMN_WEBHOOK_SECRET;
+ if (!secret) {
+ res.status(500).json({ error: "Missing webhook secret" });
+ return;
+ }
+ const signature = req.headers["autumn-signature"];
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature ?? "";
+ const rawBody = req.body instanceof Buffer ? req.body.toString("utf8") : String(req.body);
+ if (!verifySignature(signatureValue, rawBody, secret)) {
+ res.status(401).json({ error: "Invalid signature" });
+ return;
+ }
+ try {
+ const event = JSON.parse(rawBody) as { type: string; data: unknown };
+ switch (event.type) {
+ case "subscription.created":
+ case "subscription.updated":
+ case "subscription.canceled":
+ case "invoice.payment_failed":
+ case "invoice.payment_succeeded":
+ break;
+ default:
+ break;
+ }
+ res.json({ received: true });
+ } catch (err) {
+ res.status(400).json({ error: "Invalid JSON" });
+ }
+});
+
+export default router;
+`,
+ "server/index.ts": `
+import express from "express";
+import billingRoutes from "./routes/billing";
+import webhookRoutes from "./routes/webhooks";
+
+const app = express();
+app.use(express.json());
+
+app.use("/api/billing", billingRoutes);
+app.use("/api/webhooks", webhookRoutes);
+
+const port = Number(process.env.PORT ?? 4000);
+app.listen(port, () => {
+ console.log(\`Billing API listening on \${port}\`);
+});
+`,
+ "src/components/CheckoutButton.vue": `
+<script setup lang="ts">
+import { ref } from "vue";
+
+const props = defineProps<{
+ productId: string;
+ customerId: string;
+ successUrl: string;
+ cancelUrl: string;
+ label?: string;
+}>();
+
+const loading = ref(false);
+
+const startCheckout = async () => {
+ loading.value = true;
+ try {
+ const response = await fetch("/api/billing/checkout", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ productId: props.productId,
+ customerId: props.customerId,
+ successUrl: props.successUrl,
+ cancelUrl: props.cancelUrl,
+ }),
+ });
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || "Checkout failed");
+ }
+ const data = (await response.json()) as { url?: string };
+ if (data.url) {
+ window.location.href = data.url;
+ }
+ } catch (error) {
+ alert(error instanceof Error ? error.message : "Checkout failed");
+ } finally {
+ loading.value = false;
+ }
+};
+</script>
+
+<template>
+ <button
+ type="button"
+ :disabled="loading"
+ class="rounded-md bg-black px-4 py-2 text-white disabled:opacity-60"
+ @click="startCheckout"
+ >
+ {{ loading ? "Redirecting..." : props.label ?? "Upgrade" }}
+ </button>
+</template>
+`,
+ "src/components/FeatureGate.vue": `
+<script setup lang="ts">
+defineProps<{
+ allowed: boolean;
+ fallback?: string;
+}>();
+</script>
+
+<template>
+ <slot v-if="allowed" />
+ <span v-else>{{ fallback ?? "" }}</span>
+</template>
+`,
+ "src/composables/useUsage.ts": `
+export interface UsagePayload {
+ customerId: string;
+ meterId: string;
+ quantity: number;
+}
+
+export const useUsage = () => {
+ const trackUsage = async (payload: UsagePayload): Promise<void> => {
+ await fetch("/api/billing/usage", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ };
+
+ return { trackUsage };
+};
+`,
+ "src/pages/BillingSuccess.vue": `
+<template>
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Payment successful</h1>
+ <p class="text-muted-foreground">
+ Your subscription is active. You can return to the app and start using
+ your new plan immediately.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md bg-black px-4 py-2 text-white">
+ Return to app
+ </a>
+ </div>
+</template>
+`,
+ "src/pages/BillingCancel.vue": `
+<template>
+ <div class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
+ <h1 class="text-3xl font-semibold">Checkout canceled</h1>
+ <p class="text-muted-foreground">
+ Your checkout was canceled. You can restart the process at any time.
+ </p>
+ <a href="/" class="inline-flex w-fit items-center rounded-md border border-gray-200 px-4 py-2">
+ Return to app
+ </a>
+ </div>
+</template>
+`,
+ },
+};
File: src/modules/projects/ui/components/custom-domain-dialog.tsx
Changes:
@@ -0,0 +1,155 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type NetlifyDomain = {
+ id: string;
+ name: string;
+ ssl_status?: string;
+ verification?: {
+ status?: string;
+ };
+};
+
+type CustomDomainDialogProps = {
+ siteId: string;
+};
+
+export const CustomDomainDialog = ({ siteId }: CustomDomainDialogProps) => {
+ const [domains, setDomains] = useState<NetlifyDomain[]>([]);
+ const [domainInput, setDomainInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const loadDomains = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/deploy/netlify/domains?siteId=${siteId}`);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to load domains");
+ }
+ setDomains(Array.isArray(data) ? data : []);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to load domains");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAdd = async () => {
+ if (!domainInput || isSubmitting) {
+ if (!domainInput) {
+ toast.error("Enter a domain");
+ }
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const response = await fetch("/api/deploy/netlify/domains", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ siteId, domain: domainInput }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to add domain");
+ }
+ setDomainInput("");
+ await loadDomains();
+ toast.success("Domain added");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to add domain");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (domainId: string) => {
+ if (isSubmitting) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const response = await fetch(
+ `/api/deploy/netlify/domains?siteId=${siteId}&domainId=${domainId}`,
+ { method: "DELETE" }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to remove domain");
+ }
+ await loadDomains();
+ toast.success("Domain removed");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to remove domain");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ useEffect(() => {
+ void loadDomains();
+ }, [siteId]);
+
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">Custom Domains</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Custom Domains</DialogTitle>
+ <DialogDescription>Manage domains and DNS verification.</DialogDescription>
+ </DialogHeader>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Input
+ placeholder="yourdomain.com"
+ value={domainInput}
+ onChange={(event) => setDomainInput(event.target.value)}
+ disabled={isSubmitting}
+ />
+ <Button onClick={handleAdd} disabled={isLoading || isSubmitting}>
+ Add Domain
+ </Button>
+ </div>
+ <div className="space-y-2">
+ {domains.length === 0 && !isLoading && (
+ <p className="text-sm text-muted-foreground">No domains configured</p>
+ )}
+ {domains.map((domain) => (
+ <div key={domain.id} className="flex items-center justify-between text-sm">
+ <div className="flex flex-col">
+ <span>{domain.name}</span>
+ <span className="text-xs text-muted-foreground">
+ SSL: {domain.ssl_status ?? "unknown"} • Verification: {domain.verification?.status ?? "unknown"}
+ </span>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDelete(domain.id)}
+ disabled={isSubmitting}
+ >
+ Remove
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/deploy-button.tsx
Changes:
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import { NetlifyConnectDialog } from "./netlify-connect-dialog";
+
+type DeployButtonProps = {
+ projectId: string;
+};
+
+export const DeployButton = ({ projectId }: DeployButtonProps) => {
+ const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+ const [isDeploying, setIsDeploying] = useState(false);
+
+ const handleDeploy = async () => {
+ if (isDeploying) return;
+ setIsDeploying(true);
+
+ try {
+ const response = await fetch("/api/deploy/netlify/deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId }),
+ });
+
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || "Deployment failed");
+ }
+
+ toast.success(`Deployment started: ${payload.siteUrl}`);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Deployment failed");
+ } finally {
+ setIsDeploying(false);
+ }
+ };
+
+ if (!connection) {
+ return <NetlifyConnectDialog />;
+ }
+
+ return (
+ <Button size="sm" onClick={handleDeploy} disabled={isDeploying}>
+ {isDeploying ? "Deploying..." : "Deploy to Netlify"}
+ </Button>
+ );
+};
File: src/modules/projects/ui/components/deployment-dashboard.tsx
Changes:
@@ -0,0 +1,45 @@
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { EnvVarsDialog } from "./env-vars-dialog";
+import { CustomDomainDialog } from "./custom-domain-dialog";
+import { DeploymentHistory } from "./deployment-history";
+import { PreviewDeployments } from "./preview-deployments";
+
+type DeploymentDashboardProps = {
+ projectId: string;
+};
+
+export const DeploymentDashboard = ({ projectId }: DeploymentDashboardProps) => {
+ const deployment = useQuery(api.deployments.getDeployment, { projectId });
+
+ return (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <h3 className="text-sm font-medium">Netlify Deployment</h3>
+ <DeploymentStatus projectId={projectId} />
+ </div>
+ <DeployButton projectId={projectId} />
+ </div>
+
+ {deployment?.siteId && (
+ <div className="flex flex-wrap items-center gap-2">
+ <EnvVarsDialog siteId={deployment.siteId} />
+ <CustomDomainDialog siteId={deployment.siteId} />
+ </div>
+ )}
+
+ <div>
+ <h4 className="text-sm font-medium mb-2">Preview Deployments</h4>
+ <PreviewDeployments projectId={projectId} />
+ </div>
+
+ <div>
+ <h4 className="text-sm font-medium mb-2">Deployment History</h4>
+ <DeploymentHistory projectId={projectId} />
+ </div>
+ </div>
+ );
+};
File: src/modules/projects/ui/components/deployment-history.tsx
Changes:
@@ -0,0 +1,129 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { Button } from "@/components/ui/button";
+import { Loader2Icon } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type DeploymentHistoryProps = {
+ projectId: Id<"projects">;
+};
+
+type DeploymentLogsDialogProps = {
+ deployId: string;
+};
+
+const DeploymentLogsDialog = ({ deployId }: DeploymentLogsDialogProps) => {
+ const [logsByDeployId, setLogsByDeployId] = useState<Record<string, string | null>>({});
+ const [isLoading, setIsLoading] = useState(false);
+
+ const fetchLogs = async () => {
+ setIsLoading(true);
+ setLogsByDeployId((prev) => ({ ...prev, [deployId]: null }));
+ try {
+ const response = await fetch(`/api/deploy/netlify/logs?deployId=${deployId}`);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to fetch logs");
+ }
+ setLogsByDeployId((prev) => ({ ...prev, [deployId]: data.logs || "" }));
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to fetch logs");
+ setLogsByDeployId((prev) => ({ ...prev, [deployId]: null }));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const logs = logsByDeployId[deployId] ?? null;
+
+ return (
+ <Dialog onOpenChange={(open) => open && fetchLogs()}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ Logs
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Build Logs</DialogTitle>
+ <DialogDescription>Latest build output from Netlify.</DialogDescription>
+ </DialogHeader>
+ <div className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2Icon className="size-4 animate-spin" />
+ <span className="ml-2">Loading logs...</span>
+ </div>
+ ) : (
+ <pre className="whitespace-pre-wrap">{logs || "No logs available"}</pre>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+};
+
+export const DeploymentHistory = ({ projectId }: DeploymentHistoryProps) => {
+ const deployments = useQuery(api.deployments.listDeployments, { projectId });
+
+ const handleRollback = async (deployId?: string) => {
+ if (!deployId) return;
+ try {
+ const response = await fetch("/api/deploy/netlify/rollback", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ deployId }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Rollback failed");
+ }
+ toast.success("Rollback initiated");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Rollback failed");
+ }
+ };
+
+ if (!deployments || deployments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No deployments yet</p>;
+ }
+
+ return (
+ <div className="space-y-2">
+ {deployments.map((deployment) => (
+ <div
+ key={deployment._id}
+ className="flex items-center justify-between rounded-md border p-2 text-sm"
+ >
+ <div className="flex flex-col">
+ <span>Deploy #{deployment.deployNumber ?? "-"} • {deployment.status}</span>
+ <span className="text-xs text-muted-foreground">
+ {deployment.siteUrl}
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ {deployment.deployId && <DeploymentLogsDialog deployId={deployment.deployId} />}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRollback(deployment.deployId)}
+ disabled={!deployment.deployId}
+ >
+ Rollback
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+};
File: src/modules/projects/ui/components/deployment-status.tsx
Changes:
@@ -0,0 +1,86 @@
+import { useEffect, useMemo } from "react";
+import Link from "next/link";
+import { useMutation, useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { Button } from "@/components/ui/button";
+
+type DeploymentStatusProps = {
+ projectId: Id<"projects">;
+};
+
+type NetlifyStatusResponse = {
+ state?: string;
+};
+
+const statusLabelMap: Record<string, string> = {
+ pending: "Pending",
+ building: "Building",
+ ready: "Ready",
+ error: "Error",
+};
+
+export const DeploymentStatus = ({ projectId }: DeploymentStatusProps) => {
+ const deployment = useQuery(api.deployments.getDeployment, { projectId });
+ const updateDeployment = useMutation(api.deployments.updateDeployment);
+
+ const shouldPoll = useMemo(() => {
+ if (!deployment?.deployId) return false;
+ return deployment.status === "pending" || deployment.status === "building";
+ }, [deployment]);
+
+ useEffect(() => {
+ if (!shouldPoll || !deployment?.deployId) {
+ return;
+ }
+
+ let cancelled = false;
+ const pollStatus = async () => {
+ try {
+ const response = await fetch(`/api/deploy/netlify/status?deployId=${deployment.deployId}`);
+ if (!response.ok) {
+ return;
+ }
+
+ const data = (await response.json()) as NetlifyStatusResponse;
+ if (!data.state || cancelled) {
+ return;
+ }
+
+ await updateDeployment({
+ deploymentId: deployment._id,
+ status: data.state === "ready" ? "ready" : data.state === "error" ? "error" : "building",
+ });
+ } catch {
+ // ignore polling errors
+ }
+ };
+
+ const interval = setInterval(pollStatus, 10000);
+ void pollStatus();
+
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [deployment?._id, deployment?.deployId, shouldPoll, updateDeployment]);
+
+ if (!deployment) {
+ return null;
+ }
+
+ const label = statusLabelMap[deployment.status] ?? deployment.status;
+
+ return (
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <span>Netlify: {label}</span>
+ {deployment.siteUrl && deployment.status === "ready" && (
+ <Button asChild variant="link" size="sm" className="h-auto p-0">
+ <Link href={deployment.siteUrl} target="_blank" rel="noreferrer">
+ View site
+ </Link>
+ </Button>
+ )}
+ </div>
+ );
+};
File: src/modules/projects/ui/components/env-vars-dialog.tsx
Changes:
@@ -0,0 +1,149 @@
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+type EnvVar = {
+ key: string;
+};
+
+type EnvVarsDialogProps = {
+ siteId: string;
+};
+
+export const EnvVarsDialog = ({ siteId }: EnvVarsDialogProps) => {
+ const [envVars, setEnvVars] = useState<EnvVar[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [newKey, setNewKey] = useState("");
+ const [newValue, setNewValue] = useState("");
+
+ const loadEnvVars = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/deploy/netlify/env-vars?siteId=${siteId}`);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to load env vars");
+ }
+ setEnvVars(Array.isArray(data) ? data : []);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to load env vars");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAdd = async () => {
+ if (!newKey || !newValue || isSubmitting) {
+ if (!newKey || !newValue) {
+ toast.error("Provide a key and value");
+ }
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const response = await fetch("/api/deploy/netlify/env-vars", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ siteId, key: newKey, value: newValue }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to set env var");
+ }
+ setNewKey("");
+ setNewValue("");
+ await loadEnvVars();
+ toast.success("Env var saved");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to set env var");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (key: string) => {
+ if (isSubmitting) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const response = await fetch(
+ `/api/deploy/netlify/env-vars?siteId=${siteId}&key=${encodeURIComponent(key)}`,
+ { method: "DELETE" }
+ );
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to delete env var");
+ }
+ await loadEnvVars();
+ toast.success("Env var deleted");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to delete env var");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <Dialog onOpenChange={(open) => open && loadEnvVars()}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">Env Vars</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Environment Variables</DialogTitle>
+ <DialogDescription>Manage Netlify environment variables for this site.</DialogDescription>
+ </DialogHeader>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Input
+ placeholder="KEY"
+ value={newKey}
+ onChange={(event) => setNewKey(event.target.value)}
+ disabled={isSubmitting}
+ />
+ <Input
+ placeholder="VALUE"
+ value={newValue}
+ onChange={(event) => setNewValue(event.target.value)}
+ disabled={isSubmitting}
+ />
+ <Button onClick={handleAdd} disabled={isLoading || isSubmitting}>
+ Save
+ </Button>
+ </div>
+ <div className="space-y-2">
+ {envVars.length === 0 && !isLoading && (
+ <p className="text-sm text-muted-foreground">No variables set</p>
+ )}
+ {envVars.map((envVar) => (
+ <div key={envVar.key} className="flex items-center justify-between text-sm">
+ <span>{envVar.key}</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDelete(envVar.key)}
+ disabled={isLoading || isSubmitting}
+ >
+ Remove
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/github-export-button.tsx
Changes:
@@ -0,0 +1,70 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { useQuery } from "convex/react";
+
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { GitHubExportModal } from "./github-export-modal";
+
+type GitHubExportButtonProps = {
+ projectId: string;
+};
+
+export const GitHubExportButton = ({ projectId }: GitHubExportButtonProps) => {
+ const connection = useQuery(api.oauth.getConnection, { provider: "github" });
+ const [open, setOpen] = useState(false);
+
+ if (connection === undefined) {
+ return (
+ <Button size="sm" variant="outline" disabled>
+ Loading...
+ </Button>
+ );
+ }
+
+ if (connection === null) {
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button size="sm" variant="outline">
+ Connect GitHub
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Connect GitHub</DialogTitle>
+ <DialogDescription>
+ Connect your GitHub account to export projects.
+ </DialogDescription>
+ </DialogHeader>
+ <Button asChild>
+ <Link href="/api/import/github/auth">Continue to GitHub</Link>
+ </Button>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ return (
+ <>
+ <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
+ Export to GitHub
+ </Button>
+ <GitHubExportModal
+ projectId={projectId}
+ open={open}
+ onOpenChange={setOpen}
+ />
+ </>
+ );
+};
File: src/modules/projects/ui/components/github-export-modal.tsx
Changes:
@@ -0,0 +1,414 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { ExternalLinkIcon, Loader2Icon } from "lucide-react";
+import { z } from "zod";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+
+type GitHubRepoOption = {
+ id: number;
+ name: string;
+ fullName: string;
+ url: string;
+ isPrivate: boolean;
+ defaultBranch: string;
+};
+
+type ExportResult = {
+ exportId: string;
+ repositoryUrl: string;
+ repositoryFullName: string;
+ branch: string;
+ commitSha: string;
+ fileCount: number;
+};
+
+type GitHubExportModalProps = {
+ projectId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+const exportResultSchema = z.object({
+ exportId: z.string(),
+ repositoryUrl: z.string(),
+ repositoryFullName: z.string(),
+ branch: z.string(),
+ commitSha: z.string(),
+ fileCount: z.number(),
+});
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+ return typeof value === "object" && value !== null;
+};
+
+const isRepoOption = (value: unknown): value is GitHubRepoOption => {
+ if (!isRecord(value)) {
+ return false;
+ }
+
+ const record = value;
+ return (
+ typeof record.id === "number" &&
+ typeof record.name === "string" &&
+ typeof record.fullName === "string" &&
+ typeof record.url === "string" &&
+ typeof record.isPrivate === "boolean" &&
+ typeof record.defaultBranch === "string"
+ );
+};
+
+const parseRepositories = (value: unknown): Array<GitHubRepoOption> => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const repos: Array<GitHubRepoOption> = [];
+ for (const repo of value) {
+ if (isRepoOption(repo)) {
+ repos.push(repo);
+ }
+ }
+
+ return repos;
+};
+
+export const GitHubExportModal = ({
+ projectId,
+ open,
+ onOpenChange,
+}: GitHubExportModalProps) => {
+ const [mode, setMode] = useState<"new" | "existing">("new");
+ const [repoName, setRepoName] = useState("");
+ const [repoDescription, setRepoDescription] = useState("");
+ const [isPrivate, setIsPrivate] = useState(false);
+ const [repos, setRepos] = useState<Array<GitHubRepoOption>>([]);
+ const [selectedRepo, setSelectedRepo] = useState("");
+ const [branch, setBranch] = useState("");
+ const [includeReadme, setIncludeReadme] = useState(true);
+ const [includeGitignore, setIncludeGitignore] = useState(true);
+ const [commitMessage, setCommitMessage] = useState("");
+ const [isLoadingRepos, setIsLoadingRepos] = useState(false);
+ const [isExporting, setIsExporting] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [result, setResult] = useState<ExportResult | null>(null);
+
+ const selectedRepoOption = useMemo(() => {
+ return repos.find((repo) => repo.fullName === selectedRepo) ?? null;
+ }, [repos, selectedRepo]);
+
+ useEffect(() => {
+ if (!open) {
+ setError(null);
+ setResult(null);
+ setIsExporting(false);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ const controller = new AbortController();
+ const loadRepositories = async () => {
+ setIsLoadingRepos(true);
+ setError(null);
+ try {
+ const response = await fetch("/api/github/repositories", {
+ signal: controller.signal,
+ });
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || "Failed to load repositories");
+ }
+
+ const parsedRepos = parseRepositories(payload.repositories);
+ setRepos(parsedRepos);
+ if (parsedRepos.length === 0) {
+ setError("No repositories found in this GitHub account.");
+ }
+ } catch (loadError) {
+ const message =
+ loadError instanceof Error ? loadError.message : "Failed to load repositories";
+ setError(message);
+ } finally {
+ setIsLoadingRepos(false);
+ }
+ };
+
+ void loadRepositories();
+
+ return () => {
+ controller.abort();
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (mode !== "existing" || !selectedRepoOption || branch) {
+ return;
+ }
+
+ setBranch(selectedRepoOption.defaultBranch);
+ }, [mode, selectedRepoOption, branch]);
+
+ const handleExport = async () => {
+ if (isExporting) {
+ return;
+ }
+
+ setIsExporting(true);
+ setError(null);
+
+ try {
+ const payload: Record<string, unknown> = {
+ branch: branch.trim() || undefined,
+ includeReadme,
+ includeGitignore,
+ commitMessage: commitMessage.trim() || undefined,
+ };
+
+ if (mode === "existing") {
+ if (!selectedRepo) {
+ throw new Error("Select a repository to export to.");
+ }
+ payload.repositoryFullName = selectedRepo;
+ } else {
+ const trimmedName = repoName.trim();
+ if (!trimmedName) {
+ throw new Error("Repository name is required.");
+ }
+ payload.repositoryName = trimmedName;
+ payload.description = repoDescription.trim() || undefined;
+ payload.isPrivate = isPrivate;
+ }
+
+ const response = await fetch(`/api/projects/${projectId}/export/github`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Export failed");
+ }
+
+ const parsedResult = exportResultSchema.safeParse(data);
+ if (!parsedResult.success) {
+ throw new Error("Unexpected export response.");
+ }
+
+ setResult(parsedResult.data);
+ toast.success("GitHub export complete");
+ } catch (exportError) {
+ const message =
+ exportError instanceof Error ? exportError.message : "Export failed";
+ setError(message);
+ toast.error(message);
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ const isReady =
+ mode === "existing" ? selectedRepo.length > 0 : repoName.trim().length > 0;
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-xl">
+ <DialogHeader>
+ <DialogTitle>Export to GitHub</DialogTitle>
+ <DialogDescription>
+ Export your latest AI-generated files to a GitHub repository.
+ </DialogDescription>
+ </DialogHeader>
+
+ {error && (
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
+ {error}
+ </div>
+ )}
+
+ {result ? (
+ <div className="space-y-4">
+ <div className="rounded-md border p-3 text-sm">
+ <div className="font-medium">{result.repositoryFullName}</div>
+ <div className="text-muted-foreground">
+ Branch: {result.branch}
+ </div>
+ <div className="text-muted-foreground">
+ Files exported: {result.fileCount}
+ </div>
+ <div className="text-muted-foreground">
+ Commit: {result.commitSha.slice(0, 10)}
+ </div>
+ </div>
+ <div className="flex justify-end gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ <Button asChild>
+ <a href={result.repositoryUrl} target="_blank" rel="noreferrer">
+ Open GitHub
+ <ExternalLinkIcon className="ml-2 size-4" />
+ </a>
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-5">
+ <div className="space-y-2">
+ <Label>Repository</Label>
+ <RadioGroup
+ value={mode}
+ onValueChange={(value) => {
+ if (value === "new" || value === "existing") {
+ setMode(value);
+ }
+ }}
+ className="flex gap-4"
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="new" id="github-export-new" />
+ <Label htmlFor="github-export-new">New repository</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="existing" id="github-export-existing" />
+ <Label htmlFor="github-export-existing">Existing repository</Label>
+ </div>
+ </RadioGroup>
+ </div>
+
+ {mode === "new" ? (
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="repo-name">Repository name</Label>
+ <Input
+ id="repo-name"
+ placeholder="zapdev-export"
+ value={repoName}
+ onChange={(event) => setRepoName(event.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="repo-description">Description</Label>
+ <Input
+ id="repo-description"
+ placeholder="Optional description"
+ value={repoDescription}
+ onChange={(event) => setRepoDescription(event.target.value)}
+ />
+ </div>
+ <div className="flex items-center justify-between rounded-md border px-3 py-2">
+ <div>
+ <p className="text-sm font-medium">Private repository</p>
+ <p className="text-xs text-muted-foreground">
+ Limit visibility to collaborators.
+ </p>
+ </div>
+ <Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ <Label>Select repository</Label>
+ <Select
+ value={selectedRepo}
+ onValueChange={setSelectedRepo}
+ disabled={isLoadingRepos}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="Choose a repository" />
+ </SelectTrigger>
+ <SelectContent>
+ {repos.map((repo) => (
+ <SelectItem key={repo.fullName} value={repo.fullName}>
+ {repo.fullName}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ <div className="grid gap-3 md:grid-cols-2">
+ <div className="space-y-2">
+ <Label htmlFor="branch">Branch</Label>
+ <Input
+ id="branch"
+ placeholder={selectedRepoOption?.defaultBranch ?? "main"}
+ value={branch}
+ onChange={(event) => setBranch(event.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="commit-message">Commit message</Label>
+ <Input
+ id="commit-message"
+ placeholder="Export project from ZapDev"
+ value={commitMessage}
+ onChange={(event) => setCommitMessage(event.target.value)}
+ />
+ </div>
+ </div>
+
+ <div className="space-y-3">
+ <div className="flex items-center justify-between rounded-md border px-3 py-2">
+ <div>
+ <p className="text-sm font-medium">Include README</p>
+ <p className="text-xs text-muted-foreground">
+ Adds a basic project overview.
+ </p>
+ </div>
+ <Switch checked={includeReadme} onCheckedChange={setIncludeReadme} />
+ </div>
+ <div className="flex items-center justify-between rounded-md border px-3 py-2">
+ <div>
+ <p className="text-sm font-medium">Include .gitignore</p>
+ <p className="text-xs text-muted-foreground">
+ Adds framework defaults.
+ </p>
+ </div>
+ <Switch
+ checked={includeGitignore}
+ onCheckedChange={setIncludeGitignore}
+ />
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ <Button onClick={handleExport} disabled={!isReady || isExporting}>
+ {isExporting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
+ {isExporting ? "Exporting..." : "Export to GitHub"}
+ </Button>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/netlify-connect-dialog.tsx
Changes:
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export const NetlifyConnectDialog = () => {
+ const connection = useQuery(api.oauth.getConnection, { provider: "netlify" });
+
+ if (connection) {
+ return (
+ <Button variant="secondary" size="sm" disabled>
+ Netlify Connected
+ </Button>
+ );
+ }
+
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button size="sm">Connect Netlify</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Connect Netlify</DialogTitle>
+ <DialogDescription>
+ Connect your Netlify account to deploy projects directly from ZapDev.
+ </DialogDescription>
+ </DialogHeader>
+ <Button asChild>
+ <Link href="/api/deploy/netlify/auth">Continue to Netlify</Link>
+ </Button>
+ </DialogContent>
+ </Dialog>
+ );
+};
File: src/modules/projects/ui/components/preview-deployments.tsx
Changes:
@@ -0,0 +1,96 @@
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { useQuery } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
+import { Button } from "@/components/ui/button";
+
+type PreviewDeploymentsProps = {
+ projectId: Id<"projects">;
+};
+
+export const PreviewDeployments = ({ projectId }: PreviewDeploymentsProps) => {
+ const deployments = useQuery(api.deployments.listDeployments, { projectId });
+ const [isCreating, setIsCreating] = useState(false);
+ const [deletingId, setDeletingId] = useState<string | null>(null);
+
+ const previews = useMemo(
+ () => (deployments ?? []).filter((deployment) => deployment.isPreview),
+ [deployments]
+ );
+
+ const handleCreatePreview = async () => {
+ setIsCreating(true);
+ try {
+ const response = await fetch("/api/deploy/netlify/deploy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId, deployType: "preview" }),
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Preview deployment failed");
+ }
+ toast.success("Preview deployment started");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Preview deployment failed");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleDeletePreview = async (deployId?: string) => {
+ if (!deployId) return;
+ setDeletingId(deployId);
+ try {
+ const response = await fetch(`/api/deploy/netlify/preview?deployId=${deployId}`, {
+ method: "DELETE",
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to delete preview");
+ }
+ toast.success("Preview deleted");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to delete preview");
+ } finally {
+ setDeletingId(null);
+ }
+ };
+
+ return (
+ <div className="space-y-3">
+ <Button size="sm" onClick={handleCreatePreview} disabled={isCreating}>
+ {isCreating ? "Creating preview..." : "Create Preview Deployment"}
+ </Button>
+ {previews.length === 0 && (
+ <p className="text-sm text-muted-foreground">No preview deployments yet</p>
+ )}
+ {previews.map((deployment) => (
+ <div key={deployment._id} className="flex items-center justify-between text-sm">
+ <div className="flex flex-col">
+ <span>Preview #{deployment.deployNumber ?? "-"}</span>
+ <span className="text-xs text-muted-foreground">{deployment.status}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ {deployment.siteUrl && (
+ <Button asChild variant="link" size="sm" className="h-auto p-0">
+ <a href={deployment.siteUrl} target="_blank" rel="noreferrer">
+ Open
+ </a>
+ </Button>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeletePreview(deployment.deployId)}
+ disabled={!deployment.deployId || deletingId === deployment.deployId}
+ >
+ {deletingId === deployment.deployId ? "Deleting..." : "Delete"}
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+};
File: src/modules/projects/ui/components/project-header.tsx
Changes:
@@ -13,6 +13,9 @@ import {
import { useState } from "react";
import { Button } from "@/components/ui/button";
+import { DeployButton } from "./deploy-button";
+import { DeploymentStatus } from "./deployment-status";
+import { GitHubExportButton } from "./github-export-button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -126,15 +129,20 @@ export const ProjectHeader = ({ projectId }: Props) => {
</DropdownMenu>
</div>
- <Button
- variant="ghost"
- size="sm"
- onClick={handleDownload}
- disabled={isDownloading}
- >
- <DownloadIcon className="size-4 mr-2" />
- {isDownloading ? 'Downloading...' : 'Download Code'}
- </Button>
+ <div className="flex items-center gap-3">
+ <DeploymentStatus projectId={projectId} />
+ <DeployButton projectId={projectId} />
+ <GitHubExportButton projectId={projectId} />
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleDownload}
+ disabled={isDownloading}
+ >
+ <DownloadIcon className="size-4 mr-2" />
+ {isDownloading ? "Downloading..." : "Download Code"}
+ </Button>
+ </div>
</header>
);
};
File: src/modules/projects/ui/views/project-view.tsx
Changes:
@@ -3,7 +3,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { Suspense, useEffect, useMemo, useState } from "react";
-import { EyeIcon, CodeIcon, CrownIcon } from "lucide-react";
+import { EyeIcon, CodeIcon, CrownIcon, RocketIcon } from "lucide-react";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
@@ -18,6 +18,7 @@ import {
import { ProjectHeader } from "../components/project-header";
import { MessagesContainer } from "../components/messages-container";
+import { DeploymentDashboard } from "../components/deployment-dashboard";
import { ErrorBoundary } from "react-error-boundary";
import type { Doc } from "@/convex/_generated/dataModel";
import { filterAIGeneratedFiles } from "@/lib/filter-ai-files";
@@ -42,7 +43,7 @@ export const ProjectView = ({ projectId }: Props) => {
const hasProAccess = usage?.planType === "pro";
const [activeFragment, setActiveFragment] = useState<Doc<"fragments"> | null>(null);
- const [tabState, setTabState] = useState<"preview" | "code">("preview");
+ const [tabState, setTabState] = useState<"preview" | "code" | "deploy">("preview");
const [streamingFiles, setStreamingFiles] = useState<Record<string, string>>({});
const explorerFiles = useMemo(() => {
@@ -119,7 +120,7 @@ export const ProjectView = ({ projectId }: Props) => {
className="h-full gap-y-0"
defaultValue="preview"
value={tabState}
- onValueChange={(value) => setTabState(value as "preview" | "code")}
+ onValueChange={(value) => setTabState(value as "preview" | "code" | "deploy")}
>
<div className="w-full flex items-center p-2 border-b gap-x-2">
<TabsList className="h-8 p-0 border rounded-md">
@@ -129,6 +130,9 @@ export const ProjectView = ({ projectId }: Props) => {
<TabsTrigger value="code" className="rounded-md">
<CodeIcon /> <span>Code</span>
</TabsTrigger>
+ <TabsTrigger value="deploy" className="rounded-md">
+ <RocketIcon /> <span>Deploy</span>
+ </TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center gap-x-2">
{!hasProAccess && (
@@ -149,6 +153,9 @@ export const ProjectView = ({ projectId }: Props) => {
<FileExplorer files={explorerFiles} />
)}
</TabsContent>
+ <TabsContent value="deploy" className="min-h-0 p-4">
+ <DeploymentDashboard projectId={projectId} />
+ </TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
File: src/prompt.ts
Changes:
@@ -5,4 +5,5 @@ export { REACT_PROMPT } from "./prompts/react";
export { VUE_PROMPT } from "./prompts/vue";
export { SVELTE_PROMPT } from "./prompts/svelte";
export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector";
+export { PAYMENT_INTEGRATION_RULES } from "./prompts/payment-integration";
export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs";
File: src/prompts/angular.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const ANGULAR_PROMPT = `
You are a senior software engineer working in a sandboxed Angular 19 environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
Angular Specific Environment:
- Main component: src/app/app.component.ts
File: src/prompts/nextjs.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const NEXTJS_PROMPT = `
You are a senior Next.js engineer in a sandboxed environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
Environment:
- Framework: Next.js 15.3.3
File: src/prompts/payment-integration.ts
Changes:
@@ -0,0 +1,9 @@
+export const PAYMENT_INTEGRATION_RULES = `
+Payment Integration (Stripe via Autumn):
+- If the user asks for payments, billing, subscriptions, or checkout flows, implement Stripe through Autumn.
+- Use server-side routes for checkout, billing portal, usage tracking, and webhook handling.
+- Always validate request payloads and verify webhook signatures.
+- Store API keys and secrets in environment variables only (no hardcoding).
+- You may call external APIs for Autumn/Stripe only when payment features are explicitly requested.
+- Provide a FeatureGate component and a usage tracking helper.
+`;
File: src/prompts/react.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const REACT_PROMPT = `
You are a senior software engineer working in a sandboxed React 18 + Vite environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
React + Vite Specific Environment:
- Main file: src/App.tsx
File: src/prompts/svelte.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const SVELTE_PROMPT = `
You are a senior software engineer working in a sandboxed SvelteKit environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
SvelteKit Specific Environment:
- Main page: src/routes/+page.svelte
File: src/prompts/vue.ts
Changes:
@@ -1,9 +1,11 @@
import { SHARED_RULES } from "./shared";
+import { PAYMENT_INTEGRATION_RULES } from "./payment-integration";
export const VUE_PROMPT = `
You are a senior software engineer working in a sandboxed Vue 3 + Vite environment.
${SHARED_RULES}
+${PAYMENT_INTEGRATION_RULES}
Vue + Vite Specific Environment:
- Main component: src/App.vue
|
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
There was a problem hiding this comment.
8 issues found across 32 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="convex/schema.ts">
<violation number="1" location="convex/schema.ts:325">
P3: Missing `createdAt` field breaks schema consistency. All other tables with `updatedAt` also include `createdAt` (e.g., `deployments`, `subscriptions`, `sandboxSessions`). Consider adding `createdAt: v.number()` for debugging and audit purposes.</violation>
</file>
<file name="src/app/api/deploy/netlify/env-vars/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/env-vars/route.ts:67">
P2: Validation allows `null` values which may cause issues downstream. Consider using `body.value == null` to reject both `null` and `undefined` while still allowing empty strings, or use `typeof body.value !== 'string'` for stricter type checking.</violation>
</file>
<file name="src/app/api/deploy/netlify/deploy/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/deploy/route.ts:24">
P1: The fallback value `"pending"` is not handled by `normalizeDeploymentStatus`. When `deploy.state` is undefined, this will incorrectly return `"error"` instead of `"pending"`. Add `"pending"` to the first condition.</violation>
</file>
<file name="src/modules/projects/ui/components/github-export-modal.tsx">
<violation number="1" location="src/modules/projects/ui/components/github-export-modal.tsx:139">
P2: Aborting the fetch now triggers the catch path, which sets an error even though the abort is intentional. This can surface a misleading error when the modal closes/reopens. Guard against AbortError (or controller.signal.aborted) before calling setError.</violation>
</file>
<file name="src/lib/payment-templates/types.ts">
<violation number="1" location="src/lib/payment-templates/types.ts:3">
P1: Type safety regression: `keyof typeof frameworks` resolves to `string` because `frameworks` is typed as `Record<string, FrameworkData>`. This loses the specific union type that was previously enforced.
To preserve type safety while deriving from the source, either:
1. Keep the explicit union type as before, or
2. Change `frameworks.ts` to use `satisfies` instead of a type annotation:
```typescript
export const frameworks = { ... } satisfies Record<string, FrameworkData>;
```</violation>
</file>
<file name="src/lib/payment-templates/vue.ts">
<violation number="1" location="src/lib/payment-templates/vue.ts:206">
P1: Webhook signature verification will always fail because `express.raw()` middleware is applied after the global `express.json()` has already parsed the body. The raw body is unavailable, so `String(req.body)` returns `"[object Object]"` instead of the actual payload.
To fix this, mount webhook routes BEFORE applying `express.json()`, or exclude the webhook path from JSON parsing:
```ts
app.use("/api/webhooks", webhookRoutes); // Mount before express.json()
app.use(express.json());
app.use("/api/billing", billingRoutes);
```</violation>
</file>
<file name="convex/oauth.ts">
<violation number="1" location="convex/oauth.ts:7">
P0: Critical security vulnerability: hardcoded fallback encryption key defeats the purpose of encryption. If `OAUTH_ENCRYPTION_KEY` env var is not set, the app silently uses a predictable key visible in source code, allowing anyone with codebase access to decrypt all OAuth tokens. Throw an error instead of using a fallback.</violation>
</file>
<file name="src/app/api/deploy/netlify/callback/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/callback/route.ts:9">
P1: Using a hardcoded fallback secret for HMAC signature verification defeats CSRF protection. If `NETLIFY_OAUTH_STATE_SECRET` is unset, attackers can forge valid state tokens. Either throw an error when the secret is missing (like you do for `NETLIFY_CLIENT_ID`/`NETLIFY_CLIENT_SECRET`) or generate a secure random secret at startup.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Fix all issues with AI agents
In `@convex/githubExports.ts`:
- Around line 283-288: The import statement "import { internal }" is incorrectly
placed inside the action handler where
ctx.runQuery(internal.oauth.getGithubAccessToken, ...) is called; move the
import to the module's top-level import block with the other imports and delete
the in-function import lines so the handler uses the already-imported internal
symbol; ensure there are no duplicate imports and run a quick type/lint check to
confirm the module compiles.
In `@convex/oauth.ts`:
- Around line 7-8: Replace the insecure fallback for ENCRYPTION_KEY so the app
fails fast when OAUTH_ENCRYPTION_KEY is not set: remove the default
"fallback-key-change-me-in-production" and add a startup check that throws a
clear error (or exits) if process.env.OAUTH_ENCRYPTION_KEY is missing, leaving
ALGORITHM as-is; update any code that references ENCRYPTION_KEY to use the
validated/required value so encryption never silently falls back to an insecure
key.
In `@src/app/api/deploy/netlify/callback/route.ts`:
- Line 9: The code uses a hardcoded fallback for NETLIFY_OAUTH_STATE_SECRET
which weakens CSRF protection; remove the fallback and validate the secret in
the request handler (the module-level constant NETLIFY_OAUTH_STATE_SECRET and
the callback route handler) so that if the env var is missing you short-circuit
with an error response (e.g., return a 500 JSON error like "OAuth state secret
not configured") instead of continuing with a weak default; ensure the handler
checks NETLIFY_OAUTH_STATE_SECRET before using it.
In `@src/app/api/deploy/netlify/deploy/route.ts`:
- Around line 22-34: normalizeDeploymentStatus currently treats an input of
"pending" as an unknown status and returns "error", which causes fresh deploys
(where the caller may pass "pending" as a fallback) to be recorded as errors;
update normalizeDeploymentStatus to explicitly handle the "pending" value (e.g.,
check if normalized === "pending" and return "pending" before falling back to
"error"), ensuring you compare the lowercased (and optionally trimmed) status
string so callers that pass "pending" (such as the Netlify deploy route) are
recorded correctly.
- Around line 114-116: convex.query(api.projects.get, { projectId }) can return
null, so before dereferencing project.name update the handler around
getConvexClientWithAuth / convex.query to check if project is null and return a
404 response (or throw a NotFound error) when missing; specifically, after
calling convex.query(api.projects.get, { projectId }) verify the returned
project object and handle the missing case rather than accessing project.name
directly.
In `@src/app/api/projects/`[projectId]/export/github/route.ts:
- Line 44: The code calls getToken in the line creating connection (const
connection = await fetchQuery(api.oauth.getConnection, { provider: "github" }, {
token: (await getToken()) ?? undefined });) but getToken is not imported,
causing a runtime error; fix by adding getToken to the imports from
"@/lib/auth-server" (or the correct module that exports it) alongside other
imports so the function is available at runtime, and run a quick build/test to
ensure no other missing imports remain.
- Around line 53-70: The code uses an undefined accessToken when calling
getRepository and createRepository; either obtain the token before use or stop
passing it and delegate repository ops to the backend action that handles
tokens. Fix by (A) retrieving the token into accessToken (using the same
helper/flow used elsewhere in this route/module) before calling
getRepository/createRepository, or (B) refactor to call the Convex/ backend
action that encapsulates token handling and pass only repository details (i.e.,
remove accessToken from getRepository/createRepository calls and instead invoke
the action that performs repository creation/lookup). Ensure you update the
calls around getRepository, createRepository and the repository variable to
match the chosen approach.
In `@src/modules/projects/ui/components/github-export-modal.tsx`:
- Around line 133-156: The catch block in loadRepositories currently sets
setError for any thrown error including fetch aborts; update the catch to detect
an AbortError (check error.name === "AbortError") and skip calling setError when
aborted so the modal doesn't show a stale error after controller.abort(); keep
the rest of the error handling intact for non-abort errors and ensure
setIsLoadingRepos(false) still runs in finally.
♻️ Duplicate comments (4)
src/lib/payment-provider.ts (1)
204-231: Unsafenull as Ttype assertion bypasses type safety.The change from
undefined as Ttonull as Tdoesn't resolve the core issue: methods likecreateCheckoutSession,updateSubscription, andcheckFeaturedeclare non-nullable return types, but a 204 response would returnnullcast to those types, causing runtime errors when callers access properties.Consider one of these approaches:
- Use a conditional return type or overloads to distinguish void-returning endpoints
- Throw an error for unexpected 204 responses on endpoints that require a body
- Return a discriminated union with a success flag
🔧 Suggested fix: Throw on unexpected 204 for non-void endpoints
private async request<T>( path: string, - options: AutumnRequestOptions + options: AutumnRequestOptions & { allowEmpty?: boolean } ): Promise<T> { const url = `${this.baseUrl}${path}`; const response = await fetch(url, { ...options, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, ...(options.headers ?? {}), }, body: options.body ? JSON.stringify(options.body) : undefined, }); if (!response.ok) { const errorText = await response.text(); throw new Error( `Autumn API request failed: ${response.status} ${response.statusText} - ${errorText}` ); } if (response.status === 204) { + if (!options.allowEmpty) { + throw new Error(`Unexpected 204 No Content response from ${path}`); + } - return null as T; + return undefined as unknown as T; } return (await response.json()) as T; }Then for
trackUsage, pass{ allowEmpty: true }:async trackUsage(input: UsageEvent): Promise<void> { await this.request<void>("/v1/usage", { method: "POST", body: { ... }, allowEmpty: true, }); }src/lib/payment-templates/vue.ts (1)
107-143: PATCH and DELETE routes missing try-catch blocks.Unlike other routes in this template,
PATCHandDELETEhandlers don't wrapautumn.request()in try-catch. In Express 4.x, unhandled async errors cause hanging requests or unhandled promise rejections.🐛 Proposed fix
router.patch("/subscription", async (req: Request, res: Response) => { + try { const { subscriptionId, productId } = req.body as { subscriptionId?: string; productId?: string; }; if (!subscriptionId || !productId) { res.status(400).json({ error: "Invalid payload" }); return; } const updated = await autumn.request<unknown>( \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}\`, { method: "PATCH", body: { productId }, } ); res.json(updated); + } catch (error) { + res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" }); + } }); router.delete("/subscription", async (req: Request, res: Response) => { + try { const { subscriptionId, cancelAtPeriodEnd } = req.body as { subscriptionId?: string; cancelAtPeriodEnd?: boolean; }; if (!subscriptionId) { res.status(400).json({ error: "Invalid payload" }); return; } const canceled = await autumn.request<unknown>( \`/v1/subscriptions/\${encodeURIComponent(subscriptionId)}/cancel\`, { method: "POST", body: { cancelAtPeriodEnd: cancelAtPeriodEnd ?? true }, } ); res.json(canceled); + } catch (error) { + res.status(500).json({ error: error instanceof Error ? error.message : "Internal server error" }); + } });convex/deployments.ts (1)
111-131: RemoveuserIdfrom public query responses.The return schema exposes
userId(Clerk ID) to clients. Per coding guidelines, Clerk user IDs should never be exposed in public APIs. This field should be removed from the return validators.🔒 Proposed fix
Remove
userId: v.string(),from lines 115 and 158 in the return validators.convex/githubExports.ts (1)
21-36: RemoveuserIdfrom public query response schema.The
githubExportRecordincludesuserIdwhich exposes Clerk IDs to clients via thelist,get, andgetLatestqueries. This violates the guideline to never expose Clerk user IDs in public APIs.
🧹 Nitpick comments (8)
src/lib/payment-templates/angular.ts (1)
298-314: Client methods lack error handling for failed requests.
checkFeatureparses JSON without checkingresponse.ok, which may throw on error responses.trackUsagesilently ignores failures. For billing accuracy, consider adding error handling.♻️ Suggested improvement
async checkFeature(customerId: string, featureId: string): Promise<boolean> { const response = await fetch("/api/billing/feature-check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customerId, featureId }), }); + if (!response.ok) { + return false; + } const data = (await response.json()) as { allowed?: boolean }; return data.allowed === true; } async trackUsage(customerId: string, meterId: string, quantity: number): Promise<void> { - await fetch("/api/billing/usage", { + const response = await fetch("/api/billing/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customerId, meterId, quantity }), }); + if (!response.ok) { + console.error("Failed to track usage"); + } }src/lib/payment-templates/react.ts (1)
344-350: Consider adding error handling to trackUsage.This function silently ignores API failures, which could lead to billing inaccuracies going unnoticed.
♻️ Suggested improvement
export async function trackUsage(payload: UsagePayload): Promise<void> { - await fetch("/api/billing/usage", { + const response = await fetch("/api/billing/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); + if (!response.ok) { + console.error("Failed to track usage"); + } }src/lib/payment-templates/svelte.ts (1)
305-311: Consider adding error handling to trackUsage.Similar to other templates, this function silently ignores API failures.
♻️ Suggested improvement
export const trackUsage = async (payload: UsagePayload): Promise<void> => { - await fetch("/api/billing/usage", { + const response = await fetch("/api/billing/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); + if (!response.ok) { + console.error("Failed to track usage"); + } };src/lib/payment-templates/nextjs.ts (1)
300-331: Consider wrapping JSON.parse in try-catch for robustness.While signature verification should prevent malformed payloads, wrapping
JSON.parse(rawBody)in try-catch would provide an additional safety layer and consistent error responses for edge cases.♻️ Suggested improvement
if (!verifySignature(signature, rawBody, secret)) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } - const event = JSON.parse(rawBody) as { type: string; data: unknown }; + let event: { type: string; data: unknown }; + try { + event = JSON.parse(rawBody) as { type: string; data: unknown }; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } switch (event.type) {convex/oauth.ts (1)
84-98: Consider excludingaccessTokenfrom public query response.
getConnectionreturns the full connection record including the encryptedaccessTokenfield to the client. While encrypted, exposing this blob to the client is unnecessary and increases attack surface. Consider returning only the fields needed for UI display.♻️ Proposed refactor
export const getConnection = query({ args: { provider: oauthProviderEnum, }, + returns: v.union( + v.null(), + v.object({ + _id: v.id("oauthConnections"), + provider: oauthProviderEnum, + scope: v.string(), + metadata: v.optional(v.any()), + createdAt: v.number(), + updatedAt: v.number(), + }) + ), handler: async (ctx, args) => { const userId = await requireAuth(ctx); - return await ctx.db + const connection = await ctx.db .query("oauthConnections") .withIndex("by_userId_provider", (q) => q.eq("userId", userId).eq("provider", args.provider) ) .first(); + + if (!connection) return null; + + const { accessToken, refreshToken, ...safe } = connection; + return safe; }, });convex/schema.ts (1)
53-58: Case inconsistency with existing status enums.The new
githubExportStatusEnumuses lowercase values ("pending","processing") while existing enums likeimportStatusEnumandmessageStatusEnumuse uppercase ("PENDING","PROCESSING"). Consider aligning for consistency across the schema.src/app/api/deploy/netlify/domains/route.ts (1)
64-70: Validate request JSON instead of casting toDomainPayload.
request.json()is untyped; the cast can hide malformed inputs. Prefer a type guard to validatesiteId/domainbefore using them. As per coding guidelines, avoidastype assertions.🛠️ Suggested refactor
type DomainPayload = { siteId: string; domain: string; }; +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null; + +const isDomainPayload = (value: unknown): value is DomainPayload => + isRecord(value) && + typeof value.siteId === "string" && + typeof value.domain === "string"; + export async function POST(request: Request) { try { const user = await getUser(); if (!user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const body = (await request.json()) as DomainPayload; - if (!body.siteId || !body.domain) { + const body = await request.json(); + if (!isDomainPayload(body)) { return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 }); } + const { siteId, domain } = body; const netlifyClient = createNetlifyClient(await getNetlifyAccessToken()); - const domain = await netlifyClient.addDomain(body.siteId, body.domain); + const domain = await netlifyClient.addDomain(siteId, domain);src/app/api/deploy/netlify/deploy/route.ts (1)
113-113: Avoidas Id<"projects">type assertion.Prefer a validated/typed helper to produce the branded
Idinstead of asserting (e.g., a Convex-provided validator or a local wrapper). Please verify the recommended approach in your Convex/TS setup. As per coding guidelines, avoidastype assertions.
Add provider selection/templates and persist choices so generated projects get the right integration rules, plus add color theme selection and refresh the roadmap.
|
🔍 Analyzing PR changes and preparing to run tests... |
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
There was a problem hiding this comment.
6 issues found across 23 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="src/lib/database-templates/drizzle-neon/nextjs.ts">
<violation number="1" location="src/lib/database-templates/drizzle-neon/nextjs.ts:106">
P2: The `redirectTo` query parameter set by the middleware is never used. The form hardcodes `callbackURL: "/dashboard"` and `router.push("/dashboard")`, ignoring the intended redirect destination. Users won't be returned to their original protected route after signing in.</violation>
</file>
<file name="src/prompts/shared.ts">
<violation number="1" location="src/prompts/shared.ts:223">
P2: The shared prompt now mandates databaseTemplates for all frameworks, but the tool only supports Next.js. For non-Next.js frameworks this instruction will produce tool errors and leave the agent without templates. Clarify the Next.js-only limitation or add a fallback path in the prompt.</violation>
</file>
<file name="src/lib/database-templates/convex/nextjs.ts">
<violation number="1" location="src/lib/database-templates/convex/nextjs.ts:50">
P1: Incorrect usage of `toNextJsHandler`: it expects an auth instance directly, not an async callback function. This will cause authentication routes to fail. The auth instance should be created once at module level and passed directly to `toNextJsHandler(auth)`.</violation>
</file>
<file name="src/components/color-theme-picker.tsx">
<violation number="1" location="src/components/color-theme-picker.tsx:29">
P3: Add an explicit button type to avoid accidental form submissions when the picker is rendered inside a form.</violation>
</file>
<file name="src/components/color-theme-provider.tsx">
<violation number="1" location="src/components/color-theme-provider.tsx:52">
P2: Wrap `localStorage.getItem` in a try-catch block to prevent crashes in Safari private browsing mode or when localStorage is unavailable. This is a common source of runtime errors in production.</violation>
<violation number="2" location="src/components/color-theme-provider.tsx:70">
P2: Wrap `localStorage.setItem` in a try-catch block to prevent crashes when storage is unavailable or quota is exceeded.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
ROADMAP.md (1)
1-179: Move ROADMAP.md to the /explanations/ directory.As per coding guidelines, all
.mddocumentation files (except CLAUDE.md and README.md) should be placed in theexplanations/directory, never in the project root.📁 Relocation required
Move this file from:
ROADMAP.mdTo:
explanations/ROADMAP.md
🤖 Fix all issues with AI agents
In `@ROADMAP.md`:
- Line 71: Replace the redundant phrase "SEO Optimization" in the ROADMAP bullet
point with a non-redundant term; update the list item that currently reads "SEO
Optimization: Structured data, meta tags, OpenGraph" to use either "SEO:
Structured data, meta tags, OpenGraph" or the unabbreviated "Search Engine
Optimization: Structured data, meta tags, OpenGraph" so the wording is not
repetitive.
In `@src/lib/database-templates/convex/nextjs.ts`:
- Around line 43-58: The template incorrectly constructs createAuth inline with
a ConvexHttpClient and uses a type assertion; instead, move the createAuth
construction into your Convex server code (e.g., a module that exports the
created auth and its handler), then import and pass auth.handler into
toNextJsHandler in the Next.js API file (removing the as Parameters<typeof
createAuth>[0] assertion). Also validate process.env.NEXT_PUBLIC_CONVEX_URL at
startup (throw a clear error if missing) rather than using a non-null assertion
so the ConvexHttpClient is only constructed with a guaranteed URL.
In `@src/lib/database-templates/index.ts`:
- Around line 50-56: isDatabaseSupported currently uses the `in` operator which
can return true for keys present with undefined values in the Partial<Record>
`templates`; change it to the same pattern as getDatabaseTemplate by looking up
`templates[key]` and returning a boolean based on the value (e.g.,
`!!templates[key]` or `templates[key] !== undefined && templates[key] !== null`)
so that TemplateKey presence only counts when the template value is defined;
update the isDatabaseSupported(provider, framework) implementation to compute
`const key: TemplateKey = \`${provider}-${framework}\`` then check
`templates[key]` instead of using `key in templates`.
In `@src/prompts/database-integration.ts`:
- Around line 34-37: Update the two npm install command occurrences that
currently read "npm install drizzle-orm `@neondatabase/serverless` better-auth"
and "npm install -D drizzle-kit" (and the repeated instance later) to include
the --yes flag (e.g., "npm install --yes drizzle-orm `@neondatabase/serverless`
better-auth" and "npm install -D --yes drizzle-kit") so the installs run
non-interactively and conform to tool policy.
♻️ Duplicate comments (1)
convex/schema.ts (1)
332-337: MissingcreatedAtfield breaks schema consistency.As noted in a previous review, all other tables with
updatedAtalso includecreatedAt(e.g.,deployments,subscriptions,sandboxSessions). AddingcreatedAtwould maintain consistency and aid debugging.
🧹 Nitpick comments (15)
src/prompts/database-selector.ts (1)
60-66: Centralize provider unions to avoid drift.
DatabaseSelectionduplicates other provider unions in the repo (e.g., database templates/agent types). Consider sourcing this from a single shared type so adding a provider doesn’t require multiple updates.src/lib/database-templates/types.ts (1)
1-4: Use@/lib/...for internal lib imports.This file is under
src/lib, so internal imports should use the@/libalias for consistency. As per coding guidelines.♻️ Proposed change
-import type { frameworks } from "../frameworks"; +import type { frameworks } from "@/lib/frameworks";src/lib/database-templates/env-example.ts (1)
1-38: Sanitize large template strings before persistence.These sizable env example strings live under
src/lib. If they’re persisted (e.g., via databaseTemplates tooling), they should be passed throughsanitizeAnyForDatabase()to prevent NULL-byte issues in Postgres. As per coding guidelines.♻️ Proposed change (if you persist these values)
+import { sanitizeAnyForDatabase } from "@/lib/sanitizeAnyForDatabase"; + -export const drizzleNeonEnvExample = `# Database (Neon PostgreSQL) +export const drizzleNeonEnvExample = sanitizeAnyForDatabase(`# Database (Neon PostgreSQL) ... -# GITHUB_CLIENT_SECRET="your-github-client-secret" -`; +# GITHUB_CLIENT_SECRET="your-github-client-secret" +`); -export const convexEnvExample = `# Convex +export const convexEnvExample = sanitizeAnyForDatabase(`# Convex ... -# GITHUB_CLIENT_SECRET="your-github-client-secret" -`; +# GITHUB_CLIENT_SECRET="your-github-client-secret" +`);src/lib/database-templates/drizzle-neon/nextjs.ts (1)
122-166: Consider adding focus-visible states for accessibility compliance.Per coding guidelines, form inputs should include
focus-visible:ring-ring/50 focus-visible:ring-[3px]classes. The current template uses basic border styling without visible focus indicators.♻️ Suggested input styling enhancement
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required - className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:border-ring" placeholder="you@example.com" />src/lib/database-templates/convex/nextjs.ts (1)
339-377: Client-side dashboard differs from drizzle-neon's server-side pattern.This template uses client-side auth checking with
useEffectredirect, while the drizzle-neon template uses server-side session validation. Both patterns work, but the inconsistency may confuse users comparing templates. The client-side approach briefly shows content before redirect if unauthenticated.Consider documenting this architectural difference or aligning both templates to use middleware-based protection consistently, as the drizzle-neon template includes middleware while this one doesn't.
src/lib/database-templates/index.ts (1)
28-48: Hardcoded frameworks array may drift from type definition.The
frameworksarray duplicates theDatabaseFrameworktype values. If new frameworks are added to the type, this array must be manually updated.Consider deriving from a single source of truth if the
DatabaseFrameworktype is defined as a union of string literals that can be extracted, or add a comment noting the maintenance requirement.src/lib/database-templates/convex/shared.ts (1)
34-71: Template queries missingreturnsvalidators per Convex guidelines.The
getCurrentUserandgetAuthUserIdqueries in the template don't includereturnsvalidators. Per coding guidelines, all Convex functions should include argument and return validators.♻️ Suggested template improvement
export const getCurrentUser = query({ args: {}, + returns: v.union(v.object({ ... }), v.null()), handler: async (ctx) => { return authComponent.getAuthUser(ctx); }, }); export const getAuthUserId = query({ args: {}, + returns: v.union(v.id("users"), v.null()), handler: async (ctx) => { const user = await authComponent.getAuthUser(ctx); return user?._id ?? null; }, });Note: The exact return type depends on
@convex-dev/better-auth'sgetAuthUserreturn type. Consider adding a TODO comment in the template for users to complete the validators.src/lib/database-templates/drizzle-neon/shared.ts (1)
80-99: Non-null assertions on DATABASE_URL in templates.Both
drizzleDbClient(line 84) anddrizzleConfig(line 96) useprocess.env.DATABASE_URL!. While common in templates, this will cause cryptic errors if the env var is missing.Consider adding runtime validation in the db client template:
const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { throw new Error("DATABASE_URL environment variable is required"); } const sql = neon(databaseUrl);convex/schema.ts (1)
59-64: Enum casing inconsistency with existing patterns.The new
githubExportStatusEnumuses lowercase values (pending,processing,complete,failed), while the similarimportStatusEnumuses UPPERCASE (PENDING,PROCESSING,COMPLETE,FAILED). Consider aligning for consistency.♻️ Suggested alignment with existing patterns
export const githubExportStatusEnum = v.union( - v.literal("pending"), - v.literal("processing"), - v.literal("complete"), - v.literal("failed") + v.literal("PENDING"), + v.literal("PROCESSING"), + v.literal("COMPLETE"), + v.literal("FAILED") );Alternatively, document the casing convention if lowercase is intentional for newer enums.
convex/projects.ts (2)
327-333: Inconsistent handling of optional field updates.The
databaseProviderfield uses a truthiness check (args.databaseProvider && ...), which will correctly include all enum values ("NONE", "DRIZZLE_NEON", "CONVEX") since they're all truthy strings. However, this pattern differs frommodelPreferencewhich uses!== undefined. More critically, if a user wants to "unset" thedatabaseProvider(though the enum doesn't have an explicit unset value), the current pattern won't allow setting it toundefined.Consider using a consistent pattern across all optional fields:
♻️ Suggested consistent pattern
await ctx.db.patch(args.projectId, { ...(args.name && { name: args.name }), ...(args.framework && { framework: args.framework }), - ...(args.databaseProvider && { databaseProvider: args.databaseProvider }), + ...(args.databaseProvider !== undefined && { databaseProvider: args.databaseProvider }), ...(args.modelPreference !== undefined && { modelPreference: args.modelPreference }), updatedAt: Date.now(), });
562-568: Same inconsistency inupdateForUserhandler.Apply the same fix here for consistency with the
updatemutation and themodelPreferencefield pattern.♻️ Suggested fix
await ctx.db.patch(args.projectId, { ...(args.name && { name: args.name }), ...(args.framework && { framework: args.framework }), - ...(args.databaseProvider && { databaseProvider: args.databaseProvider }), + ...(args.databaseProvider !== undefined && { databaseProvider: args.databaseProvider }), ...(args.modelPreference !== undefined && { modelPreference: args.modelPreference }), updatedAt: Date.now(), });src/modules/projects/ui/components/project-header.tsx (1)
145-158: Consider addingtext-muted-foregroundto the download icon for consistency.The
DownloadIconusessize-4which is correct per guidelines, but other icons in the codebase typically includetext-muted-foregroundfor consistent styling.♻️ Optional styling improvement
- <DownloadIcon className="size-4 mr-2" /> + <DownloadIcon className="size-4 mr-2 text-muted-foreground" />The overall integration of
DeploymentStatus,DeployButton, andGitHubExportButtoncomponents is clean and follows the established patterns. Thegap-3spacing is consistent with the design system.src/agents/types.ts (1)
4-4: Duplicate type definition detected.
DatabaseProvideris also defined insrc/lib/database-templates/types.tswith identical values. This duplication could lead to type drift if one is updated without the other.Consider importing from a single source of truth:
♻️ Consolidate type definition
-export type DatabaseProvider = "none" | "drizzle-neon" | "convex"; +export type { DatabaseProvider } from "@/lib/database-templates/types";Alternatively, if the agents module should be the source of truth, update
src/lib/database-templates/types.tsto import from here.src/lib/themes.ts (1)
262-264: Consider using theDEFAULT_COLOR_THEMEconstant for fallback consistency.The fallback uses
COLOR_THEMES[0]directly, but using the constant would be more maintainable and explicit:♻️ Use constant for fallback
export function getColorTheme(id: string): ColorTheme { - return COLOR_THEMES.find((theme) => theme.id === id) || COLOR_THEMES[0]; + return COLOR_THEMES.find((theme) => theme.id === id) ?? getColorTheme(DEFAULT_COLOR_THEME); }Or to avoid potential recursion, use a non-null assertion since we control the data:
export function getColorTheme(id: string): ColorTheme { - return COLOR_THEMES.find((theme) => theme.id === id) || COLOR_THEMES[0]; + const theme = COLOR_THEMES.find((t) => t.id === id); + if (theme) return theme; + // Fallback to default theme - we know it exists + return COLOR_THEMES.find((t) => t.id === DEFAULT_COLOR_THEME)!; }The current implementation works correctly since
COLOR_THEMES[0]is the "default" theme, but tying it to the constant makes the relationship explicit.src/agents/code-agent.ts (1)
1114-1130: Consider persisting database provider earlier, like framework detection.The database provider is persisted at the end of a successful run, but framework is persisted immediately after detection (line 408). If the agent run fails partway through, framework will be saved but database provider won't be. Consider moving database persistence to after detection for consistency.
♻️ Suggested refactor
Move the persistence logic to after detection (around line 419-422):
if (needsDatabaseDetection) { selectedDatabase = detectedDatabase; console.log("[INFO] Detected database provider:", selectedDatabase); + + try { + await convex.mutation(api.projects.updateForUser, { + userId: project.userId, + projectId: projectId as Id<"projects">, + databaseProvider: databaseProviderToConvexEnum(selectedDatabase), + }); + console.log("[INFO] Database provider saved to project"); + } catch (error) { + console.warn("[WARN] Failed to save database provider to project:", error); + } }Then remove or simplify lines 1114-1130 (the existing persistence at end of run).
- Added creation timestamps to project deployment counters for better tracking. - Improved GitHub export functionality by integrating a new action to retrieve the current user's GitHub access token. - Updated OAuth handling to ensure required environment variables are set, enhancing security and error management. - Enhanced error handling in various API routes and improved user feedback in UI components. These changes streamline deployment processes and strengthen OAuth security, contributing to a more robust application.
|
🔍 Analyzing PR changes and preparing to run tests... |
|
🚀 Launching Scrapybara desktop... |
|
🚀 Launching Scrapybara desktop... |
|
❌ Something went wrong: |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@convex/oauth.ts`:
- Around line 100-112: Both getGithubAccessTokenForCurrentUser and
getAnthropicAccessTokenForCurrentUser are calling other actions via
ctx.runAction inside the same Node runtime; extract the shared token-fetching
logic into a plain async helper (e.g., async function
fetchAccessTokenForUser(userId, provider) or two small helpers
fetchGithubAccessTokenForUser / fetchAnthropicAccessTokenForUser) and have the
action handlers (getGithubAccessTokenForCurrentUser,
getAnthropicAccessTokenForCurrentUser) call that helper instead of
ctx.runAction; update any internal.oauth.getGithubAccessToken /
internal.oauth.getAnthropicAccessToken callers to delegate to the new helper or
remove those internal action wrappers if they aren’t used across runtimes,
ensuring you keep the same return shapes (string | null) and reuse
ctx.auth.getUserIdentity() to obtain identity.subject as before.
- Around line 92-96: The current try/catch around
decryptToken(connection.accessToken) silently swallows all errors; change the
catch to capture the error (catch (err)) and log it before returning null so
decryption failures are observable. Update the block in convex/oauth.ts where
decryptToken is called (reference decryptToken and connection.accessToken) to
call an available logger (e.g., console.error or the module's logger) with a
clear message including identifying context (e.g., connection id or user info)
and the error object, then return null as before.
- Around line 55-75: The public action currently returns the raw Convex document
id (returns: v.id("oauthConnections")) and exposes it from the handler (returns
connectionId); change the action to return v.null() and update the handler in
storeConnection so it does not return the stored document id — call
ctx.runMutation(internal.oauthQueries.storeConnectionInternal) as before to
persist the connection (keep variables like
encryptedAccessToken/encryptedRefreshToken and connectionId if needed for local
logic) but end the handler by returning null instead of connectionId to avoid
exposing internal document IDs.
In `@src/modules/projects/ui/components/netlify-cli-dialog.tsx`:
- Around line 36-38: The error handling currently calls response.json()
unguarded which can throw for non-JSON bodies; update the block that checks if
(!response.ok) to try parsing JSON in a try/catch (e.g., attempt await
response.json()), if parsing fails fall back to await response.text(), then
throw a new Error that includes response.status and the parsed body (or raw
text) so the HTTP status and response content are preserved; reference the
response variable and response.json()/response.text() usage when making the
change.
- Line 1: Add the Next.js client directive by inserting "use client" as the very
first line of src/modules/projects/ui/components/netlify-cli-dialog.tsx (before
the import of useState) so this component (which uses useState and browser APIs
like window.URL and document.createElement) is explicitly marked a client
component and won’t break if imported into a Server Component.
🧹 Nitpick comments (6)
convex/oauth.ts (1)
24-32: Use 12-byte IV for AES-GCM per NIST recommendation.AES-GCM is designed for a 96-bit (12-byte) nonce. A 16-byte IV works but triggers an internal GHASH step that slightly reduces the security margin and adds overhead. NIST SP 800-38D recommends 12 bytes.
Suggested fix
- const iv = crypto.randomBytes(16); + const iv = crypto.randomBytes(12);src/modules/projects/ui/components/netlify-cli-dialog.tsx (5)
96-96: Icon sizing: prefersize-4per coding guidelines.All icons in this file use
h-4 w-4(lines 96, 129, 131, 149, 151, 171, 173, 191, 193, 203, 212, 270), but the project coding guidelines specifysize-4as the default icon size class.size-4is the Tailwind shorthand equivalent (width+heightin one utility).Example fix for one instance:
-<Terminal className="mr-2 h-4 w-4" /> +<Terminal className="mr-2 size-4" />Apply similarly to all icon usages.
As per coding guidelines, "Use
lucide-reactas the icon library with default sizesize-4(16px)."
202-204: Consider adding a spinner for the loading state.When
isDownloadingis true, the button text changes to "Preparing..." but there's no visual loading indicator. The project guidelines recommend usinganimate-spinfor loading states. Consider showing a spinner icon:- <Download className="mr-2 size-4" /> - {isDownloading ? "Preparing..." : "Download Project ZIP"} + {isDownloading ? ( + <Download className="mr-2 size-4 animate-spin" /> + ) : ( + <Download className="mr-2 size-4" /> + )} + {isDownloading ? "Preparing..." : "Download Project ZIP"}Alternatively, import
Loader2fromlucide-reactfor a dedicated spinner icon, which is more conventional.As per coding guidelines, "Implement loading states with CSS animations: Spinner using
animate-spin."
115-199: Repetitive command card pattern could be extracted.The four command cards (install, login, init, deploy) follow an identical structure differing only in title, description, command text, and key. Consider extracting a reusable
CommandCardcomponent or mapping over an array of command definitions to reduce ~80 lines of repetition to ~15:Sketch
const commands = [ { key: "install", title: "1. Install Netlify CLI", command: installCommand }, { key: "login", title: "2. Login to Netlify", command: loginCommand }, { key: "init", title: "3. Initialize your site", command: initCommand, description: "Navigate to your project folder and run:" }, { key: "deploy", title: "4. Deploy", command: deployCommand }, ] as const; // Then map: {commands.map(({ key, title, command, description }) => ( <div key={key} className="rounded-lg bg-muted p-4"> <h4 className="mb-2 font-medium">{title}</h4> {description && <p className="mb-2 text-sm text-muted-foreground">{description}</p>} <div className="flex items-center gap-2"> <code className="flex-1 rounded bg-background px-3 py-2 text-sm">{command}</code> <Button size="sm" variant="ghost" onClick={() => copyToClipboard(command, key)}> {copiedCommand === key ? <Check className="size-4" /> : <Copy className="size-4" />} </Button> </div> </div> ))}
219-260: Commands tab lacks copy-to-clipboard, unlike Quick Start.The Quick Start tab provides copy buttons for each command, but the Commands tab shows similar CLI commands without copy functionality. Users will likely want to copy these as well. Consider reusing the same copy pattern here for a consistent experience.
269-272: Long project names may overflow the download button text.Line 271 interpolates
projectNamedirectly into the button label. If the project name is long, the button text could overflow or wrap awkwardly. Consider truncating or capping the displayed name:-{isDownloading ? "Preparing..." : `Download ${projectName}-netlify-ready.zip`} +{isDownloading ? "Preparing..." : `Download ${projectName.length > 20 ? projectName.slice(0, 20) + "…" : projectName}-netlify-ready.zip`}Or simply use a generic label like
"Download Netlify-Ready ZIP"to avoid the issue entirely.
| returns: v.id("oauthConnections"), | ||
| handler: async (ctx, args) => { | ||
| const userId = await requireAuth(ctx); | ||
|
|
||
| // Check if connection already exists | ||
| const existing = await ctx.db | ||
| .query("oauthConnections") | ||
| .withIndex("by_userId_provider", (q) => | ||
| q.eq("userId", userId).eq("provider", args.provider) | ||
| ) | ||
| .first(); | ||
|
|
||
| const now = Date.now(); | ||
|
|
||
| if (existing) { | ||
| // Update existing connection | ||
| return await ctx.db.patch(existing._id, { | ||
| accessToken: args.accessToken, | ||
| refreshToken: args.refreshToken || existing.refreshToken, | ||
| expiresAt: args.expiresAt, | ||
| scope: args.scope, | ||
| metadata: args.metadata || existing.metadata, | ||
| updatedAt: now, | ||
| }); | ||
| const identity = await ctx.auth.getUserIdentity(); | ||
| if (!identity?.subject) { | ||
| throw new Error("Unauthorized"); | ||
| } | ||
| const userId = identity.subject; | ||
|
|
||
| // Create new connection | ||
| return await ctx.db.insert("oauthConnections", { | ||
| const encryptedAccessToken = encryptToken(args.accessToken); | ||
| const encryptedRefreshToken = args.refreshToken ? encryptToken(args.refreshToken) : undefined; | ||
|
|
||
| const connectionId: Id<"oauthConnections"> = await ctx.runMutation(internal.oauthQueries.storeConnectionInternal, { | ||
| userId, | ||
| provider: args.provider, | ||
| accessToken: args.accessToken, | ||
| refreshToken: args.refreshToken, | ||
| accessToken: encryptedAccessToken, | ||
| refreshToken: encryptedRefreshToken, | ||
| expiresAt: args.expiresAt, | ||
| scope: args.scope, | ||
| metadata: args.metadata, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| }); | ||
| return connectionId; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Search for all usages of storeConnection outside of oauth.ts
rg -n "storeConnection" --type=ts -C3 -g '!convex/oauth.ts'Repository: Jackson57279/zapdev
Length of output: 2817
🏁 Script executed:
# Also check the full function definition to confirm it's public and understand the context
rg -B5 "storeConnection" convex/oauth.ts | head -40Repository: Jackson57279/zapdev
Length of output: 551
Public action unnecessarily exposes internal document ID.
storeConnection is a public action that returns v.id("oauthConnections"), exposing the raw Convex document ID to all callers in OAuth callback routes. The returned ID is unused at all call sites (Netlify, Anthropic, GitHub, Figma callbacks). Return v.null() instead.
Suggested fix
- returns: v.id("oauthConnections"),
+ returns: v.null(),
handler: async (ctx, args) => {
...
- return connectionId;
+ return null;
},🤖 Prompt for AI Agents
In `@convex/oauth.ts` around lines 55 - 75, The public action currently returns
the raw Convex document id (returns: v.id("oauthConnections")) and exposes it
from the handler (returns connectionId); change the action to return v.null()
and update the handler in storeConnection so it does not return the stored
document id — call
ctx.runMutation(internal.oauthQueries.storeConnectionInternal) as before to
persist the connection (keep variables like
encryptedAccessToken/encryptedRefreshToken and connectionId if needed for local
logic) but end the handler by returning null instead of connectionId to avoid
exposing internal document IDs.
| try { | ||
| return decryptToken(connection.accessToken); | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Silent catch swallows all decryption errors.
Returning null on decryption failure makes a key-rotation mistake, corrupted data, or a misconfigured env var indistinguishable from "no token exists." At minimum, log the error for observability.
Suggested fix
try {
return decryptToken(connection.accessToken);
- } catch {
+ } catch (error) {
+ console.error("Failed to decrypt GitHub access token for user", args.userId, error);
return null;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { | |
| return decryptToken(connection.accessToken); | |
| } catch { | |
| return null; | |
| } | |
| try { | |
| return decryptToken(connection.accessToken); | |
| } catch (error) { | |
| console.error("Failed to decrypt GitHub access token for user", args.userId, error); | |
| return null; | |
| } |
🤖 Prompt for AI Agents
In `@convex/oauth.ts` around lines 92 - 96, The current try/catch around
decryptToken(connection.accessToken) silently swallows all errors; change the
catch to capture the error (catch (err)) and log it before returning null so
decryption failures are observable. Update the block in convex/oauth.ts where
decryptToken is called (reference decryptToken and connection.accessToken) to
call an available logger (e.g., console.error or the module's logger) with a
clear message including identifying context (e.g., connection id or user info)
and the error object, then return null as before.
| if (!response.ok) { | ||
| const error = await response.json(); | ||
| throw new Error(error.error || "Failed to prepare deployment package"); |
There was a problem hiding this comment.
response.json() may throw on non-JSON error bodies.
If the server returns a non-JSON error response (e.g., 502 HTML page from a gateway), response.json() will reject and fall into the outer catch, losing the HTTP status context. Consider guarding the parse:
Suggested improvement
if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || "Failed to prepare deployment package");
+ let message = "Failed to prepare deployment package";
+ try {
+ const error = await response.json();
+ message = error.error || message;
+ } catch {
+ // non-JSON response body (e.g. gateway HTML error page)
+ }
+ throw new Error(message);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.error || "Failed to prepare deployment package"); | |
| if (!response.ok) { | |
| let message = "Failed to prepare deployment package"; | |
| try { | |
| const error = await response.json(); | |
| message = error.error || message; | |
| } catch { | |
| // non-JSON response body (e.g. gateway HTML error page) | |
| } | |
| throw new Error(message); | |
| } |
🤖 Prompt for AI Agents
In `@src/modules/projects/ui/components/netlify-cli-dialog.tsx` around lines 36 -
38, The error handling currently calls response.json() unguarded which can throw
for non-JSON bodies; update the block that checks if (!response.ok) to try
parsing JSON in a try/catch (e.g., attempt await response.json()), if parsing
fails fall back to await response.text(), then throw a new Error that includes
response.status and the parsed body (or raw text) so the HTTP status and
response content are preserved; reference the response variable and
response.json()/response.text() usage when making the change.
Deployment failedThis pull request failed while building automatically on Stormkit. You can preview the logs using the following link. |
|
❌ Error while analyzing PR and generating tests: |
There was a problem hiding this comment.
6 issues found across 22 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="convex/oauth.ts">
<violation number="1" location="convex/oauth.ts:173">
P2: Significant code duplication exists between `getAccessTokenForCurrentUser`, `getGithubAccessTokenForCurrentUser`, and `getAnthropicAccessTokenForCurrentUser`.
All three actions implement the exact same logic (identity check, query connection, decrypt).
The explicit provider functions (`getGithub...`) now duplicate the logic instead of reusing the generic one or a shared helper.
**Recommendation**:
Refactor the common logic into a shared (non-exported or internal) helper function, or deprecate the specific actions in favor of the generic one.</violation>
<violation number="2" location="convex/oauth.ts:173">
P0: Exposing decrypted OAuth access tokens (including sensitive ones like GitHub and Netlify) to the client is a critical security risk.
This public action allows any script running in the user's browser (including via XSS) to retrieve the raw token and compromise the user's third-party accounts.
**Risk**: Account takeover, data exfiltration, malicious deployments.
**Recommendation**:
1. Make this action `internal` so it cannot be called by the client.
2. Refactor the deployment logic (currently in Next.js API routes) to run within Convex Actions. This allows the token to be retrieved and used server-side without ever exposing it to the client or Next.js server (unless necessary).
3. If Next.js must perform the deployment, use an internal query/action accessed via an Admin Key, rather than relying on the user's session which exposes the endpoint to the browser.</violation>
</file>
<file name="src/app/api/deploy/netlify/env-vars/route.ts">
<violation number="1" location="src/app/api/deploy/netlify/env-vars/route.ts:22">
P3: The error handling logic in the route handlers checks for the specific string "Netlify connection not found". This is fragile. Use a shared constant or a custom error subclass to ensure the throw and catch logic remain in sync, especially if you extract this helper to a shared file.</violation>
</file>
<file name="src/app/api/import/figma/files/route.ts">
<violation number="1" location="src/app/api/import/figma/files/route.ts:34">
P1: The Figma API endpoint `https://api.figma.com/v1/files` is invalid. Figma's REST API does not provide a root endpoint to list all files for a user. You must query by team or project (e.g., `GET /v1/projects/:project_id/files`) or ask the user for a specific file key. This request will likely fail with a 404.</violation>
</file>
<file name="src/app/api/agent/run/route.ts">
<violation number="1" location="src/app/api/agent/run/route.ts:48">
P2: The `safeWrite` function suppresses write errors (e.g., from client disconnects) but does not signal the failure to the caller. As a result, the subscription loop continues to process events and attempt writes until the agent run completes, wasting server resources on a disconnected client.
Update `safeWrite` to return a boolean indicating success, and check this return value in the subscription callback to abort early.</violation>
</file>
<file name="src/app/api/import/figma/process/route.ts">
<violation number="1" location="src/app/api/import/figma/process/route.ts:77">
P2: The `processFigmaImport` function is executed asynchronously without being awaited before the response is returned. In serverless environments (like Netlify or Vercel), the runtime may freeze or terminate the process immediately after the response is sent, causing the background import to fail.
Since this PR introduces Inngest, consider offloading this task to an Inngest function or using `waitUntil` to ensure the process completes.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| }, | ||
| handler: async (ctx, args) => { | ||
| const userId = await requireAuth(ctx); | ||
| export const getAccessTokenForCurrentUser = action({ |
There was a problem hiding this comment.
P0: Exposing decrypted OAuth access tokens (including sensitive ones like GitHub and Netlify) to the client is a critical security risk.
This public action allows any script running in the user's browser (including via XSS) to retrieve the raw token and compromise the user's third-party accounts.
Risk: Account takeover, data exfiltration, malicious deployments.
Recommendation:
- Make this action
internalso it cannot be called by the client. - Refactor the deployment logic (currently in Next.js API routes) to run within Convex Actions. This allows the token to be retrieved and used server-side without ever exposing it to the client or Next.js server (unless necessary).
- If Next.js must perform the deployment, use an internal query/action accessed via an Admin Key, rather than relying on the user's session which exposes the endpoint to the browser.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/oauth.ts, line 173:
<comment>Exposing decrypted OAuth access tokens (including sensitive ones like GitHub and Netlify) to the client is a critical security risk.
This public action allows any script running in the user's browser (including via XSS) to retrieve the raw token and compromise the user's third-party accounts.
**Risk**: Account takeover, data exfiltration, malicious deployments.
**Recommendation**:
1. Make this action `internal` so it cannot be called by the client.
2. Refactor the deployment logic (currently in Next.js API routes) to run within Convex Actions. This allows the token to be retrieved and used server-side without ever exposing it to the client or Next.js server (unless necessary).
3. If Next.js must perform the deployment, use an internal query/action accessed via an Admin Key, rather than relying on the user's session which exposes the endpoint to the browser.</comment>
<file context>
@@ -140,8 +152,46 @@ export const getAnthropicAccessTokenForCurrentUser = action({
+ },
+});
+
+export const getAccessTokenForCurrentUser = action({
+ args: { provider: oauthProviderEnum },
+ returns: v.union(v.string(), v.null()),
</file context>
| const response = await fetch("https://api.figma.com/v1/files", { | ||
| headers: { | ||
| Authorization: `Bearer ${connection.accessToken}`, | ||
| Authorization: `Bearer ${accessToken}`, |
There was a problem hiding this comment.
P1: The Figma API endpoint https://api.figma.com/v1/files is invalid. Figma's REST API does not provide a root endpoint to list all files for a user. You must query by team or project (e.g., GET /v1/projects/:project_id/files) or ask the user for a specific file key. This request will likely fail with a 404.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/import/figma/files/route.ts, line 34:
<comment>The Figma API endpoint `https://api.figma.com/v1/files` is invalid. Figma's REST API does not provide a root endpoint to list all files for a user. You must query by team or project (e.g., `GET /v1/projects/:project_id/files`) or ask the user for a specific file key. This request will likely fail with a 404.</comment>
<file context>
@@ -33,7 +31,7 @@ export async function GET() {
const response = await fetch("https://api.figma.com/v1/files", {
headers: {
- Authorization: `Bearer ${connection.accessToken}`,
+ Authorization: `Bearer ${accessToken}`,
},
});
</file context>
| }, | ||
| handler: async (ctx, args) => { | ||
| const userId = await requireAuth(ctx); | ||
| export const getAccessTokenForCurrentUser = action({ |
There was a problem hiding this comment.
P2: Significant code duplication exists between getAccessTokenForCurrentUser, getGithubAccessTokenForCurrentUser, and getAnthropicAccessTokenForCurrentUser.
All three actions implement the exact same logic (identity check, query connection, decrypt).
The explicit provider functions (getGithub...) now duplicate the logic instead of reusing the generic one or a shared helper.
Recommendation:
Refactor the common logic into a shared (non-exported or internal) helper function, or deprecate the specific actions in favor of the generic one.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/oauth.ts, line 173:
<comment>Significant code duplication exists between `getAccessTokenForCurrentUser`, `getGithubAccessTokenForCurrentUser`, and `getAnthropicAccessTokenForCurrentUser`.
All three actions implement the exact same logic (identity check, query connection, decrypt).
The explicit provider functions (`getGithub...`) now duplicate the logic instead of reusing the generic one or a shared helper.
**Recommendation**:
Refactor the common logic into a shared (non-exported or internal) helper function, or deprecate the specific actions in favor of the generic one.</comment>
<file context>
@@ -140,8 +152,46 @@ export const getAnthropicAccessTokenForCurrentUser = action({
+ },
+});
+
+export const getAccessTokenForCurrentUser = action({
+ args: { provider: oauthProviderEnum },
+ returns: v.union(v.string(), v.null()),
</file context>
| let subscriptionStream: Awaited<ReturnType<typeof subscribe>> | null = null; | ||
| let writerClosed = false; | ||
|
|
||
| const safeWrite = async (data: Uint8Array) => { |
There was a problem hiding this comment.
P2: The safeWrite function suppresses write errors (e.g., from client disconnects) but does not signal the failure to the caller. As a result, the subscription loop continues to process events and attempt writes until the agent run completes, wasting server resources on a disconnected client.
Update safeWrite to return a boolean indicating success, and check this return value in the subscription callback to abort early.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/agent/run/route.ts, line 48:
<comment>The `safeWrite` function suppresses write errors (e.g., from client disconnects) but does not signal the failure to the caller. As a result, the subscription loop continues to process events and attempt writes until the agent run completes, wasting server resources on a disconnected client.
Update `safeWrite` to return a boolean indicating success, and check this return value in the subscription callback to abort early.</comment>
<file context>
@@ -43,6 +43,26 @@ export async function POST(request: NextRequest) {
let subscriptionStream: Awaited<ReturnType<typeof subscribe>> | null = null;
+ let writerClosed = false;
+
+ const safeWrite = async (data: Uint8Array) => {
+ if (writerClosed) return;
+ try {
</file context>
| projectId, | ||
| fileKey, | ||
| accessToken: connection.accessToken, | ||
| accessToken, |
There was a problem hiding this comment.
P2: The processFigmaImport function is executed asynchronously without being awaited before the response is returned. In serverless environments (like Netlify or Vercel), the runtime may freeze or terminate the process immediately after the response is sent, causing the background import to fail.
Since this PR introduces Inngest, consider offloading this task to an Inngest function or using waitUntil to ensure the process completes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/import/figma/process/route.ts, line 77:
<comment>The `processFigmaImport` function is executed asynchronously without being awaited before the response is returned. In serverless environments (like Netlify or Vercel), the runtime may freeze or terminate the process immediately after the response is sent, causing the background import to fail.
Since this PR introduces Inngest, consider offloading this task to an Inngest function or using `waitUntil` to ensure the process completes.</comment>
<file context>
@@ -71,7 +74,7 @@ export async function POST(request: Request) {
projectId,
fileKey,
- accessToken: connection.accessToken,
+ accessToken,
}).catch((error) => {
console.error("[ERROR] Background Figma import failed:", error);
</file context>
| { token: token ?? undefined }, | ||
| ) as string | null; | ||
|
|
||
| if (!accessToken) { |
There was a problem hiding this comment.
P3: The error handling logic in the route handlers checks for the specific string "Netlify connection not found". This is fragile. Use a shared constant or a custom error subclass to ensure the throw and catch logic remain in sync, especially if you extract this helper to a shared file.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/deploy/netlify/env-vars/route.ts, line 22:
<comment>The error handling logic in the route handlers checks for the specific string "Netlify connection not found". This is fragile. Use a shared constant or a custom error subclass to ensure the throw and catch logic remain in sync, especially if you extract this helper to a shared file.</comment>
<file context>
@@ -17,17 +13,17 @@ type EnvVarPayload = {
+ ) as string | null;
- if (!connection?.accessToken) {
+ if (!accessToken) {
throw new Error("Netlify connection not found.");
}
</file context>
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/app/api/agent/run/route.ts (2)
126-132:⚠️ Potential issue | 🟡 MinorInternal error details leaked to client.
Line 129 forwards the raw
error.messageto the caller. This can expose internal implementation details (dependency names, file paths, database errors). Return a generic message and log the details server-side only.Proposed fix
return NextResponse.json( { error: "Failed to process request", - details: error instanceof Error ? error.message : "Unknown error", }, { status: 500 } );
16-37:⚠️ Potential issue | 🔴 CriticalAdd authentication check to prevent unauthenticated agent runs.
The
/api/agent/runendpoint has no authentication, whilegetUser()is available and used in the sibling/api/agent/tokenroute. Any caller can trigger expensive Inngest agent runs. Addconst user = await getUser();near the top of the POST handler and return a 401 if user is null, matching the pattern in/api/agent/token/route.ts.src/app/api/import/github/process/route.ts (1)
59-75:⚠️ Potential issue | 🟠 Major
fetchMutationis called without the auth token, unlikefetchActionabove.Line 28–32 passes
{ token: token ?? undefined }tofetchAction, but thefetchMutationon line 59 omits the token option entirely. This means the mutation runs unauthenticated, which will likely fail ifcreateImportenforcesrequireAuth(ctx). Pass the token consistently.Additionally,
(api as any)violates the strict typing guidelines. Ifimportsisn't on the generated API type, the type definition inconvex-extended-api.d.tsneeds updating rather than bypassing withany.Proposed fix
- const importRecord = await fetchMutation((api as any).imports.createImport, { + const importRecord = await fetchMutation(api.imports.createImport, { projectId, source: "GITHUB", sourceId: repoId.toString(), sourceName: repoName, sourceUrl: repoUrl || repoData.html_url, metadata: { githubRepoData: { fullName: repoData.full_name, description: repoData.description, language: repoData.language, defaultBranch: repoData.default_branch, topics: repoData.topics || [], isPrivate: repoData.private, }, }, - }); + }, { token: token ?? undefined });As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly." Based on learnings: "Use
const userId = await requireAuth(ctx)at the start of protected handlers to enforce authentication" — if the mutation enforces auth, the missing token will cause a runtime failure.src/app/api/import/figma/process/route.ts (1)
57-71:⚠️ Potential issue | 🟠 MajorSame unauthenticated
fetchMutationandas anycast as the GitHub route.This is the same issue as in
src/app/api/import/github/process/route.ts: the mutation is called without the auth token and uses(api as any)to bypass type safety. Pass the token and fix the type definition.Proposed fix
- const importRecord = await fetchMutation((api as any).imports.createImport, { + const importRecord = await fetchMutation(api.imports.createImport, { projectId, source: "FIGMA", sourceId: fileKey, sourceName: fileName, sourceUrl: fileUrl || `https://figma.com/file/${fileKey}`, metadata: { figmaFileData: { name: fileData.name, lastModified: fileData.lastModified, version: fileData.version, pages: fileData.pages?.length || 0, }, }, - }); + }, { token: token ?? undefined });As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."
src/app/api/import/figma/files/route.ts (1)
31-47:⚠️ Potential issue | 🔴 CriticalReplace the Figma API endpoint with a valid list endpoint.
The endpoint
https://api.figma.com/v1/filesdoes not exist in the Figma REST API. According to official documentation, there is no endpoint to list all files without a specific file key. To list files, use:
GET /v1/projects/:project_id/files— list files in a specific projectGET /v1/teams/:team_id/projects— list projects in a teamThe current request will fail with a 404 error. You need to provide the
project_idorteam_idparameter and adjust the endpoint accordingly, or clarify which resource (projects vs. files) should be returned to the client.
🤖 Fix all issues with AI agents
In `@convex/oauth.ts`:
- Around line 24-32: The encryptToken function currently uses a 16-byte IV
(crypto.randomBytes(16)); change it to a 12-byte (96-bit) IV per NIST GCM
guidance by using crypto.randomBytes(12) so AES-GCM will use the recommended IV
size; ensure the corresponding decrypt function (wherever getAuthTag is
consumed) expects the same IV length and that the returned iv:authTag:encrypted
format still matches, and run/update any tests relying on IV length to reflect
the 12-byte IV change (refer to encryptToken, getEncryptionKey, ALGORITHM and
the decrypt counterpart).
In `@convex/oauthQueries.ts`:
- Around line 74-119: In storeConnectionInternal, the fallback logic uses ||
which wrongly treats empty strings or 0 as absent; change the two occurrences
where it does args.refreshToken || existing.refreshToken and args.metadata ||
existing.metadata to use nullish coalescing (args.refreshToken ??
existing.refreshToken and args.metadata ?? existing.metadata) so only
null/undefined fall back to the existing values.
- Around line 37-72: The public query getConnection (and similarly
listConnections) currently returns sensitive/internal fields (userId and
_creationTime and optional metadata); update the v.object return schema to
remove userId, _creationTime, and metadata, and in the handler for getConnection
destructure/drop connection.userId, connection._creationTime and
connection.metadata (in addition to accessToken/refreshToken) before returning
safeConnection; apply the same removal logic to listConnections so no Clerk IDs
or internal metadata are returned to clients and keep only public-safe fields
like provider, expiresAt, scope, createdAt, updatedAt.
In `@src/app/api/agent/run/route.ts`:
- Around line 67-94: The subscription is created after triggering the Inngest
run (inngest.send) causing a race where emitted events can be lost; modify the
flow to call subscribe(...) and set subscriptionStream first (using
agentChannel(runId)) and only after the subscribe promise resolves send the
event via inngest.send, keeping the same message payload (runId, projectId,
value, model) and retaining the same message handler logic (formatSSE,
safeWrite, cancel on "complete"/"error"); ensure any errors from subscribe or
send are caught and logged so the client can be closed cleanly.
In `@src/app/api/deploy/netlify/env-vars/route.ts`:
- Around line 62-97: The POST handler currently returns the raw Netlify response
from netlifyClient.setEnvVar (in POST) and the PUT handler returns
netlifyClient.updateEnvVar, which leak plaintext secrets; change both handlers
to sanitize the returned NetlifyEnvVar by removing or omitting the values array
and returning the same safe shape the GET handler uses, and replace the
unchecked cast of request.json() to EnvVarPayload with Zod input validation
(reuse the schema pattern from cli/route.ts) to validate siteId, key, value and
optional context before calling setEnvVar/updateEnvVar.
🧹 Nitpick comments (15)
src/modules/home/ui/components/project-form.tsx (2)
62-69: Consider makingmodelOptionstype-safe withoutasassertions.All entries use
as ModelId, which defeats the purpose of theModelIdunion type. A typed array would catch invalid IDs at compile time.♻️ Suggested refactor
- const modelOptions = [ - { id: "auto" as ModelId, name: "Auto", image: "/auto.svg", description: "Auto-selects the best model" }, - { id: "anthropic/claude-haiku-4.5" as ModelId, name: "Claude Haiku 4.5", image: "/haiku.svg", description: "Fast and efficient" }, - ... + const modelOptions: { id: ModelId; name: string; image: string; description: string }[] = [ + { id: "auto", name: "Auto", image: "/auto.svg", description: "Auto-selects the best model" }, + { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", image: "/haiku.svg", description: "Fast and efficient" }, + ...This way, any model ID not in the
ModelIdunion will produce a type error immediately.As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."
68-68: Model ID "moonshotai/kimi-k2.5" is valid and properly configured.The new model ID exists in
MODEL_CONFIGSwith full provider configuration, so runtime selection will work correctly.As an optional improvement (though pre-existing across all model options), consider typing
modelOptionsdirectly asArray<{ id: ModelId; ... }>to enforce type safety on theidfield without needing theas ModelIdassertions on each entry. This would align with the guideline to avoid type assertions and resolve types properly.src/agents/types.ts (2)
32-33:AgentProvidertype is declared after its first use.
AgentProvideris referenced on line 19 (provider?: AgentProviderinAgentRunInput) but defined on line 32. While TypeScript hoists type aliases so this works at compile time, placing the definition before its usage improves readability and is consistent with howDatabaseProvider(line 4) is positioned before its usage on line 10.
4-4: Remove duplicateDatabaseProvidertype definition.
DatabaseProvideris already defined and exported fromsrc/lib/database-templates/types.ts. Instead of redefining it insrc/agents/types.ts, import it from the shared source:-export type DatabaseProvider = "none" | "drizzle-neon" | "convex"; +import type { DatabaseProvider } from "../lib/database-templates";This eliminates the risk of the two definitions diverging if one is updated without the other.
src/app/api/agent/run/route.ts (1)
86-88: Avoidas StreamEventtype assertion.
message.datais typed as{ type: string; data: unknown; timestamp?: number }from the"event"topic, which is structurally close toStreamEventbut not identical (typeisstringvs. a union). Consider narrowing with a runtime guard or tightening the topic type to eliminate the assertion.As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."
src/app/api/deploy/netlify/preview/route.ts (1)
7-20:getNetlifyAccessTokenis duplicated across Netlify routes.This exact helper appears identically in
src/app/api/deploy/netlify/status/route.ts(and likely other Netlify routes). Extract it into a shared module (e.g.,src/lib/netlify-auth.ts) to avoid duplication.src/app/api/import/github/repos/route.ts (1)
44-52: GitHub repo listing is limited to 100 repositories with no pagination.The
per_page=100parameter caps results at 100 repos. Users with more repositories won't see the rest. Consider addingpageparameter support or documenting this limitation.Also, line 65 uses
as GitHubRepo[]on unvalidated external data. This is a mild type-safety gap — if GitHub's response shape changes, runtime errors would surface far from the assertion.Also applies to: 65-65
src/modules/projects/ui/components/netlify-cli-dialog.tsx (2)
96-101: Icon sizing usesh-4 w-4instead of the guideline-preferredsize-4.The coding guidelines specify
size-4(16px) as the default icon size for lucide-react icons. The current code usesh-4 w-4throughout. While functionally equivalent,size-4is the preferred convention per project guidelines.As per coding guidelines: "Use
lucide-reactas the icon library with default sizesize-4(16px)."Also applies to: 131-134, 150-154, 173-177, 193-197
117-201: Consider extracting the repeated command block into a reusable component.The Quick Start tab has four nearly identical command blocks (install, login, init, deploy), each with a label, code display, and copy button. Extracting a small
CommandBlockcomponent would reduce duplication and make additions easier.Example extraction
const CommandBlock = ({ step, label, command, commandKey, description }: { step: number; label: string; command: string; commandKey: string; description?: string; }) => ( <div className="rounded-lg bg-muted p-4"> <h4 className="mb-2 font-medium">{step}. {label}</h4> {description && <p className="mb-2 text-sm text-muted-foreground">{description}</p>} <div className="flex items-center gap-2"> <code className="flex-1 rounded bg-background px-3 py-2 text-sm">{command}</code> <Button size="sm" variant="ghost" onClick={() => copyToClipboard(command, commandKey)}> {copiedCommand === commandKey ? <Check className="size-4" /> : <Copy className="size-4" />} </Button> </div> </div> );src/app/api/deploy/netlify/env-vars/route.ts (1)
14-27: ExtractgetNetlifyAccessTokeninto a shared utility.This helper is duplicated verbatim across
env-vars/route.ts,sites/route.ts, anddomains/route.ts(and likely other Netlify routes). Extract it to a shared module (e.g.,src/lib/netlify-auth.ts) to avoid drift and reduce duplication.src/app/api/deploy/netlify/cli/route.ts (2)
58-77: Zod validatesprojectIdas a string but the cast toId<"projects">is unchecked.Line 76 casts the Zod-validated string to
Id<"projects">without verifying it's a valid Convex document ID. WhilefetchQueryon line 77 will fail gracefully (caught at line 113), consider using Convex's ID validator (v.id("projects")) or at minimum documenting this assumption.
25-37:normalizeFilessilently drops non-string entries; consider stricter schema validation or explicit error handling.The schema allows
files: v.any(), but the function only preserves string values—nested objects are silently discarded. The agent currently produces flatRecord<string, string>(per webcontainer-sync.ts), but the schema doesn't enforce this. Consider either:
- Validating
filesas a flatRecord<string, string>at the schema level- Throwing an error in
normalizeFilesfor non-strings to catch upstream bugs early- Implementing recursive flattening if nested structures should be supported
Note: This function is duplicated across 4 routes (
githubExports.ts, two netlify routes, and the download route)—consider extracting it to a shared utility.src/app/api/deploy/netlify/sites/route.ts (1)
29-32: Consider sanitizing the Netlify sites response.
listSites()returns the full Netlify site objects which may include deploy keys, webhook URLs, and other internal metadata. If this data is consumed by a client component, filter to only the fields the UI needs (e.g.,id,name,url,ssl_url,custom_domain).src/app/api/deploy/netlify/domains/route.ts (1)
67-75: Weak body validation — prefer Zod schema for consistency.The
typeof parsedBody !== "object"check (line 68) passes for arrays and theas DomainPayloadcast (line 72) skips runtime type verification. The siblingcli/route.tsuses Zod'ssafeParsefor input validation. Standardize on Zod across all Netlify routes for consistent, type-safe input handling.Suggested approach
+import { z } from "zod"; + +const domainPayloadSchema = z.object({ + siteId: z.string().min(1), + domain: z.string().min(1), +}); + // In POST handler: - const parsedBody = await request.json(); - if (!parsedBody || typeof parsedBody !== "object") { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const body = parsedBody as DomainPayload; - if (!body.siteId || !body.domain) { - return NextResponse.json({ error: "Missing siteId or domain" }, { status: 400 }); - } + const parseResult = domainPayloadSchema.safeParse(await request.json()); + if (!parseResult.success) { + return NextResponse.json({ error: "Invalid request body", details: parseResult.error.issues }, { status: 400 }); + } + const body = parseResult.data;convex/oauthQueries.ts (1)
176-203:v.any()used formetadataacross multiple functions.Lines 21, 49, 82, and 179 use
v.any()for themetadatafield. The coding guidelines prohibitanyin function signatures. Define a concrete validator (e.g.,v.record(v.string(), v.string())or a union of known metadata shapes) to preserve type safety.As per coding guidelines: "DO NOT use
anyin function signatures; leverageDoc<"table">orId<"table">for type safety".
| export function encryptToken(token: string): string { | ||
| const keyBuffer = getEncryptionKey(); | ||
| const iv = crypto.randomBytes(16); | ||
| const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); | ||
| let encrypted = cipher.update(token, "utf8", "hex"); | ||
| encrypted += cipher.final("hex"); | ||
| const authTag = cipher.getAuthTag(); | ||
| return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
NIST SP 800-38D AES-GCM IV length 12 bytes 96-bit recommendation
💡 Result:
NIST SP 800-38D (GCM/GMAC) recommends using a 96-bit (12-byte) IV:
- Recommendation: “For IVs, it is recommended that implementations restrict support to the length of 96 bits … (interoperability, efficiency, simplicity).” [1]
- Why 96 bits matters in the spec: When len(IV)=96, GCM forms the pre-counter block as J0 = IV || 0^31 || 1; for non-96-bit IVs, J0 is derived by hashing the IV with GHASH (more processing/complexity). [1]
- Core requirement (regardless of length): IVs must be unique for each (key, IV) pair; reuse breaks GCM security assumptions. [1]
Sources:
[1] NIST SP 800-38D (Nov 2007), “supported lengths / IV recommendation” and “J0 formation” sections. (nvlpubs.nist.gov)
Citations:
Use NIST-recommended 12-byte IV for AES-GCM instead of 16 bytes.
Line 26 uses crypto.randomBytes(16) for the IV. NIST SP 800-38D recommends 96-bit (12-byte) IVs for GCM. With 96-bit IVs, the pre-counter block (J0) is formed directly; other lengths require an additional GHASH hashing step that adds processing overhead and complexity.
Proposed fix
- const iv = crypto.randomBytes(16);
+ const iv = crypto.randomBytes(12);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function encryptToken(token: string): string { | |
| const keyBuffer = getEncryptionKey(); | |
| const iv = crypto.randomBytes(16); | |
| const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); | |
| let encrypted = cipher.update(token, "utf8", "hex"); | |
| encrypted += cipher.final("hex"); | |
| const authTag = cipher.getAuthTag(); | |
| return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; | |
| } | |
| export function encryptToken(token: string): string { | |
| const keyBuffer = getEncryptionKey(); | |
| const iv = crypto.randomBytes(12); | |
| const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); | |
| let encrypted = cipher.update(token, "utf8", "hex"); | |
| encrypted += cipher.final("hex"); | |
| const authTag = cipher.getAuthTag(); | |
| return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; | |
| } |
🤖 Prompt for AI Agents
In `@convex/oauth.ts` around lines 24 - 32, The encryptToken function currently
uses a 16-byte IV (crypto.randomBytes(16)); change it to a 12-byte (96-bit) IV
per NIST GCM guidance by using crypto.randomBytes(12) so AES-GCM will use the
recommended IV size; ensure the corresponding decrypt function (wherever
getAuthTag is consumed) expects the same IV length and that the returned
iv:authTag:encrypted format still matches, and run/update any tests relying on
IV length to reflect the 12-byte IV change (refer to encryptToken,
getEncryptionKey, ALGORITHM and the decrypt counterpart).
| export const getConnection = query({ | ||
| args: { | ||
| provider: oauthProviderEnum, | ||
| }, | ||
| returns: v.union( | ||
| v.object({ | ||
| _id: v.id("oauthConnections"), | ||
| _creationTime: v.number(), | ||
| userId: v.string(), | ||
| provider: oauthProviderEnum, | ||
| expiresAt: v.optional(v.number()), | ||
| scope: v.string(), | ||
| metadata: v.optional(v.any()), | ||
| createdAt: v.number(), | ||
| updatedAt: v.number(), | ||
| }), | ||
| v.null() | ||
| ), | ||
| handler: async (ctx, args) => { | ||
| const userId = await requireAuth(ctx); | ||
|
|
||
| const connection = await ctx.db | ||
| .query("oauthConnections") | ||
| .withIndex("by_userId_provider", (q) => | ||
| q.eq("userId", userId).eq("provider", args.provider) | ||
| ) | ||
| .first(); | ||
|
|
||
| if (!connection) { | ||
| return null; | ||
| } | ||
|
|
||
| const { accessToken: _, refreshToken: _rt, ...safeConnection } = connection; | ||
| return safeConnection; | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Public query exposes userId (Clerk ID) and _creationTime to the client.
getConnection is a public query that returns userId and _creationTime in its response. Per project learnings, Clerk user IDs should not be exposed in public APIs, and internal metadata should be excluded from public return values. The same applies to listConnections (lines 141-148).
Proposed fix
returns: v.union(
v.object({
_id: v.id("oauthConnections"),
- _creationTime: v.number(),
- userId: v.string(),
provider: oauthProviderEnum,
expiresAt: v.optional(v.number()),
scope: v.string(),
- metadata: v.optional(v.any()),
createdAt: v.number(),
updatedAt: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
const userId = await requireAuth(ctx);
...
- const { accessToken: _, refreshToken: _rt, ...safeConnection } = connection;
- return safeConnection;
+ return {
+ _id: connection._id,
+ provider: connection.provider,
+ expiresAt: connection.expiresAt,
+ scope: connection.scope,
+ createdAt: connection.createdAt,
+ updatedAt: connection.updatedAt,
+ };
},Based on learnings: "Never expose Clerk user IDs in public APIs — sanitize user identifiers before returning in API responses" and "NEVER expose internal IDs or sensitive metadata in public return values from Convex functions".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const getConnection = query({ | |
| args: { | |
| provider: oauthProviderEnum, | |
| }, | |
| returns: v.union( | |
| v.object({ | |
| _id: v.id("oauthConnections"), | |
| _creationTime: v.number(), | |
| userId: v.string(), | |
| provider: oauthProviderEnum, | |
| expiresAt: v.optional(v.number()), | |
| scope: v.string(), | |
| metadata: v.optional(v.any()), | |
| createdAt: v.number(), | |
| updatedAt: v.number(), | |
| }), | |
| v.null() | |
| ), | |
| handler: async (ctx, args) => { | |
| const userId = await requireAuth(ctx); | |
| const connection = await ctx.db | |
| .query("oauthConnections") | |
| .withIndex("by_userId_provider", (q) => | |
| q.eq("userId", userId).eq("provider", args.provider) | |
| ) | |
| .first(); | |
| if (!connection) { | |
| return null; | |
| } | |
| const { accessToken: _, refreshToken: _rt, ...safeConnection } = connection; | |
| return safeConnection; | |
| }, | |
| }); | |
| export const getConnection = query({ | |
| args: { | |
| provider: oauthProviderEnum, | |
| }, | |
| returns: v.union( | |
| v.object({ | |
| _id: v.id("oauthConnections"), | |
| provider: oauthProviderEnum, | |
| expiresAt: v.optional(v.number()), | |
| scope: v.string(), | |
| createdAt: v.number(), | |
| updatedAt: v.number(), | |
| }), | |
| v.null() | |
| ), | |
| handler: async (ctx, args) => { | |
| const userId = await requireAuth(ctx); | |
| const connection = await ctx.db | |
| .query("oauthConnections") | |
| .withIndex("by_userId_provider", (q) => | |
| q.eq("userId", userId).eq("provider", args.provider) | |
| ) | |
| .first(); | |
| if (!connection) { | |
| return null; | |
| } | |
| return { | |
| _id: connection._id, | |
| provider: connection.provider, | |
| expiresAt: connection.expiresAt, | |
| scope: connection.scope, | |
| createdAt: connection.createdAt, | |
| updatedAt: connection.updatedAt, | |
| }; | |
| }, | |
| }); |
🤖 Prompt for AI Agents
In `@convex/oauthQueries.ts` around lines 37 - 72, The public query getConnection
(and similarly listConnections) currently returns sensitive/internal fields
(userId and _creationTime and optional metadata); update the v.object return
schema to remove userId, _creationTime, and metadata, and in the handler for
getConnection destructure/drop connection.userId, connection._creationTime and
connection.metadata (in addition to accessToken/refreshToken) before returning
safeConnection; apply the same removal logic to listConnections so no Clerk IDs
or internal metadata are returned to clients and keep only public-safe fields
like provider, expiresAt, scope, createdAt, updatedAt.
| export const storeConnectionInternal = internalMutation({ | ||
| args: { | ||
| userId: v.string(), | ||
| provider: oauthProviderEnum, | ||
| accessToken: v.string(), | ||
| refreshToken: v.optional(v.string()), | ||
| expiresAt: v.optional(v.number()), | ||
| scope: v.string(), | ||
| metadata: v.optional(v.any()), | ||
| }, | ||
| returns: v.id("oauthConnections"), | ||
| handler: async (ctx, args) => { | ||
| const existing = await ctx.db | ||
| .query("oauthConnections") | ||
| .withIndex("by_userId_provider", (q) => | ||
| q.eq("userId", args.userId).eq("provider", args.provider) | ||
| ) | ||
| .first(); | ||
|
|
||
| const now = Date.now(); | ||
|
|
||
| if (existing) { | ||
| await ctx.db.patch(existing._id, { | ||
| accessToken: args.accessToken, | ||
| refreshToken: args.refreshToken || existing.refreshToken, | ||
| expiresAt: args.expiresAt, | ||
| scope: args.scope, | ||
| metadata: args.metadata || existing.metadata, | ||
| updatedAt: now, | ||
| }); | ||
| return existing._id; | ||
| } | ||
|
|
||
| return await ctx.db.insert("oauthConnections", { | ||
| userId: args.userId, | ||
| provider: args.provider, | ||
| accessToken: args.accessToken, | ||
| refreshToken: args.refreshToken, | ||
| expiresAt: args.expiresAt, | ||
| scope: args.scope, | ||
| metadata: args.metadata, | ||
| createdAt: now, | ||
| updatedAt: now, | ||
| }); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Falsy || guards silently discard empty strings and 0 values.
Lines 98 and 101 use || to fall back to existing values. If args.refreshToken is intentionally set to "" (e.g., to clear it), the existing value is preserved instead. Use nullish coalescing (??) to only fall back on undefined/null.
Proposed fix
- refreshToken: args.refreshToken || existing.refreshToken,
+ refreshToken: args.refreshToken ?? existing.refreshToken,
expiresAt: args.expiresAt,
scope: args.scope,
- metadata: args.metadata || existing.metadata,
+ metadata: args.metadata ?? existing.metadata,🤖 Prompt for AI Agents
In `@convex/oauthQueries.ts` around lines 74 - 119, In storeConnectionInternal,
the fallback logic uses || which wrongly treats empty strings or 0 as absent;
change the two occurrences where it does args.refreshToken ||
existing.refreshToken and args.metadata || existing.metadata to use nullish
coalescing (args.refreshToken ?? existing.refreshToken and args.metadata ??
existing.metadata) so only null/undefined fall back to the existing values.
| try { | ||
| await inngest.send({ | ||
| name: "code-agent/run.requested", | ||
| data: { | ||
| runId, | ||
| projectId, | ||
| value, | ||
| model: model || "auto", | ||
| }, | ||
| }); | ||
|
|
||
| console.log("[Agent Run] Triggered Inngest event:", { runId, projectId }); | ||
|
|
||
| subscriptionStream = await subscribe( | ||
| { | ||
| app: inngest, | ||
| channel: agentChannel(runId), | ||
| topics: ["event"], | ||
| }, | ||
| async (message) => { | ||
| const event = message.data as StreamEvent; | ||
| await safeWrite(formatSSE(event)); | ||
|
|
||
| if (event.type === "complete" || event.type === "error") { | ||
| await subscriptionStream?.cancel(); | ||
| } | ||
| } | ||
| ); |
There was a problem hiding this comment.
Race condition: events may be lost because subscribe is called after inngest.send.
The Inngest function is triggered on line 68 before the realtime subscription is established on line 80. If the function starts executing and emits events in that window, those events will never reach the client. Subscribe first, then trigger the run.
Proposed fix — subscribe before send
try {
+ // Subscribe FIRST to avoid missing early events
+ subscriptionStream = await subscribe(
+ {
+ app: inngest,
+ channel: agentChannel(runId),
+ topics: ["event"],
+ },
+ async (message) => {
+ const event = message.data as StreamEvent;
+ await safeWrite(formatSSE(event));
+
+ if (event.type === "complete" || event.type === "error") {
+ await subscriptionStream?.cancel();
+ }
+ }
+ );
+
+ // Emit initial status event
+ await safeWrite(formatSSE({ type: "status", data: "Starting agent run..." }));
+
+ // THEN trigger the Inngest function
await inngest.send({
name: "code-agent/run.requested",
data: {
runId,
projectId,
value,
model: model || "auto",
},
});
console.log("[Agent Run] Triggered Inngest event:", { runId, projectId });
- subscriptionStream = await subscribe(
- {
- app: inngest,
- channel: agentChannel(runId),
- topics: ["event"],
- },
- async (message) => {
- const event = message.data as StreamEvent;
- await safeWrite(formatSSE(event));
-
- if (event.type === "complete" || event.type === "error") {
- await subscriptionStream?.cancel();
- }
- }
- );
-
await subscriptionStream;Based on learnings: "Data streamed from /api/agent/run endpoint must emit SSE events in order: status, text, file-created, tool-call, complete."
🤖 Prompt for AI Agents
In `@src/app/api/agent/run/route.ts` around lines 67 - 94, The subscription is
created after triggering the Inngest run (inngest.send) causing a race where
emitted events can be lost; modify the flow to call subscribe(...) and set
subscriptionStream first (using agentChannel(runId)) and only after the
subscribe promise resolves send the event via inngest.send, keeping the same
message payload (runId, projectId, value, model) and retaining the same message
handler logic (formatSSE, safeWrite, cancel on "complete"/"error"); ensure any
errors from subscribe or send are caught and logged so the client can be closed
cleanly.
| export async function POST(request: Request) { | ||
| try { | ||
| const user = await getUser(); | ||
| if (!user?.id) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
|
|
||
| const body = (await request.json()) as EnvVarPayload; | ||
| if ( | ||
| !body.siteId || | ||
| !body.key || | ||
| typeof body.value !== "string" || | ||
| body.value.length === 0 | ||
| ) { | ||
| return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); | ||
| } | ||
|
|
||
| const netlifyClient = createNetlifyClient(await getNetlifyAccessToken()); | ||
| const envVar = await netlifyClient.setEnvVar( | ||
| body.siteId, | ||
| body.key, | ||
| body.value, | ||
| body.context | ||
| ); | ||
|
|
||
| return NextResponse.json(envVar); | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : "Failed to set env var"; | ||
|
|
||
| if (message.includes("Netlify connection not found")) { | ||
| return NextResponse.json({ error: message }, { status: 401 }); | ||
| } | ||
|
|
||
| return NextResponse.json({ error: message }, { status: 500 }); | ||
| } | ||
| } |
There was a problem hiding this comment.
POST and PUT responses may leak newly-set env var values.
The Netlify setEnvVar and updateEnvVar methods return the full NetlifyEnvVar object (including the values array with plaintext secrets). The GET handler sanitizes this, but POST (line 87) and PUT (line 124) return the raw response directly. Apply the same sanitization.
Also, request.json() is cast via as EnvVarPayload without runtime validation. Consider using Zod (as done in cli/route.ts) for consistent input validation.
Proposed fix for POST (same pattern for PUT)
const envVar = await netlifyClient.setEnvVar(
body.siteId,
body.key,
body.value,
body.context
);
- return NextResponse.json(envVar);
+ const { values, ...safeEnvVar } = envVar as { values?: unknown; [key: string]: unknown };
+ return NextResponse.json(safeEnvVar);Also applies to: 99-134
🤖 Prompt for AI Agents
In `@src/app/api/deploy/netlify/env-vars/route.ts` around lines 62 - 97, The POST
handler currently returns the raw Netlify response from netlifyClient.setEnvVar
(in POST) and the PUT handler returns netlifyClient.updateEnvVar, which leak
plaintext secrets; change both handlers to sanitize the returned NetlifyEnvVar
by removing or omitting the values array and returning the same safe shape the
GET handler uses, and replace the unchecked cast of request.json() to
EnvVarPayload with Zod input validation (reuse the schema pattern from
cli/route.ts) to validate siteId, key, value and optional context before calling
setEnvVar/updateEnvVar.
|
❌ Error while analyzing PR and generating tests: |
There was a problem hiding this comment.
2 issues found across 18 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="env.example">
<violation number="1" location="env.example:43">
P2: The env example enables WebContainers by default, but the project docs describe this as a feature‑flagged option with a default of false. This will flip the default sandbox behavior for new setups and contradicts the documented default. Keep the example aligned with the default flag.</violation>
</file>
<file name="src/agents/code-agent.ts">
<violation number="1" location="src/agents/code-agent.ts:510">
P2: Memory leak: `PENDING_SANDBOX_REQUESTS` entries are never cleaned up when the agent run completes. Each sandbox ID creates a permanent entry in the global Map. Consider adding cleanup in a `finally` block or at the end of the generator:
```typescript
PENDING_SANDBOX_REQUESTS.delete(adapter.id);
```</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| # E2B | ||
| E2B_API_KEY="" | ||
| # WebContainers (browser-based sandboxes) | ||
| NEXT_PUBLIC_USE_WEBCONTAINERS="true" |
There was a problem hiding this comment.
P2: The env example enables WebContainers by default, but the project docs describe this as a feature‑flagged option with a default of false. This will flip the default sandbox behavior for new setups and contradicts the documented default. Keep the example aligned with the default flag.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At env.example, line 43:
<comment>The env example enables WebContainers by default, but the project docs describe this as a feature‑flagged option with a default of false. This will flip the default sandbox behavior for new setups and contradicts the documented default. Keep the example aligned with the default flag.</comment>
<file context>
@@ -39,12 +39,8 @@ NETLIFY_CLIENT_SECRET=""
-# Set to "true" to enable WebContainer-based preview instead of E2B sandboxes
-NEXT_PUBLIC_USE_WEBCONTAINERS="false"
+# WebContainers (browser-based sandboxes)
+NEXT_PUBLIC_USE_WEBCONTAINERS="true"
# Firecrawl
</file context>
| NEXT_PUBLIC_USE_WEBCONTAINERS="true" | |
| NEXT_PUBLIC_USE_WEBCONTAINERS="false" |
| ]); | ||
|
|
||
| adapterSandboxId = adapter.id; | ||
| PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests); |
There was a problem hiding this comment.
P2: Memory leak: PENDING_SANDBOX_REQUESTS entries are never cleaned up when the agent run completes. Each sandbox ID creates a permanent entry in the global Map. Consider adding cleanup in a finally block or at the end of the generator:
PENDING_SANDBOX_REQUESTS.delete(adapter.id);Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/code-agent.ts, line 510:
<comment>Memory leak: `PENDING_SANDBOX_REQUESTS` entries are never cleaned up when the agent run completes. Each sandbox ID creates a permanent entry in the global Map. Consider adding cleanup in a `finally` block or at the end of the generator:
```typescript
PENDING_SANDBOX_REQUESTS.delete(adapter.id);
```</comment>
<file context>
@@ -411,15 +479,35 @@ export async function* runCodeAgent(
]);
+ adapterSandboxId = adapter.id;
+ PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests);
console.log("[DEBUG] Sandbox adapter created:", adapter.id);
</file context>
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/agents/sandbox-utils.ts (1)
39-45:⚠️ Potential issue | 🔴 Critical
getFindCommandproduces a malformedfindcommand — all ignore patterns collapse into one-pathargument.The
joinembeds raw-not -pathtokens inside a single-quoted shell string, so the shell sees one giant glob pattern instead of separate predicates. For["node_modules", ".git", "dist", "build", ".next"]the output is:find /home/user -type f -not -path '*/node_modules/* -not -path */.git/* … */build/* -not -path */.next/*' 2>/dev/nullEverything between the outer
'…'is a single argument to the first-path. The remaining-not -pathclauses are swallowed, so no directories are actually excluded.Proposed fix
export const getFindCommand = (framework: Framework): string => { const ignorePatterns = ["node_modules", ".git", "dist", "build"]; if (framework === "nextjs") ignorePatterns.push(".next"); if (framework === "svelte") ignorePatterns.push(".svelte-kit"); - return `find /home/user -type f -not -path '*/${ignorePatterns.join('/* -not -path */')}/*' 2>/dev/null`; + const excludes = ignorePatterns + .map((p) => `-not -path '*/${p}/*'`) + .join(" "); + return `find /home/user -type f ${excludes} 2>/dev/null`; };
🤖 Fix all issues with AI agents
In `@src/agents/code-agent.ts`:
- Around line 134-152: The PENDING_SANDBOX_REQUESTS map entries are never
removed, causing a memory leak; modify runCodeAgent so the adapter-scoped entry
(the value assigned via PENDING_SANDBOX_REQUESTS.set(adapter.id,
sandboxPendingRequests)) is deleted when the agent finishes or errors — move or
hoist the adapter declaration so you can use a try/finally around the run logic
and call PENDING_SANDBOX_REQUESTS.delete(adapter.id) in the finally block (also
ensure any remaining per-request entries are cleaned if needed); keep
resolveSandboxResponse behavior unchanged but rely on the finally cleanup to
remove the outer map entry.
- Around line 103-111: The getFindCommand function builds a malformed shell
command by joining ignorePatterns into a single quoted segment so the -not -path
clauses don't apply; fix getFindCommand by mapping each ignorePatterns entry to
its own "-not -path '*/<pattern>/*'" clause and joining those clauses with
spaces (instead of injecting a joined string inside one quoted clause), then
interpolate that resulting string into the final find command (keep the search
root /home/user and preserve redirect 2>/dev/null); update the getFindCommand
implementation to use this mapped-and-joined clauses string so node_modules,
.git, .next, .svelte-kit, etc. are properly excluded during file discovery.
- Around line 1156-1161: The current code launches up to 500 concurrent
adapter.readFile calls (see Promise.all over filePaths.slice(0, 500) that builds
entries), which can overwhelm DeferredSandboxAdapter/SSE; change to a batched
read strategy: chunk the filePaths (e.g., batch size 20–50) and for each chunk
run Promise.all on that slice and await it before starting the next chunk,
collecting [fp, content] pairs into the same entries array; reference the
adapter.readFile calls, the entries variable, and the filePaths.slice(...) usage
when locating where to implement the chunking/loop to avoid flooding the SSE
channel.
- Around line 493-498: sendRequest creates a Promise stored in
sandboxPendingRequests that can hang forever if the client never responds; add a
timeout so the Promise is rejected and cleaned up after a configurable interval
(e.g., SANDBOX_REQUEST_TIMEOUT default 30s). Implement by starting a timer when
creating the promise in sendRequest, storing the timer alongside resolve/reject
in sandboxPendingRequests, and on timeout call reject(new Error(...)) and remove
the entry and emit any cleanup event; also ensure resolveSandboxResponse clears
the timer when resolving so it doesn't fire after a successful response. Update
code paths that access sandboxPendingRequests (sendRequest and
resolveSandboxResponse) to handle the timer lifecycle and use a configurable
timeout constant.
- Around line 1301-1310: The runErrorFix function currently throws
unconditionally which will crash any production caller (the API route handler
that calls runErrorFix); change runErrorFix to return a well-formed failure
result instead of throwing — e.g., return { success: false, message: "Error
fixing not supported with WebContainer backend; please regenerate the fragment
or fix manually.", summary?: undefined, remainingErrors?: undefined } — so
callers can handle the failure safely; ensure the exported function signature
remains the same and update any internal code paths in runErrorFix to use
try/catch and return structured error results rather than throwing.
- Around line 75-132: This file duplicates utilities from
src/agents/sandbox-utils.ts (isValidFilePath, getFindCommand,
AUTO_FIX_ERROR_PATTERNS, shouldTriggerAutoFix); remove the local copies and
import these symbols from sandbox-utils instead, update any local usages to
reference the imported names, and delete the duplicated ALLOWED_WORKSPACE_PATHS
constant; ensure the imported getFindCommand (which contains the corrected find
command) is used so the malformed find command bug is fixed in this module as
well, then run tests/lint to confirm no unresolved references remain.
In `@src/app/api/agent/sandbox-result/route.ts`:
- Around line 12-25: The POST handler currently accepts sandbox results without
authentication; update the POST(request: NextRequest) function to verify the
caller before using sandboxId/response: either validate a Clerk session from the
request (e.g., call a verifyClerkSession(request) helper and reject with 401) or
require and validate a per-sandbox HMAC token (e.g., read an Authorization or
X-Sandbox-HMAC header or a token field, compute/verify against a stored secret
for sandboxId via a validateSandboxHmac(sandboxId, token) helper). On failed
verification return NextResponse.json({ error: "Unauthorized" }, { status: 401
}) and only proceed to check response.requestId and process the result after
successful auth. Ensure the validation helpers are used in this module or
imported so POST enforces auth before any sandbox processing.
In `@src/lib/sandbox-adapter.ts`:
- Around line 494-497: The cleanup() method on WebContainerAdapter currently
unconditionally calls the shared teardownWebContainer(), which will destroy the
singleton webcontainer and break other adapters; change this to only teardown
when this adapter is the last owner: introduce or use a shared reference-count
(or an ownership flag) in the webcontainer management module and increment it
when an adapter creates/attaches to the container and decrement in cleanup();
only call teardownWebContainer() when the refcount reaches zero (or when this
adapter holds the ownership flag), and ensure WebContainerAdapter construction
and cleanup update that counter to avoid destroying the singleton while other
instances still need it.
In `@src/lib/sandbox-bridge.ts`:
- Around line 24-27: The sandbox-bridge module is dead code because
createPendingRequest() is never used, so resolveRequest() always fails; fix by
updating the /api route to use the real pending storage and resolver: replace
imports of resolveRequest (and the sandbox-bridge pending map) with
resolveSandboxResponse (the resolver that uses PENDING_SANDBOX_REQUESTS) from
the code-agent module, and update the call site in the sandbox-result route to
call resolveSandboxResponse(...) instead of resolveRequest(...); alternatively,
if you intend to keep sandbox-bridge as the single source of truth, remove
PENDING_SANDBOX_REQUESTS and ensure the agent handler calls
createPendingRequest(...) to populate the pending Map and that
resolveRequest(...) is used by the route—pick one approach and remove the unused
module or wire up createPendingRequest/resolveRequest consistently (symbols:
createPendingRequest, resolveRequest, pending, REQUEST_TIMEOUT_MS,
PENDING_SANDBOX_REQUESTS, resolveSandboxResponse).
In `@src/lib/use-sandbox-executor.ts`:
- Around line 86-94: The fetch in use-sandbox-executor.ts must detect non-OK
HTTP responses and propagate an error so failures aren't silently ignored: after
awaiting fetch("/api/agent/sandbox-result", ...), check the returned Response.ok
and if false read the response text and throw or console.error with the status
and body (instead of only catching network errors), so callers know the POST
failed; meanwhile, update the server-side sendRequest handler in code-agent.ts
(the DeferredSandboxAdapter pending-request logic / sendRequest callback) to
attach a timeout when creating the pending promise that rejects after a
configurable window (e.g., 10–30s) and cleans up the pending map entry, ensuring
the agent doesn't hang indefinitely if the client never successfully POSTs a
result.
🧹 Nitpick comments (7)
src/inngest/client.ts (1)
40-40: The catch-alleventtopic undermines the channel's type safety.All other topics are strongly typed with specific discriminated
typefields and structureddatapayloads. Theeventtopic acceptstype: stringanddata: unknown, which allows any arbitrary payload to bypass the typed topic system. If this is a deliberate escape hatch, consider documenting it. Otherwise, consider removing it or narrowing the type.package.json (1)
22-24: Minor: stray blank lines left from dependency removal.Lines 22 and 77 have blank lines that appear to be artifacts from removing
@e2b/code-interpreterand possibly another package. Consider removing them to keep the manifest tidy.src/app/api/agent/sandbox-result/route.ts (1)
14-18: Replaceastype assertion with runtime validation.The
body as { sandboxId: string; response: SandboxResponse }cast bypasses type safety. The check on line 20 only validates presence ofsandboxIdandresponse.requestId, but doesn't validate the shape ofresponseagainst theSandboxResponseunion. Malformed payloads could propagate unexpected data to the agent.Consider using a Zod schema to validate the incoming body. As per coding guidelines: "Avoid using 'as' type assertions … resolve types properly."
src/agents/sandbox-utils.ts (2)
17-33:isValidFilePathhas a redundant branch on Line 32.
normalizedPath.startsWith("/home/user/")is already covered byALLOWED_WORKSPACE_PATHScontaining"/home/user"and the.startsWith(${basePath}/)check on Line 28. The redundancy isn't harmful but makes the logic harder to reason about.
88-89:runLintCheckis a permanent no-op — consider removing or adding a TODO.A function that silently returns
nullcould mask missing lint validation down the road. If lint checking is intentionally skipped, a brief inline comment explaining when it's expected to be reinstated would help.src/lib/sandbox-adapter.ts (1)
86-89:frameworkfield inStartDevServerRequestandGetPreviewUrlRequestis typedstringinstead ofFramework.The
ISandboxAdapterinterface declaresstartDevServer(framework: Framework)andgetPreviewUrl(framework: Framework), but the request protocol types use a plainstring. This weakens type safety and forcesas Frameworkcasts on the consumer side (visible inuse-sandbox-executor.tslines 49, 59).Proposed fix
export interface StartDevServerRequest extends BaseRequest { type: "start-dev-server"; - framework: string; + framework: Framework; } ... export interface GetPreviewUrlRequest extends BaseRequest { type: "get-preview-url"; - framework: string; + framework: Framework; }Also applies to: 101-104
src/lib/use-sandbox-executor.ts (1)
48-51:as Frameworktype assertions are a downstream effect of thestringtyping in request protocols.These casts violate the coding guideline to avoid
asassertions. The root fix is to typeframeworkasFrameworkinStartDevServerRequestandGetPreviewUrlRequest(flagged insandbox-adapter.ts), which would eliminate the need for these casts.Also applies to: 58-61
| const ALLOWED_WORKSPACE_PATHS = ["/home/user", "."]; | ||
|
|
||
| const isValidFilePath = (filePath: string): boolean => { | ||
| if (!filePath || typeof filePath !== "string") return false; | ||
| const normalizedPath = filePath.trim(); | ||
| if (normalizedPath.length === 0 || normalizedPath.length > 4096) return false; | ||
| if (normalizedPath.includes("..")) return false; | ||
| if ( | ||
| normalizedPath.includes("\0") || | ||
| normalizedPath.includes("\n") || | ||
| normalizedPath.includes("\r") | ||
| ) | ||
| return false; | ||
|
|
||
| const isInWorkspace = ALLOWED_WORKSPACE_PATHS.some( | ||
| (basePath) => | ||
| normalizedPath === basePath || | ||
| normalizedPath.startsWith(`${basePath}/`) || | ||
| normalizedPath.startsWith(`./`) | ||
| ); | ||
|
|
||
| return ( | ||
| isInWorkspace || | ||
| normalizedPath.startsWith("/home/user/") || | ||
| !normalizedPath.startsWith("/") | ||
| ); | ||
| }; | ||
|
|
||
| const getFindCommand = (framework: Framework): string => { | ||
| const ignorePatterns = ["node_modules", ".git", "dist", "build"]; | ||
| if (framework === "nextjs") ignorePatterns.push(".next"); | ||
| if (framework === "svelte") ignorePatterns.push(".svelte-kit"); | ||
|
|
||
| return `find /home/user -type f -not -path '*/${ignorePatterns.join( | ||
| "/* -not -path */" | ||
| )}/*' 2>/dev/null`; | ||
| }; | ||
|
|
||
| const AUTO_FIX_ERROR_PATTERNS = [ | ||
| /Error:/i, | ||
| /\[ERROR\]/i, | ||
| /ERROR/, | ||
| /Failed\b/i, | ||
| /failure\b/i, | ||
| /Exception\b/i, | ||
| /SyntaxError/i, | ||
| /TypeError/i, | ||
| /ReferenceError/i, | ||
| /Module not found/i, | ||
| /Cannot find module/i, | ||
| /Build failed/i, | ||
| /Compilation error/i, | ||
| ]; | ||
|
|
||
| const shouldTriggerAutoFix = (message?: string): boolean => { | ||
| if (!message) return false; | ||
| return AUTO_FIX_ERROR_PATTERNS.some((pattern) => pattern.test(message)); | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Duplicated utility functions from sandbox-utils.ts — import instead of redefining.
isValidFilePath, getFindCommand, AUTO_FIX_ERROR_PATTERNS, and shouldTriggerAutoFix are exported from src/agents/sandbox-utils.ts and copy-pasted here with identical logic. This violates DRY and means the getFindCommand bug (malformed find command) exists in two places.
Proposed fix
+import {
+ isValidFilePath,
+ getFindCommand,
+ AUTO_FIX_ERROR_PATTERNS,
+ shouldTriggerAutoFix,
+} from "./sandbox-utils";
-
-const ALLOWED_WORKSPACE_PATHS = ["/home/user", "."];
-
-const isValidFilePath = (filePath: string): boolean => {
- ...
-};
-
-const getFindCommand = (framework: Framework): string => {
- ...
-};
-
-const AUTO_FIX_ERROR_PATTERNS = [
- ...
-];
-
-const shouldTriggerAutoFix = (message?: string): boolean => {
- ...
-};Based on learnings: Use @/lib/[filename] for internal imports from the lib directory to maintain modularity — though for agent-internal imports, relative imports within src/agents/ are appropriate.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const ALLOWED_WORKSPACE_PATHS = ["/home/user", "."]; | |
| const isValidFilePath = (filePath: string): boolean => { | |
| if (!filePath || typeof filePath !== "string") return false; | |
| const normalizedPath = filePath.trim(); | |
| if (normalizedPath.length === 0 || normalizedPath.length > 4096) return false; | |
| if (normalizedPath.includes("..")) return false; | |
| if ( | |
| normalizedPath.includes("\0") || | |
| normalizedPath.includes("\n") || | |
| normalizedPath.includes("\r") | |
| ) | |
| return false; | |
| const isInWorkspace = ALLOWED_WORKSPACE_PATHS.some( | |
| (basePath) => | |
| normalizedPath === basePath || | |
| normalizedPath.startsWith(`${basePath}/`) || | |
| normalizedPath.startsWith(`./`) | |
| ); | |
| return ( | |
| isInWorkspace || | |
| normalizedPath.startsWith("/home/user/") || | |
| !normalizedPath.startsWith("/") | |
| ); | |
| }; | |
| const getFindCommand = (framework: Framework): string => { | |
| const ignorePatterns = ["node_modules", ".git", "dist", "build"]; | |
| if (framework === "nextjs") ignorePatterns.push(".next"); | |
| if (framework === "svelte") ignorePatterns.push(".svelte-kit"); | |
| return `find /home/user -type f -not -path '*/${ignorePatterns.join( | |
| "/* -not -path */" | |
| )}/*' 2>/dev/null`; | |
| }; | |
| const AUTO_FIX_ERROR_PATTERNS = [ | |
| /Error:/i, | |
| /\[ERROR\]/i, | |
| /ERROR/, | |
| /Failed\b/i, | |
| /failure\b/i, | |
| /Exception\b/i, | |
| /SyntaxError/i, | |
| /TypeError/i, | |
| /ReferenceError/i, | |
| /Module not found/i, | |
| /Cannot find module/i, | |
| /Build failed/i, | |
| /Compilation error/i, | |
| ]; | |
| const shouldTriggerAutoFix = (message?: string): boolean => { | |
| if (!message) return false; | |
| return AUTO_FIX_ERROR_PATTERNS.some((pattern) => pattern.test(message)); | |
| }; | |
| import { | |
| isValidFilePath, | |
| getFindCommand, | |
| AUTO_FIX_ERROR_PATTERNS, | |
| shouldTriggerAutoFix, | |
| } from "./sandbox-utils"; |
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 75 - 132, This file duplicates
utilities from src/agents/sandbox-utils.ts (isValidFilePath, getFindCommand,
AUTO_FIX_ERROR_PATTERNS, shouldTriggerAutoFix); remove the local copies and
import these symbols from sandbox-utils instead, update any local usages to
reference the imported names, and delete the duplicated ALLOWED_WORKSPACE_PATHS
constant; ensure the imported getFindCommand (which contains the corrected find
command) is used so the malformed find command bug is fixed in this module as
well, then run tests/lint to confirm no unresolved references remain.
| const getFindCommand = (framework: Framework): string => { | ||
| const ignorePatterns = ["node_modules", ".git", "dist", "build"]; | ||
| if (framework === "nextjs") ignorePatterns.push(".next"); | ||
| if (framework === "svelte") ignorePatterns.push(".svelte-kit"); | ||
|
|
||
| return `find /home/user -type f -not -path '*/${ignorePatterns.join( | ||
| "/* -not -path */" | ||
| )}/*' 2>/dev/null`; | ||
| }; |
There was a problem hiding this comment.
getFindCommand has the same malformed shell command bug as in sandbox-utils.ts.
The join produces a single-quoted string containing all -not -path clauses, making the find command match all files. See the detailed explanation in the sandbox-utils.ts review. Since this is the copy that actually executes in agent runs, it means file discovery in the sandbox will return all files including node_modules, leading to massive file reads and potential timeouts.
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 103 - 111, The getFindCommand function
builds a malformed shell command by joining ignorePatterns into a single quoted
segment so the -not -path clauses don't apply; fix getFindCommand by mapping
each ignorePatterns entry to its own "-not -path '*/<pattern>/*'" clause and
joining those clauses with spaces (instead of injecting a joined string inside
one quoted clause), then interpolate that resulting string into the final find
command (keep the search root /home/user and preserve redirect 2>/dev/null);
update the getFindCommand implementation to use this mapped-and-joined clauses
string so node_modules, .git, .next, .svelte-kit, etc. are properly excluded
during file discovery.
| type PendingRequest = { | ||
| resolve: (response: SandboxResponse) => void; | ||
| reject: (error: Error) => void; | ||
| }; | ||
|
|
||
| const PENDING_SANDBOX_REQUESTS = new Map<string, Map<string, PendingRequest>>(); | ||
|
|
||
| export function resolveSandboxResponse( | ||
| sandboxId: string, | ||
| response: SandboxResponse | ||
| ): boolean { | ||
| const pending = PENDING_SANDBOX_REQUESTS.get(sandboxId); | ||
| if (!pending) return false; | ||
| const entry = pending.get(response.requestId); | ||
| if (!entry) return false; | ||
| pending.delete(response.requestId); | ||
| entry.resolve(response); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
PENDING_SANDBOX_REQUESTS outer map entries are never cleaned up — memory leak across agent runs.
PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests) at Line 510 adds an entry, but there's no corresponding PENDING_SANDBOX_REQUESTS.delete(adapter.id) when the agent run completes or fails. Over many runs, this map grows unboundedly.
Proposed fix — clean up in a finally block
Add cleanup at the end of the runCodeAgent generator, e.g. in both the success path and the catch block:
+ } finally {
+ // Clean up pending sandbox request map
+ if (adapter?.id) {
+ PENDING_SANDBOX_REQUESTS.delete(adapter.id);
+ }
+ }You'll need to hoist adapter declaration or restructure the try/finally scope accordingly.
Also applies to: 490-510
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 134 - 152, The
PENDING_SANDBOX_REQUESTS map entries are never removed, causing a memory leak;
modify runCodeAgent so the adapter-scoped entry (the value assigned via
PENDING_SANDBOX_REQUESTS.set(adapter.id, sandboxPendingRequests)) is deleted
when the agent finishes or errors — move or hoist the adapter declaration so you
can use a try/finally around the run logic and call
PENDING_SANDBOX_REQUESTS.delete(adapter.id) in the finally block (also ensure
any remaining per-request entries are cleaned if needed); keep
resolveSandboxResponse behavior unchanged but rely on the finally cleanup to
remove the outer map entry.
| const sendRequest: SendRequestCallback = (request) => { | ||
| return new Promise((resolve, reject) => { | ||
| sandboxPendingRequests.set(request.id, { resolve, reject }); | ||
| queueEvent({ type: "sandbox-request", data: { sandboxId: adapterSandboxId, request } }); | ||
| }); | ||
| }; |
There was a problem hiding this comment.
sendRequest callback has no timeout — agent hangs forever if client never responds.
The promise created here is stored in sandboxPendingRequests and only resolved when the client POSTs a response via resolveSandboxResponse. If the client disconnects, crashes, or the network drops, this promise never settles, blocking the agent indefinitely.
Proposed fix — add a timeout to the pending promise
+const SANDBOX_REQUEST_TIMEOUT_MS = 60_000; // 60 seconds
+
const sendRequest: SendRequestCallback = (request) => {
return new Promise((resolve, reject) => {
sandboxPendingRequests.set(request.id, { resolve, reject });
queueEvent({ type: "sandbox-request", data: { sandboxId: adapterSandboxId, request } });
+
+ setTimeout(() => {
+ if (sandboxPendingRequests.has(request.id)) {
+ sandboxPendingRequests.delete(request.id);
+ reject(new Error(`Sandbox request ${request.id} timed out after ${SANDBOX_REQUEST_TIMEOUT_MS}ms`));
+ }
+ }, SANDBOX_REQUEST_TIMEOUT_MS);
});
};🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 493 - 498, sendRequest creates a
Promise stored in sandboxPendingRequests that can hang forever if the client
never responds; add a timeout so the Promise is rejected and cleaned up after a
configurable interval (e.g., SANDBOX_REQUEST_TIMEOUT default 30s). Implement by
starting a timer when creating the promise in sendRequest, storing the timer
alongside resolve/reject in sandboxPendingRequests, and on timeout call
reject(new Error(...)) and remove the entry and emit any cleanup event; also
ensure resolveSandboxResponse clears the timer when resolving so it doesn't fire
after a successful response. Update code paths that access
sandboxPendingRequests (sendRequest and resolveSandboxResponse) to handle the
timer lifecycle and use a configurable timeout constant.
| const entries = await Promise.all( | ||
| filePaths.slice(0, 500).map(async (fp) => { | ||
| const content = await adapter.readFile(fp); | ||
| return [fp, content] as const; | ||
| }) | ||
| ); |
There was a problem hiding this comment.
500 concurrent adapter.readFile calls via Promise.all may overwhelm the client.
For DeferredSandboxAdapter, each readFile call creates a sandbox request that must be serialized as an SSE event, executed by the client in WebContainer, and POSTed back. Firing 500 of these in parallel could flood the SSE channel and cause timeouts or dropped requests. Consider batching reads (e.g. 20-50 at a time).
Proposed batched read approach
- const entries = await Promise.all(
- filePaths.slice(0, 500).map(async (fp) => {
- const content = await adapter.readFile(fp);
- return [fp, content] as const;
- })
- );
+ const BATCH_SIZE = 30;
+ const entries: (readonly [string, string | null])[] = [];
+ const paths = filePaths.slice(0, 500);
+ for (let i = 0; i < paths.length; i += BATCH_SIZE) {
+ const batch = paths.slice(i, i + BATCH_SIZE);
+ const results = await Promise.all(
+ batch.map(async (fp) => {
+ const content = await adapter.readFile(fp);
+ return [fp, content] as const;
+ })
+ );
+ entries.push(...results);
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const entries = await Promise.all( | |
| filePaths.slice(0, 500).map(async (fp) => { | |
| const content = await adapter.readFile(fp); | |
| return [fp, content] as const; | |
| }) | |
| ); | |
| const BATCH_SIZE = 30; | |
| const entries: (readonly [string, string | null])[] = []; | |
| const paths = filePaths.slice(0, 500); | |
| for (let i = 0; i < paths.length; i += BATCH_SIZE) { | |
| const batch = paths.slice(i, i + BATCH_SIZE); | |
| const results = await Promise.all( | |
| batch.map(async (fp) => { | |
| const content = await adapter.readFile(fp); | |
| return [fp, content] as const; | |
| }) | |
| ); | |
| entries.push(...results); | |
| } |
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 1156 - 1161, The current code launches
up to 500 concurrent adapter.readFile calls (see Promise.all over
filePaths.slice(0, 500) that builds entries), which can overwhelm
DeferredSandboxAdapter/SSE; change to a batched read strategy: chunk the
filePaths (e.g., batch size 20–50) and for each chunk run Promise.all on that
slice and await it before starting the next chunk, collecting [fp, content]
pairs into the same entries array; reference the adapter.readFile calls, the
entries variable, and the filePaths.slice(...) usage when locating where to
implement the chunking/loop to avoid flooding the SSE channel.
| export async function runErrorFix(_fragmentId: string): Promise<{ | ||
| success: boolean; | ||
| message: string; | ||
| summary?: string; | ||
| remainingErrors?: string; | ||
| }> { | ||
| const fragment = await convex.query(api.messages.getFragmentById, { | ||
| fragmentId: fragmentId as Id<"fragments">, | ||
| }); | ||
|
|
||
| if (!fragment) { | ||
| throw new Error("Fragment not found"); | ||
| } | ||
|
|
||
| if (!fragment.sandboxId) { | ||
| throw new Error("Fragment has no active sandbox"); | ||
| } | ||
|
|
||
| const message = await convex.query(api.messages.get, { | ||
| messageId: fragment.messageId as Id<"messages">, | ||
| }); | ||
| if (!message) { | ||
| throw new Error("Message not found"); | ||
| } | ||
|
|
||
| const project = await convex.query(api.projects.getForSystem, { | ||
| projectId: message.projectId as Id<"projects">, | ||
| }); | ||
| if (!project) { | ||
| throw new Error("Project not found"); | ||
| } | ||
|
|
||
| const fragmentFramework = (fragment.framework?.toLowerCase() || | ||
| "nextjs") as Framework; | ||
| const sandboxId = fragment.sandboxId; | ||
|
|
||
| let sandbox: Sandbox; | ||
| try { | ||
| sandbox = await getSandbox(sandboxId); | ||
| } catch { | ||
| throw new Error("Sandbox is no longer active. Please refresh the fragment."); | ||
| } | ||
|
|
||
| const fragmentMetadata = | ||
| typeof fragment.metadata === "object" && fragment.metadata !== null | ||
| ? (fragment.metadata as Record<string, unknown>) | ||
| : {}; | ||
|
|
||
| const fragmentModel = | ||
| (fragmentMetadata.model as keyof typeof MODEL_CONFIGS) || | ||
| "anthropic/claude-haiku-4.5"; | ||
|
|
||
| // Skip lint check for speed - only run build validation | ||
| const buildErrors = await runBuildCheck(sandbox); | ||
|
|
||
| const validationErrors = buildErrors || ""; | ||
|
|
||
| if (!validationErrors) { | ||
| return { | ||
| success: true, | ||
| message: "No errors detected", | ||
| }; | ||
| } | ||
|
|
||
| const state: AgentState = { | ||
| summary: "", | ||
| files: fragment.files as Record<string, string>, | ||
| selectedFramework: fragmentFramework, | ||
| summaryRetryCount: 0, | ||
| }; | ||
|
|
||
| const tools = createAgentTools({ | ||
| sandboxId, | ||
| state, | ||
| updateFiles: (files) => { | ||
| state.files = files; | ||
| }, | ||
| }); | ||
|
|
||
| const frameworkPrompt = getFrameworkPrompt(fragmentFramework); | ||
| const modelConfig = MODEL_CONFIGS[fragmentModel]; | ||
|
|
||
| const fixPrompt = `CRITICAL ERROR FIX REQUEST | ||
|
|
||
| The following errors were detected in the application and need to be fixed immediately: | ||
|
|
||
| ${validationErrors} | ||
|
|
||
| REQUIRED ACTIONS: | ||
| 1. Carefully analyze the error messages to identify the root cause | ||
| 2. Check for common issues: missing imports, type errors, syntax errors, missing packages | ||
| 3. Apply the necessary fixes to resolve ALL errors completely | ||
| 4. Verify the fixes by ensuring the code is syntactically correct | ||
| 5. Provide a <task_summary> explaining what was fixed`; | ||
|
|
||
| const result = await withRateLimitRetry( | ||
| () => generateText({ | ||
| model: getClientForModel(fragmentModel).chat(fragmentModel), | ||
| system: frameworkPrompt, | ||
| messages: [{ role: "user", content: fixPrompt }], | ||
| tools, | ||
| stopWhen: stepCountIs(10), | ||
| temperature: modelConfig.temperature, | ||
| }), | ||
| { context: "runErrorFix" } | ||
| throw new Error( | ||
| "Error fix is not supported with the WebContainer backend. " + | ||
| "Please regenerate the fragment or fix errors manually." | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "runErrorFix" --type ts -B 2 -A 2Repository: Jackson57279/zapdev
Length of output: 3038
runErrorFix is a hard-fail stub with an active production caller — will crash at runtime.
The function unconditionally throws. Active code at src/app/api/fix-errors/route.ts:50 calls runErrorFix directly in a production API endpoint. Any client invoking this endpoint will receive a runtime error instead of error-fixing functionality.
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` around lines 1301 - 1310, The runErrorFix function
currently throws unconditionally which will crash any production caller (the API
route handler that calls runErrorFix); change runErrorFix to return a
well-formed failure result instead of throwing — e.g., return { success: false,
message: "Error fixing not supported with WebContainer backend; please
regenerate the fragment or fix manually.", summary?: undefined,
remainingErrors?: undefined } — so callers can handle the failure safely; ensure
the exported function signature remains the same and update any internal code
paths in runErrorFix to use try/catch and return structured error results rather
than throwing.
| export async function POST(request: NextRequest) { | ||
| try { | ||
| const body = await request.json(); | ||
| const { sandboxId, response } = body as { | ||
| sandboxId: string; | ||
| response: SandboxResponse; | ||
| }; | ||
|
|
||
| if (!sandboxId || !response || !response.requestId) { | ||
| return NextResponse.json( | ||
| { error: "Missing sandboxId or response with requestId" }, | ||
| { status: 400 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Missing authentication — endpoint is publicly accessible.
This endpoint allows any unauthenticated caller to resolve pending agent sandbox requests with arbitrary response data. An attacker who can guess or enumerate sandboxId and requestId values could inject malicious command outputs or file contents into the agent's execution flow.
Add authentication (e.g., Clerk session verification or a per-sandbox HMAC token) before processing the result.
🤖 Prompt for AI Agents
In `@src/app/api/agent/sandbox-result/route.ts` around lines 12 - 25, The POST
handler currently accepts sandbox results without authentication; update the
POST(request: NextRequest) function to verify the caller before using
sandboxId/response: either validate a Clerk session from the request (e.g., call
a verifyClerkSession(request) helper and reject with 401) or require and
validate a per-sandbox HMAC token (e.g., read an Authorization or X-Sandbox-HMAC
header or a token field, compute/verify against a stored secret for sandboxId
via a validateSandboxHmac(sandboxId, token) helper). On failed verification
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) and only
proceed to check response.requestId and process the result after successful
auth. Ensure the validation helpers are used in this module or imported so POST
enforces auth before any sandbox processing.
| async cleanup(): Promise<void> { | ||
| const { teardownWebContainer } = await import("@/lib/webcontainer"); | ||
| teardownWebContainer(); | ||
| } |
There was a problem hiding this comment.
cleanup() calls the global teardownWebContainer(), which destroys the singleton — risky if multiple adapters coexist.
If two WebContainerAdapter instances are created (e.g. across tabs or re-init scenarios), calling cleanup() on one will tear down the shared WebContainer for both. Consider guarding against this or documenting the singleton constraint.
🤖 Prompt for AI Agents
In `@src/lib/sandbox-adapter.ts` around lines 494 - 497, The cleanup() method on
WebContainerAdapter currently unconditionally calls the shared
teardownWebContainer(), which will destroy the singleton webcontainer and break
other adapters; change this to only teardown when this adapter is the last
owner: introduce or use a shared reference-count (or an ownership flag) in the
webcontainer management module and increment it when an adapter creates/attaches
to the container and decrement in cleanup(); only call teardownWebContainer()
when the refcount reaches zero (or when this adapter holds the ownership flag),
and ensure WebContainerAdapter construction and cleanup update that counter to
avoid destroying the singleton while other instances still need it.
| try { | ||
| await fetch("/api/agent/sandbox-result", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ sandboxId, response }), | ||
| }); | ||
| } catch (postError) { | ||
| console.error("[sandbox-executor] Failed to POST result:", postError); | ||
| } |
There was a problem hiding this comment.
Agent can hang indefinitely if the result POST fails or returns a non-OK status.
The server-side DeferredSandboxAdapter blocks on a promise that is only resolved when the client POSTs the result back to /api/agent/sandbox-result. If this fetch fails (network error caught at Line 92) or returns a non-OK HTTP status (not checked), the corresponding promise on the server side is never resolved or rejected — the agent hangs forever.
At minimum, check response.ok and consider a timeout on the server-side pending request map to avoid unbounded waits.
Proposed fix (client side)
try {
- await fetch("/api/agent/sandbox-result", {
+ const res = await fetch("/api/agent/sandbox-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sandboxId, response }),
});
+ if (!res.ok) {
+ console.error("[sandbox-executor] POST returned", res.status, await res.text());
+ }
} catch (postError) {
console.error("[sandbox-executor] Failed to POST result:", postError);
}Additionally, the server-side sendRequest callback in code-agent.ts (Line 493-498) should implement a timeout to reject the pending promise if no response arrives within a reasonable window, preventing the agent from blocking indefinitely.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { | |
| await fetch("/api/agent/sandbox-result", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ sandboxId, response }), | |
| }); | |
| } catch (postError) { | |
| console.error("[sandbox-executor] Failed to POST result:", postError); | |
| } | |
| try { | |
| const res = await fetch("/api/agent/sandbox-result", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ sandboxId, response }), | |
| }); | |
| if (!res.ok) { | |
| console.error("[sandbox-executor] POST returned", res.status, await res.text()); | |
| } | |
| } catch (postError) { | |
| console.error("[sandbox-executor] Failed to POST result:", postError); | |
| } |
🤖 Prompt for AI Agents
In `@src/lib/use-sandbox-executor.ts` around lines 86 - 94, The fetch in
use-sandbox-executor.ts must detect non-OK HTTP responses and propagate an error
so failures aren't silently ignored: after awaiting
fetch("/api/agent/sandbox-result", ...), check the returned Response.ok and if
false read the response text and throw or console.error with the status and body
(instead of only catching network errors), so callers know the POST failed;
meanwhile, update the server-side sendRequest handler in code-agent.ts (the
DeferredSandboxAdapter pending-request logic / sendRequest callback) to attach a
timeout when creating the pending promise that rejects after a configurable
window (e.g., 10–30s) and cleans up the pending map entry, ensuring the agent
doesn't hang indefinitely if the client never successfully POSTs a result.
|
❌ Error while analyzing PR and generating tests: |
There was a problem hiding this comment.
4 issues found across 10 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name=".sisyphus/boulder.json">
<violation number="1" location=".sisyphus/boulder.json:2">
P2: Avoid committing absolute filesystem paths; they break portability and violate the repository’s anti-pattern guidance. Store a repo-relative path instead.</violation>
</file>
<file name="src/agents/code-agent.ts">
<violation number="1" location="src/agents/code-agent.ts:937">
P2: The retry-with-tool-use flow generates new text, but fullText is never updated with retryResult.text. As a result, the summary generation can use the original (empty/no-tool) response instead of the retry output, producing inaccurate summaries for the files actually created.</violation>
</file>
<file name="src/agents/tools.ts">
<violation number="1" location="src/agents/tools.ts:185">
P1: Sanitize or safely quote the user-provided path before interpolating it into shell commands to avoid command injection.</violation>
<violation number="2" location="src/agents/tools.ts:213">
P1: Escape or validate pattern/path inputs before interpolating into a shell command to prevent command injection and invalid grep execution.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| try { | ||
| const searchPath = path || "."; | ||
| const includePattern = filePattern ? `--include="${filePattern}"` : ""; | ||
| const command = `grep -r ${includePattern} -l "${pattern}" ${searchPath} 2>/dev/null | head -20`; |
There was a problem hiding this comment.
P1: Escape or validate pattern/path inputs before interpolating into a shell command to prevent command injection and invalid grep execution.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/tools.ts, line 213:
<comment>Escape or validate pattern/path inputs before interpolating into a shell command to prevent command injection and invalid grep execution.</comment>
<file context>
@@ -171,5 +171,158 @@ export function createAgentTools(context: ToolContext) {
+ try {
+ const searchPath = path || ".";
+ const includePattern = filePattern ? `--include="${filePattern}"` : "";
+ const command = `grep -r ${includePattern} -l "${pattern}" ${searchPath} 2>/dev/null | head -20`;
+ const result = await adapter.runCommand(command);
+ const files = result.stdout.split("\n").filter(f => f.trim());
</file context>
| console.log("[DEBUG] listFiles tool called for path:", path); | ||
| onToolCall?.("listFiles", { path, recursive }); | ||
| try { | ||
| const command = recursive |
There was a problem hiding this comment.
P1: Sanitize or safely quote the user-provided path before interpolating it into shell commands to avoid command injection.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/tools.ts, line 185:
<comment>Sanitize or safely quote the user-provided path before interpolating it into shell commands to avoid command injection.</comment>
<file context>
@@ -171,5 +171,158 @@ export function createAgentTools(context: ToolContext) {
+ console.log("[DEBUG] listFiles tool called for path:", path);
+ onToolCall?.("listFiles", { path, recursive });
+ try {
+ const command = recursive
+ ? `find ${path} -type f 2>/dev/null | head -50`
+ : `ls -la ${path} 2>/dev/null`;
</file context>
| @@ -0,0 +1,6 @@ | |||
| { | |||
| "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md", | |||
There was a problem hiding this comment.
P2: Avoid committing absolute filesystem paths; they break portability and violate the repository’s anti-pattern guidance. Store a repo-relative path instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .sisyphus/boulder.json, line 2:
<comment>Avoid committing absolute filesystem paths; they break portability and violate the repository’s anti-pattern guidance. Store a repo-relative path instead.</comment>
<file context>
@@ -0,0 +1,6 @@
+{
+ "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
+ "started_at": "2026-02-08T06:29:01.522Z",
+ "session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
</file context>
| } | ||
| } | ||
|
|
||
| const retrySummary = extractSummaryText(retryResult.text || ""); |
There was a problem hiding this comment.
P2: The retry-with-tool-use flow generates new text, but fullText is never updated with retryResult.text. As a result, the summary generation can use the original (empty/no-tool) response instead of the retry output, producing inaccurate summaries for the files actually created.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/agents/code-agent.ts, line 937:
<comment>The retry-with-tool-use flow generates new text, but fullText is never updated with retryResult.text. As a result, the summary generation can use the original (empty/no-tool) response instead of the retry output, producing inaccurate summaries for the files actually created.</comment>
<file context>
@@ -892,6 +896,55 @@ export async function* runCodeAgent(
+ }
+ }
+
+ const retrySummary = extractSummaryText(retryResult.text || "");
+ if (retrySummary) {
+ state.summary = retrySummary;
</file context>
| const retrySummary = extractSummaryText(retryResult.text || ""); | |
| const retryText = retryResult.text || ""; | |
| const retrySummary = extractSummaryText(retryText); | |
| if (retryText) { | |
| fullText = retryText; | |
| } |
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Fix all issues with AI agents
In @.sisyphus/boulder.json:
- Around line 1-6: The .sisyphus/boulder.json currently contains an absolute
path in the "active_plan" field and should not be committed; change the
"active_plan" value to a relative path (e.g.,
".sisyphus/plans/export-to-polaris.md") in .sisyphus/boulder.json and add the
filename .sisyphus/boulder.json to .gitignore so the transient session state
(session_ids, started_at) is not tracked; update the file and commit the
.gitignore change.
In @.sisyphus/drafts/export-to-polaris.md:
- Around line 69-76: Add two new subsections under the "Data Scope: Everything
Including Chat History" area in .sisyphus/drafts/export-to-polaris.md: "Security
Considerations" and "Scalability Considerations". In "Security Considerations"
list encryption requirements (TLS in transit, AES-256 at rest), API
token/credential handling (scoped credentials, rotation, storage), temporary
token TTLs, PII/data classification and redaction rules for chat history, and
rate-limiting/abuse controls. In "Scalability Considerations" specify max
project size and file/attachment/message limits (or recommend defaults),
attachment size limits, chunked/streamed transfer for large projects,
timeout/heartbeat behavior, and strategies for retries, mid-transfer
rollback/consistency, and user notification on partial failures.
- Line 154: Populate the "Open Questions for Metis Review" section by listing
the seven specific decision points extracted from the document and providing
concise options for each so reviewers can pick or comment: 1) For Authentication
(refer to the three approaches noted around lines 118-132) enumerate pros/cons
and recommend one (OAuth with token exchange, API-key transfer, or
user-initiated SSO) and ask which to adopt; 2) For Data size limits state
concrete proposals for max project size, max file count, and message history
retention (e.g., 5GB / 10k files / 12 months) and ask for agreement or changes;
3) For Failure handling propose rollback vs retry semantics and an idempotent
resume mechanism and ask which behavior to standardize; 4) For Security specify
how to protect API keys/tokens/user data in transit and at rest (TLS, envelope
encryption, short-lived tokens) and request approval; 5) For Attachments ask
whether to include attachments, propose size/format limits and optional
on-demand transfer, and request decision; 6) For Performance propose
chunking/streaming strategies for large projects (e.g., chunk size, parallelism,
backpressure) and ask which to prefer; 7) For User experience propose requiring
pre-existing Polaris accounts vs auto-creating accounts with user consent and
ask which flow to implement. Keep each question as a short bullet-like decision
prompt so Metis can answer succinctly.
- Around line 118-132: Add a clear recommendation to the "Authentication"
section: choose Option 3 (Temporary Transfer Token) as the default for the
export flow because it best supports the "Export with Redirect" UX; explicitly
state that users do NOT need pre-existing accounts on both platforms, that
export history should be tied to the Polaris account created/claimed via the
token (or mapped if an existing account is claimed) to preserve auditability,
and that ZapDev should issue short-lived transfer tokens used in the redirect to
create or link a Polaris account; also update the "Open Questions for Metis
Review" to surface these three decisions (pre-existing accounts required, export
history identity mapping, and alignment with Export with Redirect).
In @.sisyphus/plans/export-to-polaris.md:
- Around line 190-198: The dependency matrix and Task 3/Task 4 descriptions are
inconsistent: decide whether Task 3 and Task 4 are parallel (Wave 2) or Task 4
truly blocks Task 3, then make both places match; if you choose parallel, remove
Task 4 from the "Blocks" column for Task 3 in the matrix and remove any "Blocked
By: Task 4" text in Task 3's description (ensure both list only Task 2 as
blocker), or if you choose that Task 4 blocks Task 3, update Task 3's "Blocked
By" section to include Task 4 and move Task 4 out of Wave 2 (or adjust waves so
they aren't parallel) so the matrix and task descriptions are consistent.
In `@convex/schema.ts`:
- Around line 100-104: The exported v.union named skillStatusEnum is declared
but unused; either remove the skillStatusEnum export or add a status field to
the skills table that references it. To fix, locate the skills table definition
(symbol: skills) and add a property status: skillStatusEnum to its schema if you
intend to track skill state, or delete the skillStatusEnum declaration/export if
it’s not needed; if you add the field, ensure default/optional behavior is
handled (e.g., required vs optional) and update any code that constructs or
reads skills to account for the new status property.
- Around line 351-357: The query that reads the projectDeploymentCounters row
should enforce a single-row-per-project invariant—replace the use of .first()
with .unique() on the query that selects from projectDeploymentCounters (the
call in convex/deployments.ts that currently uses .first()). Keep the existing
missing-row handling as-is; switching to .unique() will cause an error if
multiple rows exist, guaranteeing the one-counter-per-project constraint.
In `@src/agents/code-agent.ts`:
- Line 71: The constant MAX_AGENT_ITERATIONS is set to 12 which violates the
guideline limit of 8; change the constant declaration MAX_AGENT_ITERATIONS to 8
(e.g., const MAX_AGENT_ITERATIONS = 8) and ensure any code paths that rely on
this value (agent loop/iteration checks) continue to use this constant so the
agent enforces the 8-iteration max consistently.
In `@src/agents/tools.ts`:
- Around line 226-251: The installDependencies tool builds devFlag incorrectly
for pnpm: change the devFlag calculation in the installDependencies execute
function so that when dev is true you use the npm/pnpm flag "--save-dev" (or
"-D") and for other managers (e.g., bun if applicable) use the appropriate flag;
specifically update the line that sets devFlag (currently using pkgManager ===
"npm" ? "--save-dev" : "--dev") to use pkgManager === "npm" || pkgManager ===
"pnpm" ? "--save-dev" : "<other-manager-flag-or-empty>" (or "-D" for pnpm if you
prefer), and ensure the final command string (built via pkgManager and
packages.join(" ")) handles an empty devFlag cleanly. Reference:
installDependencies, pkgManager, devFlag, adapter.runCommand.
- Around line 253-297: The runBuildAndLint tool currently treats any fulfilled
Promise as success; update execute in runBuildAndLint to determine success by
checking fulfilled results' value.exitCode (treat exitCode === 0 as success) and
use value.stdout/value.stderr for outputs and errors, while handling rejected
promises as failures; also simplify the dead ternary for buildCmd (replace
framework === "nextjs" ? "npm run build" : "npm run build" with a single
assignment). Ensure you reference adapter.runCommand results (buildResult and
lintResult) and set results.build.success/results.lint.success based on
exitCode, and populate results.build.error/results.lint.error from stderr or
reason.message when appropriate.
🧹 Nitpick comments (8)
src/app/api/agent/sandbox-result/route.ts (1)
20-23: Avoidastype assertion; validate the request body properly.The
ascast bypasses type safety — if the client sends a malformed payload (e.g.,responsemissing fields of theSandboxResponseunion), it will silently pass the shallow!responsecheck on line 25 and resolve a pending promise with an unexpected shape, potentially corrupting agent state.Use runtime validation (e.g., Zod) to parse the body and reject invalid payloads at the boundary. As per coding guidelines: "Avoid using 'as' type assertions or 'any' types when encountering TypeScript errors; resolve types properly."
Proposed fix (sketch with Zod)
+import { z } from "zod"; + +const SandboxResultSchema = z.object({ + sandboxId: z.string().min(1), + response: z.object({ + requestId: z.string().min(1), + }).passthrough(), // allow additional SandboxResponse fields +}); + export async function POST(request: NextRequest) { try { const body = await request.json(); - const { sandboxId, response } = body as { - sandboxId: string; - response: SandboxResponse; - }; - - if (!sandboxId || !response || !response.requestId) { - return NextResponse.json( - { error: "Missing sandboxId or response with requestId" }, - { status: 400 } - ); - } + const parsed = SandboxResultSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 } + ); + } + const { sandboxId, response } = parsed.data as { + sandboxId: string; + response: SandboxResponse; + };.sisyphus/drafts/export-to-polaris.md (1)
86-104: Add language identifier to fenced code block.The data flow diagram is clear and helpful, but the fenced code block should specify a language for proper rendering and accessibility.
📝 Suggested fix
-``` +```text ZapDev Polaris | |convex/schema.ts (3)
61-66: Inconsistent casing:githubExportStatusEnumuses lowercase whileimportStatusEnumuses UPPERCASE.
importStatusEnum(line 54) uses"PENDING","PROCESSING","COMPLETE","FAILED", butgithubExportStatusEnumuses"pending","processing","complete","failed"for semantically identical statuses. Similarly, the inlinedeployments.statusenum (line 209) also uses lowercase. Pick one convention to avoid confusion and bugs when comparing status values across tables.
209-214: Extract the deployment status enum into a named constant for consistency.Every other status/type union in this schema is defined as a top-level exported const (e.g.,
importStatusEnum,githubExportStatusEnum). This inline union breaks that pattern and makes it harder to reuse in argument validators or other files.♻️ Suggested refactor
+export const deploymentStatusEnum = v.union( + v.literal("pending"), + v.literal("building"), + v.literal("ready"), + v.literal("error") +);Then reference it in the table definition:
- status: v.union( - v.literal("pending"), - v.literal("building"), - v.literal("ready"), - v.literal("error") - ), + status: deploymentStatusEnum,
124-124:importMetadata: v.optional(v.any())bypasses type safety on a new field.This is a newly added field. Per coding guidelines,
anyshould be avoided. Define a typed validator (even a loosev.record(v.string(), v.any())is better) or a specific object shape that captures the expected import metadata structure.As per coding guidelines: "Enable TypeScript strict mode and never use
anytype" and "DO NOT useanyin function signatures; leverageDoc<'table'>orId<'table'>for type safety.".sisyphus/plans/export-to-polaris.md (1)
149-160: Consider SHA-256 instead of MD5 for integrity checks.Line 156 recommends comparing MD5 hashes for data integrity verification. While MD5 is fine for non-adversarial checksumming, SHA-256 is a better default — especially given the security-conscious nature of this integration (HMAC, API keys, replay protection). It's cheap to use the stronger hash from the start.
src/agents/tools.ts (2)
175-198: Shell arguments are interpolated without escaping — injection risk (sandbox-mitigated).In
listFiles(Line 186) andsearchFiles(Line 213), user-providedpathandpatternvalues are interpolated directly into shell commands. While the blast radius is contained within the E2B sandbox, an LLM-supplied path like; rm -rf /or a pattern containing$(...)could cause unexpected behavior within the sandbox session.Consider escaping or quoting arguments:
♻️ Example for listFiles
- const command = recursive - ? `find ${path} -type f 2>/dev/null | head -50` - : `ls -la ${path} 2>/dev/null`; + const safePath = path.replace(/'/g, "'\\''"); + const command = recursive + ? `find '${safePath}' -type f 2>/dev/null | head -50` + : `ls -la '${safePath}' 2>/dev/null`;Also applies to: 200-224
299-326:JSON.parseon potentially malformed file content will discard all results.Lines 313–314: if
package.jsonortsconfig.jsoncontains invalid JSON, theJSON.parsethrows, and the outer catch returns a generic error string — losing the directory listing and whichever file parsed successfully.♻️ Safer parsing
+ const safeParse = (raw: string | null): unknown => { + try { return JSON.parse(raw || "{}"); } catch { return null; } + }; const result = { - packageJson: packageJson.status === "fulfilled" ? JSON.parse(packageJson.value || "{}") : null, - tsconfigJson: tsconfigJson.status === "fulfilled" ? JSON.parse(tsconfigJson.value || "{}") : null, + packageJson: packageJson.status === "fulfilled" ? safeParse(packageJson.value) : null, + tsconfigJson: tsconfigJson.status === "fulfilled" ? safeParse(tsconfigJson.value) : null, rootFiles: dirListing.status === "fulfilled" ? dirListing.value.stdout : "", };
| { | ||
| "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md", | ||
| "started_at": "2026-02-08T06:29:01.522Z", | ||
| "session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"], | ||
| "plan_name": "export-to-polaris" | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if .gitignore already has sisyphus entries
echo "=== .gitignore contents related to sisyphus ==="
rg -n "sisyphus" .gitignore 2>/dev/null || echo "No sisyphus entries in .gitignore (or .gitignore not found)"
echo ""
echo "=== Other .sisyphus files in the repo ==="
fd . .sisyphus --type f 2>/dev/null
echo ""
echo "=== Full .gitignore content ==="
cat .gitignore 2>/dev/null || echo ".gitignore not found"Repository: Jackson57279/zapdev
Length of output: 1167
🏁 Script executed:
#!/bin/bash
# Check if boulder.json is tracked in git
echo "=== Is .sisyphus/boulder.json tracked by git? ==="
git ls-files .sisyphus/boulder.json && echo "YES - boulder.json is committed to the repository" || echo "NO - boulder.json is not tracked"
echo ""
echo "=== Current git status ==="
git status .sisyphus/boulder.json 2>/dev/null || echo "File status check result shown above"Repository: Jackson57279/zapdev
Length of output: 279
Use a relative path and add .sisyphus/boulder.json to .gitignore.
The active_plan contains the absolute path /home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/... which is specific to one developer's machine and will not resolve for anyone else. Additionally, this file is ephemeral Sisyphus session state (session IDs, timestamps) and should not be committed to the repository.
Change to a relative path:
Proposed fix
{
- "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md",
+ "active_plan": ".sisyphus/plans/export-to-polaris.md",
"started_at": "2026-02-08T06:29:01.522Z",
"session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"],
"plan_name": "export-to-polaris"
}Also add .sisyphus/boulder.json to .gitignore since this is transient local state.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| { | |
| "active_plan": "/home/dih/zapdev-no-pr/zapdev/.sisyphus/plans/export-to-polaris.md", | |
| "started_at": "2026-02-08T06:29:01.522Z", | |
| "session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"], | |
| "plan_name": "export-to-polaris" | |
| } | |
| { | |
| "active_plan": ".sisyphus/plans/export-to-polaris.md", | |
| "started_at": "2026-02-08T06:29:01.522Z", | |
| "session_ids": ["ses_3c433fc21ffeFQz5bAH7YbQBMr"], | |
| "plan_name": "export-to-polaris" | |
| } |
🤖 Prompt for AI Agents
In @.sisyphus/boulder.json around lines 1 - 6, The .sisyphus/boulder.json
currently contains an absolute path in the "active_plan" field and should not be
committed; change the "active_plan" value to a relative path (e.g.,
".sisyphus/plans/export-to-polaris.md") in .sisyphus/boulder.json and add the
filename .sisyphus/boulder.json to .gitignore so the transient session state
(session_ids, started_at) is not tracked; update the file and commit the
.gitignore change.
| ### Data Scope: **Everything Including Chat History** | ||
| Transfer to Polaris: | ||
| - ✅ Generated code files (from fragments) | ||
| - ✅ Project settings (framework, database provider, etc.) | ||
| - ✅ Full conversation/message history | ||
| - ✅ Message attachments (images, Figma files) | ||
| - ✅ Project metadata (name, created date) | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Add security and scalability considerations sections.
The design mentions transferring "everything including chat history" but lacks critical sections on security and scalability:
Security Considerations Needed:
- How is data protected during transfer (encryption in transit/at rest)?
- How are API tokens/credentials secured?
- How long are temporary tokens valid?
- What about PII or sensitive data in chat history?
- Rate limiting to prevent abuse?
Scalability Considerations Needed:
- Maximum project size (MB/GB)?
- File count limits?
- Message history limits (e.g., last 1000 messages)?
- Attachment size limits?
- Timeout handling for large transfers?
- Should large projects use chunked/streamed transfer?
Error Handling:
- What happens if transfer fails mid-way?
- Retry logic?
- Rollback mechanism?
- User notification strategy?
These considerations are essential before implementation, especially given the PR comments mention existing security vulnerabilities in the broader codebase.
🤖 Prompt for AI Agents
In @.sisyphus/drafts/export-to-polaris.md around lines 69 - 76, Add two new
subsections under the "Data Scope: Everything Including Chat History" area in
.sisyphus/drafts/export-to-polaris.md: "Security Considerations" and
"Scalability Considerations". In "Security Considerations" list encryption
requirements (TLS in transit, AES-256 at rest), API token/credential handling
(scoped credentials, rotation, storage), temporary token TTLs, PII/data
classification and redaction rules for chat history, and rate-limiting/abuse
controls. In "Scalability Considerations" specify max project size and
file/attachment/message limits (or recommend defaults), attachment size limits,
chunked/streamed transfer for large projects, timeout/heartbeat behavior, and
strategies for retries, mid-transfer rollback/consistency, and user notification
on partial failures.
| ### Authentication | ||
| Option 1: **API Keys** | ||
| - ZapDev has Polaris API key | ||
| - Simple but no user identity transfer | ||
|
|
||
| Option 2: **User Account Linking** | ||
| - Users have accounts on both platforms | ||
| - OAuth or token-based user mapping | ||
| - Better UX but more complex | ||
|
|
||
| Option 3: **Temporary Transfer Token** | ||
| - ZapDev generates temporary token | ||
| - Polaris creates account/project from token | ||
| - Email-based claim if no existing account | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check if the file exists and examine its content
if [ -f ".sisyphus/drafts/export-to-polaris.md" ]; then
echo "=== File exists, checking line count ==="
wc -l ".sisyphus/drafts/export-to-polaris.md"
echo "=== Lines 110-140 ==="
sed -n '110,140p' ".sisyphus/drafts/export-to-polaris.md"
else
echo "File not found at .sisyphus/drafts/export-to-polaris.md"
# Try to locate it
find . -name "export-to-polaris.md" -type f 2>/dev/null
fiRepository: Jackson57279/zapdev
Length of output: 1052
🏁 Script executed:
# Read the entire document to check for authentication decision
cat -n ".sisyphus/drafts/export-to-polaris.md"Repository: Jackson57279/zapdev
Length of output: 8222
Clarify the authentication approach within this draft.
The three authentication options (lines 118-132) lack a decision. Since this is a design draft with an "Open Questions for Metis Review" section, consider adding a recommendation here that addresses:
- Whether users must have accounts on both platforms
- Whether to tie export history to user identity
- Which approach aligns with the "Export with Redirect" UX strategy (lines 63-67)
Option 3 (Temporary Transfer Token) appears most aligned with the redirect flow, allowing new Polaris accounts to be created on-the-fly without requiring pre-existing user accounts.
🤖 Prompt for AI Agents
In @.sisyphus/drafts/export-to-polaris.md around lines 118 - 132, Add a clear
recommendation to the "Authentication" section: choose Option 3 (Temporary
Transfer Token) as the default for the export flow because it best supports the
"Export with Redirect" UX; explicitly state that users do NOT need pre-existing
accounts on both platforms, that export history should be tied to the Polaris
account created/claimed via the token (or mapped if an existing account is
claimed) to preserve auditability, and that ZapDev should issue short-lived
transfer tokens used in the redirect to create or link a Polaris account; also
update the "Open Questions for Metis Review" to surface these three decisions
(pre-existing accounts required, export history identity mapping, and alignment
with Export with Redirect).
| - Maybe "Imported from ZapDev" badge on projects | ||
| - Welcome/onboarding for imported projects | ||
|
|
||
| ## Open Questions for Metis Review |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Populate the "Open Questions for Metis Review" section.
This section is empty but critical for a design document. Based on the document content, several important questions need to be addressed:
- Authentication: Which of the three authentication approaches (lines 118-132) should be implemented?
- Data size limits: What are the maximum project size, file count, and message history limits?
- Failure handling: What happens if the import fails mid-transfer? Is there rollback or retry logic?
- Security: How is sensitive data (API keys, tokens, user data) protected during transfer?
- Attachments: Should message attachments (images, Figma files) be transferred? What's the size limit?
- Performance: How should large projects (e.g., 1000+ files) be handled? Chunking? Streaming?
- User experience: Should users be required to have Polaris accounts before export, or auto-create them?
🤖 Prompt for AI Agents
In @.sisyphus/drafts/export-to-polaris.md at line 154, Populate the "Open
Questions for Metis Review" section by listing the seven specific decision
points extracted from the document and providing concise options for each so
reviewers can pick or comment: 1) For Authentication (refer to the three
approaches noted around lines 118-132) enumerate pros/cons and recommend one
(OAuth with token exchange, API-key transfer, or user-initiated SSO) and ask
which to adopt; 2) For Data size limits state concrete proposals for max project
size, max file count, and message history retention (e.g., 5GB / 10k files / 12
months) and ask for agreement or changes; 3) For Failure handling propose
rollback vs retry semantics and an idempotent resume mechanism and ask which
behavior to standardize; 4) For Security specify how to protect API
keys/tokens/user data in transit and at rest (TLS, envelope encryption,
short-lived tokens) and request approval; 5) For Attachments ask whether to
include attachments, propose size/format limits and optional on-demand transfer,
and request decision; 6) For Performance propose chunking/streaming strategies
for large projects (e.g., chunk size, parallelism, backpressure) and ask which
to prefer; 7) For User experience propose requiring pre-existing Polaris
accounts vs auto-creating accounts with user consent and ask which flow to
implement. Keep each question as a short bullet-like decision prompt so Metis
can answer succinctly.
| | Task | Depends On | Blocks | Can Parallelize With | | ||
| |------|------------|--------|---------------------| | ||
| | 1 (Schema) | None | 2 | None | | ||
| | 2 (API) | 1 | 3, 4 | None | | ||
| | 3 (Export Service) | 2 | 5, 6 | 4 | | ||
| | 4 (Security) | 2 | 3, 5, 6 | 3 | | ||
| | 5 (UI) | 3, 4 | 7 | 6 | | ||
| | 6 (Error Handling) | 3, 4 | 7 | 5 | | ||
| | 7 (Testing) | 5, 6 | None | None | |
There was a problem hiding this comment.
Dependency matrix is inconsistent with Task 3's description.
The matrix (Line 195) states Task 4 blocks Task 3, but Task 3's own "Blocked By" section (around Line 351) only lists Task 2. Meanwhile, both Task 3 and Task 4 are assigned to Wave 2 as parallel work. If they're truly parallel, Task 4 cannot block Task 3 — pick one interpretation and update both the matrix and the task descriptions to match.
🤖 Prompt for AI Agents
In @.sisyphus/plans/export-to-polaris.md around lines 190 - 198, The dependency
matrix and Task 3/Task 4 descriptions are inconsistent: decide whether Task 3
and Task 4 are parallel (Wave 2) or Task 4 truly blocks Task 3, then make both
places match; if you choose parallel, remove Task 4 from the "Blocks" column for
Task 3 in the matrix and remove any "Blocked By: Task 4" text in Task 3's
description (ensure both list only Task 2 as blocker), or if you choose that
Task 4 blocks Task 3, update Task 3's "Blocked By" section to include Task 4 and
move Task 4 out of Wave 2 (or adjust waves so they aren't parallel) so the
matrix and task descriptions are consistent.
| export const skillStatusEnum = v.union( | ||
| v.literal("active"), | ||
| v.literal("disabled"), | ||
| v.literal("draft") | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n 'skillStatusEnum' --type=tsRepository: Jackson57279/zapdev
Length of output: 123
🏁 Script executed:
rg -A 20 'skills.*defineTable\(' convex/schema.ts --type=tsRepository: Jackson57279/zapdev
Length of output: 699
Remove unused skillStatusEnum or add status field to skills table.
The skillStatusEnum (lines 100–104) is defined and exported but never referenced anywhere in the codebase. The skills table does not have a status field. Either remove the enum if it's not needed, or add status: skillStatusEnum to the skills table definition.
🤖 Prompt for AI Agents
In `@convex/schema.ts` around lines 100 - 104, The exported v.union named
skillStatusEnum is declared but unused; either remove the skillStatusEnum export
or add a status field to the skills table that references it. To fix, locate the
skills table definition (symbol: skills) and add a property status:
skillStatusEnum to its schema if you intend to track skill state, or delete the
skillStatusEnum declaration/export if it’s not needed; if you add the field,
ensure default/optional behavior is handled (e.g., required vs optional) and
update any code that constructs or reads skills to account for the new status
property.
| projectDeploymentCounters: defineTable({ | ||
| projectId: v.id("projects"), | ||
| deployNumber: v.number(), | ||
| createdAt: v.number(), | ||
| updatedAt: v.number(), | ||
| }) | ||
| .index("by_projectId", ["projectId"]), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n 'projectDeploymentCounters' --type=ts -C 5Repository: Jackson57279/zapdev
Length of output: 1799
Use .unique() instead of .first() to enforce the single-counter-per-project constraint.
The projectDeploymentCounters table is designed with one row per project, but the query in convex/deployments.ts (line 36) uses .first(). Switch to .unique() to strictly enforce this invariant—it will throw if multiple rows exist, providing safety against data corruption. The current code properly handles the missing-row case, so only the query method needs updating.
🤖 Prompt for AI Agents
In `@convex/schema.ts` around lines 351 - 357, The query that reads the
projectDeploymentCounters row should enforce a single-row-per-project
invariant—replace the use of .first() with .unique() on the query that selects
from projectDeploymentCounters (the call in convex/deployments.ts that currently
uses .first()). Keep the existing missing-row handling as-is; switching to
.unique() will cause an error if multiple rows exist, guaranteeing the
one-counter-per-project constraint.
|
|
||
| const AUTO_FIX_MAX_ATTEMPTS = 1; | ||
| const MAX_AGENT_ITERATIONS = 8; | ||
| const MAX_AGENT_ITERATIONS = 12; |
There was a problem hiding this comment.
MAX_AGENT_ITERATIONS = 12 violates the configured limit of 8.
The coding guidelines specify "AI agent must respect MAX_AGENT_ITERATIONS = 8 as the maximum tool call rounds per request." This was increased to 12 without documented justification. Higher iteration counts increase latency, cost, and timeout risk.
🔧 Proposed fix
-const MAX_AGENT_ITERATIONS = 12;
+const MAX_AGENT_ITERATIONS = 8;As per coding guidelines: "AI agent must respect MAX_AGENT_ITERATIONS = 8 as the maximum tool call rounds per request."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const MAX_AGENT_ITERATIONS = 12; | |
| const MAX_AGENT_ITERATIONS = 8; |
🤖 Prompt for AI Agents
In `@src/agents/code-agent.ts` at line 71, The constant MAX_AGENT_ITERATIONS is
set to 12 which violates the guideline limit of 8; change the constant
declaration MAX_AGENT_ITERATIONS to 8 (e.g., const MAX_AGENT_ITERATIONS = 8) and
ensure any code paths that rely on this value (agent loop/iteration checks)
continue to use this constant so the agent enforces the 8-iteration max
consistently.
| installDependencies: tool({ | ||
| description: "Install npm/bun/pnpm dependencies. Automatically detects package manager from lock files.", | ||
| inputSchema: z.object({ | ||
| packages: z.array(z.string()).describe("Package names to install"), | ||
| dev: z.boolean().optional().describe("If true, installs as dev dependencies"), | ||
| }), | ||
| execute: async ({ packages, dev }) => { | ||
| console.log("[DEBUG] installDependencies tool called for", packages.length, "packages"); | ||
| onToolCall?.("installDependencies", { packages, dev }); | ||
| try { | ||
| const pkgManagerCmd = await adapter.runCommand("test -f bun.lock && echo 'bun' || test -f pnpm-lock.yaml && echo 'pnpm' || echo 'npm'"); | ||
| const pkgManager = pkgManagerCmd.stdout.trim(); | ||
| const devFlag = dev ? (pkgManager === "npm" ? "--save-dev" : "--dev") : ""; | ||
| const command = `${pkgManager} install ${devFlag} ${packages.join(" ")}`; | ||
| console.log("[INFO] Running:", command); | ||
| const result = await adapter.runCommand(command); | ||
| if (result.stdout) onToolOutput?.("stdout", result.stdout); | ||
| if (result.stderr) onToolOutput?.("stderr", result.stderr); | ||
| return `Installed ${packages.length} package(s) with ${pkgManager}`; | ||
| } catch (e) { | ||
| const errorMessage = e instanceof Error ? e.message : String(e); | ||
| console.error("[ERROR] installDependencies failed:", errorMessage); | ||
| return `Error installing packages: ${errorMessage}`; | ||
| } | ||
| }, | ||
| }), |
There was a problem hiding this comment.
--dev is not valid for pnpm; use --save-dev or -D instead.
Line 238: when pkgManager is "pnpm", the --dev flag will cause an error. pnpm uses --save-dev (or -D), same as npm.
🐛 Proposed fix
- const devFlag = dev ? (pkgManager === "npm" ? "--save-dev" : "--dev") : "";
+ const devFlag = dev ? (pkgManager === "bun" ? "--dev" : "--save-dev") : "";🤖 Prompt for AI Agents
In `@src/agents/tools.ts` around lines 226 - 251, The installDependencies tool
builds devFlag incorrectly for pnpm: change the devFlag calculation in the
installDependencies execute function so that when dev is true you use the
npm/pnpm flag "--save-dev" (or "-D") and for other managers (e.g., bun if
applicable) use the appropriate flag; specifically update the line that sets
devFlag (currently using pkgManager === "npm" ? "--save-dev" : "--dev") to use
pkgManager === "npm" || pkgManager === "pnpm" ? "--save-dev" :
"<other-manager-flag-or-empty>" (or "-D" for pnpm if you prefer), and ensure the
final command string (built via pkgManager and packages.join(" ")) handles an
empty devFlag cleanly. Reference: installDependencies, pkgManager, devFlag,
adapter.runCommand.
| runBuildAndLint: tool({ | ||
| description: "Run both build and lint checks in parallel. Returns combined results for faster validation.", | ||
| inputSchema: z.object({ | ||
| framework: z.enum(["nextjs", "react", "vue", "angular", "svelte"]).describe("Framework to determine build command"), | ||
| }), | ||
| execute: async ({ framework }) => { | ||
| console.log("[DEBUG] runBuildAndLint tool called for", framework); | ||
| onToolCall?.("runBuildAndLint", { framework }); | ||
| try { | ||
| const buildCmd = framework === "nextjs" ? "npm run build" : "npm run build"; | ||
| const lintCmd = "npm run lint"; | ||
|
|
||
| const [buildResult, lintResult] = await Promise.allSettled([ | ||
| adapter.runCommand(buildCmd), | ||
| adapter.runCommand(lintCmd), | ||
| ]); | ||
|
|
||
| const results = { | ||
| build: buildResult.status === "fulfilled" | ||
| ? { success: true, output: buildResult.value.stdout, error: buildResult.value.stderr } | ||
| : { success: false, error: buildResult.reason?.message || "Build failed" }, | ||
| lint: lintResult.status === "fulfilled" | ||
| ? { success: true, output: lintResult.value.stdout, error: lintResult.value.stderr } | ||
| : { success: false, error: lintResult.reason?.message || "Lint failed" }, | ||
| }; | ||
|
|
||
| const hasErrors = !results.build.success || !results.lint.success; | ||
| if (hasErrors) { | ||
| const errors = [ | ||
| results.build.error || "", | ||
| results.lint.error || "", | ||
| ].filter(Boolean).join("\n"); | ||
| console.error("[ERROR] Build/lint failed:", errors.substring(0, 200)); | ||
| return `Build/Lint Errors:\n${errors}`; | ||
| } | ||
|
|
||
| console.log("[INFO] Build and lint completed successfully"); | ||
| return "Build and lint checks passed successfully"; | ||
| } catch (e) { | ||
| const errorMessage = e instanceof Error ? e.message : String(e); | ||
| console.error("[ERROR] runBuildAndLint failed:", errorMessage); | ||
| return `Error: ${errorMessage}`; | ||
| } | ||
| }, | ||
| }), |
There was a problem hiding this comment.
runBuildAndLint never detects actual build/lint failures — exit code is not checked.
adapter.runCommand resolves (not rejects) on non-zero exit codes, returning { stdout, stderr, exitCode }. Since Promise.allSettled will always report "fulfilled", results.build.success and results.lint.success are always true, and hasErrors is always false. A failing build (exit code 1 with errors in stderr) will be reported as "Build and lint checks passed successfully."
Additionally, Line 262 has a dead ternary — both branches are "npm run build".
🐛 Proposed fix — check exitCode for actual failure
execute: async ({ framework }) => {
console.log("[DEBUG] runBuildAndLint tool called for", framework);
onToolCall?.("runBuildAndLint", { framework });
try {
- const buildCmd = framework === "nextjs" ? "npm run build" : "npm run build";
+ const buildCmd = "npm run build";
const lintCmd = "npm run lint";
const [buildResult, lintResult] = await Promise.allSettled([
adapter.runCommand(buildCmd),
adapter.runCommand(lintCmd),
]);
const results = {
build: buildResult.status === "fulfilled"
- ? { success: true, output: buildResult.value.stdout, error: buildResult.value.stderr }
+ ? { success: buildResult.value.exitCode === 0, output: buildResult.value.stdout, error: buildResult.value.stderr }
: { success: false, error: buildResult.reason?.message || "Build failed" },
lint: lintResult.status === "fulfilled"
- ? { success: true, output: lintResult.value.stdout, error: lintResult.value.stderr }
+ ? { success: lintResult.value.exitCode === 0, output: lintResult.value.stdout, error: lintResult.value.stderr }
: { success: false, error: lintResult.reason?.message || "Lint failed" },
};🤖 Prompt for AI Agents
In `@src/agents/tools.ts` around lines 253 - 297, The runBuildAndLint tool
currently treats any fulfilled Promise as success; update execute in
runBuildAndLint to determine success by checking fulfilled results'
value.exitCode (treat exitCode === 0 as success) and use
value.stdout/value.stderr for outputs and errors, while handling rejected
promises as failures; also simplify the dead ternary for buildCmd (replace
framework === "nextjs" ? "npm run build" : "npm run build" with a single
assignment). Ensure you reference adapter.runCommand results (buildResult and
lintResult) and set results.build.success/results.lint.success based on
exitCode, and populate results.build.error/results.lint.error from stderr or
reason.message when appropriate.
|
❌ Error while analyzing PR and generating tests: |
|
❌ Error while analyzing PR and generating tests: |
… import paths for API calls
|
❌ Error while analyzing PR and generating tests: |
There was a problem hiding this comment.
2 issues found across 2 files (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="convex/skills.ts">
<violation number="1" location="convex/skills.ts:415">
P2: Security: `getCoreSkillContents` changed from `internalQuery` to public `query`, exposing all core skill content without authentication. Although core skills are global, this still unnecessarily increases the API surface. Prefer configuring `ConvexHttpClient` with a deploy key to call internal functions from server-side code.</violation>
<violation number="2" location="convex/skills.ts:605">
P1: Security: `getInstalledSkillContents` changed from `internalQuery` to public `query` without adding any authentication. Any unauthenticated client can now query any user's installed skills by supplying an arbitrary `userId`. Instead of making this public, configure `ConvexHttpClient` with a deploy key to call internal functions, or add `requireAuth(ctx)` and verify the caller matches the `userId` argument.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| * Returns name, slug, and content for each active installed skill. | ||
| * Public so server-side agents can call via ConvexHttpClient without deploy key. | ||
| */ | ||
| export const getInstalledSkillContents = query({ |
There was a problem hiding this comment.
P1: Security: getInstalledSkillContents changed from internalQuery to public query without adding any authentication. Any unauthenticated client can now query any user's installed skills by supplying an arbitrary userId. Instead of making this public, configure ConvexHttpClient with a deploy key to call internal functions, or add requireAuth(ctx) and verify the caller matches the userId argument.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/skills.ts, line 605:
<comment>Security: `getInstalledSkillContents` changed from `internalQuery` to public `query` without adding any authentication. Any unauthenticated client can now query any user's installed skills by supplying an arbitrary `userId`. Instead of making this public, configure `ConvexHttpClient` with a deploy key to call internal functions, or add `requireAuth(ctx)` and verify the caller matches the `userId` argument.</comment>
<file context>
@@ -599,8 +600,9 @@ export const seedCoreSkills = internalMutation({
+ * Public so server-side agents can call via ConvexHttpClient without deploy key.
*/
-export const getInstalledSkillContents = internalQuery({
+export const getInstalledSkillContents = query({
args: {
projectId: v.optional(v.id("projects")),
</file context>
| * Returns an array of objects with name and content for each core skill. | ||
| * Public so server-side agents can call via ConvexHttpClient without deploy key. | ||
| */ | ||
| export const getCoreSkillContents = query({ |
There was a problem hiding this comment.
P2: Security: getCoreSkillContents changed from internalQuery to public query, exposing all core skill content without authentication. Although core skills are global, this still unnecessarily increases the API surface. Prefer configuring ConvexHttpClient with a deploy key to call internal functions from server-side code.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At convex/skills.ts, line 415:
<comment>Security: `getCoreSkillContents` changed from `internalQuery` to public `query`, exposing all core skill content without authentication. Although core skills are global, this still unnecessarily increases the API surface. Prefer configuring `ConvexHttpClient` with a deploy key to call internal functions from server-side code.</comment>
<file context>
@@ -410,8 +410,9 @@ export const getForSystem = internalQuery({
+ * Public so server-side agents can call via ConvexHttpClient without deploy key.
*/
-export const getCoreSkillContents = internalQuery({
+export const getCoreSkillContents = query({
args: {},
returns: v.array(
</file context>
This commit significantly expands the deployment capabilities and payment processing features of the application.
Summary by cubic
Implements Netlify deployments, GitHub export, a skills.sh‑compatible skill system with WebContainer previews, and Inngest‑powered agent streaming. Adds Anthropic OAuth with AES‑GCM token encryption, database/payment templates, color themes, and new SEO content; makes skill queries public for server‑side use.
New Features
Migration
Written for commit a50e05f. Summary will update on new commits.
Summary by CodeRabbit