diff --git a/electron/main.ts b/electron/main.ts index aeb11c4..a836388 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,6 @@ -import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess } from 'electron'; +import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain } from 'electron'; import path from 'path'; -import { execFileSync } from 'child_process'; +import { execFileSync, spawn, ChildProcess } from 'child_process'; import fs from 'fs'; import net from 'net'; import os from 'os'; @@ -14,6 +14,30 @@ let serverExitCode: number | null = null; let userShellEnv: Record = {}; let isQuitting = false; +// --- Install orchestrator --- +interface InstallStep { + id: string; + label: string; + status: 'pending' | 'running' | 'success' | 'failed' | 'skipped'; + error?: string; +} + +interface InstallState { + status: 'idle' | 'running' | 'success' | 'failed' | 'cancelled'; + currentStep: string | null; + steps: InstallStep[]; + logs: string[]; +} + +let installState: InstallState = { + status: 'idle', + currentStep: null, + steps: [], + logs: [], +}; + +let installProcess: ChildProcess | null = null; + const isDev = !app.isPackaged; /** @@ -141,6 +165,35 @@ function loadUserShellEnv(): Record { } } +/** + * Build an expanded PATH that includes common locations for node, npm globals, + * claude, nvm, homebrew, etc. Shared by the server launcher and install orchestrator. + */ +function getExpandedShellPath(): string { + const home = os.homedir(); + const shellPath = userShellEnv.PATH || process.env.PATH || ''; + const sep = path.delimiter; + + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const winExtra = [ + path.join(appData, 'npm'), + path.join(localAppData, 'npm'), + path.join(home, '.npm-global', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + ]; + const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); + return [...new Set(allParts)].join(sep); + } else { + const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; + const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; + const allParts = raw.split(':').filter(Boolean); + return [...new Set(allParts)].join(':'); + } +} + function getPort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -202,28 +255,7 @@ function startServer(port: number): Electron.UtilityProcess { serverExitCode = null; const home = os.homedir(); - const shellPath = userShellEnv.PATH || process.env.PATH || ''; - const sep = path.delimiter; // ';' on Windows, ':' on Unix - - let constructedPath: string; - if (process.platform === 'win32') { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const winExtra = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - ]; - const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); - constructedPath = [...new Set(allParts)].join(sep); - } else { - const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; - const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; - const allParts = raw.split(':').filter(Boolean); - constructedPath = [...new Set(allParts)].join(':'); - } + const constructedPath = getExpandedShellPath(); const env: Record = { ...userShellEnv, @@ -353,6 +385,227 @@ app.whenReady().then(async () => { app.dock.setIcon(nativeImage.createFromPath(iconPath)); } + // --- Install wizard IPC handlers --- + + ipcMain.handle('install:check-prerequisites', async () => { + const expandedPath = getExpandedShellPath(); + const execEnv = { ...process.env, ...userShellEnv, PATH: expandedPath }; + const execOpts = { timeout: 5000, encoding: 'utf-8' as const, env: execEnv }; + + let hasNode = false; + let nodeVersion: string | undefined; + try { + const result = execFileSync('node', ['--version'], execOpts); + nodeVersion = result.trim(); + hasNode = true; + } catch { + // node not found + } + + let hasClaude = false; + let claudeVersion: string | undefined; + try { + const claudeOpts = process.platform === 'win32' + ? { ...execOpts, shell: true } + : execOpts; + const result = execFileSync('claude', ['--version'], claudeOpts); + claudeVersion = result.trim(); + hasClaude = true; + } catch { + // claude not found + } + + return { hasNode, nodeVersion, hasClaude, claudeVersion }; + }); + + ipcMain.handle('install:start', () => { + if (installState.status === 'running') { + return { error: 'Installation is already running' }; + } + + // Reset state + installState = { + status: 'running', + currentStep: null, + steps: [ + { id: 'check-node', label: 'Checking Node.js', status: 'pending' }, + { id: 'install-claude', label: 'Installing Claude Code', status: 'pending' }, + { id: 'verify', label: 'Verifying installation', status: 'pending' }, + ], + logs: [], + }; + + const expandedPath = getExpandedShellPath(); + const execEnv: Record = { + ...userShellEnv, + ...(process.env as Record), + ...userShellEnv, + PATH: expandedPath, + }; + + function sendProgress() { + mainWindow?.webContents.send('install:progress', installState); + } + + function setStep(id: string, status: InstallStep['status'], error?: string) { + const step = installState.steps.find(s => s.id === id); + if (step) { + step.status = status; + step.error = error; + } + installState.currentStep = id; + sendProgress(); + } + + function addLog(line: string) { + installState.logs.push(line); + sendProgress(); + } + + // Run the installation sequence asynchronously + (async () => { + try { + // Step 1: Check node + setStep('check-node', 'running'); + try { + const nodeResult = execFileSync('node', ['--version'], { + timeout: 5000, + encoding: 'utf-8', + env: execEnv, + }); + addLog(`Node.js found: ${nodeResult.trim()}`); + setStep('check-node', 'success'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`Node.js not found: ${msg}`); + setStep('check-node', 'failed', 'Node.js is not installed. Please install Node.js first.'); + installState.status = 'failed'; + sendProgress(); + return; + } + + // Step 2: Install Claude Code via npm + setStep('install-claude', 'running'); + addLog('Running: npm install -g @anthropic-ai/claude-code'); + + const npmInstallSuccess = await new Promise((resolve) => { + const isWin = process.platform === 'win32'; + const npmCmd = isWin ? 'npm.cmd' : 'npm'; + + const child = spawn(npmCmd, ['install', '-g', '@anthropic-ai/claude-code'], { + env: execEnv, + shell: isWin, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + installProcess = child; + + child.stdout?.on('data', (data: Buffer) => { + const lines = data.toString().split('\n').filter(Boolean); + for (const line of lines) { + addLog(line); + } + }); + + child.stderr?.on('data', (data: Buffer) => { + const lines = data.toString().split('\n').filter(Boolean); + for (const line of lines) { + addLog(line); + } + }); + + child.on('error', (err) => { + addLog(`npm error: ${err.message}`); + resolve(false); + }); + + child.on('close', (code) => { + installProcess = null; + if (code === 0) { + addLog('npm install completed successfully'); + resolve(true); + } else if (installState.status === 'cancelled') { + addLog('Installation was cancelled'); + resolve(false); + } else { + addLog(`npm install exited with code ${code}`); + resolve(false); + } + }); + }); + + if (installState.status === 'cancelled') { + setStep('install-claude', 'failed', 'Cancelled'); + return; + } + + if (!npmInstallSuccess) { + setStep('install-claude', 'failed', 'npm install failed. Check logs for details.'); + installState.status = 'failed'; + sendProgress(); + return; + } + + setStep('install-claude', 'success'); + + // Step 3: Verify claude is available + setStep('verify', 'running'); + try { + const verifyOpts = process.platform === 'win32' + ? { timeout: 5000, encoding: 'utf-8' as const, env: execEnv, shell: true } + : { timeout: 5000, encoding: 'utf-8' as const, env: execEnv }; + const claudeResult = execFileSync('claude', ['--version'], verifyOpts); + addLog(`Claude Code installed: ${claudeResult.trim()}`); + setStep('verify', 'success'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`Verification failed: ${msg}`); + setStep('verify', 'failed', 'Claude Code was installed but could not be verified.'); + installState.status = 'failed'; + sendProgress(); + return; + } + + installState.status = 'success'; + installState.currentStep = null; + sendProgress(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addLog(`Unexpected error: ${msg}`); + installState.status = 'failed'; + sendProgress(); + } + })(); + + return { ok: true }; + }); + + ipcMain.handle('install:cancel', () => { + if (installState.status !== 'running') { + return { error: 'No installation is running' }; + } + + installState.status = 'cancelled'; + + if (installProcess) { + try { + installProcess.kill(); + } catch { + // already dead + } + installProcess = null; + } + + mainWindow?.webContents.send('install:progress', installState); + return { ok: true }; + }); + + ipcMain.handle('install:get-logs', () => { + return installState.logs; + }); + + // --- End install wizard IPC handlers --- + try { let port: number; @@ -403,6 +656,12 @@ app.on('activate', async () => { }); app.on('before-quit', async (e) => { + // Kill any running install process + if (installProcess) { + try { installProcess.kill(); } catch { /* already dead */ } + installProcess = null; + } + if (serverProcess && !isQuitting) { isQuitting = true; e.preventDefault(); diff --git a/electron/preload.ts b/electron/preload.ts index f9b469b..2377ac7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports -const { contextBridge } = require('electron'); +const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { versions: { @@ -7,4 +7,15 @@ contextBridge.exposeInMainWorld('electronAPI', { node: process.versions.node, chrome: process.versions.chrome, }, + install: { + checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'), + start: () => ipcRenderer.invoke('install:start'), + cancel: () => ipcRenderer.invoke('install:cancel'), + getLogs: () => ipcRenderer.invoke('install:get-logs'), + onProgress: (callback: (data: unknown) => void) => { + const listener = (_event: unknown, data: unknown) => callback(data); + ipcRenderer.on('install:progress', listener); + return () => { ipcRenderer.removeListener('install:progress', listener); }; + }, + }, }); diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index 9e69041..f3efcea 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { InstallWizard } from "@/components/layout/InstallWizard"; interface ClaudeStatus { connected: boolean; @@ -24,6 +25,12 @@ const STABLE_THRESHOLD = 3; export function ConnectionStatus() { const [status, setStatus] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); + + const isElectron = + typeof window !== "undefined" && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !!(window as any).electronAPI?.install; const stableCountRef = useRef(0); const lastConnectedRef = useRef(null); const timerRef = useRef | null>(null); @@ -162,6 +169,20 @@ export function ConnectionStatus() { claude --version + + {isElectron && ( +
+ +
+ )} )} @@ -175,6 +196,12 @@ export function ConnectionStatus() { + + ); } diff --git a/src/components/layout/InstallWizard.tsx b/src/components/layout/InstallWizard.tsx new file mode 100644 index 0000000..aef5fe2 --- /dev/null +++ b/src/components/layout/InstallWizard.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + CheckIcon, + XIcon, + MinusIcon, + LoaderIcon, + CircleIcon, + CopyIcon, +} from "lucide-react"; + +interface InstallProgress { + status: "idle" | "running" | "success" | "failed" | "cancelled"; + currentStep: string | null; + steps: Array<{ + id: string; + label: string; + status: "pending" | "running" | "success" | "failed" | "skipped"; + error?: string; + }>; + logs: string[]; +} + +interface InstallWizardProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onInstallComplete?: () => void; +} + +type WizardPhase = + | "checking" + | "node-missing" + | "already-installed" + | "installing" + | "success" + | "failed"; + +function getInstallAPI() { + if (typeof window !== "undefined") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (window as any).electronAPI?.install as + | { + checkPrerequisites: () => Promise<{ + hasNode: boolean; + nodeVersion?: string; + hasClaude: boolean; + claudeVersion?: string; + }>; + start: () => Promise; + cancel: () => Promise; + getLogs: () => Promise; + onProgress: ( + callback: (progress: InstallProgress) => void + ) => () => void; + } + | undefined; + } + return undefined; +} + +function StepIcon({ status }: { status: string }) { + switch (status) { + case "success": + return ; + case "running": + return ; + case "failed": + return ; + case "skipped": + return ; + default: + return ; + } +} + +export function InstallWizard({ + open, + onOpenChange, + onInstallComplete, +}: InstallWizardProps) { + const [phase, setPhase] = useState("checking"); + const [progress, setProgress] = useState(null); + const [logs, setLogs] = useState([]); + const [copied, setCopied] = useState(false); + const logEndRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + const scrollToBottom = useCallback(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [logs, scrollToBottom]); + + const startInstall = useCallback(async () => { + const api = getInstallAPI(); + if (!api) return; + + setPhase("installing"); + + // Subscribe to progress updates + if (cleanupRef.current) cleanupRef.current(); + cleanupRef.current = api.onProgress((p) => { + setProgress(p); + setLogs(p.logs); + + if (p.status === "success") { + setPhase("success"); + } else if (p.status === "failed" || p.status === "cancelled") { + setPhase("failed"); + } + }); + + try { + await api.start(); + } catch (err: unknown) { + setPhase("failed"); + const msg = err instanceof Error ? err.message : String(err); + setLogs((prev) => [...prev, `Installation error: ${msg}`]); + } + }, []); + + const checkPrereqs = useCallback(async () => { + const api = getInstallAPI(); + if (!api) return; + + setPhase("checking"); + setLogs(["Checking environment..."]); + setProgress(null); + + try { + const result = await api.checkPrerequisites(); + + if (!result.hasNode) { + setPhase("node-missing"); + setLogs((prev) => [ + ...prev, + "Node.js not found.", + ]); + return; + } + + setLogs((prev) => [ + ...prev, + `Node.js ${result.nodeVersion} found.`, + ]); + + if (result.hasClaude) { + setPhase("already-installed"); + setLogs((prev) => [ + ...prev, + `Claude Code ${result.claudeVersion} already installed.`, + ]); + return; + } + + setLogs((prev) => [...prev, "Claude Code not found. Starting installation..."]); + startInstall(); + } catch (err: unknown) { + setPhase("failed"); + const msg = err instanceof Error ? err.message : String(err); + setLogs((prev) => [...prev, `Error checking prerequisites: ${msg}`]); + } + }, [startInstall]); + + const handleCancel = useCallback(async () => { + const api = getInstallAPI(); + if (!api) return; + try { + await api.cancel(); + } catch { + // ignore cancel errors + } + }, []); + + const handleCopyLogs = useCallback(async () => { + try { + await navigator.clipboard.writeText(logs.join("\n")); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard not available + } + }, [logs]); + + const handleDone = useCallback(() => { + onOpenChange(false); + onInstallComplete?.(); + }, [onOpenChange, onInstallComplete]); + + // Auto-check when dialog opens + useEffect(() => { + if (open) { + setPhase("checking"); // eslint-disable-line react-hooks/set-state-in-effect -- reset state before async check + setLogs([]); // eslint-disable-line react-hooks/set-state-in-effect + setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect + setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect + checkPrereqs(); + } + return () => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + }; + }, [open, checkPrereqs]); + + const steps = progress?.steps ?? []; + + return ( + + + + Install Claude Code + + Automatically install Claude Code CLI + + + +
+ {/* Step list */} + {steps.length > 0 && ( +
+ {steps.map((step) => ( +
+ + + {step.label} + + {step.error && ( + + {step.error} + + )} +
+ ))} +
+ )} + + {/* Phase-specific messages */} + {phase === "checking" && steps.length === 0 && ( +
+ + Checking environment... +
+ )} + + {phase === "node-missing" && ( +
+

+ Node.js 18+ is required +

+

+ Please install it from{" "} + + https://nodejs.org + {" "} + and try again. +

+
+ )} + + {phase === "already-installed" && ( +
+ +
+

+ Already installed +

+

+ Claude Code is already available. +

+
+
+ )} + + {phase === "success" && ( +
+ +
+

+ Installation complete +

+

+ Claude Code CLI has been installed successfully. +

+
+
+ )} + + {/* Log output */} + {logs.length > 0 && ( +
+
+ {logs.map((line, i) => ( +
+ {line} +
+ ))} +
+
+
+ )} +
+ + + + + {phase === "node-missing" && ( + + )} + + {phase === "installing" && ( + + )} + + {phase === "failed" && ( + + )} + + {(phase === "success" || phase === "already-installed") && ( + + )} + + +
+ ); +}