Skip to content

Commit 081b94c

Browse files
betegonclaude
andauthored
feat(init): add experimental warning before wizard runs (#378)
## Summary Shows a confirm prompt when running `sentry init` warning that the feature is experimental and may modify code. Users must explicitly agree before the wizard proceeds. The prompt is skipped when `--yes` is passed (non-interactive mode). ## Changes <img width="1172" height="688" alt="Screenshot 2026-03-10 at 11 35 02" src="https://github.com/user-attachments/assets/213e9f1c-74de-4635-bb90-42b76a9e5f55" /> Adds a `confirmExperimental()` helper that gates on the `--yes` flag, and extracts a `preamble()` function from `runWizard` to bundle the TTY check, banner, intro, experimental warning, and dry-run notice. This also resolves the cognitive complexity lint violation that would have been introduced by inlining the new branch. Three new tests cover the confirm/skip/decline paths. ## Test Plan - `bun test test/lib/init/wizard-runner.test.ts` — 22 tests pass - `bun run lint` — clean - Manual: `bun run cli/src/bin.ts init` shows the experimental confirm prompt - Manual: `bun run cli/src/bin.ts init --yes` skips it Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent db15463 commit 081b94c

File tree

2 files changed

+104
-7
lines changed

2 files changed

+104
-7
lines changed

src/lib/init/wizard-runner.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
*/
88

99
import { randomBytes } from "node:crypto";
10-
import { cancel, intro, log, spinner } from "@clack/prompts";
10+
import { cancel, confirm, intro, log, spinner } from "@clack/prompts";
1111
import { MastraClient } from "@mastra/client-js";
1212
import { formatBanner } from "../banner.js";
1313
import { CLI_VERSION } from "../constants.js";
1414
import { getAuthToken } from "../db/auth.js";
15-
import { STEP_LABELS, WizardCancelledError } from "./clack-utils.js";
15+
import {
16+
abortIfCancelled,
17+
STEP_LABELS,
18+
WizardCancelledError,
19+
} from "./clack-utils.js";
1620
import {
1721
API_TIMEOUT_MS,
1822
MASTRA_API_URL,
@@ -170,24 +174,56 @@ function withTimeout<T>(
170174
});
171175
}
172176

