diff --git a/src/components/RuntimeModeSelector.tsx b/src/components/RuntimeModeSelector.tsx new file mode 100644 index 0000000..11eefc0 --- /dev/null +++ b/src/components/RuntimeModeSelector.tsx @@ -0,0 +1,74 @@ +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useSettings } from "@/hooks/useSettings"; +import { showError } from "@/lib/toast"; +import { IpcClient } from "@/ipc/ipc_client"; + +export function RuntimeModeSelector() { + const { settings, updateSettings } = useSettings(); + + if (!settings) { + return null; + } + + const isDockerMode = settings?.runtimeMode2 === "docker"; + + const handleRuntimeModeChange = async (value: "host" | "docker") => { + try { + await updateSettings({ runtimeMode2: value }); + } catch (error: any) { + showError(`Failed to update runtime mode: ${error.message}`); + } + }; + + return ( +
+
+
+ + +
+
+ Choose whether to run apps directly on the local machine or in Docker + containers +
+
+ {isDockerMode && ( +
+ ⚠️ Docker mode is experimental and requires{" "} + {" "} + to be installed and running +
+ )} +
+ ); +} diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index 4238a07..9f3eb4a 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -51,10 +51,11 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { const [isCollapsed, setIsCollapsed] = useState(true); const { isStreaming } = useStreamChat(); if (!error) return null; + const isDockerError = error.includes("Cannot connect to the Docker"); const getTruncatedError = () => { const firstLine = error.split("\n")[0]; - const snippetLength = 200; + const snippetLength = 250; const snippet = error.substring(0, snippetLength); return firstLine.length < snippet.length ? firstLine @@ -97,23 +98,27 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { - Tip: Check if restarting the - app fixes the error. + Tip: + {isDockerError + ? "Make sure Docker Desktop is running and try restarting the app." + : "Check if restarting the app fixes the error."} {/* AI Fix button at the bottom */} -
- -
+ {!isDockerError && ( +
+ +
+ )} ); }; diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index a6fc78c..4f03e9c 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -13,7 +13,7 @@ import type { import fs from "node:fs"; import path from "node:path"; import { getDyadAppPath, getUserDataPath } from "../../paths/paths"; -import { spawn } from "node:child_process"; +import { ChildProcess, spawn } from "node:child_process"; import git from "isomorphic-git"; import { promises as fsPromises } from "node:fs"; @@ -23,8 +23,9 @@ import { getFilesRecursively } from "../utils/file_utils"; import { runningApps, processCounter, - killProcess, removeAppIfCurrentProcess, + stopAppByInfo, + removeDockerVolumesForApp, } from "../utils/process_manager"; import { getEnvVar } from "../utils/read_env"; import { readSettings } from "../../main/settings"; @@ -50,6 +51,8 @@ import { isServerFunction } from "@/supabase_admin/supabase_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; +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, @@ -97,14 +100,28 @@ async function executeApp({ proxyWorker.terminate(); proxyWorker = null; } - await executeAppLocalNode({ - appPath, - appId, - event, - isNeon, - installCommand, - startCommand, - }); + 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({ @@ -122,12 +139,7 @@ async function executeAppLocalNode({ installCommand?: string | null; startCommand?: string | null; }): Promise { - const defaultCommand = - "(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)"; - const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim(); - const command = hasCustomCommands - ? `${installCommand!.trim()} && ${startCommand!.trim()}` - : defaultCommand; + const command = getCommand({ installCommand, startCommand }); const spawnedProcess = spawn(command, [], { cwd: appPath, shell: true, @@ -153,8 +165,28 @@ async function executeAppLocalNode({ 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()); @@ -169,7 +201,7 @@ async function executeAppLocalNode({ // 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`); + spawnedProcess.stdin?.write(`\r\n`); logger.info( `App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`, ); @@ -239,6 +271,169 @@ async function executeAppLocalNode({ }); } +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 { + const containerName = `dyad-app-${appId}`; + + // First, check if Docker is available + try { + await new Promise((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((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((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 = ""; + 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 Docker container 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, + 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 { try { @@ -248,6 +443,49 @@ async function killProcessOnPort(port: number): Promise { } } +// Helper to stop any Docker containers publishing a given host port +async function stopDockerContainersOnPort(port: number): Promise { + 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((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((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(); @@ -523,8 +761,8 @@ export function registerAppHandlers() { const appPath = getDyadAppPath(app.path); try { - // Kill any orphaned process on port 32100 (in case previous run left it) - await killProcessOnPort(32100); + // There may have been a previous run that left a process on port 32100. + await cleanUpPort(32100); await executeApp({ appPath, appId, @@ -581,8 +819,7 @@ export function registerAppHandlers() { } try { - // Use the killProcess utility to stop the process - await killProcess(process); + await stopAppByInfo(appId, appInfo); // Now, safely remove the app from the map *after* confirming closure removeAppIfCurrentProcess(appId, process); @@ -616,19 +853,17 @@ export function registerAppHandlers() { // First stop the app if it's running const appInfo = runningApps.get(appId); if (appInfo) { - const { process, processId } = appInfo; + const { processId } = appInfo; logger.log( `Stopping app ${appId} (processId ${processId}) before restart`, ); - - await killProcess(process); - runningApps.delete(appId); + await stopAppByInfo(appId, appInfo); } 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); + // 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({ @@ -643,6 +878,9 @@ export function registerAppHandlers() { // 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}`, @@ -656,6 +894,24 @@ export function registerAppHandlers() { } 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( @@ -786,8 +1042,7 @@ export function registerAppHandlers() { const appInfo = runningApps.get(appId)!; try { logger.log(`Stopping app ${appId} before deletion.`); // Adjusted log - await killProcess(appInfo.process); - runningApps.delete(appId); + 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 @@ -860,8 +1115,7 @@ export function registerAppHandlers() { if (runningApps.has(appId)) { const appInfo = runningApps.get(appId)!; try { - await killProcess(appInfo.process); - runningApps.delete(appId); + await stopAppByInfo(appId, appInfo); } catch (error: any) { logger.error(`Error stopping app ${appId} before renaming:`, error); throw new Error( @@ -957,8 +1211,7 @@ export function registerAppHandlers() { for (const appId of runningAppIds) { try { const appInfo = runningApps.get(appId)!; - await killProcess(appInfo.process); - runningApps.delete(appId); + await stopAppByInfo(appId, appInfo); } catch (error) { logger.error(`Error stopping app ${appId} during reset:`, error); // Continue with reset even if stopping fails @@ -1088,3 +1341,25 @@ export function registerAppHandlers() { }, ); } + +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); + } +} diff --git a/src/ipc/utils/process_manager.ts b/src/ipc/utils/process_manager.ts index 6b4233b..3dff2f8 100644 --- a/src/ipc/utils/process_manager.ts +++ b/src/ipc/utils/process_manager.ts @@ -1,10 +1,12 @@ -import { ChildProcess } from "node:child_process"; +import { ChildProcess, spawn } from "node:child_process"; import treeKill from "tree-kill"; // Define a type for the value stored in runningApps export interface RunningAppInfo { process: ChildProcess; processId: number; + isDocker: boolean; + containerName?: string; } // Store running app processes @@ -81,6 +83,49 @@ export function killProcess(process: ChildProcess): Promise { }); } +/** + * Gracefully stops a Docker container by name. Resolves even if the container doesn't exist. + */ +export function stopDockerContainer(containerName: string): Promise { + return new Promise((resolve) => { + const stop = spawn("docker", ["stop", containerName], { stdio: "pipe" }); + stop.on("close", () => resolve()); + stop.on("error", () => resolve()); + }); +} + +/** + * Removes Docker named volumes used for an app's dependencies. + * Best-effort: resolves even if volumes don't exist. + */ +export function removeDockerVolumesForApp(appId: number): Promise { + return new Promise((resolve) => { + const pnpmVolume = `dyad-pnpm-${appId}`; + + const rm = spawn("docker", ["volume", "rm", "-f", pnpmVolume], { + stdio: "pipe", + }); + rm.on("close", () => resolve()); + rm.on("error", () => resolve()); + }); +} + +/** + * Stops an app based on its RunningAppInfo (container vs host) and removes it from the running map. + */ +export async function stopAppByInfo( + appId: number, + appInfo: RunningAppInfo, +): Promise { + if (appInfo.isDocker) { + const containerName = appInfo.containerName || `dyad-app-${appId}`; + await stopDockerContainer(containerName); + } else { + await killProcess(appInfo.process); + } + runningApps.delete(appId); +} + /** * Removes an app from the running apps map if it's the current process * @param appId The app ID diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index e0b6aab..37bd236 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -69,6 +69,9 @@ export type ProviderSetting = z.infer; export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]); export type RuntimeMode = z.infer; +export const RuntimeMode2Schema = z.enum(["host", "docker"]); +export type RuntimeMode2 = z.infer; + export const ChatModeSchema = z.enum(["build", "ask"]); export type ChatMode = z.infer; @@ -170,6 +173,7 @@ export const UserSettingsSchema = z.object({ enableNativeGit: z.boolean().optional(), enableAutoUpdate: z.boolean(), releaseChannel: ReleaseChannelSchema, + runtimeMode2: RuntimeMode2Schema.optional(), //////////////////////////////// // E2E TESTING ONLY. diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index d278abf..5303fe4 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -23,6 +23,7 @@ import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch"; import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch"; import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector"; import { NeonIntegration } from "@/components/NeonIntegration"; +import { RuntimeModeSelector } from "@/components/RuntimeModeSelector"; export default function SettingsPage() { const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); @@ -256,6 +257,10 @@ export function GeneralSettings({ appVersion }: { appVersion: string | null }) { +
+ +
+
App Version: