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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions app/api/analyses/[id]/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
updateAnalysisStatus,
createRepoFile,
createBlueprint,
deleteBlueprintsByAnalysis,
deleteBlueprintsByAnalysisExcept,
deleteBlueprintsByIds,
getBlueprintsByAnalysis,
getSubscriptionByGithubId,
upsertSubscription,
Expand Down Expand Up @@ -200,7 +201,6 @@ export async function POST(

// Update status to scanning
await updateAnalysisStatus(id, 'scanning')
await deleteBlueprintsByAnalysis(id)
send({ status: 'scanning', progress: 10 })

// Fetch file trees from GitHub for each repository
Expand Down Expand Up @@ -429,23 +429,34 @@ For each app blueprint:
const rankedBlueprints = blueprintsFromAI
.map((bp) => normalizeBlueprint(bp))
.sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a))
const newBlueprintIds: string[] = []

for (const bp of rankedBlueprints) {
await createBlueprint({
analysis_id: id,
user_id: user.id,
name: bp.name.slice(0, 255),
description: bp.description,
app_type: bp.app_type?.slice(0, 100) ?? null,
complexity: bp.complexity,
reuse_percentage: bp.reuse_percentage,
existing_files: bp.existing_files,
missing_files: bp.missing_files,
estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length),
technologies: bp.technologies,
ai_explanation: bp.explanation,
try {
for (const bp of rankedBlueprints) {
const created = await createBlueprint({
analysis_id: id,
user_id: user.id,
name: bp.name.slice(0, 255),
description: bp.description,
app_type: bp.app_type?.slice(0, 100) ?? null,
complexity: bp.complexity,
reuse_percentage: bp.reuse_percentage,
existing_files: bp.existing_files,
missing_files: bp.missing_files,
estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length),
technologies: bp.technologies,
ai_explanation: bp.explanation,
})
newBlueprintIds.push(created.id)
}
} catch (error) {
await deleteBlueprintsByIds(newBlueprintIds).catch((cleanupError) => {
console.error('[analysis] Failed to clean up partial replacement blueprints:', cleanupError)
})
throw error
}

await deleteBlueprintsByAnalysisExcept(id, newBlueprintIds)
}

