Files
moreminimore-vibe/src/ipc/handlers/app_handlers.ts
Will Chen 133ca57628 Support Supabase branches (#1394)
<!-- This is an auto-generated description by cubic. -->

## Summary by cubic
Adds Supabase database branch selection per app, with a new schema field
and UI to choose a branch after connecting a project. Resets branch when
changing or disconnecting the project to keep state consistent.

- **New Features**
  - Added apps.supabase_branch_id column.
- Branch dropdown in SupabaseConnector shown after a project is
connected; selection persists and triggers app refresh.
- New state and hooks: supabaseBranchesAtom, loadBranches(projectId),
setAppBranch(branchId).
- IPC endpoints: supabase:list-branches and supabase:set-app-branch;
setting/unsetting project also clears the branch.

- **Migration**
- Apply drizzle migration 0013_supabase_branch.sql to add the
supabase_branch_id column (defaults to null).

<!-- End of auto-generated description by cubic. -->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Supabase database branch selection per app, including parent
project tracking, new IPC endpoints, UI dropdown, and an accompanying DB
migration with e2e tests.
> 
> - **Database**:
> - Add `apps.supabase_parent_project_id` via migration
`drizzle/0015_complete_old_lace.sql`; snapshot and journal updated.
> - **IPC/Main**:
> - New `supabase:list-branches` handler and management client
`listSupabaseBranches` (real API + test stubs).
> - Update `supabase:set-app-project` to accept `{ projectId,
parentProjectId?, appId }`; unset clears both IDs.
> - `get-app` resolves `supabaseProjectName` using
`supabase_parent_project_id` when present.
> - **Types & Client**:
> - Add `SupabaseBranch`, `SetSupabaseAppProjectParams`, and
`App.supabaseParentProjectId`; expose `listSupabaseBranches` and updated
`setSupabaseAppProject` in `ipc_client` and preload whitelist.
> - **UI/Hooks**:
> - Supabase UI: branch dropdown in `SupabaseConnector` with
`loadBranches`, selection persists via updated `setAppProject`.
> - State: add `supabaseBranchesAtom`; `useSupabase` gets `branches`,
`loadBranches`, new param shape for `setAppProject`.
>   - TokenBar/ChatInput: add `data-testid` for token bar and toggle.
> - **Supabase Context (tests)**:
> - Test build returns large context for `test-branch-project-id` to
validate branch selection.
> - **E2E Tests**:
> - Add `supabase_branch.spec.ts` and snapshot verifying branch
selection affects token usage.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
33054278db8396b4371ed6e8224105cb5684b7ac. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-10-14 15:34:42 -07:00

1559 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ipcMain, app } from "electron";
import { db, getDatabasePath } from "../../db";
import { apps, chats, messages } from "../../db/schema";
import { desc, eq, like } from "drizzle-orm";
import type {
App,
CreateAppParams,
RenameBranchParams,
CopyAppParams,
EditAppFileReturnType,
RespondToAppInputParams,
} from "../ipc_types";
import fs from "node:fs";
import path from "node:path";
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
import { ChildProcess, spawn } from "node:child_process";
import git from "isomorphic-git";
import { promises as fsPromises } from "node:fs";
// Import our utility modules
import { withLock } from "../utils/lock_utils";
import { getFilesRecursively } from "../utils/file_utils";
import {
runningApps,
processCounter,
removeAppIfCurrentProcess,
stopAppByInfo,
removeDockerVolumesForApp,
} from "../utils/process_manager";
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
import fixPath from "fix-path";
import killPort from "kill-port";
import util from "util";
import log from "electron-log";
import {
deploySupabaseFunctions,
getSupabaseProjectName,
} from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate";
import { gitCommit } from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath";
import { isServerFunction } from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { AppSearchResult } from "@/lib/schemas";
const DEFAULT_COMMAND =
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
async function copyDir(
source: string,
destination: string,
filter?: (source: string) => boolean,
) {
await fsPromises.cp(source, destination, {
recursive: true,
filter: (src: string) => {
if (path.basename(src) === "node_modules") {
return false;
}
if (filter) {
return filter(src);
}
return true;
},
});
}
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
let proxyWorker: Worker | null = null;
// Needed, otherwise electron in MacOS/Linux will not be able
// to find node/pnpm.
fixPath();
async function executeApp({
appPath,
appId,
event, // Keep event for local-node case
isNeon,
installCommand,
startCommand,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
if (proxyWorker) {
proxyWorker.terminate();
proxyWorker = null;
}
const settings = readSettings();
const runtimeMode = settings.runtimeMode2 ?? "host";
if (runtimeMode === "docker") {
await executeAppInDocker({
appPath,
appId,
event,
isNeon,
installCommand,
startCommand,
});
} else {
await executeAppLocalNode({
appPath,
appId,
event,
isNeon,
installCommand,
startCommand,
});
}
}
async function executeAppLocalNode({
appPath,
appId,
event,
isNeon,
installCommand,
startCommand,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
const command = getCommand({ installCommand, startCommand });
const spawnedProcess = spawn(command, [], {
cwd: appPath,
shell: true,
stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close
detached: false, // Ensure child process is attached to the main process lifecycle unless explicitly backgrounded
});
// Check if process spawned correctly
if (!spawnedProcess.pid) {
// Attempt to capture any immediate errors if possible
let errorOutput = "";
let spawnErr: any | null = null;
spawnedProcess.stderr?.on(
"data",
(data) => (errorOutput += data.toString()),
);
await new Promise<void>((resolve) => {
spawnedProcess.once("error", (err) => {
spawnErr = err;
resolve();
});
}); // Wait for error event
const details = [
spawnErr?.message ? `message=${spawnErr.message}` : null,
spawnErr?.code ? `code=${spawnErr.code}` : null,
spawnErr?.errno ? `errno=${spawnErr.errno}` : null,
spawnErr?.syscall ? `syscall=${spawnErr.syscall}` : null,
spawnErr?.path ? `path=${spawnErr.path}` : null,
spawnErr?.spawnargs
? `spawnargs=${JSON.stringify(spawnErr.spawnargs)}`
: null,
]
.filter(Boolean)
.join(", ");
logger.error(
`Failed to spawn process for app ${appId}. Command="${command}", CWD="${appPath}", ${details}\nSTDERR:\n${
errorOutput || "(empty)"
}`,
);
throw new Error(
`Failed to spawn process for app ${appId}.
Error output:
${errorOutput || "(empty)"}
Details: ${details || "n/a"}
`,
);
}
// Increment the counter and store the process reference with its ID
const currentProcessId = processCounter.increment();
runningApps.set(appId, {
process: spawnedProcess,
processId: currentProcessId,
isDocker: false,
});
listenToProcess({
process: spawnedProcess,
appId,
isNeon,
event,
});
}
function listenToProcess({
process: spawnedProcess,
appId,
isNeon,
event,
}: {
process: ChildProcess;
appId: number;
isNeon: boolean;
event: Electron.IpcMainInvokeEvent;
}) {
// Log output
spawnedProcess.stdout?.on("data", async (data) => {
const message = util.stripVTControlCharacters(data.toString());
logger.debug(
`App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`,
);
// This is a hacky heuristic to pick up when drizzle is asking for user
// to select from one of a few choices. We automatically pick the first
// option because it's usually a good default choice. We guard this with
// isNeon because: 1) only Neon apps (for the official Dyad templates) should
// get this template and 2) it's safer to do this with Neon apps because
// their databases have point in time restore built-in.
if (isNeon && message.includes("created or renamed from another")) {
spawnedProcess.stdin?.write(`\r\n`);
logger.info(
`App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`,
);
}
// Check if this is an interactive prompt requiring user input
const inputRequestPattern = /\s*\s*\([yY]\/[nN]\)\s*$/;
const isInputRequest = inputRequestPattern.test(message);
if (isInputRequest) {
// Send special input-requested event for interactive prompts
safeSend(event.sender, "app:output", {
type: "input-requested",
message,
appId,
});
} else {
// Normal stdout handling
safeSend(event.sender, "app:output", {
type: "stdout",
message,
appId,
});
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
if (urlMatch) {
proxyWorker = await startProxy(urlMatch[1], {
onStarted: (proxyUrl) => {
safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`,
appId,
});
},
});
}
}
});
spawnedProcess.stderr?.on("data", (data) => {
const message = util.stripVTControlCharacters(data.toString());
logger.error(
`App ${appId} (PID: ${spawnedProcess.pid}) stderr: ${message}`,
);
safeSend(event.sender, "app:output", {
type: "stderr",
message,
appId,
});
});
// Handle process exit/close
spawnedProcess.on("close", (code, signal) => {
logger.log(
`App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`,
);
removeAppIfCurrentProcess(appId, spawnedProcess);
});
// Handle errors during process lifecycle (e.g., command not found)
spawnedProcess.on("error", (err) => {
logger.error(
`Error in app ${appId} (PID: ${spawnedProcess.pid}) process: ${err.message}`,
);
removeAppIfCurrentProcess(appId, spawnedProcess);
// Note: We don't throw here as the error is asynchronous. The caller got a success response already.
// Consider adding ipcRenderer event emission to notify UI of the error.
});
}
async function executeAppInDocker({
appPath,
appId,
event,
isNeon,
installCommand,
startCommand,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
isNeon: boolean;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
const containerName = `dyad-app-${appId}`;
// First, check if Docker is available
try {
await new Promise<void>((resolve, reject) => {
const checkDocker = spawn("docker", ["--version"], { stdio: "pipe" });
checkDocker.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error("Docker is not available"));
}
});
checkDocker.on("error", () => {
reject(new Error("Docker is not available"));
});
});
} catch {
throw new Error(
"Docker is required but not available. Please install Docker Desktop and ensure it's running.",
);
}
// Stop and remove any existing container with the same name
try {
await new Promise<void>((resolve) => {
const stopContainer = spawn("docker", ["stop", containerName], {
stdio: "pipe",
});
stopContainer.on("close", () => {
const removeContainer = spawn("docker", ["rm", containerName], {
stdio: "pipe",
});
removeContainer.on("close", () => resolve());
removeContainer.on("error", () => resolve()); // Container might not exist
});
stopContainer.on("error", () => resolve()); // Container might not exist
});
} catch (error) {
logger.info(
`Docker container ${containerName} not found. Ignoring error: ${error}`,
);
}
// Create a Dockerfile in the app directory if it doesn't exist
const dockerfilePath = path.join(appPath, "Dockerfile.dyad");
if (!fs.existsSync(dockerfilePath)) {
const dockerfileContent = `FROM node:22-alpine
# Install pnpm
RUN npm install -g pnpm
`;
try {
await fsPromises.writeFile(dockerfilePath, dockerfileContent, "utf-8");
} catch (error) {
logger.error(`Failed to create Dockerfile for app ${appId}:`, error);
throw new Error(`Failed to create Dockerfile: ${error}`);
}
}
// Build the Docker image
const buildProcess = spawn(
"docker",
["build", "-f", "Dockerfile.dyad", "-t", `dyad-app-${appId}`, "."],
{
cwd: appPath,
stdio: "pipe",
},
);
let buildError = "";
buildProcess.stderr?.on("data", (data) => {
buildError += data.toString();
});
await new Promise<void>((resolve, reject) => {
buildProcess.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Docker build failed: ${buildError}`));
}
});
buildProcess.on("error", (err) => {
reject(new Error(`Docker build process error: ${err.message}`));
});
});
// Run the Docker container
const process = spawn(
"docker",
[
"run",
"--rm",
"--name",
containerName,
"-p",
"32100:32100",
"-v",
`${appPath}:/app`,
"-v",
`dyad-pnpm-${appId}:/app/.pnpm-store`,
"-e",
"PNPM_STORE_PATH=/app/.pnpm-store",
"-w",
"/app",
`dyad-app-${appId}`,
"sh",
"-c",
getCommand({ installCommand, startCommand }),
],
{
stdio: "pipe",
detached: false,
},
);
// Check if process spawned correctly
if (!process.pid) {
// Attempt to capture any immediate errors if possible
let errorOutput = "";
let spawnErr: any = null;
process.stderr?.on("data", (data) => (errorOutput += data.toString()));
await new Promise<void>((resolve) => {
process.once("error", (err) => {
spawnErr = err;
resolve();
});
}); // Wait for error event
const details = [
spawnErr?.message ? `message=${spawnErr.message}` : null,
spawnErr?.code ? `code=${spawnErr.code}` : null,
spawnErr?.errno ? `errno=${spawnErr.errno}` : null,
spawnErr?.syscall ? `syscall=${spawnErr.syscall}` : null,
spawnErr?.path ? `path=${spawnErr.path}` : null,
spawnErr?.spawnargs
? `spawnargs=${JSON.stringify(spawnErr.spawnargs)}`
: null,
]
.filter(Boolean)
.join(", ");
logger.error(
`Failed to spawn Docker container for app ${appId}. ${details}\nSTDERR:\n${
errorOutput || "(empty)"
}`,
);
throw new Error(
`Failed to spawn Docker container for app ${appId}.
Details: ${details || "n/a"}
STDERR:
${errorOutput || "(empty)"}`,
);
}
// Increment the counter and store the process reference with its ID
const currentProcessId = processCounter.increment();
runningApps.set(appId, {
process,
processId: currentProcessId,
isDocker: true,
containerName,
});
listenToProcess({
process,
appId,
isNeon,
event,
});
}
// Helper to kill process on a specific port (cross-platform, using kill-port)
async function killProcessOnPort(port: number): Promise<void> {
try {
await killPort(port, "tcp");
} catch {
// Ignore if nothing was running on that port
}
}
// Helper to stop any Docker containers publishing a given host port
async function stopDockerContainersOnPort(port: number): Promise<void> {
try {
// List container IDs that publish the given port
const list = spawn("docker", ["ps", "--filter", `publish=${port}`, "-q"], {
stdio: "pipe",
});
let stdout = "";
list.stdout?.on("data", (data) => {
stdout += data.toString();
});
await new Promise<void>((resolve) => {
list.on("close", () => resolve());
list.on("error", () => resolve());
});
const containerIds = stdout
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (containerIds.length === 0) {
return;
}
// Stop each container best-effort
await Promise.all(
containerIds.map(
(id) =>
new Promise<void>((resolve) => {
const stop = spawn("docker", ["stop", id], { stdio: "pipe" });
stop.on("close", () => resolve());
stop.on("error", () => resolve());
}),
),
);
} catch (e) {
logger.warn(`Failed stopping Docker containers on port ${port}: ${e}`);
}
}
export function registerAppHandlers() {
handle("restart-dyad", async () => {
app.relaunch();
app.quit();
});
handle(
"create-app",
async (
_,
params: CreateAppParams,
): Promise<{ app: any; chatId: number }> => {
const appPath = params.name;
const fullAppPath = getDyadAppPath(appPath);
if (fs.existsSync(fullAppPath)) {
throw new Error(`App already exists at: ${fullAppPath}`);
}
// Create a new app
const [app] = await db
.insert(apps)
.values({
name: params.name,
// Use the name as the path for now
path: appPath,
})
.returning();
// Create an initial chat for this app
const [chat] = await db
.insert(chats)
.values({
appId: app.id,
})
.returning();
await createFromTemplate({
fullAppPath,
});
// Initialize git repo and create first commit
await git.init({
fs: fs,
dir: fullAppPath,
defaultBranch: "main",
});
// Stage all files
await git.add({
fs: fs,
dir: fullAppPath,
filepath: ".",
});
// Create initial commit
const commitHash = await gitCommit({
path: fullAppPath,
message: "Init Dyad app",
});
// Update chat with initial commit hash
await db
.update(chats)
.set({
initialCommitHash: commitHash,
})
.where(eq(chats.id, chat.id));
return { app, chatId: chat.id };
},
);
handle(
"copy-app",
async (_, params: CopyAppParams): Promise<{ app: any }> => {
const { appId, newAppName, withHistory } = params;
// 1. Check if an app with the new name already exists
const existingApp = await db.query.apps.findFirst({
where: eq(apps.name, newAppName),
});
if (existingApp) {
throw new Error(`An app named "${newAppName}" already exists.`);
}
// 2. Find the original app
const originalApp = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!originalApp) {
throw new Error("Original app not found.");
}
const originalAppPath = getDyadAppPath(originalApp.path);
const newAppPath = getDyadAppPath(newAppName);
// 3. Copy the app folder
try {
await copyDir(originalAppPath, newAppPath, (source: string) => {
if (!withHistory && path.basename(source) === ".git") {
return false;
}
return true;
});
} catch (error) {
logger.error("Failed to copy app directory:", error);
throw new Error("Failed to copy app directory.");
}
if (!withHistory) {
// Initialize git repo and create first commit
await git.init({
fs: fs,
dir: newAppPath,
defaultBranch: "main",
});
// Stage all files
await git.add({
fs: fs,
dir: newAppPath,
filepath: ".",
});
// Create initial commit
await gitCommit({
path: newAppPath,
message: "Init Dyad app",
});
}
// 4. Create a new app entry in the database
const [newDbApp] = await db
.insert(apps)
.values({
name: newAppName,
path: newAppName, // Use the new name for the path
// Explicitly set these to null because we don't want to copy them over.
// Note: we could just leave them out since they're nullable field, but this
// is to make it explicit we intentionally don't want to copy them over.
supabaseProjectId: null,
githubOrg: null,
githubRepo: null,
installCommand: originalApp.installCommand,
startCommand: originalApp.startCommand,
})
.returning();
return { app: newDbApp };
},
);
handle("get-app", async (_, appId: number): Promise<App> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
// Get app files
const appPath = getDyadAppPath(app.path);
let files: string[] = [];
try {
files = getFilesRecursively(appPath, appPath);
// Normalize the path to use forward slashes so file tree (UI)
// can parse it more consistently across platforms.
files = files.map((path) => normalizePath(path));
} catch (error) {
logger.error(`Error reading files for app ${appId}:`, error);
// Return app even if files couldn't be read
}
let supabaseProjectName: string | null = null;
const settings = readSettings();
if (app.supabaseProjectId && settings.supabase?.accessToken?.value) {
supabaseProjectName = await getSupabaseProjectName(
app.supabaseParentProjectId || app.supabaseProjectId,
);
}
let vercelTeamSlug: string | null = null;
if (app.vercelTeamId) {
vercelTeamSlug = await getVercelTeamSlug(app.vercelTeamId);
}
return {
...app,
files,
supabaseProjectName,
vercelTeamSlug,
};
});
ipcMain.handle("list-apps", async () => {
const allApps = await db.query.apps.findMany({
orderBy: [desc(apps.createdAt)],
});
return {
apps: allApps,
appBasePath: getDyadAppPath("$APP_BASE_PATH"),
};
});
ipcMain.handle(
"read-app-file",
async (_, { appId, filePath }: { appId: number; filePath: string }) => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
const fullPath = path.join(appPath, filePath);
// Check if the path is within the app directory (security check)
if (!fullPath.startsWith(appPath)) {
throw new Error("Invalid file path");
}
if (!fs.existsSync(fullPath)) {
throw new Error("File not found");
}
try {
const contents = fs.readFileSync(fullPath, "utf-8");
return contents;
} catch (error) {
logger.error(`Error reading file ${filePath} for app ${appId}:`, error);
throw new Error("Failed to read file");
}
},
);
// Do NOT use handle for this, it contains sensitive information.
ipcMain.handle("get-env-vars", async () => {
const envVars: Record<string, string | undefined> = {};
const providers = await getLanguageModelProviders();
for (const provider of providers) {
if (provider.envVarName) {
envVars[provider.envVarName] = getEnvVar(provider.envVarName);
}
}
return envVars;
});
ipcMain.handle(
"run-app",
async (
event: Electron.IpcMainInvokeEvent,
{ appId }: { appId: number },
): Promise<void> => {
return withLock(appId, async () => {
// Check if app is already running
if (runningApps.has(appId)) {
logger.debug(`App ${appId} is already running.`);
return;
}
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
logger.debug(`Starting app ${appId} in path ${app.path}`);
const appPath = getDyadAppPath(app.path);
try {
// There may have been a previous run that left a process on port 32100.
await cleanUpPort(32100);
await executeApp({
appPath,
appId,
event,
isNeon: !!app.neonProjectId,
installCommand: app.installCommand,
startCommand: app.startCommand,
});
return;
} catch (error: any) {
logger.error(`Error running app ${appId}:`, error);
// Ensure cleanup if error happens during setup but before process events are handled
if (
runningApps.has(appId) &&
runningApps.get(appId)?.processId === processCounter.value
) {
runningApps.delete(appId);
}
throw new Error(`Failed to run app ${appId}: ${error.message}`);
}
});
},
);
ipcMain.handle(
"stop-app",
async (_, { appId }: { appId: number }): Promise<void> => {
logger.log(
`Attempting to stop app ${appId}. Current running apps: ${runningApps.size}`,
);
return withLock(appId, async () => {
const appInfo = runningApps.get(appId);
if (!appInfo) {
logger.log(
`App ${appId} not found in running apps map. Assuming already stopped.`,
);
return;
}
const { process, processId } = appInfo;
logger.log(
`Found running app ${appId} with processId ${processId} (PID: ${process.pid}). Attempting to stop.`,
);
// Check if the process is already exited or closed
if (process.exitCode !== null || process.signalCode !== null) {
logger.log(
`Process for app ${appId} (PID: ${process.pid}) already exited (code: ${process.exitCode}, signal: ${process.signalCode}). Cleaning up map.`,
);
runningApps.delete(appId); // Ensure cleanup if somehow missed
return;
}
try {
await stopAppByInfo(appId, appInfo);
// Now, safely remove the app from the map *after* confirming closure
removeAppIfCurrentProcess(appId, process);
return;
} catch (error: any) {
logger.error(
`Error stopping app ${appId} (PID: ${process.pid}, processId: ${processId}):`,
error,
);
// Attempt cleanup even if an error occurred during the stop process
removeAppIfCurrentProcess(appId, process);
throw new Error(`Failed to stop app ${appId}: ${error.message}`);
}
});
},
);
ipcMain.handle(
"restart-app",
async (
event: Electron.IpcMainInvokeEvent,
{
appId,
removeNodeModules,
}: { appId: number; removeNodeModules?: boolean },
): Promise<void> => {
logger.log(`Restarting app ${appId}`);
return withLock(appId, async () => {
try {
// First stop the app if it's running
const appInfo = runningApps.get(appId);
if (appInfo) {
const { processId } = appInfo;
logger.log(
`Stopping app ${appId} (processId ${processId}) before restart`,
);
await stopAppByInfo(appId, appInfo);
} else {
logger.log(`App ${appId} not running. Proceeding to start.`);
}
// There may have been a previous run that left a process on port 32100.
await cleanUpPort(32100);
// Now start the app again
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
// Remove node_modules if requested
if (removeNodeModules) {
const settings = readSettings();
const runtimeMode = settings.runtimeMode2 ?? "host";
const nodeModulesPath = path.join(appPath, "node_modules");
logger.log(
`Removing node_modules for app ${appId} at ${nodeModulesPath}`,
);
if (fs.existsSync(nodeModulesPath)) {
await fsPromises.rm(nodeModulesPath, {
recursive: true,
force: true,
});
logger.log(`Successfully removed node_modules for app ${appId}`);
} else {
logger.log(`No node_modules directory found for app ${appId}`);
}
// If running in Docker mode, also remove container volumes so deps reinstall freshly
if (runtimeMode === "docker") {
logger.log(
`Docker mode detected for app ${appId}. Removing Docker volumes dyad-pnpm-${appId}...`,
);
try {
await removeDockerVolumesForApp(appId);
logger.log(
`Removed Docker volumes for app ${appId} (dyad-pnpm-${appId}).`,
);
} catch (e) {
// Best-effort cleanup; log and continue
logger.warn(
`Failed to remove Docker volumes for app ${appId}. Continuing: ${e}`,
);
}
}
}
logger.debug(
`Executing app ${appId} in path ${app.path} after restart request`,
); // Adjusted log
await executeApp({
appPath,
appId,
event,
isNeon: !!app.neonProjectId,
installCommand: app.installCommand,
startCommand: app.startCommand,
}); // This will handle starting either mode
return;
} catch (error) {
logger.error(`Error restarting app ${appId}:`, error);
throw error;
}
});
},
);
ipcMain.handle(
"edit-app-file",
async (
_,
{
appId,
filePath,
content,
}: { appId: number; filePath: string; content: string },
): Promise<EditAppFileReturnType> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
const fullPath = path.join(appPath, filePath);
// Check if the path is within the app directory (security check)
if (!fullPath.startsWith(appPath)) {
throw new Error("Invalid file path");
}
if (app.neonProjectId && app.neonDevelopmentBranchId) {
try {
await storeDbTimestampAtCurrentVersion({
appId: app.id,
});
} catch (error) {
logger.error(
"Error storing Neon timestamp at current version:",
error,
);
throw new Error(
"Could not store Neon timestamp at current version; database versioning functionality is not working: " +
error,
);
}
}
// Ensure directory exists
const dirPath = path.dirname(fullPath);
await fsPromises.mkdir(dirPath, { recursive: true });
try {
await fsPromises.writeFile(fullPath, content, "utf-8");
// Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) {
await git.add({
fs,
dir: appPath,
filepath: filePath,
});
await gitCommit({
path: appPath,
message: `Updated ${filePath}`,
});
}
} catch (error: any) {
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
throw new Error(`Failed to write file: ${error.message}`);
}
if (isServerFunction(filePath) && app.supabaseProjectId) {
try {
await deploySupabaseFunctions({
supabaseProjectId: app.supabaseProjectId,
functionName: path.basename(path.dirname(filePath)),
content: content,
});
} catch (error) {
logger.error(`Error deploying Supabase function ${filePath}:`, error);
return {
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
};
}
}
return {};
},
);
ipcMain.handle(
"delete-app",
async (_, { appId }: { appId: number }): Promise<void> => {
// Static server worker is NOT terminated here anymore
return withLock(appId, async () => {
// Check if app exists
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
// Stop the app if it's running
if (runningApps.has(appId)) {
const appInfo = runningApps.get(appId)!;
try {
logger.log(`Stopping app ${appId} before deletion.`); // Adjusted log
await stopAppByInfo(appId, appInfo);
} catch (error: any) {
logger.error(`Error stopping app ${appId} before deletion:`, error); // Adjusted log
// Continue with deletion even if stopping fails
}
}
// Delete app from database
try {
await db.delete(apps).where(eq(apps.id, appId));
// Note: Associated chats will cascade delete
} catch (error: any) {
logger.error(`Error deleting app ${appId} from database:`, error);
throw new Error(
`Failed to delete app from database: ${error.message}`,
);
}
// Delete app files
const appPath = getDyadAppPath(app.path);
try {
await fsPromises.rm(appPath, { recursive: true, force: true });
} catch (error: any) {
logger.error(`Error deleting app files for app ${appId}:`, error);
throw new Error(
`App deleted from database, but failed to delete app files. Please delete app files from ${appPath} manually.\n\nError: ${error.message}`,
);
}
});
},
);
ipcMain.handle(
"add-to-favorite",
async (
_,
{ appId }: { appId: number },
): Promise<{ isFavorite: boolean }> => {
return withLock(appId, async () => {
try {
// Fetch the current isFavorite value
const result = await db
.select({ isFavorite: apps.isFavorite })
.from(apps)
.where(eq(apps.id, appId))
.limit(1);
if (result.length === 0) {
throw new Error(`App with ID ${appId} not found.`);
}
const currentIsFavorite = result[0].isFavorite;
// Toggle the isFavorite value
const updated = await db
.update(apps)
.set({ isFavorite: !currentIsFavorite })
.where(eq(apps.id, appId))
.returning({ isFavorite: apps.isFavorite });
if (updated.length === 0) {
throw new Error(
`Failed to update favorite status for app ID ${appId}.`,
);
}
// Return the updated isFavorite value
return { isFavorite: updated[0].isFavorite };
} catch (error: any) {
logger.error(
`Error in add-to-favorite handler for app ID ${appId}:`,
error,
);
throw new Error(`Failed to toggle favorite status: ${error.message}`);
}
});
},
);
ipcMain.handle(
"rename-app",
async (
_,
{
appId,
appName,
appPath,
}: { appId: number; appName: string; appPath: string },
): Promise<void> => {
return withLock(appId, async () => {
// Check if app exists
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
// Check for conflicts with existing apps
const nameConflict = await db.query.apps.findFirst({
where: eq(apps.name, appName),
});
const pathConflict = await db.query.apps.findFirst({
where: eq(apps.path, appPath),
});
if (nameConflict && nameConflict.id !== appId) {
throw new Error(`An app with the name '${appName}' already exists`);
}
if (pathConflict && pathConflict.id !== appId) {
throw new Error(`An app with the path '${appPath}' already exists`);
}
// Stop the app if it's running
if (runningApps.has(appId)) {
const appInfo = runningApps.get(appId)!;
try {
await stopAppByInfo(appId, appInfo);
} catch (error: any) {
logger.error(`Error stopping app ${appId} before renaming:`, error);
throw new Error(
`Failed to stop app before renaming: ${error.message}`,
);
}
}
const oldAppPath = getDyadAppPath(app.path);
const newAppPath = getDyadAppPath(appPath);
// Only move files if needed
if (newAppPath !== oldAppPath) {
// Move app files
try {
// Check if destination directory already exists
if (fs.existsSync(newAppPath)) {
throw new Error(
`Destination path '${newAppPath}' already exists`,
);
}
// Create parent directory if it doesn't exist
await fsPromises.mkdir(path.dirname(newAppPath), {
recursive: true,
});
// Copy the directory without node_modules
await copyDir(oldAppPath, newAppPath);
} catch (error: any) {
logger.error(
`Error moving app files from ${oldAppPath} to ${newAppPath}:`,
error,
);
throw new Error(`Failed to move app files: ${error.message}`);
}
try {
// Delete the old directory
await fsPromises.rm(oldAppPath, { recursive: true, force: true });
} catch (error: any) {
// Why is this just a warning? This happens quite often on Windows
// because it has an aggressive file lock.
//
// Not deleting the old directory is annoying, but not a big deal
// since the user can do it themselves if they need to.
logger.warn(
`Error deleting old app directory ${oldAppPath}:`,
error,
);
}
}
// Update app in database
try {
await db
.update(apps)
.set({
name: appName,
path: appPath,
})
.where(eq(apps.id, appId))
.returning();
return;
} catch (error: any) {
// Attempt to rollback the file move
if (newAppPath !== oldAppPath) {
try {
// Copy back from new to old
await copyDir(newAppPath, oldAppPath);
// Delete the new directory
await fsPromises.rm(newAppPath, { recursive: true, force: true });
} catch (rollbackError) {
logger.error(
`Failed to rollback file move during rename error:`,
rollbackError,
);
}
}
logger.error(`Error updating app ${appId} in database:`, error);
throw new Error(`Failed to update app in database: ${error.message}`);
}
});
},
);
ipcMain.handle("reset-all", async (): Promise<void> => {
logger.log("start: resetting all apps and settings.");
// Stop all running apps first
logger.log("stopping all running apps...");
const runningAppIds = Array.from(runningApps.keys());
for (const appId of runningAppIds) {
try {
const appInfo = runningApps.get(appId)!;
await stopAppByInfo(appId, appInfo);
} catch (error) {
logger.error(`Error stopping app ${appId} during reset:`, error);
// Continue with reset even if stopping fails
}
}
logger.log("all running apps stopped.");
logger.log("deleting database...");
// 1. Drop the database by deleting the SQLite file
const dbPath = getDatabasePath();
if (fs.existsSync(dbPath)) {
// Close database connections first
if (db.$client) {
db.$client.close();
}
await fsPromises.unlink(dbPath);
logger.log(`Database file deleted: ${dbPath}`);
}
logger.log("database deleted.");
logger.log("deleting settings...");
// 2. Remove settings
const userDataPath = getUserDataPath();
const settingsPath = path.join(userDataPath, "user-settings.json");
if (fs.existsSync(settingsPath)) {
await fsPromises.unlink(settingsPath);
logger.log(`Settings file deleted: ${settingsPath}`);
}
logger.log("settings deleted.");
// 3. Remove all app files recursively
// Doing this last because it's the most time-consuming and the least important
// in terms of resetting the app state.
logger.log("removing all app files...");
const dyadAppPath = getDyadAppPath(".");
if (fs.existsSync(dyadAppPath)) {
await fsPromises.rm(dyadAppPath, { recursive: true, force: true });
// Recreate the base directory
await fsPromises.mkdir(dyadAppPath, { recursive: true });
}
logger.log("all app files removed.");
logger.log("reset all complete.");
});
ipcMain.handle("get-app-version", async (): Promise<{ version: string }> => {
// Read version from package.json at project root
const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
return { version: packageJson.version };
});
handle("rename-branch", async (_, params: RenameBranchParams) => {
const { appId, oldBranchName, newBranchName } = params;
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(app.path);
return withLock(appId, async () => {
try {
// Check if the old branch exists
const branches = await git.listBranches({ fs, dir: appPath });
if (!branches.includes(oldBranchName)) {
throw new Error(`Branch '${oldBranchName}' not found.`);
}
// Check if the new branch name already exists
if (branches.includes(newBranchName)) {
// If newBranchName is 'main' and oldBranchName is 'master',
// and 'main' already exists, we might want to allow this if 'main' is the current branch
// and just switch to it, or delete 'master'.
// For now, let's keep it simple and throw an error.
throw new Error(
`Branch '${newBranchName}' already exists. Cannot rename.`,
);
}
await git.renameBranch({
fs: fs,
dir: appPath,
oldref: oldBranchName,
ref: newBranchName,
});
logger.info(
`Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`,
);
} catch (error: any) {
logger.error(
`Failed to rename branch for app ${appId}: ${error.message}`,
);
throw new Error(
`Failed to rename branch '${oldBranchName}' to '${newBranchName}': ${error.message}`,
);
}
});
});
handle(
"respond-to-app-input",
async (_, { appId, response }: RespondToAppInputParams) => {
if (response !== "y" && response !== "n") {
throw new Error(`Invalid response: ${response}`);
}
const appInfo = runningApps.get(appId);
if (!appInfo) {
throw new Error(`App ${appId} is not running`);
}
const { process } = appInfo;
if (!process.stdin) {
throw new Error(`App ${appId} process has no stdin available`);
}
try {
// Write the response to stdin with a newline
process.stdin.write(`${response}\n`);
logger.debug(`Sent response '${response}' to app ${appId} stdin`);
} catch (error: any) {
logger.error(`Error sending response to app ${appId}:`, error);
throw new Error(`Failed to send response to app: ${error.message}`);
}
},
);
handle(
"search-app",
async (_, searchQuery: string): Promise<AppSearchResult[]> => {
// Use parameterized query to prevent SQL injection
const pattern = `%${searchQuery.replace(/[%_]/g, "\\$&")}%`;
// 1) Apps whose name matches
const appNameMatches = await db
.select({
id: apps.id,
name: apps.name,
createdAt: apps.createdAt,
})
.from(apps)
.where(like(apps.name, pattern))
.orderBy(desc(apps.createdAt));
const appNameMatchesResult: AppSearchResult[] = appNameMatches.map(
(r) => ({
id: r.id,
name: r.name,
createdAt: r.createdAt,
matchedChatTitle: null,
matchedChatMessage: null,
}),
);
// 2) Apps whose chat title matches
const chatTitleMatches = await db
.select({
id: apps.id,
name: apps.name,
createdAt: apps.createdAt,
matchedChatTitle: chats.title,
})
.from(apps)
.innerJoin(chats, eq(apps.id, chats.appId))
.where(like(chats.title, pattern))
.orderBy(desc(apps.createdAt));
const chatTitleMatchesResult: AppSearchResult[] = chatTitleMatches.map(
(r) => ({
id: r.id,
name: r.name,
createdAt: r.createdAt,
matchedChatTitle: r.matchedChatTitle,
matchedChatMessage: null,
}),
);
// 3) Apps whose chat message content matches
const chatMessageMatches = await db
.select({
id: apps.id,
name: apps.name,
createdAt: apps.createdAt,
matchedChatTitle: chats.title,
matchedChatMessage: messages.content,
})
.from(apps)
.innerJoin(chats, eq(apps.id, chats.appId))
.innerJoin(messages, eq(chats.id, messages.chatId))
.where(like(messages.content, pattern))
.orderBy(desc(apps.createdAt));
// Flatten and dedupe by app id
const allMatches: AppSearchResult[] = [
...appNameMatchesResult,
...chatTitleMatchesResult,
...chatMessageMatches,
];
const uniqueApps = Array.from(
new Map(allMatches.map((app) => [app.id, app])).values(),
);
// Sort newest apps first
uniqueApps.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return uniqueApps;
},
);
}
function getCommand({
installCommand,
startCommand,
}: {
installCommand?: string | null;
startCommand?: string | null;
}) {
const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim();
return hasCustomCommands
? `${installCommand!.trim()} && ${startCommand!.trim()}`
: DEFAULT_COMMAND;
}
async function cleanUpPort(port: number) {
const settings = readSettings();
if (settings.runtimeMode2 === "docker") {
await stopDockerContainersOnPort(port);
} else {
await killProcessOnPort(port);
}
}