Add ability to manually configure Node.js path for users who have Node.js installed but not in their system PATH. Features: - Browse and select custom Node.js installation folder - Visual status indicator showing Node.js version or "Not found" - Reset to system default PATH option - Manual configuration option in setup banner - Real-time Node.js status checking closes #1050 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds manual Node.js path configuration so the app works even when Node isn’t on PATH, fulfilling #1050. Users can browse to their install, reset to default, and see real-time status in Settings and during setup. - New Features - Settings: NodePathSelector to browse a Node.js folder, show version/“Not found” status, and reset to system PATH (persists customNodePath). - Setup banner: manual config flow with a folder picker if Node is already installed. - IPC: select-node-folder, set-node-path, get-node-path; reloads env and prepends custom path to PATH. - Real-time Node.js status check with visual indicator (CheckCircle on valid). - E2E tests for browse, reset, and valid-status display. <!-- End of auto-generated description by cubic. -->
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
import { useNavigate } from "@tanstack/react-router";
|
||
import {
|
||
ChevronRight,
|
||
GiftIcon,
|
||
Sparkles,
|
||
CheckCircle,
|
||
AlertCircle,
|
||
XCircle,
|
||
Loader2,
|
||
Settings,
|
||
Folder,
|
||
} from "lucide-react";
|
||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
||
|
||
import SetupProviderCard from "@/components/SetupProviderCard";
|
||
|
||
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 { NodeSystemInfo } from "@/ipc/ipc_types";
|
||
import { usePostHog } from "posthog-js/react";
|
||
import { useLanguageModelProviders } from "@/hooks/useLanguageModelProviders";
|
||
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"
|
||
| "waiting-for-continue"
|
||
| "continue-processing"
|
||
| "finished-checking";
|
||
|
||
export function SetupBanner() {
|
||
const posthog = usePostHog();
|
||
const navigate = useNavigate();
|
||
const [isOnboardingVisible, setIsOnboardingVisible] = useState(true);
|
||
const { isAnyProviderSetup, isLoading: loading } =
|
||
useLanguageModelProviders();
|
||
const [nodeSystemInfo, setNodeSystemInfo] = useState<NodeSystemInfo | null>(
|
||
null,
|
||
);
|
||
const [nodeCheckError, setNodeCheckError] = useState<boolean>(false);
|
||
const [nodeInstallStep, setNodeInstallStep] =
|
||
useState<NodeInstallStep>("install");
|
||
const checkNode = useCallback(async () => {
|
||
try {
|
||
setNodeCheckError(false);
|
||
const status = await IpcClient.getInstance().getNodejsStatus();
|
||
setNodeSystemInfo(status);
|
||
} catch (error) {
|
||
console.error("Failed to check Node.js status:", error);
|
||
setNodeSystemInfo(null);
|
||
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();
|
||
}, [checkNode]);
|
||
|
||
const settingsScrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
|
||
behavior: "smooth",
|
||
block: "start",
|
||
});
|
||
|
||
const handleGoogleSetupClick = () => {
|
||
posthog.capture("setup-flow:ai-provider-setup:google:click");
|
||
navigate({
|
||
to: providerSettingsRoute.id,
|
||
params: { provider: "google" },
|
||
});
|
||
};
|
||
|
||
const handleOpenRouterSetupClick = () => {
|
||
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
|
||
navigate({
|
||
to: providerSettingsRoute.id,
|
||
params: { provider: "openrouter" },
|
||
});
|
||
};
|
||
const handleDyadProSetupClick = () => {
|
||
posthog.capture("setup-flow:ai-provider-setup:dyad:click");
|
||
IpcClient.getInstance().openExternalUrl(
|
||
"https://www.dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=setup-banner",
|
||
);
|
||
};
|
||
|
||
const handleOtherProvidersClick = () => {
|
||
posthog.capture("setup-flow:ai-provider-setup:other:click");
|
||
settingsScrollAndNavigateTo("provider-settings");
|
||
};
|
||
|
||
const handleNodeInstallClick = useCallback(async () => {
|
||
posthog.capture("setup-flow:start-node-install-click");
|
||
setNodeInstallStep("waiting-for-continue");
|
||
IpcClient.getInstance().openExternalUrl(nodeSystemInfo!.nodeDownloadUrl);
|
||
}, [nodeSystemInfo, setNodeInstallStep]);
|
||
|
||
const finishNodeInstall = useCallback(async () => {
|
||
posthog.capture("setup-flow:continue-node-install-click");
|
||
setNodeInstallStep("continue-processing");
|
||
await IpcClient.getInstance().reloadEnvPath();
|
||
await checkNode();
|
||
setNodeInstallStep("finished-checking");
|
||
}, [checkNode, setNodeInstallStep]);
|
||
|
||
// We only check for node version because pnpm is not required for the app to run.
|
||
const isNodeSetupComplete = Boolean(nodeSystemInfo?.nodeVersion);
|
||
|
||
const itemsNeedAction: string[] = [];
|
||
if (!isNodeSetupComplete && nodeSystemInfo) {
|
||
itemsNeedAction.push("node-setup");
|
||
}
|
||
if (!isAnyProviderSetup() && !loading) {
|
||
itemsNeedAction.push("ai-setup");
|
||
}
|
||
|
||
if (itemsNeedAction.length === 0) {
|
||
return (
|
||
<h1 className="text-center text-5xl font-bold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-gray-100 dark:to-gray-400 tracking-tight">
|
||
Build your dream app
|
||
</h1>
|
||
);
|
||
}
|
||
|
||
const bannerClasses = cn(
|
||
"w-full mb-6 border rounded-xl shadow-sm overflow-hidden",
|
||
"border-zinc-200 dark:border-zinc-700",
|
||
);
|
||
|
||
const getStatusIcon = (isComplete: boolean, hasError: boolean = false) => {
|
||
if (hasError) {
|
||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||
}
|
||
return isComplete ? (
|
||
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-500" />
|
||
) : (
|
||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500" />
|
||
);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<p className="text-xl font-medium text-zinc-700 dark:text-zinc-300 p-4">
|
||
Setup Dyad
|
||
</p>
|
||
<OnboardingBanner
|
||
isVisible={isOnboardingVisible}
|
||
setIsVisible={setIsOnboardingVisible}
|
||
/>
|
||
<div className={bannerClasses}>
|
||
<Accordion
|
||
type="multiple"
|
||
className="w-full"
|
||
defaultValue={itemsNeedAction}
|
||
>
|
||
<AccordionItem
|
||
value="node-setup"
|
||
className={cn(
|
||
nodeCheckError
|
||
? "bg-red-50 dark:bg-red-900/30"
|
||
: isNodeSetupComplete
|
||
? "bg-green-50 dark:bg-green-900/30"
|
||
: "bg-yellow-50 dark:bg-yellow-900/30",
|
||
)}
|
||
>
|
||
<AccordionTrigger className="px-4 py-3 transition-colors w-full hover:no-underline">
|
||
<div className="flex items-center justify-between w-full">
|
||
<div className="flex items-center gap-3">
|
||
{getStatusIcon(isNodeSetupComplete, nodeCheckError)}
|
||
<span className="font-medium text-sm">
|
||
1. Install Node.js (App Runtime)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</AccordionTrigger>
|
||
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
|
||
{nodeCheckError && (
|
||
<p className="text-sm text-red-600 dark:text-red-400">
|
||
Error checking Node.js status. Try installing Node.js.
|
||
</p>
|
||
)}
|
||
{isNodeSetupComplete ? (
|
||
<p className="text-sm">
|
||
Node.js ({nodeSystemInfo!.nodeVersion}) installed.{" "}
|
||
{nodeSystemInfo!.pnpmVersion && (
|
||
<span className="text-xs text-gray-500">
|
||
{" "}
|
||
(optional) pnpm ({nodeSystemInfo!.pnpmVersion}) installed.
|
||
</span>
|
||
)}
|
||
</p>
|
||
) : (
|
||
<div className="text-sm">
|
||
<p>Node.js is required to run apps locally.</p>
|
||
{nodeInstallStep === "waiting-for-continue" && (
|
||
<p className="mt-1">
|
||
After you have installed Node.js, click "Continue". If the
|
||
installer didn't work, try{" "}
|
||
<a
|
||
className="text-blue-500 dark:text-blue-400 hover:underline"
|
||
onClick={() => {
|
||
IpcClient.getInstance().openExternalUrl(
|
||
"https://nodejs.org/en/download",
|
||
);
|
||
}}
|
||
>
|
||
more download options
|
||
</a>
|
||
.
|
||
</p>
|
||
)}
|
||
<NodeInstallButton
|
||
nodeInstallStep={nodeInstallStep}
|
||
handleNodeInstallClick={handleNodeInstallClick}
|
||
finishNodeInstall={finishNodeInstall}
|
||
/>
|
||
|
||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||
<button
|
||
onClick={() => setShowManualConfig(!showManualConfig)}
|
||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||
>
|
||
Node.js already installed? Configure path manually →
|
||
</button>
|
||
|
||
{showManualConfig && (
|
||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||
<Button
|
||
onClick={handleManualNodeConfig}
|
||
disabled={isSelectingPath}
|
||
variant="outline"
|
||
size="sm"
|
||
>
|
||
{isSelectingPath ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Selecting...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Folder className="mr-2 h-4 w-4" />
|
||
Browse for Node.js folder
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<NodeJsHelpCallout />
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
|
||
<AccordionItem
|
||
value="ai-setup"
|
||
className={cn(
|
||
isAnyProviderSetup()
|
||
? "bg-green-50 dark:bg-green-900/30"
|
||
: "bg-yellow-50 dark:bg-yellow-900/30",
|
||
)}
|
||
>
|
||
<AccordionTrigger
|
||
className={cn(
|
||
"px-4 py-3 transition-colors w-full hover:no-underline",
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between w-full">
|
||
<div className="flex items-center gap-3">
|
||
{getStatusIcon(isAnyProviderSetup())}
|
||
<span className="font-medium text-sm">
|
||
2. Setup AI Access
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</AccordionTrigger>
|
||
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
|
||
<p className="text-[15px] mb-3">
|
||
Not sure what to do? Watch the Get Started video above ☝️
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<SetupProviderCard
|
||
className="flex-1"
|
||
variant="google"
|
||
onClick={handleGoogleSetupClick}
|
||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
||
leadingIcon={
|
||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||
}
|
||
title="Setup Google Gemini API Key"
|
||
chip={<>Free</>}
|
||
/>
|
||
|
||
<SetupProviderCard
|
||
className="flex-1"
|
||
variant="openrouter"
|
||
onClick={handleOpenRouterSetupClick}
|
||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
||
leadingIcon={
|
||
<Sparkles className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||
}
|
||
title="Setup OpenRouter API Key"
|
||
chip={<>Free</>}
|
||
/>
|
||
</div>
|
||
|
||
<SetupProviderCard
|
||
className="mt-2"
|
||
variant="dyad"
|
||
onClick={handleDyadProSetupClick}
|
||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
||
leadingIcon={
|
||
<img src={logo} alt="Dyad Logo" className="w-6 h-6 mr-0.5" />
|
||
}
|
||
title="Setup Dyad Pro"
|
||
subtitle="Access all AI models with one plan"
|
||
chip={<>Recommended</>}
|
||
/>
|
||
|
||
<div
|
||
className="mt-2 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/70 transition-colors"
|
||
onClick={handleOtherProvidersClick}
|
||
role="button"
|
||
tabIndex={isNodeSetupComplete ? 0 : -1}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="bg-gray-100 dark:bg-gray-700 p-1.5 rounded-full">
|
||
<Settings className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||
</div>
|
||
<div>
|
||
<h4 className="font-medium text-[15px] text-gray-800 dark:text-gray-300">
|
||
Setup other AI providers
|
||
</h4>
|
||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||
OpenAI, Anthropic and more
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<ChevronRight className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function NodeJsHelpCallout() {
|
||
return (
|
||
<div className="mt-3 p-3 bg-(--background-lighter) border rounded-lg text-sm">
|
||
<p>
|
||
If you run into issues, read our{" "}
|
||
<a
|
||
onClick={() => {
|
||
IpcClient.getInstance().openExternalUrl(
|
||
"https://www.dyad.sh/docs/help/nodejs",
|
||
);
|
||
}}
|
||
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||
>
|
||
Node.js troubleshooting guide
|
||
</a>
|
||
.{" "}
|
||
</p>
|
||
<p className="mt-2">
|
||
Still stuck? Click the <b>Help</b> button in the bottom-left corner and
|
||
then <b>Report a Bug</b>.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeInstallButton({
|
||
nodeInstallStep,
|
||
handleNodeInstallClick,
|
||
finishNodeInstall,
|
||
}: {
|
||
nodeInstallStep: NodeInstallStep;
|
||
handleNodeInstallClick: () => void;
|
||
finishNodeInstall: () => void;
|
||
}) {
|
||
switch (nodeInstallStep) {
|
||
case "install":
|
||
return (
|
||
<Button className="mt-3" onClick={handleNodeInstallClick}>
|
||
Install Node.js Runtime
|
||
</Button>
|
||
);
|
||
case "continue-processing":
|
||
return (
|
||
<Button className="mt-3" onClick={finishNodeInstall} disabled>
|
||
<div className="flex items-center gap-2">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
Checking Node.js setup...
|
||
</div>
|
||
</Button>
|
||
);
|
||
case "waiting-for-continue":
|
||
return (
|
||
<Button className="mt-3" onClick={finishNodeInstall}>
|
||
<div className="flex items-center gap-2">
|
||
Continue | I installed Node.js
|
||
</div>
|
||
</Button>
|
||
);
|
||
case "finished-checking":
|
||
return (
|
||
<div className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||
Node.js not detected. Closing and re-opening Dyad usually fixes this.
|
||
</div>
|
||
);
|
||
default:
|
||
const _exhaustiveCheck: never = nodeInstallStep;
|
||
}
|
||
}
|
||
|
||
export const OpenRouterSetupBanner = ({
|
||
className,
|
||
}: {
|
||
className?: string;
|
||
}) => {
|
||
const posthog = usePostHog();
|
||
const navigate = useNavigate();
|
||
return (
|
||
<SetupProviderCard
|
||
className={cn("mt-2", className)}
|
||
variant="openrouter"
|
||
onClick={() => {
|
||
posthog.capture("setup-flow:ai-provider-setup:openrouter:click");
|
||
navigate({
|
||
to: providerSettingsRoute.id,
|
||
params: { provider: "openrouter" },
|
||
});
|
||
}}
|
||
tabIndex={0}
|
||
leadingIcon={
|
||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||
}
|
||
title="Setup OpenRouter API Key"
|
||
chip={
|
||
<>
|
||
<GiftIcon className="w-3 h-3" />
|
||
Free models available
|
||
</>
|
||
}
|
||
/>
|
||
);
|
||
};
|