// Update to complete
Expand Down
79 changes: 48 additions & 31 deletions app/api/build-app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,42 @@ Just the file content itself, ready to save.`
/** Build the list of all files to generate */
function getFilesToGenerate(blueprint: BuildAppRequest['blueprint']): Array<{ path: string; purpose: string }> {
const files: Array<{ path: string; purpose: string }> = []
const seenPaths = new Set<string>()

const addFile = (path: string, purpose: string) => {
const cleanPath = path.trim()
if (!cleanPath || seenPaths.has(cleanPath)) return
seenPaths.add(cleanPath)
files.push({ path: cleanPath, purpose })
}

// Missing files from the blueprint
for (const f of blueprint.missing_files) {
files.push({ path: f.name, purpose: f.purpose })
addFile(f.name, f.purpose)
}

// Standard project files
files.push({ path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' })
files.push({ path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' })
files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' })
files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' })
addFile('README.md', 'Comprehensive setup, usage, and API documentation')
addFile('package.json', 'Project dependencies and scripts for the tech stack')
addFile('.env.example', 'All required environment variables with placeholder values')
addFile('.gitignore', 'Gitignore file appropriate for this stack')

return files
}

async function getApiErrorMessage(res: Response, fallback: string): Promise<string> {
const text = await res.text().catch(() => '')
if (!text) return fallback

try {
const err = JSON.parse(text) as { message?: unknown; error?: unknown }
const message = typeof err.message === 'string' ? err.message : err.error
return typeof message === 'string' ? message : fallback
} catch {
return text.slice(0, 300)
}
}

async function createGitHubRepo(
accessToken: string,
username: string,
Expand All @@ -88,14 +109,13 @@ async function createGitHubRepo(
body: JSON.stringify({
name: repoName,
description,
private: false,
private: true,
auto_init: false,
}),
})

if (!res.ok) {
const err = (await res.json()) as { message?: string }
throw new Error(err.message ?? 'Failed to create GitHub repository')
throw new Error(await getApiErrorMessage(res, 'Failed to create GitHub repository'))
}

const repo = (await res.json()) as { html_url: string }
Expand Down Expand Up @@ -127,8 +147,7 @@ async function pushFileToGitHub(
)

if (!res.ok) {
const err = (await res.json()) as { message?: string }
console.warn(`[build-app] Failed to push ${path}: ${err.message}`)
throw new Error(await getApiErrorMessage(res, `Failed to push ${path} to GitHub`))
}
}

Expand All @@ -152,12 +171,7 @@ async function createGitLabProject(
})

if (!res.ok) {
const err = (await res.json()) as { message?: string | Record<string, string[]> }
const msg =
typeof err.message === 'string'
? err.message
: JSON.stringify(err.message)
throw new Error(msg ?? 'Failed to create GitLab project')
throw new Error(await getApiErrorMessage(res, 'Failed to create GitLab project'))
}

return res.json() as Promise<{ id: number; web_url: string; default_branch: string }>
Expand Down Expand Up @@ -189,8 +203,7 @@ async function pushFileToGitLab(
)

if (!res.ok) {
const err = (await res.json()) as { message?: string }
console.warn(`[build-app] Failed to push ${path} to GitLab: ${err.message}`)
throw new Error(await getApiErrorMessage(res, `Failed to push ${path} to GitLab`))
}
}

Expand All @@ -201,12 +214,19 @@ export async function POST(request: NextRequest) {
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
let closed = false
const close = () => {
if (!closed) {
controller.close()
closed = true
}
}

try {
const user = await getCurrentUser()
if (!user) {
send({ step: 'error', message: 'Sign in before building an app.' })
controller.close()
close()
return
}

Expand All @@ -216,7 +236,7 @@ export async function POST(request: NextRequest) {
}
if (!hasProAccess(user, sub)) {
send({ step: 'error', message: 'Build This App is available on paid plans. Upgrade to create and push a generated repo.' })
controller.close()
close()
return
}

Expand All @@ -225,7 +245,7 @@ export async function POST(request: NextRequest) {

if (!repoName?.trim()) {
send({ step: 'error', message: 'Repository name is required.' })
controller.close()
close()
return
}

Expand Down Expand Up @@ -268,7 +288,7 @@ export async function POST(request: NextRequest) {
step: 'error',
message: `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`,
})
controller.close()
close()
return
}

Expand All @@ -279,13 +299,9 @@ export async function POST(request: NextRequest) {
const total = filesToGenerate.length

for (const { path, purpose } of filesToGenerate) {
// Generate this file
let content: string
try {
content = await generateSingleFile(blueprint, path, purpose, user.id)
} catch (e) {
console.warn(`[build-app] Failed to generate ${path}:`, e)
content = `# Error generating ${path}\n# ${e instanceof Error ? e.message : String(e)}\n`
const content = await generateSingleFile(blueprint, path, purpose, user.id)
if (!content.trim()) {
throw new Error(`Generated empty content for ${path}`)
}

// Push to platform
Expand Down Expand Up @@ -314,13 +330,14 @@ export async function POST(request: NextRequest) {
})
} catch (e) {
console.error('[build-app] unhandled error:', e)
const errorDetail = e instanceof Error ? e.message : String(e)
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`,
`data: ${JSON.stringify({ step: 'error', message: `Build failed: ${errorDetail}` })}\n\n`,
),
)
} finally {
controller.close()
close()
}
},
})
Expand Down
24 changes: 24 additions & 0 deletions lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,30 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise<vo
await sql`DELETE FROM app_blueprints WHERE analysis_id = ${analysisId}`
}

export async function deleteBlueprintsByAnalysisExcept(
analysisId: string,
blueprintIdsToKeep: string[],
): Promise<void> {
if (blueprintIdsToKeep.length === 0) {
await deleteBlueprintsByAnalysis(analysisId)
return
}

const sql = getDb()
await sql`
DELETE FROM app_blueprints
WHERE analysis_id = ${analysisId}
AND id <> ALL(${blueprintIdsToKeep}::uuid[])
`
}

export async function deleteBlueprintsByIds(blueprintIds: string[]): Promise<void> {
if (blueprintIds.length === 0) return

const sql = getDb()
await sql`DELETE FROM app_blueprints WHERE id = ANY(${blueprintIds}::uuid[])`
}

export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise<void> {
const sql = getDb()
await sql`
Expand Down
Loading