Skip to content

Commit 572bd35

Browse files
7418claude
andcommitted
fix: require explicit user confirmation before install, cancel on close, tree-kill on Windows
P0: Add "confirm" phase — wizard checks prereqs then shows Install button, user must explicitly click before any brew/winget/npm command runs. P1: Closing the wizard dialog while install is running now auto-calls install:cancel to stop the backend process. P2: On Windows, use taskkill /T /F /PID to kill the entire process tree instead of just the shell wrapper, ensuring npm/winget actually stops. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 51d4906 commit 572bd35

File tree

2 files changed

+123
-39
lines changed

2 files changed

+123
-39
lines changed

electron/main.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -669,14 +669,23 @@ app.whenReady().then(async () => {
669669
}
670670

671671
installState.status = 'cancelled';
672+
installState.logs.push('Cancelling installation...');
672673

673674
if (installProcess) {
675+
const pid = installProcess.pid;
674676
try {
675-
installProcess.kill();
677+
if (process.platform === 'win32' && pid) {
678+
// Windows: kill entire process tree (shell: true spawns cmd.exe which
679+
// spawns npm/winget — child.kill() only kills the shell, not the tree)
680+
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
681+
} else {
682+
installProcess.kill();
683+
}
676684
} catch {
677685
// already dead
678686
}
679687
installProcess = null;
688+
installState.logs.push('Installation process terminated.');
680689
}
681690

682691
mainWindow?.webContents.send('install:progress', installState);
@@ -738,9 +747,16 @@ app.on('activate', async () => {
738747
});
739748

740749
app.on('before-quit', async (e) => {
741-
// Kill any running install process
750+
// Kill any running install process (tree-kill on Windows)
742751
if (installProcess) {
743-
try { installProcess.kill(); } catch { /* already dead */ }
752+
const pid = installProcess.pid;
753+
try {
754+
if (process.platform === 'win32' && pid) {
755+
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
756+
} else {
757+
installProcess.kill();
758+
}
759+
} catch { /* already dead */ }
744760
installProcess = null;
745761
}
746762

src/components/layout/InstallWizard.tsx

Lines changed: 104 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
LoaderIcon,
1919
CircleIcon,
2020
CopyIcon,
21+
DownloadIcon,
2122
} from "lucide-react";
2223

2324
interface InstallProgress {
@@ -40,22 +41,25 @@ interface InstallWizardProps {
4041

4142
type WizardPhase =
4243
| "checking"
44+
| "confirm"
4345
| "already-installed"
4446
| "installing"
4547
| "success"
4648
| "failed";
4749

50+
interface PrereqResult {
51+
hasNode: boolean;
52+
nodeVersion?: string;
53+
hasClaude: boolean;
54+
claudeVersion?: string;
55+
}
56+
4857
function getInstallAPI() {
4958
if (typeof window !== "undefined") {
5059
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5160
return (window as any).electronAPI?.install as
5261
| {
53-
checkPrerequisites: () => Promise<{
54-
hasNode: boolean;
55-
nodeVersion?: string;
56-
hasClaude: boolean;
57-
claudeVersion?: string;
58-
}>;
62+
checkPrerequisites: () => Promise<PrereqResult>;
5963
start: (options?: { includeNode?: boolean }) => Promise<void>;
6064
cancel: () => Promise<void>;
6165
getLogs: () => Promise<string[]>;
@@ -92,6 +96,7 @@ export function InstallWizard({
9296
const [progress, setProgress] = useState<InstallProgress | null>(null);
9397
const [logs, setLogs] = useState<string[]>([]);
9498
const [copied, setCopied] = useState(false);
99+
const [prereqs, setPrereqs] = useState<PrereqResult | null>(null);
95100
const logEndRef = useRef<HTMLDivElement>(null);
96101
const cleanupRef = useRef<(() => void) | null>(null);
97102

@@ -103,6 +108,17 @@ export function InstallWizard({
103108
scrollToBottom();
104109
}, [logs, scrollToBottom]);
105110

111+
// Cancel backend install and clean up listener
112+
const cancelInstall = useCallback(async () => {
113+
const api = getInstallAPI();
114+
if (!api) return;
115+
try {
116+
await api.cancel();
117+
} catch {
118+
// ignore cancel errors
119+
}
120+
}, []);
121+
106122
const startInstall = useCallback(async (options?: { includeNode?: boolean }) => {
107123
const api = getInstallAPI();
108124
if (!api) return;
@@ -138,9 +154,11 @@ export function InstallWizard({
138154
setPhase("checking");
139155
setLogs(["Checking environment..."]);
140156
setProgress(null);
157+
setPrereqs(null);
141158

142159
try {
143160
const result = await api.checkPrerequisites();
161+
setPrereqs(result);
144162

145163
if (result.hasClaude) {
146164
setLogs((prev) => [
@@ -152,37 +170,34 @@ export function InstallWizard({
152170
return;
153171
}
154172

155-
if (!result.hasNode) {
173+
// Don't auto-install — show confirmation first
174+
if (result.hasNode) {
156175
setLogs((prev) => [
157176
...prev,
158-
"Node.js not found. Will attempt to install Node.js and Claude Code...",
177+
`Node.js ${result.nodeVersion} found.`,
178+
"Claude Code CLI not detected.",
159179
]);
160-
startInstall({ includeNode: true });
161180
} else {
162181
setLogs((prev) => [
163182
...prev,
164-
`Node.js ${result.nodeVersion} found.`,
165-
"Claude Code not found. Starting installation...",
183+
"Node.js not found.",
184+
"Claude Code CLI not detected.",
166185
]);
167-
startInstall();
168186
}
187+
setPhase("confirm");
169188
} catch (err: unknown) {
170189
setPhase("failed");
171190
const msg = err instanceof Error ? err.message : String(err);
172191
setLogs((prev) => [...prev, `Error checking prerequisites: ${msg}`]);
173192
}
174-
}, [startInstall]);
175-
176-
const handleCancel = useCallback(async () => {
177-
const api = getInstallAPI();
178-
if (!api) return;
179-
try {
180-
await api.cancel();
181-
} catch {
182-
// ignore cancel errors
183-
}
184193
}, []);
185194

195+
// User explicitly clicks "Install" — only then start the actual install
196+
const handleConfirmInstall = useCallback(() => {
197+
const needsNode = prereqs ? !prereqs.hasNode : false;
198+
startInstall({ includeNode: needsNode });
199+
}, [prereqs, startInstall]);
200+
186201
const handleCopyLogs = useCallback(async () => {
187202
try {
188203
await navigator.clipboard.writeText(logs.join("\n"));
@@ -198,13 +213,25 @@ export function InstallWizard({
198213
onInstallComplete?.();
199214
}, [onOpenChange, onInstallComplete]);
200215

216+
// [P1] Close dialog = cancel running install
217+
const handleOpenChange = useCallback(
218+
async (nextOpen: boolean) => {
219+
if (!nextOpen && phase === "installing") {
220+
await cancelInstall();
221+
}
222+
onOpenChange(nextOpen);
223+
},
224+
[phase, cancelInstall, onOpenChange]
225+
);
226+
201227
// Auto-check when dialog opens
202228
useEffect(() => {
203229
if (open) {
204230
setPhase("checking"); // eslint-disable-line react-hooks/set-state-in-effect -- reset state before async check
205231
setLogs([]); // eslint-disable-line react-hooks/set-state-in-effect
206232
setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect
207233
setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect
234+
setPrereqs(null); // eslint-disable-line react-hooks/set-state-in-effect
208235
checkPrereqs();
209236
}
210237
return () => {
@@ -218,17 +245,19 @@ export function InstallWizard({
218245
const steps = progress?.steps ?? [];
219246

220247
return (
221-
<Dialog open={open} onOpenChange={onOpenChange}>
248+
<Dialog open={open} onOpenChange={handleOpenChange}>
222249
<DialogContent className="sm:max-w-lg">
223250
<DialogHeader>
224251
<DialogTitle>Install Claude Code</DialogTitle>
225252
<DialogDescription>
226-
Automatically install Claude Code CLI
253+
{phase === "confirm"
254+
? "Claude Code CLI was not detected. Install it now?"
255+
: "Automatically install Claude Code CLI"}
227256
</DialogDescription>
228257
</DialogHeader>
229258

230259
<div className="space-y-4">
231-
{/* Step list */}
260+
{/* Step list (only during/after install) */}
232261
{steps.length > 0 && (
233262
<div className="space-y-2">
234263
{steps.map((step) => (
@@ -258,14 +287,40 @@ export function InstallWizard({
258287
</div>
259288
)}
260289

261-
{/* Phase-specific messages */}
290+
{/* Phase: checking */}
262291
{phase === "checking" && steps.length === 0 && (
263292
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
264293
<LoaderIcon className="size-4 animate-spin" />
265294
<span>Checking environment...</span>
266295
</div>
267296
)}
268297

298+
{/* Phase: confirm — ask user before installing */}
299+
{phase === "confirm" && (
300+
<div className="space-y-3">
301+
<div className="rounded-lg bg-amber-500/10 px-4 py-3 text-sm space-y-1.5">
302+
{prereqs && !prereqs.hasNode && (
303+
<p className="text-amber-700 dark:text-amber-400">
304+
Node.js — not found (will be installed via {process.platform === "win32" ? "winget" : "Homebrew"})
305+
</p>
306+
)}
307+
{prereqs?.hasNode && (
308+
<p className="text-emerald-700 dark:text-emerald-400">
309+
Node.js {prereqs.nodeVersion} — found
310+
</p>
311+
)}
312+
<p className="text-amber-700 dark:text-amber-400">
313+
Claude Code CLI — not found
314+
</p>
315+
</div>
316+
<p className="text-sm text-muted-foreground">
317+
Click <strong>Install</strong> to automatically set up{" "}
318+
{prereqs && !prereqs.hasNode ? "Node.js and " : ""}Claude Code CLI.
319+
</p>
320+
</div>
321+
)}
322+
323+
{/* Phase: already-installed */}
269324
{phase === "already-installed" && (
270325
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
271326
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
@@ -280,6 +335,7 @@ export function InstallWizard({
280335
</div>
281336
)}
282337

338+
{/* Phase: success */}
283339
{phase === "success" && (
284340
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
285341
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
@@ -310,28 +366,40 @@ export function InstallWizard({
310366
</div>
311367

312368
<DialogFooter>
313-
<Button
314-
variant="outline"
315-
size="sm"
316-
onClick={handleCopyLogs}
317-
disabled={logs.length === 0}
318-
>
319-
<CopyIcon />
320-
{copied ? "Copied" : "Copy Logs"}
321-
</Button>
369+
{logs.length > 0 && (
370+
<Button
371+
variant="outline"
372+
size="sm"
373+
onClick={handleCopyLogs}
374+
>
375+
<CopyIcon />
376+
{copied ? "Copied" : "Copy Logs"}
377+
</Button>
378+
)}
379+
380+
{/* Confirm phase: single "Install" button */}
381+
{phase === "confirm" && (
382+
<Button size="sm" onClick={handleConfirmInstall}>
383+
<DownloadIcon />
384+
Install
385+
</Button>
386+
)}
322387

388+
{/* Installing: cancel button */}
323389
{phase === "installing" && (
324-
<Button variant="destructive" size="sm" onClick={handleCancel}>
390+
<Button variant="destructive" size="sm" onClick={cancelInstall}>
325391
Cancel
326392
</Button>
327393
)}
328394

395+
{/* Failed: retry */}
329396
{phase === "failed" && (
330397
<Button size="sm" onClick={checkPrereqs}>
331398
Retry
332399
</Button>
333400
)}
334401

402+
{/* Success / already-installed: done */}
335403
{(phase === "success" || phase === "already-installed") && (
336404
<Button size="sm" onClick={handleDone}>
337405
Done

0 commit comments

Comments
 (0)