| import { useState, useEffect, useCallback, useRef } from 'react'; |
| import { createLogger } from '@automaker/utils/logger'; |
| import { Button } from '@/components/ui/button'; |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; |
| import { Badge } from '@/components/ui/badge'; |
| import { useSetupStore } from '@/store/setup-store'; |
| import { getElectronAPI } from '@/lib/electron'; |
| import { |
| CheckCircle2, |
| ArrowRight, |
| ArrowLeft, |
| ExternalLink, |
| Copy, |
| RefreshCw, |
| AlertTriangle, |
| XCircle, |
| Terminal, |
| } from 'lucide-react'; |
| import { Spinner } from '@/components/ui/spinner'; |
| import { toast } from 'sonner'; |
| import { StatusBadge } from '../components'; |
|
|
| const logger = createLogger('OpencodeSetupStep'); |
|
|
| interface OpencodeSetupStepProps { |
| onNext: () => void; |
| onBack: () => void; |
| onSkip: () => void; |
| } |
|
|
| interface OpencodeCliStatus { |
| installed: boolean; |
| version?: string | null; |
| path?: string | null; |
| auth?: { |
| authenticated: boolean; |
| method: string; |
| }; |
| installCommand?: string; |
| loginCommand?: string; |
| } |
|
|
| export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepProps) { |
| const { opencodeCliStatus, setOpencodeCliStatus } = useSetupStore(); |
| const [isChecking, setIsChecking] = useState(false); |
| const [isLoggingIn, setIsLoggingIn] = useState(false); |
| const pollIntervalRef = useRef<NodeJS.Timeout | null>(null); |
|
|
| const checkStatus = useCallback(async () => { |
| setIsChecking(true); |
| try { |
| const api = getElectronAPI(); |
| if (!api.setup?.getOpencodeStatus) { |
| return; |
| } |
| const result = await api.setup.getOpencodeStatus(); |
| if (result.success) { |
| |
| const installCommand = |
| result.installCommands?.npm || |
| result.installCommands?.macos || |
| result.installCommands?.linux; |
| const status: OpencodeCliStatus = { |
| installed: result.installed ?? false, |
| version: result.version ?? null, |
| path: result.path ?? null, |
| auth: result.auth, |
| installCommand, |
| loginCommand: 'opencode auth login', |
| }; |
| setOpencodeCliStatus(status); |
|
|
| if (result.auth?.authenticated) { |
| toast.success('OpenCode CLI is ready!'); |
| } |
| } |
| } catch (error) { |
| logger.error('Failed to check OpenCode status:', error); |
| } finally { |
| setIsChecking(false); |
| } |
| }, [setOpencodeCliStatus]); |
|
|
| useEffect(() => { |
| checkStatus(); |
| |
| return () => { |
| if (pollIntervalRef.current) { |
| clearInterval(pollIntervalRef.current); |
| } |
| }; |
| }, [checkStatus]); |
|
|
| const copyCommand = (command: string) => { |
| navigator.clipboard.writeText(command); |
| toast.success('Command copied to clipboard'); |
| }; |
|
|
| const handleLogin = async () => { |
| setIsLoggingIn(true); |
|
|
| try { |
| |
| const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login'; |
| await navigator.clipboard.writeText(loginCommand); |
| toast.info('Login command copied! Paste in terminal to authenticate.'); |
|
|
| |
| let attempts = 0; |
| const maxAttempts = 60; |
|
|
| pollIntervalRef.current = setInterval(async () => { |
| attempts++; |
|
|
| try { |
| const api = getElectronAPI(); |
| if (!api.setup?.getOpencodeStatus) { |
| return; |
| } |
| const result = await api.setup.getOpencodeStatus(); |
|
|
| if (result.auth?.authenticated) { |
| if (pollIntervalRef.current) { |
| clearInterval(pollIntervalRef.current); |
| pollIntervalRef.current = null; |
| } |
| setOpencodeCliStatus({ |
| ...opencodeCliStatus, |
| installed: result.installed ?? true, |
| version: result.version, |
| path: result.path, |
| auth: result.auth, |
| } as OpencodeCliStatus); |
| setIsLoggingIn(false); |
| toast.success('Successfully logged in to OpenCode!'); |
| } |
| } catch { |
| |
| } |
|
|
| if (attempts >= maxAttempts) { |
| if (pollIntervalRef.current) { |
| clearInterval(pollIntervalRef.current); |
| pollIntervalRef.current = null; |
| } |
| setIsLoggingIn(false); |
| toast.error('Login timed out. Please try again.'); |
| } |
| }, 2000); |
| } catch (error) { |
| logger.error('Login failed:', error); |
| toast.error('Failed to start login process'); |
| setIsLoggingIn(false); |
| } |
| }; |
|
|
| const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; |
|
|
| const getStatusBadge = () => { |
| if (isChecking) { |
| return <StatusBadge status="checking" label="Checking..." />; |
| } |
| if (opencodeCliStatus?.auth?.authenticated) { |
| return <StatusBadge status="authenticated" label="Ready" />; |
| } |
| if (opencodeCliStatus?.installed) { |
| return <StatusBadge status="unverified" label="Not Logged In" />; |
| } |
| return <StatusBadge status="not_installed" label="Not Installed" />; |
| }; |
|
|
| return ( |
| <div className="space-y-6"> |
| <div className="text-center mb-8"> |
| <div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4"> |
| <Terminal className="w-8 h-8 text-green-500" /> |
| </div> |
| <h2 className="text-2xl font-bold text-foreground mb-2">OpenCode CLI Setup</h2> |
| <p className="text-muted-foreground">Optional - Use OpenCode as an AI provider</p> |
| </div> |
| |
| {/* Info Banner */} |
| <Card className="bg-green-500/10 border-green-500/20"> |
| <CardContent className="pt-4"> |
| <div className="flex items-start gap-3"> |
| <AlertTriangle className="w-5 h-5 text-green-500 shrink-0 mt-0.5" /> |
| <div> |
| <p className="font-medium text-foreground">This step is optional</p> |
| <p className="text-sm text-muted-foreground mt-1"> |
| Configure OpenCode CLI for access to free tier models and connected providers. You |
| can skip this and use other providers, or configure it later in Settings. |
| </p> |
| </div> |
| </div> |
| </CardContent> |
| </Card> |
| |
| {/* Status Card */} |
| <Card className="bg-card border-border"> |
| <CardHeader> |
| <div className="flex items-center justify-between"> |
| <CardTitle className="text-lg flex items-center gap-2"> |
| <Terminal className="w-5 h-5" /> |
| OpenCode CLI Status |
| <Badge variant="outline" className="ml-2"> |
| Optional |
| </Badge> |
| </CardTitle> |
| <div className="flex items-center gap-2"> |
| {getStatusBadge()} |
| <Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}> |
| {isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />} |
| </Button> |
| </div> |
| </div> |
| <CardDescription> |
| {opencodeCliStatus?.installed |
| ? opencodeCliStatus.auth?.authenticated |
| ? `Authenticated via ${opencodeCliStatus.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}${opencodeCliStatus.version ? ` (v${opencodeCliStatus.version})` : ''}` |
| : 'Installed but not authenticated' |
| : 'Not installed on your system'} |
| </CardDescription> |
| </CardHeader> |
| <CardContent className="space-y-4"> |
| {/* Success State */} |
| {isReady && ( |
| <div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20"> |
| <CheckCircle2 className="w-5 h-5 text-green-500" /> |
| <div> |
| <p className="font-medium text-foreground">OpenCode CLI is ready!</p> |
| <p className="text-sm text-muted-foreground"> |
| You can use OpenCode models for AI tasks. |
| {opencodeCliStatus?.version && ( |
| <span className="ml-1">Version: {opencodeCliStatus.version}</span> |
| )} |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {/* Not Installed */} |
| {!opencodeCliStatus?.installed && !isChecking && ( |
| <div className="space-y-4"> |
| <div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border"> |
| <XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" /> |
| <div className="flex-1"> |
| <p className="font-medium text-foreground">OpenCode CLI not found</p> |
| <p className="text-sm text-muted-foreground mt-1"> |
| Install the OpenCode CLI to use free tier models and connected providers. |
| </p> |
| </div> |
| </div> |
| |
| <div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border"> |
| <p className="font-medium text-foreground text-sm">Install OpenCode CLI:</p> |
| <div className="flex items-center gap-2"> |
| <code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto"> |
| {opencodeCliStatus?.installCommand || 'npm install -g opencode'} |
| </code> |
| <Button |
| variant="ghost" |
| size="icon" |
| onClick={() => |
| copyCommand(opencodeCliStatus?.installCommand || 'npm install -g opencode') |
| } |
| > |
| <Copy className="w-4 h-4" /> |
| </Button> |
| </div> |
| <a |
| href="https://github.com/opencode-ai/opencode" |
| target="_blank" |
| rel="noopener noreferrer" |
| className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2" |
| > |
| View installation docs |
| <ExternalLink className="w-3 h-3 ml-1" /> |
| </a> |
| </div> |
| </div> |
| )} |
| |
| {/* Installed but not authenticated */} |
| {opencodeCliStatus?.installed && |
| !opencodeCliStatus?.auth?.authenticated && |
| !isChecking && ( |
| <div className="space-y-4"> |
| <div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20"> |
| <AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" /> |
| <div className="flex-1"> |
| <p className="font-medium text-foreground">OpenCode CLI not authenticated</p> |
| <p className="text-sm text-muted-foreground mt-1"> |
| Run the login command to authenticate with OpenCode. |
| </p> |
| </div> |
| </div> |
| |
| <div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border"> |
| <p className="text-sm text-muted-foreground"> |
| Run the login command in your terminal, then complete authentication in your |
| browser: |
| </p> |
| <div className="flex items-center gap-2"> |
| <code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground"> |
| {opencodeCliStatus?.loginCommand || 'opencode auth login'} |
| </code> |
| <Button |
| variant="ghost" |
| size="icon" |
| onClick={() => |
| copyCommand(opencodeCliStatus?.loginCommand || 'opencode auth login') |
| } |
| > |
| <Copy className="w-4 h-4" /> |
| </Button> |
| </div> |
| <Button |
| onClick={handleLogin} |
| disabled={isLoggingIn} |
| className="w-full bg-brand-500 hover:bg-brand-600 text-white" |
| > |
| {isLoggingIn ? ( |
| <> |
| <Spinner size="sm" variant="foreground" className="mr-2" /> |
| Waiting for login... |
| </> |
| ) : ( |
| 'Copy Command & Wait for Login' |
| )} |
| </Button> |
| </div> |
| </div> |
| )} |
| |
| {/* Loading State */} |
| {isChecking && ( |
| <div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20"> |
| <Spinner size="md" /> |
| <div> |
| <p className="font-medium text-foreground">Checking OpenCode CLI status...</p> |
| </div> |
| </div> |
| )} |
| </CardContent> |
| </Card> |
|
|
| {} |
| <div className="flex justify-between pt-4"> |
| <Button variant="ghost" onClick={onBack} className="text-muted-foreground"> |
| <ArrowLeft className="w-4 h-4 mr-2" /> |
| Back |
| </Button> |
| <div className="flex gap-2"> |
| <Button variant="ghost" onClick={onSkip} className="text-muted-foreground"> |
| {isReady ? 'Skip' : 'Skip for now'} |
| </Button> |
| <Button |
| onClick={onNext} |
| className="bg-brand-500 hover:bg-brand-600 text-white" |
| data-testid="opencode-next-button" |
| > |
| {isReady ? 'Continue' : 'Continue without OpenCode'} |
| <ArrowRight className="w-4 h-4 ml-2" /> |
| </Button> |
| </div> |
| </div> |
|
|
| {} |
| <p className="text-xs text-muted-foreground text-center"> |
| You can always configure OpenCode later in Settings |
| </p> |
| </div> |
| ); |
| } |
|
|