diff --git a/src/components/SetupBanner.tsx b/src/components/SetupBanner.tsx index 3a7f050..08de291 100644 --- a/src/components/SetupBanner.tsx +++ b/src/components/SetupBanner.tsx @@ -1,39 +1,229 @@ import { useNavigate } from "@tanstack/react-router"; -import { ChevronRight, GiftIcon, Sparkles } from "lucide-react"; +import { + ChevronRight, + GiftIcon, + Sparkles, + CheckCircle, + AlertCircle, + XCircle, + Loader2, +} from "lucide-react"; import { providerSettingsRoute } from "@/routes/settings/providers/$provider"; +import { useSettings } from "@/hooks/useSettings"; +import { useState, useEffect, useCallback } from "react"; +import { IpcClient } from "@/ipc/ipc_client"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { showError } from "@/lib/toast"; export function SetupBanner() { const navigate = useNavigate(); + const { isAnyProviderSetup } = useSettings(); + const [nodeVersion, setNodeVersion] = useState(null); + const [nodeCheckError, setNodeCheckError] = useState(false); + const [nodeInstallError, setNodeInstallError] = useState(null); + const [nodeInstallLoading, setNodeInstallLoading] = useState(false); + const checkNode = useCallback(async () => { + try { + setNodeCheckError(false); + const status = await IpcClient.getInstance().getNodejsStatus(); + setNodeVersion(status.nodeVersion); + } catch (error) { + console.error("Failed to check Node.js status:", error); + setNodeVersion(null); + setNodeCheckError(true); + } + }, [setNodeVersion, setNodeCheckError]); - const handleSetupClick = () => { + useEffect(() => { + checkNode(); + }, [checkNode]); + + const handleAiSetupClick = () => { navigate({ to: providerSettingsRoute.id, params: { provider: "google" }, }); }; + const handleNodeInstallClick = async () => { + setNodeInstallLoading(true); + try { + const result = await IpcClient.getInstance().installNode(); + if (!result.success) { + showError(result.errorMessage); + setNodeInstallError(result.errorMessage || "Unknown error"); + } else { + setNodeVersion(result.version); + } + } catch (error) { + showError("Failed to install Node.js. " + (error as Error).message); + setNodeInstallError( + "Failed to install Node.js. " + (error as Error).message + ); + } finally { + setNodeInstallLoading(false); + } + }; + + const isNodeSetupComplete = !!nodeVersion; + const isAiProviderSetup = isAnyProviderSetup(); + + const itemsNeedAction: string[] = []; + if (!isNodeSetupComplete) itemsNeedAction.push("node-setup"); + if (isNodeSetupComplete && !isAiProviderSetup) + itemsNeedAction.push("ai-setup"); + + if (itemsNeedAction.length === 0) { + return null; + } + + const bannerClasses = cn( + "w-full mb-8 border rounded-xl shadow-sm overflow-hidden", + "border-zinc-200 dark:border-zinc-700" + ); + + const getStatusIcon = (isComplete: boolean, hasError: boolean = false) => { + if (hasError) { + return ; + } + return isComplete ? ( + + ) : ( + + ); + }; + return ( -
-
-
-
- -
-
-

- Setup your AI API access -

-

- - Use Google Gemini for free +

+ + + +
+
+ {getStatusIcon(isNodeSetupComplete, nodeCheckError)} + + 1. Check Node.js Runtime + +
+
+
+ + {nodeInstallError && ( +

+ {nodeInstallError} +

+ )} + {nodeCheckError ? ( +

+ Error checking Node.js status. Please ensure node.js are + installed correctly and accessible in your system's PATH. +

+ ) : isNodeSetupComplete ? ( +

Node.js ({nodeVersion}) installed.

+ ) : ( +
+

+ Node.js is required to run apps locally. +

+ +
+ )} +
+
+ + + !isNodeSetupComplete && e.preventDefault()} + > +
+
+ {getStatusIcon(isAiProviderSetup)} + + 2. Setup AI Model Access + +
+
+
+ +

+ Connect your preferred AI provider to start generating code.

-
-
- -
+
+ isNodeSetupComplete && e.key === "Enter" && handleAiSetupClick() + } + > +
+
+
+ +
+
+

+ Setup Google Gemini API Key +

+

+ + Use Google Gemini for free +

+
+
+ +
+
+ + +
); } diff --git a/src/components/SetupRuntimeFlow.tsx b/src/components/SetupRuntimeFlow.tsx deleted file mode 100644 index 02f790b..0000000 --- a/src/components/SetupRuntimeFlow.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { IpcClient } from "@/ipc/ipc_client"; -import { useSettings } from "@/hooks/useSettings"; // Assuming useSettings provides a refresh function -import { RuntimeMode } from "@/lib/schemas"; -import { ExternalLink } from "lucide-react"; - -interface SetupRuntimeFlowProps { - hideIntroText?: boolean; -} - -export function SetupRuntimeFlow({ hideIntroText }: SetupRuntimeFlowProps) { - const [isLoading, setIsLoading] = useState( - null - ); - const [showNodeInstallPrompt, setShowNodeInstallPrompt] = useState(false); - const [nodeVersion, setNodeVersion] = useState(null); - const [npmVersion, setNpmVersion] = useState(null); - const [downloadClicked, setDownloadClicked] = useState(false); - const { updateSettings } = useSettings(); - - // Pre-check Node.js status on component mount (optional but good UX) - useEffect(() => { - const checkNode = async () => { - try { - const status = await IpcClient.getInstance().getNodejsStatus(); - setNodeVersion(status.nodeVersion); - setNpmVersion(status.npmVersion); - } catch (error) { - console.error("Failed to check Node.js status:", error); - // Assume not installed if check fails - setNodeVersion(null); - setNpmVersion(null); - } - }; - checkNode(); - }, []); - - const handleSelect = async (mode: RuntimeMode) => { - if (isLoading) return; // Prevent double clicks - - setIsLoading(mode); - try { - await updateSettings({ runtimeMode: mode }); - // Component likely unmounts on success - } catch (error) { - console.error("Failed to set runtime mode:", error); - alert( - `Error setting runtime mode: ${ - error instanceof Error ? error.message : String(error) - }` - ); - setIsLoading(null); // Reset loading state on error - } - }; - - const handleLocalNodeClick = async () => { - if (isLoading) return; - - setIsLoading("check"); - try { - if (nodeVersion && npmVersion) { - // Node and npm found, proceed directly - handleSelect("local-node"); - } else { - // Node or npm not found, show prompt - setShowNodeInstallPrompt(true); - setIsLoading(null); - } - } catch (error) { - console.error("Failed to check Node.js status on click:", error); - // Show prompt if check fails - setShowNodeInstallPrompt(true); - setIsLoading(null); - } - }; - - return ( -
- {!hideIntroText && ( - <> -

- Welcome to Dyad -

-

- Before you start building, choose how your apps will run.
- Don’t worry — you can change this later anytime. -

- - )} - -
- - - - ) : ( - - )} -
- )} - -

