Skip to content

Commit 79052f0

Browse files
betegonclaude
andcommitted
fix(init): handle Ctrl+C gracefully across the init wizard
Catch WizardCancelledError in preamble and update the suspend loop to exit with code 0 (not 1) on user cancellation. Replace abortIfCancelled in git checks with isCancel to return false instead of throwing. Report cancellations to Sentry for telemetry. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ce44816 commit 79052f0

File tree

4 files changed

+76
-6
lines changed

4 files changed

+76
-6
lines changed

src/lib/init/git.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
*/
77

88
import { execFileSync } from "node:child_process";
9-
import { confirm, log } from "@clack/prompts";
10-
import { abortIfCancelled } from "./clack-utils.js";
9+
import { confirm, isCancel, log } from "@clack/prompts";
1110

1211
export function isInsideGitWorkTree(opts: { cwd: string }): boolean {
1312
try {
@@ -61,7 +60,9 @@ export async function checkGitStatus(opts: {
6160
message:
6261
"You are not inside a git repository. Unable to revert changes if something goes wrong. Continue?",
6362
});
64-
abortIfCancelled(proceed);
63+
if (isCancel(proceed)) {
64+
return false;
65+
}
6566
return !!proceed;
6667
}
6768

@@ -78,7 +79,9 @@ export async function checkGitStatus(opts: {
7879
const proceed = await confirm({
7980
message: "Continue with uncommitted changes?",
8081
});
81-
abortIfCancelled(proceed);
82+
if (isCancel(proceed)) {
83+
return false;
84+
}
8285
return !!proceed;
8386
}
8487

src/lib/init/wizard-runner.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { randomBytes } from "node:crypto";
1010
import { cancel, confirm, intro, log, spinner } from "@clack/prompts";
1111
import { MastraClient } from "@mastra/client-js";
12+
import { captureException } from "@sentry/bun";
1213
import { formatBanner } from "../banner.js";
1314
import { CLI_VERSION } from "../constants.js";
1415
import { getAuthToken } from "../db/auth.js";
@@ -208,7 +209,17 @@ async function preamble(
208209
process.stderr.write(`\n${formatBanner()}\n\n`);
209210
intro("sentry init");
210211

211-
const confirmed = await confirmExperimental(yes);
212+
let confirmed: boolean;
213+
try {
214+
confirmed = await confirmExperimental(yes);
215+
} catch (err) {
216+
if (err instanceof WizardCancelledError) {
217+
captureException(err);
218+
process.exitCode = 0;
219+
return false;
220+
}
221+
throw err;
222+
}
212223
if (!confirmed) {
213224
cancel("Setup cancelled.");
214225
process.exitCode = 0;
@@ -335,7 +346,8 @@ export async function runWizard(options: WizardOptions): Promise<void> {
335346
}
336347
} catch (err) {
337348
if (err instanceof WizardCancelledError) {
338-
process.exitCode = 1;
349+
captureException(err);
350+
process.exitCode = 0;
339351
return;
340352
}
341353
if (spinState.running) {

test/lib/init/git.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,24 @@ const noop = () => {
2626

2727
let execFileSyncSpy: ReturnType<typeof spyOn>;
2828
let confirmSpy: ReturnType<typeof spyOn>;
29+
let isCancelSpy: ReturnType<typeof spyOn>;
2930
let logWarnSpy: ReturnType<typeof spyOn>;
3031

3132
// ── Setup / Teardown ────────────────────────────────────────────────────────
3233

3334
beforeEach(() => {
3435
execFileSyncSpy = spyOn(cp, "execFileSync");
3536
confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true);
37+
isCancelSpy = spyOn(clack, "isCancel").mockImplementation(
38+
(v: unknown) => v === Symbol.for("cancel")
39+
);
3640
logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop);
3741
});
3842

3943
afterEach(() => {
4044
execFileSyncSpy.mockRestore();
4145
confirmSpy.mockRestore();
46+
isCancelSpy.mockRestore();
4247
logWarnSpy.mockRestore();
4348
});
4449

@@ -133,6 +138,17 @@ describe("checkGitStatus", () => {
133138
expect(result).toBe(false);
134139
});
135140

141+
test("returns false without throwing when user cancels not-in-git-repo prompt", async () => {
142+
execFileSyncSpy.mockImplementation(() => {
143+
throw new Error("not a git repo");
144+
});
145+
confirmSpy.mockResolvedValue(Symbol.for("cancel"));
146+
147+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
148+
149+
expect(result).toBe(false);
150+
});
151+
136152
test("warns and auto-continues when not in git repo with --yes", async () => {
137153
execFileSyncSpy.mockImplementation(() => {
138154
throw new Error("not a git repo");
@@ -179,6 +195,17 @@ describe("checkGitStatus", () => {
179195
expect(result).toBe(false);
180196
});
181197

198+
test("returns false without throwing when user cancels dirty-tree prompt", async () => {
199+
execFileSyncSpy
200+
.mockReturnValueOnce(Buffer.from("true\n"))
201+
.mockReturnValueOnce(Buffer.from(" M dirty.ts\n"));
202+
confirmSpy.mockResolvedValue(Symbol.for("cancel"));
203+
204+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
205+
206+
expect(result).toBe(false);
207+
});
208+
182209
test("warns with file list and auto-continues for dirty tree with --yes", async () => {
183210
execFileSyncSpy
184211
.mockReturnValueOnce(Buffer.from("true\n"))

test/lib/init/wizard-runner.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function makeOptions(overrides?: Partial<WizardOptions>): WizardOptions {
5555
// ── Spy declarations ────────────────────────────────────────────────────────
5656

5757
// clack
58+
let isCancelSpy: ReturnType<typeof spyOn>;
5859
let introSpy: ReturnType<typeof spyOn>;
5960
let confirmSpy: ReturnType<typeof spyOn>;
6061
let logInfoSpy: ReturnType<typeof spyOn>;
@@ -129,6 +130,9 @@ beforeEach(() => {
129130
process.exitCode = 0;
130131

131132
// clack spies
133+
isCancelSpy = spyOn(clack, "isCancel").mockImplementation(
134+
(v: unknown) => v === Symbol.for("cancel")
135+
);
132136
introSpy = spyOn(clack, "intro").mockImplementation(noop);
133137
confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true);
134138
logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop);
@@ -171,6 +175,7 @@ beforeEach(() => {
171175
});
172176

173177
afterEach(() => {
178+
isCancelSpy.mockRestore();
174179
introSpy.mockRestore();
175180
confirmSpy.mockRestore();
176181
logInfoSpy.mockRestore();
@@ -264,6 +269,29 @@ describe("runWizard", () => {
264269
expect(formatResultSpy).toHaveBeenCalled();
265270
});
266271

272+
test("exits cleanly when user presses Ctrl+C on experimental warning", async () => {
273+
const origIsTTY = process.stdin.isTTY;
274+
Object.defineProperty(process.stdin, "isTTY", {
275+
value: true,
276+
configurable: true,
277+
});
278+
279+
confirmSpy.mockResolvedValue(Symbol.for("cancel"));
280+
281+
await runWizard(makeOptions({ yes: false }));
282+
283+
Object.defineProperty(process.stdin, "isTTY", {
284+
value: origIsTTY,
285+
configurable: true,
286+
});
287+
288+
expect(cancelSpy).toHaveBeenCalledWith(
289+
expect.stringContaining("Setup cancelled")
290+
);
291+
expect(process.exitCode).toBe(0);
292+
expect(formatResultSpy).not.toHaveBeenCalled();
293+
});
294+
267295
test("exits cleanly when user declines experimental warning", async () => {
268296
const origIsTTY = process.stdin.isTTY;
269297
Object.defineProperty(process.stdin, "isTTY", {

0 commit comments

Comments
 (0)