feat: add one-click Claude Code install wizard
Add an auto-install wizard accessible from the ConnectionStatus dialog when Claude Code CLI is not detected (Electron only). - Electron main: install orchestrator with IPC handlers for check/start/cancel - Preload: expose install API via contextBridge - InstallWizard: multi-step dialog with real-time log streaming - ConnectionStatus: add "Install Automatically" button when disconnected Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
23d05cabe1
commit
e4b917172c
4 files changed
+694
-25
No files matched your search
+283
-24
@@ -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<string, string> = {};
|
||||
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<string, string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number> {
|
||||
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<string, string> = {
|
||||
...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<string, string> = {
|
||||
...userShellEnv,
|
||||
...(process.env as Record<string, string>),
|
||||
...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<boolean>((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();
|
||||
|
||||
+12
-1
@@ -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); };
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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<ClaudeStatus | null>(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<boolean | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -162,6 +169,20 @@ export function ConnectionStatus() {
|
||||
claude --version
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{isElectron && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
setWizardOpen(true);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Install Claude Code Automatically
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -175,6 +196,12 @@ export function ConnectionStatus() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InstallWizard
|
||||
open={wizardOpen}
|
||||
onOpenChange={setWizardOpen}
|
||||
onInstallComplete={handleManualRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
cancel: () => Promise<void>;
|
||||
getLogs: () => Promise<string[]>;
|
||||
onProgress: (
|
||||
callback: (progress: InstallProgress) => void
|
||||
) => () => void;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function StepIcon({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <CheckIcon className="size-4 text-emerald-500" />;
|
||||
case "running":
|
||||
return <LoaderIcon className="size-4 text-blue-500 animate-spin" />;
|
||||
case "failed":
|
||||
return <XIcon className="size-4 text-red-500" />;
|
||||
case "skipped":
|
||||
return <MinusIcon className="size-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <CircleIcon className="size-3.5 text-muted-foreground/40" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function InstallWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
onInstallComplete,
|
||||
}: InstallWizardProps) {
|
||||
const [phase, setPhase] = useState<WizardPhase>("checking");
|
||||
const [progress, setProgress] = useState<InstallProgress | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install Claude Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Automatically install Claude Code CLI
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step list */}
|
||||
{steps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2.5 text-sm"
|
||||
>
|
||||
<StepIcon status={step.status} />
|
||||
<span
|
||||
className={cn(
|
||||
step.status === "pending" && "text-muted-foreground",
|
||||
step.status === "running" && "text-foreground font-medium",
|
||||
step.status === "success" && "text-emerald-700 dark:text-emerald-400",
|
||||
step.status === "failed" && "text-red-700 dark:text-red-400",
|
||||
step.status === "skipped" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{step.error && (
|
||||
<span className="text-xs text-red-500 ml-auto truncate max-w-[200px]">
|
||||
{step.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase-specific messages */}
|
||||
{phase === "checking" && steps.length === 0 && (
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
<span>Checking environment...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "node-missing" && (
|
||||
<div className="rounded-lg bg-red-500/10 px-4 py-3 text-sm space-y-2">
|
||||
<p className="font-medium text-red-700 dark:text-red-400">
|
||||
Node.js 18+ is required
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Please install it from{" "}
|
||||
<a
|
||||
href="https://nodejs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
https://nodejs.org
|
||||
</a>{" "}
|
||||
and try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "already-installed" && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
|
||||
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-emerald-700 dark:text-emerald-400">
|
||||
Already installed
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Claude Code is already available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "success" && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
|
||||
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-emerald-700 dark:text-emerald-400">
|
||||
Installation complete
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Claude Code CLI has been installed successfully.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log output */}
|
||||
{logs.length > 0 && (
|
||||
<div className="rounded-md bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 max-h-48 overflow-y-auto">
|
||||
<div className="p-3 font-mono text-xs text-zinc-300 space-y-0.5">
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyLogs}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<CopyIcon />
|
||||
{copied ? "Copied" : "Copy Logs"}
|
||||
</Button>
|
||||
|
||||
{phase === "node-missing" && (
|
||||
<Button size="sm" onClick={checkPrereqs}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{phase === "installing" && (
|
||||
<Button variant="destructive" size="sm" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{phase === "failed" && (
|
||||
<Button size="sm" onClick={checkPrereqs}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(phase === "success" || phase === "already-installed") && (
|
||||
<Button size="sm" onClick={handleDone}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user