- Warning: this will run AI-generated code directly on your - computer, which could put your computer at risk. -

-
-
- - - - ); -} diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index e4d078f..53aacbc 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -209,43 +209,6 @@ async function executeAppLocalNode({ }); } -function checkCommandExists(command: string): Promise { - return new Promise((resolve) => { - let output = ""; - const process = spawn(command, ["--version"], { - shell: true, - stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr - }); - - process.stdout?.on("data", (data) => { - output += data.toString(); - }); - - process.stderr?.on("data", (data) => { - // Log stderr but don't treat it as a failure unless the exit code is non-zero - console.warn( - `Stderr from "${command} --version": ${data.toString().trim()}` - ); - }); - - process.on("error", (error) => { - console.error(`Error executing command "${command}":`, error.message); - resolve(null); // Command execution failed - }); - - process.on("close", (code) => { - if (code === 0) { - resolve(output.trim()); // Command succeeded, return trimmed output - } else { - console.error( - `Command "${command} --version" failed with code ${code}` - ); - resolve(null); // Command failed - } - }); - }); -} - // Helper to kill process on a specific port (cross-platform, using kill-port) async function killProcessOnPort(port: number): Promise { try { @@ -256,21 +219,6 @@ async function killProcessOnPort(port: number): Promise { } export function registerAppHandlers() { - ipcMain.handle( - "nodejs-status", - async (): Promise<{ - nodeVersion: string | null; - npmVersion: string | null; - }> => { - // Run checks in parallel - const [nodeVersion, npmVersion] = await Promise.all([ - checkCommandExists("node"), - checkCommandExists("npm"), - ]); - return { nodeVersion, npmVersion }; - } - ); - ipcMain.handle( "get-app-sandbox-config", async (_, { appId }: { appId: number }): Promise => { diff --git a/src/ipc/handlers/node_handlers.ts b/src/ipc/handlers/node_handlers.ts new file mode 100644 index 0000000..001cdb5 --- /dev/null +++ b/src/ipc/handlers/node_handlers.ts @@ -0,0 +1,136 @@ +import { ipcMain } from "electron"; +import { spawn } from "child_process"; +import { platform } from "os"; +import { InstallNodeResult } from "../ipc_types"; +type ShellResult = + | { + success: true; + output: string; + } + | { + success: false; + errorMessage: string; + }; + +function runShell(command: string): Promise { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + const process = spawn(command, { + shell: true, + stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr + }); + + process.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + process.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + process.on("error", (error) => { + console.error(`Error executing command "${command}":`, error.message); + resolve({ success: false, errorMessage: error.message }); + }); + + process.on("close", (code) => { + if (code === 0) { + resolve({ success: true, output: stdout.trim() }); + } else { + const errorMessage = + stderr.trim() || `Command failed with code ${code}`; + console.error( + `Command "${command}" failed with code ${code}: ${stderr.trim()}` + ); + resolve({ success: false, errorMessage }); + } + }); + }); +} + +function checkCommandExists(command: string): Promise { + return new Promise((resolve) => { + let output = ""; + const process = spawn(command, ["--version"], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr + }); + + process.stdout?.on("data", (data) => { + output += data.toString(); + }); + + process.stderr?.on("data", (data) => { + // Log stderr but don't treat it as a failure unless the exit code is non-zero + console.warn( + `Stderr from "${command} --version": ${data.toString().trim()}` + ); + }); + + process.on("error", (error) => { + console.error(`Error executing command "${command}":`, error.message); + resolve(null); // Command execution failed + }); + + process.on("close", (code) => { + if (code === 0) { + resolve(output.trim()); // Command succeeded, return trimmed output + } else { + console.error( + `Command "${command} --version" failed with code ${code}` + ); + resolve(null); // Command failed + } + }); + }); +} + +export function registerNodeHandlers() { + ipcMain.handle( + "nodejs-status", + async (): Promise<{ + nodeVersion: string | null; + npmVersion: string | null; + }> => { + // Run checks in parallel + const [nodeVersion, npmVersion] = await Promise.all([ + checkCommandExists("node"), + checkCommandExists("npm"), + ]); + return { nodeVersion, npmVersion }; + } + ); + + ipcMain.handle("install-node", async (): Promise => { + console.log("Installing Node.js..."); + if (platform() === "win32") { + let result = await runShell("winget install Volta.Volta"); + if (!result.success) { + return { success: false, errorMessage: result.errorMessage }; + } + } else { + let result = await runShell("curl https://get.volta.sh | bash"); + if (!result.success) { + return { success: false, errorMessage: result.errorMessage }; + } + } + console.log("Installed Volta"); + + process.env.PATH = ["~/.volta/bin", process.env.PATH].join(":"); + console.log("Updated PATH"); + let result = await runShell("volta install node"); + if (!result.success) { + return { success: false, errorMessage: result.errorMessage }; + } + console.log("Installed Node.js (via Volta)"); + + result = await runShell("node --version"); + if (!result.success) { + return { success: false, errorMessage: result.errorMessage }; + } + console.log("Node.js is setup with version"); + + return { success: true, version: result.output }; + }); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 65addae..d34274b 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -13,6 +13,7 @@ import type { ChatStreamParams, CreateAppParams, CreateAppResult, + InstallNodeResult, ListAppsResponse, SandboxConfig, Version, @@ -520,6 +521,17 @@ export class IpcClient { } } + // Install Node.js and npm + public async installNode(): Promise { + try { + const result = await this.ipcRenderer.invoke("install-node"); + return result; + } catch (error) { + showError(error); + throw error; + } + } + // --- GitHub Device Flow --- public startGithubDeviceFlow(appId: number | null): void { this.ipcRenderer.invoke("github:start-flow", { appId }); diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index a56157f..b969bb7 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -5,6 +5,7 @@ import { registerSettingsHandlers } from "./handlers/settings_handlers"; import { registerShellHandlers } from "./handlers/shell_handler"; import { registerDependencyHandlers } from "./handlers/dependency_handlers"; import { registerGithubHandlers } from "./handlers/github_handlers"; +import { registerNodeHandlers } from "./handlers/node_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -15,4 +16,5 @@ export function registerIpcHandlers() { registerShellHandlers(); registerDependencyHandlers(); registerGithubHandlers(); + registerNodeHandlers(); } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index e24f26c..49769ba 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -65,3 +65,13 @@ export interface SandboxConfig { dependencies: Record; entry: string; } + +export type InstallNodeResult = + | { + success: true; + version: string; + } + | { + success: false; + errorMessage: string; + }; diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index ba36451..042c4ef 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -86,9 +86,11 @@ export type GithubUser = z.infer; export const UserSettingsSchema = z.object({ selectedModel: LargeLanguageModelSchema, providerSettings: z.record(z.string(), ProviderSettingSchema), - runtimeMode: RuntimeModeSchema, githubUser: GithubUserSchema.optional(), githubAccessToken: SecretSchema.optional(), + + // DEPRECATED. + runtimeMode: RuntimeModeSchema.optional(), }); /** diff --git a/src/main/settings.ts b/src/main/settings.ts index 1f07776..74a51f4 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -12,7 +12,6 @@ const DEFAULT_SETTINGS: UserSettings = { provider: "auto", }, providerSettings: {}, - runtimeMode: "unset", }; const SETTINGS_FILE = "user-settings.json"; diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 4bc8908..31f8282 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -11,8 +11,6 @@ import { ChatInput } from "@/components/chat/ChatInput"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; import { useState, useEffect } from "react"; import { useStreamChat } from "@/hooks/useStreamChat"; -import { SetupRuntimeFlow } from "@/components/SetupRuntimeFlow"; -import { RuntimeMode } from "@/lib/schemas"; export default function HomePage() { const [inputValue, setInputValue] = useAtom(chatInputValueAtom); @@ -83,12 +81,6 @@ export default function HomePage() { ); } - // Runtime Setup Flow - // Render this only if runtimeMode is not set in settings - if (settings?.runtimeMode === "unset") { - return ; - } - // Main Home Page Content (Rendered only if runtimeMode is set) return (
@@ -96,7 +88,7 @@ export default function HomePage() { Build your dream app - {!isAnyProviderSetup() && } +
diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 2686953..dfe2b2f 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -4,44 +4,14 @@ import { ProviderSettingsGrid } from "@/components/ProviderSettings"; import ConfirmationDialog from "@/components/ConfirmationDialog"; import { IpcClient } from "@/ipc/ipc_client"; import { showSuccess, showError } from "@/lib/toast"; -import { useSettings } from "@/hooks/useSettings"; -import { RuntimeMode } from "@/lib/schemas"; -import { Button } from "@/components/ui/button"; -import { ExternalLink } from "lucide-react"; export default function SettingsPage() { const { theme, setTheme } = useTheme(); - const { - settings, - updateSettings, - loading: settingsLoading, - error: settingsError, - } = useSettings(); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); - const [isUpdatingRuntime, setIsUpdatingRuntime] = useState< - RuntimeMode | "check" | null - >(null); - const [showNodeInstallPrompt, setShowNodeInstallPrompt] = useState(false); - const [nodeVersion, setNodeVersion] = useState(null); - const [npmVersion, setNpmVersion] = useState(null); - const [downloadClicked, setDownloadClicked] = useState(false); const [appVersion, setAppVersion] = useState(null); useEffect(() => { - const checkNode = async () => { - try { - const status = await IpcClient.getInstance().getNodejsStatus(); - setNodeVersion(status.nodeVersion); - setNpmVersion(status.npmVersion); - } catch (error) { - console.error("Failed to check Node.js status:", error); - setNodeVersion(null); - setNpmVersion(null); - } - }; - checkNode(); - // Fetch app version const fetchVersion = async () => { try { @@ -75,59 +45,6 @@ export default function SettingsPage() { } }; - const handleRuntimeChange = async (newMode: RuntimeMode) => { - if (newMode === settings?.runtimeMode || isUpdatingRuntime) return; - setIsUpdatingRuntime(newMode); - setShowNodeInstallPrompt(false); - setDownloadClicked(false); - - try { - await updateSettings({ runtimeMode: newMode }); - showSuccess("Runtime mode updated successfully."); - } catch (error) { - console.error("Error updating runtime mode:", error); - showError( - error instanceof Error - ? error.message - : "Failed to update runtime mode." - ); - } finally { - setIsUpdatingRuntime(null); - } - }; - - const handleLocalNodeClick = async () => { - if (isUpdatingRuntime) return; - - if (nodeVersion && npmVersion) { - handleRuntimeChange("local-node"); - return; - } - - setIsUpdatingRuntime("check"); - try { - const status = await IpcClient.getInstance().getNodejsStatus(); - setNodeVersion(status.nodeVersion); - setNpmVersion(status.npmVersion); - if (status.nodeVersion && status.npmVersion) { - handleRuntimeChange("local-node"); - } else { - setShowNodeInstallPrompt(true); - setIsUpdatingRuntime(null); - } - } catch (error) { - console.error("Failed to check Node.js status on click:", error); - setShowNodeInstallPrompt(true); - setIsUpdatingRuntime(null); - showError("Could not verify Node.js installation."); - } - }; - - const currentRuntimeMode = - settings?.runtimeMode && settings.runtimeMode !== "unset" - ? settings.runtimeMode - : "web-sandbox"; - return (
@@ -178,251 +95,6 @@ export default function SettingsPage() {
- {/* Runtime Environment Section */} -
-

- Runtime Environment -

-

- Choose how app code is executed. This affects performance, - security, and capabilities. -

- - {settingsLoading ? ( -
- {/* Inline SVG Spinner */} - - - - -
- ) : settingsError ? ( -

- Error loading runtime settings: {settingsError.message} -

- ) : ( -
- - - - ) : ( -

- Node.js download page opened. -

- )} - -
- )} - -

- Warning: This mode runs AI-generated code directly on - your computer, which can be risky. Only use code from - trusted sources or review it carefully. -

-
-
- -
- )} - -
diff --git a/src/preload.ts b/src/preload.ts index 3ed2214..a0ba4fd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -32,6 +32,7 @@ const validInvokeChannels = [ "open-external-url", "reset-all", "nodejs-status", + "install-node", "github:start-flow", "github:is-repo-available", "github:create-repo",