173-
export async function runWizard(options: WizardOptions): Promise<void> {
174-
const { directory, yes, dryRun, features } = options;
177+
/** Returns `true` if the user confirmed, `false` if they declined. */
178+
async function confirmExperimental(yes: boolean): Promise<boolean> {
179+
if (yes) {
180+
return true;
181+
}
182+
const proceed = await confirm({
183+
message:
184+
"EXPERIMENTAL: This feature is experimental and may modify your code. Continue?",
185+
});
186+
abortIfCancelled(proceed);
187+
return !!proceed;
188+
}
175189

190+
/**
191+
* Pre-flight checks: TTY guard, banner, intro, and experimental warning.
192+
* Returns `true` when the wizard should continue, `false` to abort.
193+
*/
194+
async function preamble(yes: boolean, dryRun: boolean): Promise<boolean> {
176195
if (!(yes || process.stdin.isTTY)) {
177196
process.stderr.write(
178197
"Error: Interactive mode requires a terminal. Use --yes for non-interactive mode.\n"
179198
);
180199
process.exitCode = 1;
181-
return;
200+
return false;
182201
}
183202

184203
process.stderr.write(`\n${formatBanner()}\n\n`);
185204
intro("sentry init");
186205

206+
const confirmed = await confirmExperimental(yes);
207+
if (!confirmed) {
208+
cancel("Setup cancelled.");
209+
process.exitCode = 0;
210+
return false;
211+
}
212+
187213
if (dryRun) {
188214
log.warn("Dry-run mode: no files will be modified.");
189215
}
190216

217+
return true;
218+
}
219+
220+
export async function runWizard(options: WizardOptions): Promise<void> {
221+
const { directory, yes, dryRun, features } = options;
222+
223+
if (!(await preamble(yes, dryRun))) {
224+
return;
225+
}
226+
191227
log.info(
192228
"This wizard uses AI to analyze your project and configure Sentry." +
193229
`\nFor manual setup: ${SENTRY_DOCS_URL}`

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function makeOptions(overrides?: Partial<WizardOptions>): WizardOptions {
5454

5555
// clack
5656
let introSpy: ReturnType<typeof spyOn>;
57+
let confirmSpy: ReturnType<typeof spyOn>;
5758
let logInfoSpy: ReturnType<typeof spyOn>;
5859
let logWarnSpy: ReturnType<typeof spyOn>;
5960
let logErrorSpy: ReturnType<typeof spyOn>;
@@ -124,6 +125,7 @@ beforeEach(() => {
124125

125126
// clack spies
126127
introSpy = spyOn(clack, "intro").mockImplementation(noop);
128+
confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true);
127129
logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop);
128130
logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop);
129131
logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop);
@@ -162,6 +164,7 @@ beforeEach(() => {
162164

163165
afterEach(() => {
164166
introSpy.mockRestore();
167+
confirmSpy.mockRestore();
165168
logInfoSpy.mockRestore();
166169
logWarnSpy.mockRestore();
167170
logErrorSpy.mockRestore();
@@ -218,6 +221,62 @@ describe("runWizard", () => {
218221
});
219222
});
220223

224+
describe("experimental warning", () => {
225+
test("shows experimental warning and proceeds on confirm", async () => {
226+
const origIsTTY = process.stdin.isTTY;
227+
Object.defineProperty(process.stdin, "isTTY", {
228+
value: true,
229+
configurable: true,
230+
});
231+
232+
mockStartResult = { status: "success" };
233+
234+
await runWizard(makeOptions({ yes: false }));
235+
236+
Object.defineProperty(process.stdin, "isTTY", {
237+
value: origIsTTY,
238+
configurable: true,
239+
});
240+
241+
expect(confirmSpy).toHaveBeenCalledWith(
242+
expect.objectContaining({
243+
message: expect.stringContaining("EXPERIMENTAL"),
244+
})
245+
);
246+
expect(formatResultSpy).toHaveBeenCalled();
247+
});
248+
249+
test("skips experimental warning with --yes", async () => {
250+
mockStartResult = { status: "success" };
251+
252+
await runWizard(makeOptions({ yes: true }));
253+
254+
expect(confirmSpy).not.toHaveBeenCalled();
255+
expect(formatResultSpy).toHaveBeenCalled();
256+
});
257+
258+
test("exits cleanly when user declines experimental warning", async () => {
259+
const origIsTTY = process.stdin.isTTY;
260+
Object.defineProperty(process.stdin, "isTTY", {
261+
value: true,
262+
configurable: true,
263+
});
264+
265+
confirmSpy.mockResolvedValue(false);
266+
267+
await runWizard(makeOptions({ yes: false }));
268+
269+
Object.defineProperty(process.stdin, "isTTY", {
270+
value: origIsTTY,
271+
configurable: true,
272+
});
273+
274+
expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled.");
275+
expect(process.exitCode).toBe(0);
276+
expect(formatResultSpy).not.toHaveBeenCalled();
277+
});
278+
});
279+
221280
describe("connection error", () => {
222281
test("times out if startAsync hangs", async () => {
223282
jest.useFakeTimers();
@@ -243,8 +302,10 @@ describe("runWizard", () => {
243302
const promise = runWizard(makeOptions());
244303

245304
// Flush microtasks so runWizard reaches the withTimeout setTimeout
246-
await Promise.resolve();
247-
await Promise.resolve();
305+
// (extra ticks needed for async preamble + confirmExperimental)
306+
for (let i = 0; i < 8; i++) {
307+
await Promise.resolve();
308+
}
248309

249310
// Advance past the timeout
250311
jest.advanceTimersByTime(API_TIMEOUT_MS);

0 commit comments

Comments
 (0)