import { db } from "../../db"; import { chats, messages } from "../../db/schema"; import { and, eq } from "drizzle-orm"; import fs from "node:fs"; import { getDyadAppPath } from "../../paths/paths"; import path from "node:path"; import git from "isomorphic-git"; import { safeJoin } from "../utils/path_utils"; import log from "electron-log"; import { executeAddDependency } from "./executeAddDependency"; import { deleteSupabaseFunction, deploySupabaseFunctions, executeSupabaseSql, } from "../../supabase_admin/supabase_management_client"; import { isServerFunction } from "../../supabase_admin/supabase_utils"; import { UserSettings } from "../../lib/schemas"; import { gitCommit } from "../utils/git_utils"; import { readSettings } from "@/main/settings"; import { writeMigrationFile } from "../utils/file_utils"; import { getDyadWriteTags, getDyadRenameTags, getDyadDeleteTags, getDyadAddDependencyTags, getDyadExecuteSqlTags, } from "../utils/dyad_tag_parser"; const readFile = fs.promises.readFile; const logger = log.scope("response_processor"); interface Output { message: string; error: unknown; } function getFunctionNameFromPath(input: string): string { return path.basename(path.extname(input) ? path.dirname(input) : input); } async function readFileFromFunctionPath(input: string): Promise { // Sometimes, the path given is a directory, sometimes it's the file itself. if (path.extname(input) === "") { return readFile(path.join(input, "index.ts"), "utf8"); } return readFile(input, "utf8"); } export async function processFullResponseActions( fullResponse: string, chatId: number, { chatSummary, messageId, }: { chatSummary: string | undefined; messageId: number }, ): Promise<{ updatedFiles?: boolean; error?: string; extraFiles?: string[]; extraFilesError?: string; }> { logger.log("processFullResponseActions for chatId", chatId); // Get the app associated with the chat const chatWithApp = await db.query.chats.findFirst({ where: eq(chats.id, chatId), with: { app: true, }, }); if (!chatWithApp || !chatWithApp.app) { logger.error(`No app found for chat ID: ${chatId}`); return {}; } const settings: UserSettings = readSettings(); const appPath = getDyadAppPath(chatWithApp.app.path); const writtenFiles: string[] = []; const renamedFiles: string[] = []; const deletedFiles: string[] = []; let hasChanges = false; const warnings: Output[] = []; const errors: Output[] = []; try { // Extract all tags const dyadWriteTags = getDyadWriteTags(fullResponse); const dyadRenameTags = getDyadRenameTags(fullResponse); const dyadDeletePaths = getDyadDeleteTags(fullResponse); const dyadAddDependencyPackages = getDyadAddDependencyTags(fullResponse); const dyadExecuteSqlQueries = chatWithApp.app.supabaseProjectId ? getDyadExecuteSqlTags(fullResponse) : []; let writtenSqlMigrationFiles = 0; const message = await db.query.messages.findFirst({ where: and( eq(messages.id, messageId), eq(messages.role, "assistant"), eq(messages.chatId, chatId), ), }); if (!message) { logger.error(`No message found for ID: ${messageId}`); return {}; } // Handle SQL execution tags if (dyadExecuteSqlQueries.length > 0) { for (const query of dyadExecuteSqlQueries) { try { await executeSupabaseSql({ supabaseProjectId: chatWithApp.app.supabaseProjectId!, query: query.content, }); // Only write migration file if SQL execution succeeded if (settings.enableSupabaseWriteSqlMigration) { try { await writeMigrationFile( appPath, query.content, query.description, ); writtenSqlMigrationFiles++; } catch (error) { errors.push({ message: `Failed to write SQL migration file for: ${query.description}`, error: error, }); } } } catch (error) { errors.push({ message: `Failed to execute SQL query: ${query.content}`, error: error, }); } } logger.log(`Executed ${dyadExecuteSqlQueries.length} SQL queries`); } // TODO: Handle add dependency tags if (dyadAddDependencyPackages.length > 0) { try { await executeAddDependency({ packages: dyadAddDependencyPackages, message: message, appPath, }); } catch (error) { errors.push({ message: `Failed to add dependencies: ${dyadAddDependencyPackages.join( ", ", )}`, error: error, }); } writtenFiles.push("package.json"); const pnpmFilename = "pnpm-lock.yaml"; if (fs.existsSync(safeJoin(appPath, pnpmFilename))) { writtenFiles.push(pnpmFilename); } const packageLockFilename = "package-lock.json"; if (fs.existsSync(safeJoin(appPath, packageLockFilename))) { writtenFiles.push(packageLockFilename); } } ////////////////////// // File operations // // Do it in this order: // 1. Deletes // 2. Renames // 3. Writes // // Why? // - Deleting first avoids path conflicts before the other operations. // - LLMs like to rename and then edit the same file. ////////////////////// // Process all file deletions for (const filePath of dyadDeletePaths) { const fullFilePath = safeJoin(appPath, filePath); // Delete the file if it exists if (fs.existsSync(fullFilePath)) { if (fs.lstatSync(fullFilePath).isDirectory()) { fs.rmdirSync(fullFilePath, { recursive: true }); } else { fs.unlinkSync(fullFilePath); } logger.log(`Successfully deleted file: ${fullFilePath}`); deletedFiles.push(filePath); // Remove the file from git try { await git.remove({ fs, dir: appPath, filepath: filePath, }); } catch (error) { logger.warn(`Failed to git remove deleted file ${filePath}:`, error); // Continue even if remove fails as the file was still deleted } } else { logger.warn(`File to delete does not exist: ${fullFilePath}`); } if (isServerFunction(filePath)) { try { await deleteSupabaseFunction({ supabaseProjectId: chatWithApp.app.supabaseProjectId!, functionName: getFunctionNameFromPath(filePath), }); } catch (error) { errors.push({ message: `Failed to delete Supabase function: ${filePath}`, error: error, }); } } } // Process all file renames for (const tag of dyadRenameTags) { const fromPath = safeJoin(appPath, tag.from); const toPath = safeJoin(appPath, tag.to); // Ensure target directory exists const dirPath = path.dirname(toPath); fs.mkdirSync(dirPath, { recursive: true }); // Rename the file if (fs.existsSync(fromPath)) { fs.renameSync(fromPath, toPath); logger.log(`Successfully renamed file: ${fromPath} -> ${toPath}`); renamedFiles.push(tag.to); // Add the new file and remove the old one from git await git.add({ fs, dir: appPath, filepath: tag.to, }); try { await git.remove({ fs, dir: appPath, filepath: tag.from, }); } catch (error) { logger.warn(`Failed to git remove old file ${tag.from}:`, error); // Continue even if remove fails as the file was still renamed } } else { logger.warn(`Source file for rename does not exist: ${fromPath}`); } if (isServerFunction(tag.from)) { try { await deleteSupabaseFunction({ supabaseProjectId: chatWithApp.app.supabaseProjectId!, functionName: getFunctionNameFromPath(tag.from), }); } catch (error) { warnings.push({ message: `Failed to delete Supabase function: ${tag.from} as part of renaming ${tag.from} to ${tag.to}`, error: error, }); } } if (isServerFunction(tag.to)) { try { await deploySupabaseFunctions({ supabaseProjectId: chatWithApp.app.supabaseProjectId!, functionName: getFunctionNameFromPath(tag.to), content: await readFileFromFunctionPath(toPath), }); } catch (error) { errors.push({ message: `Failed to deploy Supabase function: ${tag.to} as part of renaming ${tag.from} to ${tag.to}`, error: error, }); } } } // Process all file writes for (const tag of dyadWriteTags) { const filePath = tag.path; const content = tag.content; const fullFilePath = safeJoin(appPath, filePath); // Ensure directory exists const dirPath = path.dirname(fullFilePath); fs.mkdirSync(dirPath, { recursive: true }); // Write file content fs.writeFileSync(fullFilePath, content); logger.log(`Successfully wrote file: ${fullFilePath}`); writtenFiles.push(filePath); if (isServerFunction(filePath)) { try { await deploySupabaseFunctions({ supabaseProjectId: chatWithApp.app.supabaseProjectId!, functionName: path.basename(path.dirname(filePath)), content: content, }); } catch (error) { errors.push({ message: `Failed to deploy Supabase function: ${filePath}`, error: error, }); } } } // If we have any file changes, commit them all at once hasChanges = writtenFiles.length > 0 || renamedFiles.length > 0 || deletedFiles.length > 0 || dyadAddDependencyPackages.length > 0 || writtenSqlMigrationFiles > 0; let uncommittedFiles: string[] = []; let extraFilesError: string | undefined; if (hasChanges) { // Stage all written files for (const file of writtenFiles) { await git.add({ fs, dir: appPath, filepath: file, }); } // Create commit with details of all changes const changes = []; if (writtenFiles.length > 0) changes.push(`wrote ${writtenFiles.length} file(s)`); if (renamedFiles.length > 0) changes.push(`renamed ${renamedFiles.length} file(s)`); if (deletedFiles.length > 0) changes.push(`deleted ${deletedFiles.length} file(s)`); if (dyadAddDependencyPackages.length > 0) changes.push( `added ${dyadAddDependencyPackages.join(", ")} package(s)`, ); if (dyadExecuteSqlQueries.length > 0) changes.push(`executed ${dyadExecuteSqlQueries.length} SQL queries`); let message = chatSummary ? `[dyad] ${chatSummary} - ${changes.join(", ")}` : `[dyad] ${changes.join(", ")}`; // Use chat summary, if provided, or default for commit message let commitHash = await gitCommit({ path: appPath, message, }); logger.log(`Successfully committed changes: ${changes.join(", ")}`); // Check for any uncommitted changes after the commit const statusMatrix = await git.statusMatrix({ fs, dir: appPath }); uncommittedFiles = statusMatrix .filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1) .map((row) => row[0]); // Get just the file paths if (uncommittedFiles.length > 0) { // Stage all changes await git.add({ fs, dir: appPath, filepath: ".", }); try { commitHash = await gitCommit({ path: appPath, message: message + " + extra files edited outside of Dyad", amend: true, }); logger.log( `Amend commit with changes outside of dyad: ${uncommittedFiles.join(", ")}`, ); } catch (error) { // Just log, but don't throw an error because the user can still // commit these changes outside of Dyad if needed. logger.error( `Failed to commit changes outside of dyad: ${uncommittedFiles.join( ", ", )}`, ); extraFilesError = (error as any).toString(); } } // Save the commit hash to the message await db .update(messages) .set({ commitHash: commitHash, }) .where(eq(messages.id, messageId)); } logger.log("mark as approved: hasChanges", hasChanges); // Update the message to approved await db .update(messages) .set({ approvalState: "approved", }) .where(eq(messages.id, messageId)); return { updatedFiles: hasChanges, extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined, extraFilesError, }; } catch (error: unknown) { logger.error("Error processing files:", error); return { error: (error as any).toString() }; } finally { const appendedContent = ` ${warnings .map( (warning) => `${warning.error}`, ) .join("\n")} ${errors .map( (error) => `${error.error}`, ) .join("\n")} `; if (appendedContent.length > 0) { await db .update(messages) .set({ content: fullResponse + "\n\n" + appendedContent, }) .where(eq(messages.id, messageId)); } } }