Support dyad docker (#674)
TODOs: - [ ] clean-up docker images https://claude.ai/chat/13b2c5d3-0d46-49e3-a771-d10edf1e29f4
This commit is contained in:
74
src/components/RuntimeModeSelector.tsx
Normal file
74
src/components/RuntimeModeSelector.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="text-sm font-medium" htmlFor="runtime-mode">
|
||||
Runtime Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.runtimeMode2 ?? "host"}
|
||||
onValueChange={handleRuntimeModeChange}
|
||||
>
|
||||
<SelectTrigger className="w-48" id="runtime-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="host">Local (default)</SelectItem>
|
||||
<SelectItem value="docker">Docker (experimental)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Choose whether to run apps directly on the local machine or in Docker
|
||||
containers
|
||||
</div>
|
||||
</div>
|
||||
{isDockerMode && (
|
||||
<div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
|
||||
⚠️ Docker mode is <b>experimental</b> and requires{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline font-medium cursor-pointer"
|
||||
onClick={() =>
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://www.docker.com/products/docker-desktop/",
|
||||
)
|
||||
}
|
||||
>
|
||||
Docker Desktop
|
||||
</button>{" "}
|
||||
to be installed and running
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
<Lightbulb size={16} className=" text-red-800 dark:text-red-300" />
|
||||
</div>
|
||||
<span className="text-sm text-red-700 dark:text-red-200">
|
||||
<span className="font-medium">Tip: </span>Check if restarting the
|
||||
app fixes the error.
|
||||
<span className="font-medium">Tip: </span>
|
||||
{isDockerError
|
||||
? "Make sure Docker Desktop is running and try restarting the app."
|
||||
: "Check if restarting the app fixes the error."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Fix button at the bottom */}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
disabled={isStreaming}
|
||||
onClick={onAIFix}
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Fix error with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isDockerError && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
disabled={isStreaming}
|
||||
onClick={onAIFix}
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Fix error with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,9 @@ export type ProviderSetting = z.infer<typeof ProviderSettingSchema>;
|
||||
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
|
||||
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
|
||||
|
||||
export const RuntimeMode2Schema = z.enum(["host", "docker"]);
|
||||
export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>;
|
||||
|
||||
export const ChatModeSchema = z.enum(["build", "ask"]);
|
||||
export type ChatMode = z.infer<typeof ChatModeSchema>;
|
||||
|
||||
@@ -170,6 +173,7 @@ export const UserSettingsSchema = z.object({
|
||||
enableNativeGit: z.boolean().optional(),
|
||||
enableAutoUpdate: z.boolean(),
|
||||
releaseChannel: ReleaseChannelSchema,
|
||||
runtimeMode2: RuntimeMode2Schema.optional(),
|
||||
|
||||
////////////////////////////////
|
||||
// E2E TESTING ONLY.
|
||||
|
||||
@@ -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 }) {
|
||||
<ReleaseChannelSelector />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RuntimeModeSelector />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
<span className="mr-2 font-medium">App Version:</span>
|
||||
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-gray-800 dark:text-gray-200 font-mono">
|
||||
|
||||
Reference in New Issue
Block a user