diff --git a/e2e-tests/nodejs_path_configuration.spec.ts b/e2e-tests/nodejs_path_configuration.spec.ts new file mode 100644 index 0000000..b4364e5 --- /dev/null +++ b/e2e-tests/nodejs_path_configuration.spec.ts @@ -0,0 +1,58 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test.describe("Node.js Path Configuration", () => { + test("should browse and set custom Node.js path", async ({ po }) => { + await po.setUp(); + await po.goToSettingsTab(); + + const browseButton = po.page.getByRole("button", { + name: /Browse for Node\.js/i, + }); + await browseButton.click(); + + // Should show selecting state + await expect( + po.page.getByRole("button", { name: /Selecting\.\.\./i }), + ).toBeVisible(); + }); + + test("should reset custom path to system default", async ({ po }) => { + await po.setUp(); + await po.goToSettingsTab(); + + const resetButton = po.page.getByRole("button", { + name: /Reset to Default/i, + }); + + if (await resetButton.isVisible()) { + await resetButton.click(); + + // Should show system PATH after reset + await expect(po.page.getByText("System PATH:")).toBeVisible(); + } + }); + + test("should show CheckCircle when Node.js is valid", async ({ po }) => { + await po.setUp(); + await po.goToSettingsTab(); + + // Wait for status check + await po.page.waitForTimeout(2000); + + // Target the specific valid status container with CheckCircle + const validStatus = po.page.locator( + "div.flex.items-center.gap-1.text-green-600, div.flex.items-center.gap-1.text-green-400", + ); + + // Skip test if Node.js is not installed + if (!(await validStatus.isVisible())) { + test.skip(); + } + + // If visible, check for CheckCircle icon + await expect(validStatus).toBeVisible(); + const checkIcon = validStatus.locator("svg").first(); + await expect(checkIcon).toBeVisible(); + }); +}); diff --git a/src/components/NodePathSelector.tsx b/src/components/NodePathSelector.tsx new file mode 100644 index 0000000..bf7e206 --- /dev/null +++ b/src/components/NodePathSelector.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useSettings } from "@/hooks/useSettings"; +import { showError, showSuccess } from "@/lib/toast"; +import { IpcClient } from "@/ipc/ipc_client"; +import { FolderOpen, RotateCcw, CheckCircle, AlertCircle } from "lucide-react"; + +export function NodePathSelector() { + const { settings, updateSettings } = useSettings(); + const [isSelectingPath, setIsSelectingPath] = useState(false); + const [nodeStatus, setNodeStatus] = useState<{ + version: string | null; + isValid: boolean; + }>({ + version: null, + isValid: false, + }); + const [isCheckingNode, setIsCheckingNode] = useState(false); + const [systemPath, setSystemPath] = useState("Loading..."); + + // Check Node.js status when component mounts or path changes + useEffect(() => { + checkNodeStatus(); + }, [settings?.customNodePath]); + + const fetchSystemPath = async () => { + try { + const debugInfo = await IpcClient.getInstance().getSystemDebugInfo(); + setSystemPath(debugInfo.nodePath || "System PATH (not available)"); + } catch (err) { + console.error("Failed to fetch system path:", err); + setSystemPath("System PATH (not available)"); + } + }; + + useEffect(() => { + // Fetch system path on mount + fetchSystemPath(); + }, []); + + const checkNodeStatus = async () => { + if (!settings) return; + setIsCheckingNode(true); + try { + const status = await IpcClient.getInstance().getNodejsStatus(); + setNodeStatus({ + version: status.nodeVersion, + isValid: !!status.nodeVersion, + }); + } catch (error) { + console.error("Failed to check Node.js status:", error); + setNodeStatus({ version: null, isValid: false }); + } finally { + setIsCheckingNode(false); + } + }; + const handleSelectNodePath = async () => { + setIsSelectingPath(true); + try { + // Call the IPC method to select folder + const result = await IpcClient.getInstance().selectNodeFolder(); + if (result.path) { + // Save the custom path to settings + await updateSettings({ customNodePath: result.path }); + // Update the environment PATH + await IpcClient.getInstance().reloadEnvPath(); + // Recheck Node.js status + await checkNodeStatus(); + showSuccess("Node.js path updated successfully"); + } else if (result.path === null && result.canceled === false) { + showError( + `Could not find Node.js at the path "${result.selectedPath}"`, + ); + } + } catch (error: any) { + showError(`Failed to set Node.js path: ${error.message}`); + } finally { + setIsSelectingPath(false); + } + }; + const handleResetToDefault = async () => { + try { + // Clear the custom path + await updateSettings({ customNodePath: null }); + // Reload environment to use system PATH + await IpcClient.getInstance().reloadEnvPath(); + // Recheck Node.js status + await fetchSystemPath(); + await checkNodeStatus(); + showSuccess("Reset to system Node.js path"); + } catch (error: any) { + showError(`Failed to reset Node.js path: ${error.message}`); + } + }; + + if (!settings) { + return null; + } + const currentPath = settings.customNodePath || systemPath; + const isCustomPath = !!settings.customNodePath; + return ( +
+
+
+ + + + + {isCustomPath && ( + + )} +
+
+
+
+
+ + {isCustomPath ? "Custom Path:" : "System PATH:"} + + {isCustomPath && ( + + Custom + + )} +
+

+ {currentPath} +

+
+ + {/* Status Indicator */} +
+ {isCheckingNode ? ( +
+ ) : nodeStatus.isValid ? ( +
+ + {nodeStatus.version} +
+ ) : ( +
+ + Not found +
+ )} +
+
+
+ + {/* Help Text */} +
+ {nodeStatus.isValid ? ( +

Node.js is properly configured and ready to use.

+ ) : ( + <> +

+ Select the folder where Node.js is installed if it's not in your + system PATH. +

+ + )} +
+
+
+ ); +} diff --git a/src/components/SetupBanner.tsx b/src/components/SetupBanner.tsx index f796d11..bebb96c 100644 --- a/src/components/SetupBanner.tsx +++ b/src/components/SetupBanner.tsx @@ -8,6 +8,7 @@ import { XCircle, Loader2, Settings, + Folder, } from "lucide-react"; import { providerSettingsRoute } from "@/routes/settings/providers/$provider"; @@ -30,6 +31,8 @@ import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo"; // @ts-ignore import logo from "../../assets/logo.svg"; import { OnboardingBanner } from "./home/OnboardingBanner"; +import { showError } from "@/lib/toast"; +import { useSettings } from "@/hooks/useSettings"; type NodeInstallStep = | "install" @@ -60,6 +63,32 @@ export function SetupBanner() { setNodeCheckError(true); } }, [setNodeSystemInfo, setNodeCheckError]); + const [showManualConfig, setShowManualConfig] = useState(false); + const [isSelectingPath, setIsSelectingPath] = useState(false); + const { updateSettings } = useSettings(); + + // Add handler for manual path selection + const handleManualNodeConfig = useCallback(async () => { + setIsSelectingPath(true); + try { + const result = await IpcClient.getInstance().selectNodeFolder(); + if (result.path) { + await updateSettings({ customNodePath: result.path }); + await IpcClient.getInstance().reloadEnvPath(); + await checkNode(); + setNodeInstallStep("finished-checking"); + setShowManualConfig(false); + } else if (result.path === null && result.canceled === false) { + showError( + `Could not find Node.js at the path "${result.selectedPath}"`, + ); + } + } catch (error) { + showError("Error setting Node.js path:" + error); + } finally { + setIsSelectingPath(false); + } + }, [checkNode]); useEffect(() => { checkNode(); @@ -222,6 +251,38 @@ export function SetupBanner() { handleNodeInstallClick={handleNodeInstallClick} finishNodeInstall={finishNodeInstall} /> + +
+ + + {showManualConfig && ( +
+ +
+ )} +
)} diff --git a/src/ipc/handlers/node_handlers.ts b/src/ipc/handlers/node_handlers.ts index d19aa41..76c9ecb 100644 --- a/src/ipc/handlers/node_handlers.ts +++ b/src/ipc/handlers/node_handlers.ts @@ -1,10 +1,13 @@ -import { ipcMain } from "electron"; +import { ipcMain, dialog } from "electron"; import { execSync } from "child_process"; import { platform, arch } from "os"; import { NodeSystemInfo } from "../ipc_types"; import fixPath from "fix-path"; import { runShellCommand } from "../utils/runShellCommand"; import log from "electron-log"; +import { existsSync } from "fs"; +import { join } from "path"; +import { readSettings } from "../../main/settings"; const logger = log.scope("node_handlers"); @@ -52,6 +55,50 @@ export function registerNodeHandlers() { } else { fixPath(); } + const settings = readSettings(); + if (settings.customNodePath) { + const separator = platform() === "win32" ? ";" : ":"; + process.env.PATH = `${settings.customNodePath}${separator}${process.env.PATH}`; + logger.debug( + "Added custom Node.js path to PATH:", + settings.customNodePath, + ); + } logger.debug("Reloaded env path, now:", process.env.PATH); }); + ipcMain.handle("select-node-folder", async () => { + const result = await dialog.showOpenDialog({ + title: "Select Node.js Installation Folder", + properties: ["openDirectory"], + message: "Select the folder where Node.js is installed", + }); + + if (result.canceled) { + return { path: null, canceled: true, selectedPath: null }; + } + + if (!result.filePaths[0]) { + return { path: null, canceled: false, selectedPath: null }; + } + + const selectedPath = result.filePaths[0]; + + // Verify Node.js exists in selected path + const nodeBinary = platform() === "win32" ? "node.exe" : "node"; + const nodePath = join(selectedPath, nodeBinary); + + if (!existsSync(nodePath)) { + // Check bin subdirectory (common on Unix systems) + const binPath = join(selectedPath, "bin", nodeBinary); + if (existsSync(binPath)) { + return { + path: join(selectedPath, "bin"), + canceled: false, + selectedPath, + }; + } + return { path: null, canceled: false, selectedPath }; + } + return { path: selectedPath, canceled: false, selectedPath }; + }); } diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index ffc9aa0..130e3d0 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -68,6 +68,7 @@ import type { CloneRepoParams, SupabaseBranch, SetSupabaseAppProjectParams, + SelectNodeFolderResult, } from "./ipc_types"; import type { Template } from "../shared/templates"; import type { @@ -1180,6 +1181,16 @@ export class IpcClient { return this.ipcRenderer.invoke("select-app-folder"); } + // Add these methods to IpcClient class + + public async selectNodeFolder(): Promise { + return this.ipcRenderer.invoke("select-node-folder"); + } + + public async getNodePath(): Promise { + return this.ipcRenderer.invoke("get-node-path"); + } + public async checkAiRules(params: { path: string; }): Promise<{ exists: boolean }> { diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 28bc544..f4f5a5b 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -523,3 +523,12 @@ export interface SetSupabaseAppProjectParams { parentProjectId?: string; appId: number; } +export interface SetNodePathParams { + nodePath: string; +} + +export interface SelectNodeFolderResult { + path: string | null; + canceled?: boolean; + selectedPath: string | null; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 67178ae..8ca3272 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -245,6 +245,7 @@ export const UserSettingsSchema = z.object({ enableAutoUpdate: z.boolean(), releaseChannel: ReleaseChannelSchema, runtimeMode2: RuntimeMode2Schema.optional(), + customNodePath: z.string().optional().nullable(), //////////////////////////////// // E2E TESTING ONLY. diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 929166b..90ce383 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -24,6 +24,7 @@ import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch"; import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector"; import { NeonIntegration } from "@/components/NeonIntegration"; import { RuntimeModeSelector } from "@/components/RuntimeModeSelector"; +import { NodePathSelector } from "@/components/NodePathSelector"; import { ToolsMcpSettings } from "@/components/settings/ToolsMcpSettings"; export default function SettingsPage() { @@ -272,6 +273,9 @@ export function GeneralSettings({ appVersion }: { appVersion: string | null }) {
+
+ +
App Version: diff --git a/src/preload.ts b/src/preload.ts index fa0bc7c..879f413 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -51,6 +51,7 @@ const validInvokeChannels = [ "reset-all", "nodejs-status", "install-node", + "select-node-folder", "github:start-flow", "github:list-repos", "github:get-repo-branches",