936 lines
28 KiB
TypeScript
936 lines
28 KiB
TypeScript
import { ipcMain } from "electron";
|
|
import { db, getDatabasePath } from "../../db";
|
|
import { apps, chats } from "../../db/schema";
|
|
import { desc, eq } from "drizzle-orm";
|
|
import type {
|
|
App,
|
|
CreateAppParams,
|
|
RenameBranchParams,
|
|
CopyAppParams,
|
|
} from "../ipc_types";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
|
|
import { 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,
|
|
killProcess,
|
|
removeAppIfCurrentProcess,
|
|
} 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 { 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";
|
|
|
|
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
|
|
}: {
|
|
appPath: string;
|
|
appId: number;
|
|
event: Electron.IpcMainInvokeEvent;
|
|
}): Promise<void> {
|
|
if (proxyWorker) {
|
|
proxyWorker.terminate();
|
|
proxyWorker = null;
|
|
}
|
|
await executeAppLocalNode({ appPath, appId, event });
|
|
}
|
|
|
|
async function executeAppLocalNode({
|
|
appPath,
|
|
appId,
|
|
event,
|
|
}: {
|
|
appPath: string;
|
|
appId: number;
|
|
event: Electron.IpcMainInvokeEvent;
|
|
}): Promise<void> {
|
|
const process = spawn(
|
|
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)",
|
|
[],
|
|
{
|
|
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 (!process.pid) {
|
|
// Attempt to capture any immediate errors if possible
|
|
let errorOutput = "";
|
|
process.stderr?.on("data", (data) => (errorOutput += data));
|
|
await new Promise((resolve) => process.on("error", resolve)); // Wait for error event
|
|
throw new Error(
|
|
`Failed to spawn process for app ${appId}. Error: ${
|
|
errorOutput || "Unknown spawn error"
|
|
}`,
|
|
);
|
|
}
|
|
|
|
// Increment the counter and store the process reference with its ID
|
|
const currentProcessId = processCounter.increment();
|
|
runningApps.set(appId, { process, processId: currentProcessId });
|
|
|
|
// Log output
|
|
process.stdout?.on("data", async (data) => {
|
|
const message = util.stripVTControlCharacters(data.toString());
|
|
logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`);
|
|
|
|
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,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
process.stderr?.on("data", (data) => {
|
|
const message = util.stripVTControlCharacters(data.toString());
|
|
logger.error(`App ${appId} (PID: ${process.pid}) stderr: ${message}`);
|
|
safeSend(event.sender, "app:output", {
|
|
type: "stderr",
|
|
message,
|
|
appId,
|
|
});
|
|
});
|
|
|
|
// Handle process exit/close
|
|
process.on("close", (code, signal) => {
|
|
logger.log(
|
|
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`,
|
|
);
|
|
removeAppIfCurrentProcess(appId, process);
|
|
});
|
|
|
|
// Handle errors during process lifecycle (e.g., command not found)
|
|
process.on("error", (err) => {
|
|
logger.error(
|
|
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`,
|
|
);
|
|
removeAppIfCurrentProcess(appId, process);
|
|
// 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.
|
|
});
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
export function registerAppHandlers() {
|
|
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,
|
|
})
|
|
.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);
|
|
} 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.supabaseProjectId);
|
|
}
|
|
|
|
return {
|
|
...app,
|
|
files,
|
|
supabaseProjectName,
|
|
};
|
|
});
|
|
|
|
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 {
|
|
// Kill any orphaned process on port 32100 (in case previous run left it)
|
|
await killProcessOnPort(32100);
|
|
await executeApp({ appPath, appId, event });
|
|
|
|
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 {
|
|
// Use the killProcess utility to stop the process
|
|
await killProcess(process);
|
|
|
|
// 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 { process, processId } = appInfo;
|
|
logger.log(
|
|
`Stopping app ${appId} (processId ${processId}) before restart`,
|
|
);
|
|
|
|
await killProcess(process);
|
|
runningApps.delete(appId);
|
|
} else {
|
|
logger.log(`App ${appId} not running. Proceeding to start.`);
|
|
}
|
|
|
|
// Kill any orphaned process on port 32100 (in case previous run left it)
|
|
await killProcessOnPort(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 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}`);
|
|
}
|
|
}
|
|
|
|
logger.debug(
|
|
`Executing app ${appId} in path ${app.path} after restart request`,
|
|
); // Adjusted log
|
|
|
|
await executeApp({ appPath, appId, event }); // 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<void> => {
|
|
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");
|
|
}
|
|
|
|
// 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}`,
|
|
});
|
|
}
|
|
|
|
return;
|
|
} catch (error: any) {
|
|
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
|
|
throw new Error(`Failed to write file: ${error.message}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
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 killProcess(appInfo.process);
|
|
runningApps.delete(appId);
|
|
} catch (error: any) {
|
|
logger.error(`Error stopping app ${appId} before deletion:`, error); // Adjusted log
|
|
// Continue with deletion even if stopping fails
|
|
}
|
|
}
|
|
|
|
// 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(`Failed to delete app files: ${error.message}`);
|
|
}
|
|
|
|
// Delete app from database
|
|
try {
|
|
await db.delete(apps).where(eq(apps.id, appId));
|
|
// Note: Associated chats will cascade delete if that's set up in the schema
|
|
return;
|
|
} catch (error: any) {
|
|
logger.error(`Error deleting app ${appId} from database:`, error);
|
|
throw new Error(
|
|
`Failed to delete app from database: ${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 killProcess(appInfo.process);
|
|
runningApps.delete(appId);
|
|
} 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 killProcess(appInfo.process);
|
|
runningApps.delete(appId);
|
|
} 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}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|