Neon / portal template support (#713)
TODOs: - [x] Do restart when checkout / restore if there is a DB - [x] List all branches (branch id, name, date) - [x] Allow checking out versions with no DB - [x] safeguard to never delete main branches - [x] create app hook for neon template - [x] weird UX with connector on configure panel - [x] tiny neon logo in connector - [x] deploy to vercel - [x] build forgot password page - [x] what about email setup - [x] lots of imgix errors - [x] edit file - db snapshot - [x] DYAD_DISABLE_DB_PUSH - [ ] update portal doc - [x] switch preview branch to be read-only endpoint - [x] disable supabase sys prompt if neon is enabled - [ ] https://payloadcms.com/docs/upload/storage-adapters - [x] need to use main branch... Phase 2? - [x] generate DB migrations
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
RenameBranchParams,
|
||||
CopyAppParams,
|
||||
EditAppFileReturnType,
|
||||
RespondToAppInputParams,
|
||||
} from "../ipc_types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -47,6 +48,7 @@ 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";
|
||||
|
||||
async function copyDir(
|
||||
source: string,
|
||||
@@ -80,28 +82,32 @@ async function executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
event, // Keep event for local-node case
|
||||
isNeon,
|
||||
}: {
|
||||
appPath: string;
|
||||
appId: number;
|
||||
event: Electron.IpcMainInvokeEvent;
|
||||
isNeon: boolean;
|
||||
}): Promise<void> {
|
||||
if (proxyWorker) {
|
||||
proxyWorker.terminate();
|
||||
proxyWorker = null;
|
||||
}
|
||||
await executeAppLocalNode({ appPath, appId, event });
|
||||
await executeAppLocalNode({ appPath, appId, event, isNeon });
|
||||
}
|
||||
|
||||
async function executeAppLocalNode({
|
||||
appPath,
|
||||
appId,
|
||||
event,
|
||||
isNeon,
|
||||
}: {
|
||||
appPath: string;
|
||||
appId: number;
|
||||
event: Electron.IpcMainInvokeEvent;
|
||||
isNeon: boolean;
|
||||
}): Promise<void> {
|
||||
const process = spawn(
|
||||
const spawnedProcess = spawn(
|
||||
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)",
|
||||
[],
|
||||
{
|
||||
@@ -113,11 +119,11 @@ async function executeAppLocalNode({
|
||||
);
|
||||
|
||||
// Check if process spawned correctly
|
||||
if (!process.pid) {
|
||||
if (!spawnedProcess.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
|
||||
spawnedProcess.stderr?.on("data", (data) => (errorOutput += data));
|
||||
await new Promise((resolve) => spawnedProcess.on("error", resolve)); // Wait for error event
|
||||
throw new Error(
|
||||
`Failed to spawn process for app ${appId}. Error: ${
|
||||
errorOutput || "Unknown spawn error"
|
||||
@@ -127,35 +133,69 @@ async function executeAppLocalNode({
|
||||
|
||||
// Increment the counter and store the process reference with its ID
|
||||
const currentProcessId = processCounter.increment();
|
||||
runningApps.set(appId, { process, processId: currentProcessId });
|
||||
runningApps.set(appId, {
|
||||
process: spawnedProcess,
|
||||
processId: currentProcessId,
|
||||
});
|
||||
|
||||
// Log output
|
||||
process.stdout?.on("data", async (data) => {
|
||||
spawnedProcess.stdout?.on("data", async (data) => {
|
||||
const message = util.stripVTControlCharacters(data.toString());
|
||||
logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`);
|
||||
logger.debug(
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) stdout: ${message}`,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.stderr?.on("data", (data) => {
|
||||
spawnedProcess.stderr?.on("data", (data) => {
|
||||
const message = util.stripVTControlCharacters(data.toString());
|
||||
logger.error(`App ${appId} (PID: ${process.pid}) stderr: ${message}`);
|
||||
logger.error(
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) stderr: ${message}`,
|
||||
);
|
||||
safeSend(event.sender, "app:output", {
|
||||
type: "stderr",
|
||||
message,
|
||||
@@ -164,19 +204,19 @@ async function executeAppLocalNode({
|
||||
});
|
||||
|
||||
// Handle process exit/close
|
||||
process.on("close", (code, signal) => {
|
||||
spawnedProcess.on("close", (code, signal) => {
|
||||
logger.log(
|
||||
`App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.`,
|
||||
`App ${appId} (PID: ${spawnedProcess.pid}) process closed with code ${code}, signal ${signal}.`,
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
removeAppIfCurrentProcess(appId, spawnedProcess);
|
||||
});
|
||||
|
||||
// Handle errors during process lifecycle (e.g., command not found)
|
||||
process.on("error", (err) => {
|
||||
spawnedProcess.on("error", (err) => {
|
||||
logger.error(
|
||||
`Error in app ${appId} (PID: ${process.pid}) process: ${err.message}`,
|
||||
`Error in app ${appId} (PID: ${spawnedProcess.pid}) process: ${err.message}`,
|
||||
);
|
||||
removeAppIfCurrentProcess(appId, process);
|
||||
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.
|
||||
});
|
||||
@@ -466,7 +506,12 @@ export function registerAppHandlers() {
|
||||
try {
|
||||
// Kill any orphaned process on port 32100 (in case previous run left it)
|
||||
await killProcessOnPort(32100);
|
||||
await executeApp({ appPath, appId, event });
|
||||
await executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
event,
|
||||
isNeon: !!app.neonProjectId,
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (error: any) {
|
||||
@@ -596,7 +641,12 @@ export function registerAppHandlers() {
|
||||
`Executing app ${appId} in path ${app.path} after restart request`,
|
||||
); // Adjusted log
|
||||
|
||||
await executeApp({ appPath, appId, event }); // This will handle starting either mode
|
||||
await executeApp({
|
||||
appPath,
|
||||
appId,
|
||||
event,
|
||||
isNeon: !!app.neonProjectId,
|
||||
}); // This will handle starting either mode
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -633,6 +683,23 @@ export function registerAppHandlers() {
|
||||
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 });
|
||||
@@ -968,4 +1035,33 @@ export function registerAppHandlers() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user