Skip to content

Commit 2009674

Browse files
betegonclaude
andauthored
refactor(init): route wizard errors through framework error pipeline (#678)
## Summary - Add `WizardError` class extending `CliError` with a `rendered` flag — tells the framework error handler to skip re-displaying errors that clack already showed - Replace all 8 `process.exitCode = 1` locations in `wizard-runner.ts` with `throw new WizardError(...)` - Errors now flow through the framework pipeline: telemetry capture via `Sentry.captureException`, consistent exit code handling, future error improvements apply automatically - User cancellation paths (`exitCode = 0`) are unchanged ## Test plan - [ ] `sentry init` in a project → cancel at experimental warning → exits 0 (unchanged) - [ ] `sentry init` with no network → shows error, exits 1 (now via framework) - [ ] `echo '' | sentry init` → shows TTY error, exits 1 (now via `WizardError({ rendered: false })`) - [ ] Verify no double error output (clack `cancel()` + framework error message) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6edb045 commit 2009674

File tree

4 files changed

+65
-50
lines changed

4 files changed

+65
-50
lines changed

src/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
getExitCode,
5252
OutputError,
5353
stringifyUnknown,
54+
WizardError,
5455
} from "./lib/errors.js";
5556
import { error as errorColor, warning } from "./lib/formatters/colors.js";
5657
import { isRouteMap, type RouteMap } from "./lib/introspect.js";
@@ -324,6 +325,11 @@ const customText: ApplicationText = {
324325
Sentry.captureException(exc);
325326

326327
if (exc instanceof CliError) {
328+
// WizardError with rendered=true: clack already displayed the error.
329+
// Return empty string to avoid double output, exit code flows through.
330+
if (exc instanceof WizardError && exc.rendered) {
331+
return "";
332+
}
327333
const prefix = ansiColor ? errorColor("Error:") : "Error:";
328334
return `${prefix} ${exc.format()}`;
329335
}

src/lib/errors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,21 @@ export class TimeoutError extends CliError {
476476
}
477477
}
478478

479+
/**
480+
* Error thrown by the init wizard when it has already displayed
481+
* the error via clack UI. The `rendered` flag tells the framework
482+
* error handler to skip its own formatting.
483+
*/
484+
export class WizardError extends CliError {
485+
readonly rendered: boolean;
486+
487+
constructor(message: string, options?: { rendered?: boolean }) {
488+
super(message);
489+
this.name = "WizardError";
490+
this.rendered = options?.rendered ?? true;
491+
}
492+
}
493+
479494
// Error Utilities
480495

