diff --git a/electron/main.ts b/electron/main.ts index a836388..9fa5ca5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -418,20 +418,28 @@ app.whenReady().then(async () => { return { hasNode, nodeVersion, hasClaude, claudeVersion }; }); - ipcMain.handle('install:start', () => { + ipcMain.handle('install:start', (_event: Electron.IpcMainInvokeEvent, options?: { includeNode?: boolean }) => { if (installState.status === 'running') { - return { error: 'Installation is already running' }; + throw new Error('Installation is already running'); } + const needsNode = options?.includeNode === true; + // Reset state + const steps: InstallStep[] = []; + if (needsNode) { + steps.push({ id: 'install-node', label: 'Installing Node.js', status: 'pending' }); + } + steps.push( + { 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' }, + ); + 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' }, - ], + steps, logs: [], }; @@ -465,6 +473,83 @@ app.whenReady().then(async () => { // Run the installation sequence asynchronously (async () => { try { + // Step 0 (optional): Install Node.js via package manager + if (needsNode) { + setStep('install-node', 'running'); + + const nodeInstalled = await new Promise((resolve) => { + const isWin = process.platform === 'win32'; + const isMac = process.platform === 'darwin'; + let cmd: string; + let args: string[]; + + if (isMac) { + // Try Homebrew + const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']; + const brewPath = brewPaths.find(p => fs.existsSync(p)); + if (!brewPath) { + addLog('Homebrew not found. Cannot auto-install Node.js on macOS without Homebrew.'); + resolve(false); + return; + } + cmd = brewPath; + args = ['install', 'node']; + addLog(`Running: ${brewPath} install node`); + } else if (isWin) { + cmd = 'winget'; + args = ['install', '-e', '--id', 'OpenJS.NodeJS.LTS', '--accept-source-agreements', '--accept-package-agreements']; + addLog('Running: winget install -e --id OpenJS.NodeJS.LTS'); + } else { + // Linux — no universal package manager + addLog('Auto-install of Node.js is not supported on this platform.'); + resolve(false); + return; + } + + const child = spawn(cmd, args, { + env: execEnv, + shell: isWin, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + installProcess = child; + + child.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().split('\n').filter(Boolean)) { + addLog(line); + } + }); + child.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().split('\n').filter(Boolean)) { + addLog(line); + } + }); + child.on('error', (err) => { + addLog(`Error: ${err.message}`); + resolve(false); + }); + child.on('close', (code) => { + installProcess = null; + resolve(code === 0); + }); + }); + + if (installState.status === 'cancelled') { + setStep('install-node', 'failed', 'Cancelled'); + return; + } + + if (!nodeInstalled) { + setStep('install-node', 'failed', 'Could not auto-install Node.js.'); + installState.status = 'failed'; + sendProgress(); + return; + } + + setStep('install-node', 'success'); + addLog('Node.js installation completed.'); + } + // Step 1: Check node setStep('check-node', 'running'); try { @@ -576,13 +661,11 @@ app.whenReady().then(async () => { sendProgress(); } })(); - - return { ok: true }; }); ipcMain.handle('install:cancel', () => { if (installState.status !== 'running') { - return { error: 'No installation is running' }; + return; } installState.status = 'cancelled'; @@ -597,7 +680,6 @@ app.whenReady().then(async () => { } mainWindow?.webContents.send('install:progress', installState); - return { ok: true }; }); ipcMain.handle('install:get-logs', () => { diff --git a/electron/preload.ts b/electron/preload.ts index 2377ac7..dfb4adc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -9,7 +9,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }, install: { checkPrerequisites: () => ipcRenderer.invoke('install:check-prerequisites'), - start: () => ipcRenderer.invoke('install:start'), + start: (options?: { includeNode?: boolean }) => ipcRenderer.invoke('install:start', options), cancel: () => ipcRenderer.invoke('install:cancel'), getLogs: () => ipcRenderer.invoke('install:get-logs'), onProgress: (callback: (data: unknown) => void) => { diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index f3efcea..45de2d0 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -34,6 +34,7 @@ export function ConnectionStatus() { const stableCountRef = useRef(0); const lastConnectedRef = useRef(null); const timerRef = useRef | null>(null); + const autoPromptedRef = useRef(false); // Use a ref-based approach to avoid circular deps between check and schedule const checkRef = useRef<() => void>(() => {}); @@ -87,6 +88,32 @@ export function ConnectionStatus() { checkStatus(); }, [checkStatus]); + // Auto-prompt install wizard on first disconnect detection (Electron only) + useEffect(() => { + if ( + status !== null && + !status.connected && + isElectron && + !autoPromptedRef.current && + !dialogOpen && + !wizardOpen + ) { + const dismissed = localStorage.getItem("codepilot:install-wizard-dismissed"); + if (!dismissed) { + autoPromptedRef.current = true; + setWizardOpen(true); // eslint-disable-line react-hooks/set-state-in-effect -- intentional: auto-prompt on first disconnect + } + } + }, [status, isElectron, dialogOpen, wizardOpen]); + + const handleWizardOpenChange = useCallback((open: boolean) => { + setWizardOpen(open); + if (!open) { + // Remember that user dismissed the wizard so we don't auto-prompt again + localStorage.setItem("codepilot:install-wizard-dismissed", "1"); + } + }, []); + const connected = status?.connected ?? false; return ( @@ -199,7 +226,7 @@ export function ConnectionStatus() { diff --git a/src/components/layout/InstallWizard.tsx b/src/components/layout/InstallWizard.tsx index aef5fe2..42fca37 100644 --- a/src/components/layout/InstallWizard.tsx +++ b/src/components/layout/InstallWizard.tsx @@ -40,7 +40,6 @@ interface InstallWizardProps { type WizardPhase = | "checking" - | "node-missing" | "already-installed" | "installing" | "success" @@ -57,7 +56,7 @@ function getInstallAPI() { hasClaude: boolean; claudeVersion?: string; }>; - start: () => Promise; + start: (options?: { includeNode?: boolean }) => Promise; cancel: () => Promise; getLogs: () => Promise; onProgress: ( @@ -104,7 +103,7 @@ export function InstallWizard({ scrollToBottom(); }, [logs, scrollToBottom]); - const startInstall = useCallback(async () => { + const startInstall = useCallback(async (options?: { includeNode?: boolean }) => { const api = getInstallAPI(); if (!api) return; @@ -124,7 +123,7 @@ export function InstallWizard({ }); try { - await api.start(); + await api.start(options); } catch (err: unknown) { setPhase("failed"); const msg = err instanceof Error ? err.message : String(err); @@ -143,31 +142,30 @@ export function InstallWizard({ 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, + `Node.js ${result.nodeVersion} found.`, `Claude Code ${result.claudeVersion} already installed.`, ]); + setPhase("already-installed"); return; } - setLogs((prev) => [...prev, "Claude Code not found. Starting installation..."]); - startInstall(); + if (!result.hasNode) { + setLogs((prev) => [ + ...prev, + "Node.js not found. Will attempt to install Node.js and Claude Code...", + ]); + startInstall({ includeNode: true }); + } else { + setLogs((prev) => [ + ...prev, + `Node.js ${result.nodeVersion} found.`, + "Claude Code not found. Starting installation...", + ]); + startInstall(); + } } catch (err: unknown) { setPhase("failed"); const msg = err instanceof Error ? err.message : String(err); @@ -268,26 +266,6 @@ export function InstallWizard({ )} - {phase === "node-missing" && ( -
-

- Node.js 18+ is required -

-

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

-
- )} - {phase === "already-installed" && (
@@ -342,12 +320,6 @@ export function InstallWizard({ {copied ? "Copied" : "Copy Logs"} - {phase === "node-missing" && ( - - )} - {phase === "installing" && (