Support dyad docker (#674)
TODOs: - [ ] clean-up docker images https://claude.ai/chat/13b2c5d3-0d46-49e3-a771-d10edf1e29f4
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user