<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Adds Supabase database branch selection per app, with a new schema field
and UI to choose a branch after connecting a project. Resets branch when
changing or disconnecting the project to keep state consistent.
- **New Features**
- Added apps.supabase_branch_id column.
- Branch dropdown in SupabaseConnector shown after a project is
connected; selection persists and triggers app refresh.
- New state and hooks: supabaseBranchesAtom, loadBranches(projectId),
setAppBranch(branchId).
- IPC endpoints: supabase:list-branches and supabase:set-app-branch;
setting/unsetting project also clears the branch.
- **Migration**
- Apply drizzle migration 0013_supabase_branch.sql to add the
supabase_branch_id column (defaults to null).
<!-- End of auto-generated description by cubic. -->
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> Adds Supabase database branch selection per app, including parent
project tracking, new IPC endpoints, UI dropdown, and an accompanying DB
migration with e2e tests.
>
> - **Database**:
> - Add `apps.supabase_parent_project_id` via migration
`drizzle/0015_complete_old_lace.sql`; snapshot and journal updated.
> - **IPC/Main**:
> - New `supabase:list-branches` handler and management client
`listSupabaseBranches` (real API + test stubs).
> - Update `supabase:set-app-project` to accept `{ projectId,
parentProjectId?, appId }`; unset clears both IDs.
> - `get-app` resolves `supabaseProjectName` using
`supabase_parent_project_id` when present.
> - **Types & Client**:
> - Add `SupabaseBranch`, `SetSupabaseAppProjectParams`, and
`App.supabaseParentProjectId`; expose `listSupabaseBranches` and updated
`setSupabaseAppProject` in `ipc_client` and preload whitelist.
> - **UI/Hooks**:
> - Supabase UI: branch dropdown in `SupabaseConnector` with
`loadBranches`, selection persists via updated `setAppProject`.
> - State: add `supabaseBranchesAtom`; `useSupabase` gets `branches`,
`loadBranches`, new param shape for `setAppProject`.
> - TokenBar/ChatInput: add `data-testid` for token bar and toggle.
> - **Supabase Context (tests)**:
> - Test build returns large context for `test-branch-project-id` to
validate branch selection.
> - **E2E Tests**:
> - Add `supabase_branch.spec.ts` and snapshot verifying branch
selection affects token usage.
>
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
33054278db8396b4371ed6e8224105cb5684b7ac. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
1559 lines
45 KiB
TypeScript
1559 lines
45 KiB
TypeScript
import { ipcMain, app } from "electron";
|
||
import { db, getDatabasePath } from "../../db";
|
||
import { apps, chats, messages } from "../../db/schema";
|
||
import { desc, eq, like } from "drizzle-orm";
|
||
import type {
|
||
App,
|
||
CreateAppParams,
|
||
RenameBranchParams,
|
||
CopyAppParams,
|
||
EditAppFileReturnType,
|
||
RespondToAppInputParams,
|
||
} from "../ipc_types";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
|
||
import { ChildProcess, 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,
|
||
removeAppIfCurrentProcess,
|
||
stopAppByInfo,
|
||
removeDockerVolumesForApp,
|
||
} 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 {
|
||
deploySupabaseFunctions,
|
||
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";
|
||
import { normalizePath } from "../../../shared/normalizePath";
|
||
import { isServerFunction } from "@/supabase_admin/supabase_utils";
|
||
import { getVercelTeamSlug } from "../utils/vercel_utils";
|
||
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
|
||
import { AppSearchResult } from "@/lib/schemas";
|
||
|
||
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,
|
||
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
|
||
isNeon,
|
||
installCommand,
|
||
startCommand,
|
||
}: {
|
||
appPath: string;
|
||
appId: number;
|
||
event: Electron.IpcMainInvokeEvent;
|
||
isNeon: boolean;
|
||
installCommand?: string | null;
|
||
startCommand?: string | null;
|
||
}): Promise<void> {
|
||
if (proxyWorker) {
|
||
proxyWorker.terminate();
|
||
proxyWorker = null;
|
||
}
|
||
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({
|
||
appPath,
|
||
appId,
|
||
event,
|
||
isNeon,
|
||
installCommand,
|
||
startCommand,
|
||
}: {
|
||
appPath: string;
|
||
appId: number;
|
||
event: Electron.IpcMainInvokeEvent;
|
||
isNeon: boolean;
|
||
installCommand?: string | null;
|
||
startCommand?: string | null;
|
||
}): Promise<void> {
|
||
const command = getCommand({ installCommand, startCommand });
|
||
const spawnedProcess = spawn(command, [], {
|
||
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 (!spawnedProcess.pid) {
|
||
// Attempt to capture any immediate errors if possible
|
||
let errorOutput = "";
|
||
let spawnErr: any | null = null;
|
||
spawnedProcess.stderr?.on(
|
||
"data",
|
||
(data) => (errorOutput += data.toString()),
|
||
);
|
||
await new Promise<void>((resolve) => {
|
||
spawnedProcess.once("error", (err) => {
|
||
spawnErr = err;
|
||
resolve();
|
||
});
|
||
}); // Wait for error event
|
||
|
||
const details = [
|
||
spawnErr?.message ? `message=${spawnErr.message}` : null,
|
||
spawnErr?.code ? `code=${spawnErr.code}` : null,
|
||
spawnErr?.errno ? `errno=${spawnErr.errno}` : null,
|
||
spawnErr?.syscall ? `syscall=${spawnErr.syscall}` : null,
|
||
spawnErr?.path ? `path=${spawnErr.path}` : null,
|
||
spawnErr?.spawnargs
|
||
? `spawnargs=${JSON.stringify(spawnErr.spawnargs)}`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
|
||
logger.error(
|
||
`Failed to spawn process for app ${appId}. Command="${command}", CWD="${appPath}", ${details}\nSTDERR:\n${
|
||
errorOutput || "(empty)"
|
||
}`,
|
||
);
|
||
|
||
throw new Error(
|
||
`Failed to spawn process for app ${appId}.
|
||
Error output:
|
||
${errorOutput || "(empty)"}
|
||
Details: ${details || "n/a"}
|
||
`,
|
||
);
|
||
}
|
||
|
||
// Increment the counter and store the process reference with its ID
|
||
const currentProcessId = processCounter.increment();
|
||
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());
|
||
logger.debug(
|
||
`App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`,
|
||
);
|
||
|
||
// This is a hacky heuristic to pick up when drizzle is asking for user
|
||
// to select from one of a few choices. We automatically pick the first
|
||
// option because it's usually a good default choice. We guard this with
|
||
// isNeon because: 1) only Neon apps (for the official Dyad templates) should
|
||
// 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`);
|
||
logger.info(
|
||
`App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`,
|
||
);
|
||
}
|
||
|
||
// Check if this is an interactive prompt requiring user input
|
||
const inputRequestPattern = /\s*›\s*\([yY]\/[nN]\)\s*$/;
|
||
const isInputRequest = inputRequestPattern.test(message);
|
||
if (isInputRequest) {
|
||
// Send special input-requested event for interactive prompts
|
||
safeSend(event.sender, "app:output", {
|
||
type: "input-requested",
|
||
message,
|
||
appId,
|
||
});
|
||
} else {
|
||
// Normal stdout handling
|
||
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,
|
||
});
|
||
},
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
spawnedProcess.stderr?.on("data", (data) => {
|
||
const message = util.stripVTControlCharacters(data.toString());
|
||
logger.error(
|
||
`App ${appId} (PID: ${spawnedProcess.pid}) stderr: ${message}`,
|
||
);
|
||
safeSend(event.sender, "app:output", {
|
||
type: "stderr",
|
||
message,
|
||
appId,
|
||
});
|
||
});
|
||
|
||
// Handle process exit/close
|
||
spawnedProcess.on("close", (code, signal) => {
|
||
logger.log(
|
||
`App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`,
|
||
);
|
||
removeAppIfCurrentProcess(appId, spawnedProcess);
|
||
});
|
||
|
||
// Handle errors during process lifecycle (e.g., command not found)
|
||
spawnedProcess.on("error", (err) => {
|
||
logger.error(
|
||
`Error in app ${appId} (PID: ${spawnedProcess.pid}) process: ${err.message}`,
|
||
);
|
||
removeAppIfCurrentProcess(appId, spawnedProcess);
|
||
// 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.
|
||
});
|
||
}
|
||
|
||
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 = "";
|
||
let spawnErr: any = null;
|
||
process.stderr?.on("data", (data) => (errorOutput += data.toString()));
|
||
await new Promise<void>((resolve) => {
|
||
process.once("error", (err) => {
|
||
spawnErr = err;
|
||
resolve();
|
||
});
|
||
}); // Wait for error event
|
||
|
||
const details = [
|
||
spawnErr?.message ? `message=${spawnErr.message}` : null,
|
||
spawnErr?.code ? `code=${spawnErr.code}` : null,
|
||
spawnErr?.errno ? `errno=${spawnErr.errno}` : null,
|
||
spawnErr?.syscall ? `syscall=${spawnErr.syscall}` : null,
|
||
spawnErr?.path ? `path=${spawnErr.path}` : null,
|
||
spawnErr?.spawnargs
|
||
? `spawnargs=${JSON.stringify(spawnErr.spawnargs)}`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
|
||
logger.error(
|
||
`Failed to spawn Docker container for app ${appId}. ${details}\nSTDERR:\n${
|
||
errorOutput || "(empty)"
|
||
}`,
|
||
);
|
||
|
||
throw new Error(
|
||
`Failed to spawn Docker container for app ${appId}.
|
||
Details: ${details || "n/a"}
|
||
STDERR:
|
||
${errorOutput || "(empty)"}`,
|
||
);
|
||
}
|
||
|
||
// 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 {
|
||
await killPort(port, "tcp");
|
||
} catch {
|
||
// Ignore if nothing was running on that port
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
app.quit();
|
||
});
|
||
|
||
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,
|
||
installCommand: originalApp.installCommand,
|
||
startCommand: originalApp.startCommand,
|
||
})
|
||
.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);
|
||
// Normalize the path to use forward slashes so file tree (UI)
|
||
// can parse it more consistently across platforms.
|
||
files = files.map((path) => normalizePath(path));
|
||
} 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.supabaseParentProjectId || app.supabaseProjectId,
|
||
);
|
||
}
|
||
|
||
let vercelTeamSlug: string | null = null;
|
||
if (app.vercelTeamId) {
|
||
vercelTeamSlug = await getVercelTeamSlug(app.vercelTeamId);
|
||
}
|
||
|
||
return {
|
||
...app,
|
||
files,
|
||
supabaseProjectName,
|
||
vercelTeamSlug,
|
||
};
|
||
});
|
||
|
||
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 {
|
||
// There may have been a previous run that left a process on port 32100.
|
||
await cleanUpPort(32100);
|
||
await executeApp({
|
||
appPath,
|
||
appId,
|
||
event,
|
||
isNeon: !!app.neonProjectId,
|
||
installCommand: app.installCommand,
|
||
startCommand: app.startCommand,
|
||
});
|
||
|
||
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 {
|
||
await stopAppByInfo(appId, appInfo);
|
||
|
||
// 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 { processId } = appInfo;
|
||
logger.log(
|
||
`Stopping app ${appId} (processId ${processId}) before restart`,
|
||
);
|
||
await stopAppByInfo(appId, appInfo);
|
||
} else {
|
||
logger.log(`App ${appId} not running. Proceeding to start.`);
|
||
}
|
||
|
||
// 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({
|
||
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 settings = readSettings();
|
||
const runtimeMode = settings.runtimeMode2 ?? "host";
|
||
|
||
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}`);
|
||
}
|
||
|
||
// 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(
|
||
`Executing app ${appId} in path ${app.path} after restart request`,
|
||
); // Adjusted log
|
||
|
||
await executeApp({
|
||
appPath,
|
||
appId,
|
||
event,
|
||
isNeon: !!app.neonProjectId,
|
||
installCommand: app.installCommand,
|
||
startCommand: app.startCommand,
|
||
}); // 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<EditAppFileReturnType> => {
|
||
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 (app.neonProjectId && app.neonDevelopmentBranchId) {
|
||
try {
|
||
await storeDbTimestampAtCurrentVersion({
|
||
appId: app.id,
|
||
});
|
||
} catch (error) {
|
||
logger.error(
|
||
"Error storing Neon timestamp at current version:",
|
||
error,
|
||
);
|
||
throw new Error(
|
||
"Could not store Neon timestamp at current version; database versioning functionality is not working: " +
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 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}`,
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
logger.error(`Error writing file ${filePath} for app ${appId}:`, error);
|
||
throw new Error(`Failed to write file: ${error.message}`);
|
||
}
|
||
|
||
if (isServerFunction(filePath) && app.supabaseProjectId) {
|
||
try {
|
||
await deploySupabaseFunctions({
|
||
supabaseProjectId: app.supabaseProjectId,
|
||
functionName: path.basename(path.dirname(filePath)),
|
||
content: content,
|
||
});
|
||
} catch (error) {
|
||
logger.error(`Error deploying Supabase function ${filePath}:`, error);
|
||
return {
|
||
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
|
||
};
|
||
}
|
||
}
|
||
return {};
|
||
},
|
||
);
|
||
|
||
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 stopAppByInfo(appId, appInfo);
|
||
} catch (error: any) {
|
||
logger.error(`Error stopping app ${appId} before deletion:`, error); // Adjusted log
|
||
// Continue with deletion even if stopping fails
|
||
}
|
||
}
|
||
|
||
// Delete app from database
|
||
try {
|
||
await db.delete(apps).where(eq(apps.id, appId));
|
||
// Note: Associated chats will cascade delete
|
||
} catch (error: any) {
|
||
logger.error(`Error deleting app ${appId} from database:`, error);
|
||
throw new Error(
|
||
`Failed to delete app from database: ${error.message}`,
|
||
);
|
||
}
|
||
|
||
// 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(
|
||
`App deleted from database, but failed to delete app files. Please delete app files from ${appPath} manually.\n\nError: ${error.message}`,
|
||
);
|
||
}
|
||
});
|
||
},
|
||
);
|
||
|
||
ipcMain.handle(
|
||
"add-to-favorite",
|
||
async (
|
||
_,
|
||
{ appId }: { appId: number },
|
||
): Promise<{ isFavorite: boolean }> => {
|
||
return withLock(appId, async () => {
|
||
try {
|
||
// Fetch the current isFavorite value
|
||
const result = await db
|
||
.select({ isFavorite: apps.isFavorite })
|
||
.from(apps)
|
||
.where(eq(apps.id, appId))
|
||
.limit(1);
|
||
|
||
if (result.length === 0) {
|
||
throw new Error(`App with ID ${appId} not found.`);
|
||
}
|
||
|
||
const currentIsFavorite = result[0].isFavorite;
|
||
|
||
// Toggle the isFavorite value
|
||
const updated = await db
|
||
.update(apps)
|
||
.set({ isFavorite: !currentIsFavorite })
|
||
.where(eq(apps.id, appId))
|
||
.returning({ isFavorite: apps.isFavorite });
|
||
|
||
if (updated.length === 0) {
|
||
throw new Error(
|
||
`Failed to update favorite status for app ID ${appId}.`,
|
||
);
|
||
}
|
||
|
||
// Return the updated isFavorite value
|
||
return { isFavorite: updated[0].isFavorite };
|
||
} catch (error: any) {
|
||
logger.error(
|
||
`Error in add-to-favorite handler for app ID ${appId}:`,
|
||
error,
|
||
);
|
||
throw new Error(`Failed to toggle favorite status: ${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 stopAppByInfo(appId, appInfo);
|
||
} 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 stopAppByInfo(appId, appInfo);
|
||
} 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}`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
handle(
|
||
"respond-to-app-input",
|
||
async (_, { appId, response }: RespondToAppInputParams) => {
|
||
if (response !== "y" && response !== "n") {
|
||
throw new Error(`Invalid response: ${response}`);
|
||
}
|
||
const appInfo = runningApps.get(appId);
|
||
|
||
if (!appInfo) {
|
||
throw new Error(`App ${appId} is not running`);
|
||
}
|
||
|
||
const { process } = appInfo;
|
||
|
||
if (!process.stdin) {
|
||
throw new Error(`App ${appId} process has no stdin available`);
|
||
}
|
||
|
||
try {
|
||
// Write the response to stdin with a newline
|
||
process.stdin.write(`${response}\n`);
|
||
logger.debug(`Sent response '${response}' to app ${appId} stdin`);
|
||
} catch (error: any) {
|
||
logger.error(`Error sending response to app ${appId}:`, error);
|
||
throw new Error(`Failed to send response to app: ${error.message}`);
|
||
}
|
||
},
|
||
);
|
||
|
||
handle(
|
||
"search-app",
|
||
async (_, searchQuery: string): Promise<AppSearchResult[]> => {
|
||
// Use parameterized query to prevent SQL injection
|
||
const pattern = `%${searchQuery.replace(/[%_]/g, "\\$&")}%`;
|
||
|
||
// 1) Apps whose name matches
|
||
const appNameMatches = await db
|
||
.select({
|
||
id: apps.id,
|
||
name: apps.name,
|
||
createdAt: apps.createdAt,
|
||
})
|
||
.from(apps)
|
||
.where(like(apps.name, pattern))
|
||
.orderBy(desc(apps.createdAt));
|
||
|
||
const appNameMatchesResult: AppSearchResult[] = appNameMatches.map(
|
||
(r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
createdAt: r.createdAt,
|
||
matchedChatTitle: null,
|
||
matchedChatMessage: null,
|
||
}),
|
||
);
|
||
|
||
// 2) Apps whose chat title matches
|
||
const chatTitleMatches = await db
|
||
.select({
|
||
id: apps.id,
|
||
name: apps.name,
|
||
createdAt: apps.createdAt,
|
||
matchedChatTitle: chats.title,
|
||
})
|
||
.from(apps)
|
||
.innerJoin(chats, eq(apps.id, chats.appId))
|
||
.where(like(chats.title, pattern))
|
||
.orderBy(desc(apps.createdAt));
|
||
|
||
const chatTitleMatchesResult: AppSearchResult[] = chatTitleMatches.map(
|
||
(r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
createdAt: r.createdAt,
|
||
matchedChatTitle: r.matchedChatTitle,
|
||
matchedChatMessage: null,
|
||
}),
|
||
);
|
||
|
||
// 3) Apps whose chat message content matches
|
||
const chatMessageMatches = await db
|
||
.select({
|
||
id: apps.id,
|
||
name: apps.name,
|
||
createdAt: apps.createdAt,
|
||
matchedChatTitle: chats.title,
|
||
matchedChatMessage: messages.content,
|
||
})
|
||
.from(apps)
|
||
.innerJoin(chats, eq(apps.id, chats.appId))
|
||
.innerJoin(messages, eq(chats.id, messages.chatId))
|
||
.where(like(messages.content, pattern))
|
||
.orderBy(desc(apps.createdAt));
|
||
|
||
// Flatten and dedupe by app id
|
||
const allMatches: AppSearchResult[] = [
|
||
...appNameMatchesResult,
|
||
...chatTitleMatchesResult,
|
||
...chatMessageMatches,
|
||
];
|
||
const uniqueApps = Array.from(
|
||
new Map(allMatches.map((app) => [app.id, app])).values(),
|
||
);
|
||
|
||
// Sort newest apps first
|
||
uniqueApps.sort(
|
||
(a, b) =>
|
||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||
);
|
||
|
||
return uniqueApps;
|
||
},
|
||
);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|