Support dyad docker (#674)

TODOs:
- [ ] clean-up docker images

https://claude.ai/chat/13b2c5d3-0d46-49e3-a771-d10edf1e29f4
This commit is contained in:
Will Chen
2025-08-26 11:07:40 -07:00
committed by GitHub
parent e6c92a24ed
commit 9869fefbcb
6 changed files with 455 additions and 47 deletions

View File

@@ -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<void> {
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<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 = "";
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<void> {
try {
@@ -248,6 +443,49 @@ async function killProcessOnPort(port: number): Promise<void> {
}
}
// 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();
@@ -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);
}
}

View File

@@ -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<void> {
});
}
/**
* Gracefully stops a Docker container by name. Resolves even if the container doesn't exist.
*/
export function stopDockerContainer(containerName: string): Promise<void> {
return new Promise<void>((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<void> {
return new Promise<void>((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<void> {
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