481496
/**

src/lib/init/wizard-runner.ts

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createTeam, listTeams } from "../api-client.js";
2424
import { formatBanner } from "../banner.js";
2525
import { CLI_VERSION } from "../constants.js";
2626
import { getAuthToken } from "../db/auth.js";
27+
import { WizardError } from "../errors.js";
2728
import { terminalLink } from "../formatters/colors.js";
2829
import { getSentryBaseUrl } from "../sentry-urls.js";
2930
import { slugify } from "../utils.js";
@@ -326,11 +327,10 @@ async function preamble(
326327
dryRun: boolean
327328
): Promise<boolean> {
328329
if (!(yes || dryRun || process.stdin.isTTY)) {
329-
process.stderr.write(
330-
"Error: Interactive mode requires a terminal. Use --yes for non-interactive mode.\n"
330+
throw new WizardError(
331+
"Interactive mode requires a terminal. Use --yes for non-interactive mode.",
332+
{ rendered: false }
331333
);
332-
process.exitCode = 1;
333-
return false;
334334
}
335335

336336
process.stderr.write(`\n${formatBanner()}\n\n`);
@@ -450,14 +450,14 @@ async function resolvePreSpinnerOptions(
450450
}
451451
log.error(errorMessage(err));
452452
cancel("Setup failed.");
453-
process.exitCode = 1;
454-
return null;
453+
throw new WizardError(errorMessage(err));
455454
}
456455
if (typeof orgResult !== "string") {
457456
log.error(orgResult.error ?? "Failed to resolve organization.");
458457
cancel("Setup failed.");
459-
process.exitCode = 1;
460-
return null;
458+
throw new WizardError(
459+
orgResult.error ?? "Failed to resolve organization."
460+
);
461461
}
462462
opts = { ...opts, org: orgResult };
463463
}
@@ -524,8 +524,7 @@ async function resolvePreSpinnerOptions(
524524
`Create one at ${terminalLink(teamsUrl)} and run sentry init again.`
525525
);
526526
cancel("Setup failed.");
527-
process.exitCode = 1;
528-
return null;
527+
throw new WizardError("No teams in your organization.");
529528
}
530529
} else if (teams.length === 1) {
531530
opts = { ...opts, team: (teams[0] as SentryTeam).slug };
@@ -565,6 +564,7 @@ async function resolvePreSpinnerOptions(
565564
return opts;
566565
}
567566

567+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential wizard orchestration with error handling branches
568568
export async function runWizard(initialOptions: WizardOptions): Promise<void> {
569569
const { directory, yes, dryRun, features } = initialOptions;
570570

@@ -646,8 +646,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
646646
spinState.running = false;
647647
log.error(errorMessage(err));
648648
cancel("Setup failed");
649-
process.exitCode = 1;
650-
return;
649+
throw new WizardError(errorMessage(err));
651650
}
652651

653652
const stepPhases = new Map<string, number>();
@@ -664,8 +663,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
664663
spinState.running = false;
665664
log.error(`No suspend payload found for step "${stepId}"`);
666665
cancel("Setup failed");
667-
process.exitCode = 1;
668-
return;
666+
throw new WizardError(`No suspend payload found for step "${stepId}"`);
669667
}
670668

671669
const resumeData = await handleSuspendedStep(
@@ -700,14 +698,17 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
700698
process.exitCode = 0;
701699
return;
702700
}
701+
// Already rendered by an inner throw — don't double-display
702+
if (err instanceof WizardError) {
703+
throw err;
704+
}
703705
if (spinState.running) {
704706
spin.stop("Error", 1);
705707
spinState.running = false;
706708
}
707709
log.error(errorMessage(err));
708710
cancel("Setup failed");
709-
process.exitCode = 1;
710-
return;
711+
throw new WizardError(errorMessage(err));
711712
}
712713

713714
handleFinalResult(result, spin, spinState);
@@ -726,14 +727,14 @@ function handleFinalResult(
726727
spinState.running = false;
727728
}
728729
formatError(result);
729-
process.exitCode = 1;
730-
} else {
731-
if (spinState.running) {
732-
spin.stop("Done");
733-
spinState.running = false;
734-
}
735-
formatResult(result);
730+
throw new WizardError("Workflow returned an error");
731+
}
732+
733+
if (spinState.running) {
734+
spin.stop("Done");
735+
spinState.running = false;
736736
}
737+
formatResult(result);
737738
}
738739

739740
function extractSuspendPayload(

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

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import * as banner from "../../../src/lib/banner.js";
2727
import * as auth from "../../../src/lib/db/auth.js";
2828
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
2929
import * as userDb from "../../../src/lib/db/user.js";
30+
import { WizardError } from "../../../src/lib/errors.js";
3031
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
3132
import * as fmt from "../../../src/lib/init/formatters.js";
3233
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
@@ -259,7 +260,11 @@ afterEach(() => {
259260

260261
// ── Tests ───────────────────────────────────────────────────────────────────
261262

262-
describe("runWizard", () => {
263+
// Guard against tests that accidentally wait for interactive input.
264+
// If a test hangs for 10s it's almost certainly blocked on stdin, not slow I/O.
265+
const TEST_TIMEOUT_MS = 10_000;
266+
267+
describe("runWizard", { timeout: TEST_TIMEOUT_MS }, () => {
263268
describe("success path", () => {
264269
test("calls formatResult when workflow completes successfully", async () => {
265270
mockStartResult = { status: "success", result: { platform: "React" } };
@@ -273,23 +278,21 @@ describe("runWizard", () => {
273278
});
274279

275280
describe("TTY check", () => {
276-
test("writes error to stderr when not TTY and not --yes", async () => {
281+
test("throws WizardError when not TTY and not --yes", async () => {
277282
const origIsTTY = process.stdin.isTTY;
278283
Object.defineProperty(process.stdin, "isTTY", {
279284
value: false,
280285
configurable: true,
281286
});
282287

283-
await runWizard(makeOptions({ yes: false }));
288+
await expect(runWizard(makeOptions({ yes: false }))).rejects.toThrow(
289+
WizardError
290+
);
284291

285292
Object.defineProperty(process.stdin, "isTTY", {
286293
value: origIsTTY,
287294
configurable: true,
288295
});
289-
290-
const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
291-
expect(written).toContain("Interactive mode requires a terminal");
292-
expect(process.exitCode).toBe(1);
293296
});
294297
});
295298

@@ -404,12 +407,11 @@ describe("runWizard", () => {
404407
// Advance past the timeout
405408
jest.advanceTimersByTime(API_TIMEOUT_MS);
406409

407-
await promise;
410+
await expect(promise).rejects.toThrow(WizardError);
408411

409412
expect(logErrorSpy).toHaveBeenCalled();
410413
const errorMsg: string = logErrorSpy.mock.calls[0][0];
411414
expect(errorMsg).toContain("timed out");
412-
expect(process.exitCode).toBe(1);
413415

414416
jest.useRealTimers();
415417
});
@@ -424,23 +426,21 @@ describe("runWizard", () => {
424426
};
425427
getWorkflowSpy.mockReturnValue(mockWorkflow as any);
426428

427-
await runWizard(makeOptions());
429+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
428430

429431
expect(logErrorSpy).toHaveBeenCalledWith("Connection refused");
430432
expect(cancelSpy).toHaveBeenCalledWith("Setup failed");
431-
expect(process.exitCode).toBe(1);
432433
});
433434
});
434435

435436
describe("workflow failure", () => {
436437
test("calls formatError when status is failed", async () => {
437438
mockStartResult = { status: "failed", error: "workflow exploded" };
438439

439-
await runWizard(makeOptions());
440+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
440441

441442
expect(formatErrorSpy).toHaveBeenCalled();
442443
expect(formatResultSpy).not.toHaveBeenCalled();
443-
expect(process.exitCode).toBe(1);
444444
});
445445
});
446446

@@ -451,10 +451,9 @@ describe("runWizard", () => {
451451
result: { exitCode: 10 },
452452
};
453453

454-
await runWizard(makeOptions());
454+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
455455

456456
expect(formatErrorSpy).toHaveBeenCalled();
457-
expect(process.exitCode).toBe(1);
458457
});
459458
});
460459

@@ -734,12 +733,11 @@ describe("runWizard", () => {
734733
},
735734
};
736735

737-
await runWizard(makeOptions());
736+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
738737

739738
expect(logErrorSpy).toHaveBeenCalled();
740739
const errorMsg: string = logErrorSpy.mock.calls[0][0];
741740
expect(errorMsg).toContain("alien");
742-
expect(process.exitCode).toBe(1);
743741
});
744742

745743
test("handles missing suspend payload", async () => {
@@ -749,12 +747,11 @@ describe("runWizard", () => {
749747
steps: {},
750748
};
751749

752-
await runWizard(makeOptions());
750+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
753751

754752
expect(logErrorSpy).toHaveBeenCalled();
755753
const errorMsg: string = logErrorSpy.mock.calls[0][0];
756754
expect(errorMsg).toContain("No suspend payload");
757-
expect(process.exitCode).toBe(1);
758755
});
759756

760757
test("non-WizardCancelledError in catch triggers log.error + cancel", async () => {
@@ -775,11 +772,10 @@ describe("runWizard", () => {
775772
},
776773
};
777774

778-
await runWizard(makeOptions());
775+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
779776

780777
expect(logErrorSpy).toHaveBeenCalledWith("string error");
781778
expect(cancelSpy).toHaveBeenCalledWith("Setup failed");
782-
expect(process.exitCode).toBe(1);
783779
});
784780

785781
test("falls back to result.suspendPayload when step payload missing", async () => {
@@ -880,12 +876,11 @@ describe("runWizard", () => {
880876
};
881877
getWorkflowSpy.mockReturnValue(badWorkflow as any);
882878

883-
await runWizard(makeOptions());
879+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
884880

885881
expect(logErrorSpy).toHaveBeenCalledWith(
886882
"Invalid workflow response: expected object"
887883
);
888-
expect(process.exitCode).toBe(1);
889884
});
890885

891886
test("rejects response with invalid status", async () => {
@@ -900,12 +895,11 @@ describe("runWizard", () => {
900895
};
901896
getWorkflowSpy.mockReturnValue(badWorkflow as any);
902897

903-
await runWizard(makeOptions());
898+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
904899

905900
expect(logErrorSpy).toHaveBeenCalledWith(
906901
"Unexpected workflow status: banana"
907902
);
908-
expect(process.exitCode).toBe(1);
909903
});
910904

911905
test("rejects null response from startAsync", async () => {
@@ -918,12 +912,11 @@ describe("runWizard", () => {
918912
};
919913
getWorkflowSpy.mockReturnValue(badWorkflow as any);
920914

921-
await runWizard(makeOptions());
915+
await expect(runWizard(makeOptions())).rejects.toThrow(WizardError);
922916

923917
expect(logErrorSpy).toHaveBeenCalledWith(
924918
"Invalid workflow response: expected object"
925919
);
926-
expect(process.exitCode).toBe(1);
927920
});
928921
});
929922
});

0 commit comments

Comments
 (0)