From 81be6e2ff676834e5fb01621afaf2c42b04ebcc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 30 Mar 2026 19:58:39 +0200 Subject: [PATCH 1/8] fix(init): move org/project prompts before spinner Clack's design is prompts-first, then tasks. The previous code called select() inside createSentryProject (a local-op) while the spinner was still running, causing the spinner's setInterval to write output below the active prompt's bottom border. Fix: resolve org and detect existing project before spin.start() in runWizard(). Since options.org is now always set by the time the spinner starts, createSentryProject's interactive prompts are automatically skipped (they already guard on options.org). Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/local-ops.ts | 4 +- src/lib/init/wizard-runner.ts | 80 +++++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 9bbc8c8fcd..7518c2ba1a 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -672,7 +672,7 @@ function applyPatchset( * * @returns The org slug on success, or a {@link LocalOpResult} error to return early. */ -async function resolveOrgSlug( +export async function resolveOrgSlug( cwd: string, yes: boolean ): Promise { @@ -766,7 +766,7 @@ async function tryGetExistingProject( * either from the local cache or via API (when the org is accessible). * Returns null when no DSN is found or the org belongs to a different account. */ -async function detectExistingProject(cwd: string): Promise<{ +export async function detectExistingProject(cwd: string): Promise<{ orgSlug: string; projectSlug: string; } | null> { diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 4db3835dd0..be247fc9a1 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -7,7 +7,15 @@ */ import { randomBytes } from "node:crypto"; -import { cancel, confirm, intro, log, spinner } from "@clack/prompts"; +import { + cancel, + confirm, + intro, + isCancel, + log, + select, + spinner, +} from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; import { captureException, getTraceData } from "@sentry/node-core/light"; import { formatBanner } from "../banner.js"; @@ -29,7 +37,12 @@ import { import { formatError, formatResult } from "./formatters.js"; import { checkGitStatus } from "./git.js"; import { handleInteractive } from "./interactive.js"; -import { handleLocalOp, precomputeDirListing } from "./local-ops.js"; +import { + detectExistingProject, + handleLocalOp, + precomputeDirListing, + resolveOrgSlug, +} from "./local-ops.js"; import type { SuspendPayload, WizardOptions, @@ -258,8 +271,9 @@ async function preamble( return true; } -export async function runWizard(options: WizardOptions): Promise { - const { directory, yes, dryRun, features } = options; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard orchestration requires sequential branching +export async function runWizard(initialOptions: WizardOptions): Promise { + const { directory, yes, dryRun, features } = initialOptions; if (!(await preamble(directory, yes, dryRun))) { return; @@ -270,6 +284,64 @@ export async function runWizard(options: WizardOptions): Promise { `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` ); + // --- Prompt phase (must complete before the spinner starts) --- + // Clack's design: all interactive prompts before tasks/spinner. + let options = initialOptions; + + // Check for an existing Sentry project when user hasn't specified org/project. + if (!(options.org || options.project)) { + const existing = await detectExistingProject(directory); + if (existing) { + if (yes) { + options = { + ...options, + org: existing.orgSlug, + project: existing.projectSlug, + }; + } else { + const choice = await select({ + message: "Found an existing Sentry project in this codebase.", + options: [ + { + value: "existing" as const, + label: `Use existing project (${existing.orgSlug}/${existing.projectSlug})`, + hint: "Sentry is already configured here", + }, + { + value: "create" as const, + label: "Create a new Sentry project", + }, + ], + }); + if (isCancel(choice)) { + cancel("Setup cancelled."); + process.exitCode = 0; + return; + } + if (choice === "existing") { + options = { + ...options, + org: existing.orgSlug, + project: existing.projectSlug, + }; + } + } + } + } + + // Resolve org before spinner so no prompt appears while spinner is running. + if (!options.org) { + const orgResult = await resolveOrgSlug(directory, yes); + if (typeof orgResult !== "string") { + log.error(orgResult.error ?? "Failed to resolve organization."); + cancel("Setup failed."); + process.exitCode = 1; + return; + } + options = { ...options, org: orgResult }; + } + // --- End prompt phase --- + const tracingOptions = { traceId: randomBytes(16).toString("hex"), tags: ["sentry-cli", "init-wizard"], From b882dbe915a08a29a6db07ba83aa5eaf45610e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 30 Mar 2026 20:25:11 +0200 Subject: [PATCH 2/8] fix(init): gracefully handle org prompt cancellation When a user cancelled the org selection prompt, resolveOrgSlug returned { ok: false, error: "Organisation selection cancelled." }, which the pre-spinner handler treated as a hard failure (exit code 1, "Setup failed."). Fix: throw WizardCancelledError on cancel (consistent with abortIfCancelled) and catch it in the pre-spinner phase for a graceful exit (code 0, "Setup cancelled."). Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/local-ops.ts | 3 ++- src/lib/init/wizard-runner.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 7518c2ba1a..f5d5d117fd 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -19,6 +19,7 @@ import { ApiError } from "../errors.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { slugify } from "../utils.js"; +import { WizardCancelledError } from "./clack-utils.js"; import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_FILE_BYTES, @@ -722,7 +723,7 @@ export async function resolveOrgSlug( })), }); if (isCancel(selected)) { - return { ok: false, error: "Organization selection cancelled." }; + throw new WizardCancelledError(); } return selected; } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index be247fc9a1..3225c60684 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -44,6 +44,7 @@ import { resolveOrgSlug, } from "./local-ops.js"; import type { + LocalOpResult, SuspendPayload, WizardOptions, WorkflowRunResult, @@ -331,7 +332,17 @@ export async function runWizard(initialOptions: WizardOptions): Promise { // Resolve org before spinner so no prompt appears while spinner is running. if (!options.org) { - const orgResult = await resolveOrgSlug(directory, yes); + let orgResult: string | LocalOpResult; + try { + orgResult = await resolveOrgSlug(directory, yes); + } catch (err) { + if (err instanceof WizardCancelledError) { + cancel("Setup cancelled."); + process.exitCode = 0; + return; + } + throw err; + } if (typeof orgResult !== "string") { log.error(orgResult.error ?? "Failed to resolve organization."); cancel("Setup failed."); From 901ab21cc878082225034edf8bd3a686183a1a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 11:52:20 +0200 Subject: [PATCH 3/8] fix(init): handle network/auth errors in org resolution gracefully Re-throwing non-WizardCancelledError exceptions from resolveOrgSlug caused unhandled rejections on network or auth failures. Handle them with log.error + cancel + exit 1, consistent with the rest of the wizard error handling. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/wizard-runner.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 3225c60684..9ef342cd40 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -341,7 +341,10 @@ export async function runWizard(initialOptions: WizardOptions): Promise { process.exitCode = 0; return; } - throw err; + log.error(errorMessage(err)); + cancel("Setup failed."); + process.exitCode = 1; + return; } if (typeof orgResult !== "string") { log.error(orgResult.error ?? "Failed to resolve organization."); From 570bed7beb6f9442197297de5fdf44decd71adfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 12:00:25 +0200 Subject: [PATCH 4/8] refactor(init): extract resolvePreSpinnerOptions for clarity Move the pre-spinner prompt phase into a dedicated helper, following the same pattern as the existing preamble() and confirmExperimental() helpers. Fixes noParameterAssign lint violation by using a local `opts` variable. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/wizard-runner.ts | 79 +++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 9ef342cd40..9a12ffbaa9 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -272,30 +272,29 @@ async function preamble( return true; } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard orchestration requires sequential branching -export async function runWizard(initialOptions: WizardOptions): Promise { - const { directory, yes, dryRun, features } = initialOptions; - - if (!(await preamble(directory, yes, dryRun))) { - return; - } - - log.info( - "This wizard uses AI to analyze your project and configure Sentry." + - `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` - ); - - // --- Prompt phase (must complete before the spinner starts) --- - // Clack's design: all interactive prompts before tasks/spinner. - let options = initialOptions; - - // Check for an existing Sentry project when user hasn't specified org/project. - if (!(options.org || options.project)) { +/** + * Resolve org and detect an existing Sentry project before the spinner starts. + * + * Clack requires all interactive prompts to complete before any spinner/task + * begins — the spinner's setInterval writes output below an active prompt if + * interleaved. This function surfaces all interactive decisions upfront. + * + * @returns Updated options with org and project resolved, or `null` to abort. + * When `null` is returned, `process.exitCode` is already set. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential wizard pre-flight branches are inherently nested +async function resolvePreSpinnerOptions( + options: WizardOptions +): Promise { + const { directory, yes } = options; + let opts = options; + + if (!(opts.org || opts.project)) { const existing = await detectExistingProject(directory); if (existing) { if (yes) { - options = { - ...options, + opts = { + ...opts, org: existing.orgSlug, project: existing.projectSlug, }; @@ -317,11 +316,11 @@ export async function runWizard(initialOptions: WizardOptions): Promise { if (isCancel(choice)) { cancel("Setup cancelled."); process.exitCode = 0; - return; + return null; } if (choice === "existing") { - options = { - ...options, + opts = { + ...opts, org: existing.orgSlug, project: existing.projectSlug, }; @@ -330,8 +329,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { } } - // Resolve org before spinner so no prompt appears while spinner is running. - if (!options.org) { + if (!opts.org) { let orgResult: string | LocalOpResult; try { orgResult = await resolveOrgSlug(directory, yes); @@ -339,22 +337,41 @@ export async function runWizard(initialOptions: WizardOptions): Promise { if (err instanceof WizardCancelledError) { cancel("Setup cancelled."); process.exitCode = 0; - return; + return null; } log.error(errorMessage(err)); cancel("Setup failed."); process.exitCode = 1; - return; + return null; } if (typeof orgResult !== "string") { log.error(orgResult.error ?? "Failed to resolve organization."); cancel("Setup failed."); process.exitCode = 1; - return; + return null; } - options = { ...options, org: orgResult }; + opts = { ...opts, org: orgResult }; + } + + return opts; +} + +export async function runWizard(initialOptions: WizardOptions): Promise { + const { directory, yes, dryRun, features } = initialOptions; + + if (!(await preamble(directory, yes, dryRun))) { + return; + } + + log.info( + "This wizard uses AI to analyze your project and configure Sentry." + + `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` + ); + + const options = await resolvePreSpinnerOptions(initialOptions); + if (!options) { + return; } - // --- End prompt phase --- const tracingOptions = { traceId: randomBytes(16).toString("hex"), From 7e8bf9e47d408bb4084c810a7187acf3ba336b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 12:44:21 +0200 Subject: [PATCH 5/8] fix(init): remove dead code and restore bare-slug project prompt After resolvePreSpinnerOptions always sets options.org, three blocks in createSentryProject became unreachable: - step 1: promptForExistingProject (guarded by !(org||project)) - step 2 else: resolveOrgSlug (guarded by !org) - step 2.5: bare-slug project check (guarded by project && !org) Removes promptForExistingProject and the dead branches. Moves the bare-slug project-exists check into resolvePreSpinnerOptions where it runs before the spinner starts, preserving the user prompt that would have appeared in the old step 2.5. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/local-ops.ts | 120 ++++------------------------------ src/lib/init/wizard-runner.ts | 42 ++++++++++++ 2 files changed, 55 insertions(+), 107 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index f5d5d117fd..26a9df87f0 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -733,7 +733,7 @@ export async function resolveOrgSlug( * LocalOpResult if the project exists, or null if it doesn't (404). * Other errors are left to propagate. */ -async function tryGetExistingProject( +export async function tryGetExistingProject( orgSlug: string, projectSlug: string ): Promise { @@ -789,56 +789,6 @@ export async function detectExistingProject(cwd: string): Promise<{ return null; } -/** - * When no explicit org/project is provided, check for an existing Sentry setup - * and either auto-select it (--yes) or prompt the user interactively. - * - * Returns a LocalOpResult to return early, or null to proceed with creation. - */ -async function promptForExistingProject( - cwd: string, - yes: boolean -): Promise { - const existing = await detectExistingProject(cwd); - if (!existing) { - return null; - } - - if (yes) { - return tryGetExistingProject(existing.orgSlug, existing.projectSlug); - } - - const choice = await select({ - message: "Found an existing Sentry project in this codebase.", - options: [ - { - value: "existing" as const, - label: `Use existing project (${existing.orgSlug}/${existing.projectSlug})`, - hint: "Sentry is already configured here", - }, - { - value: "create" as const, - label: "Create a new Sentry project", - }, - ], - }); - if (isCancel(choice)) { - return { ok: false, error: "Cancelled." }; - } - if (choice === "existing") { - const result = await tryGetExistingProject( - existing.orgSlug, - existing.projectSlug - ); - if (result) { - return result; - } - // Project deleted or inaccessible — fall through to creation - } - return null; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard orchestration requires sequential branching async function createSentryProject( payload: CreateSentryProjectPayload, options: WizardOptions @@ -868,64 +818,20 @@ async function createSentryProject( }; } - try { - // 1. When no explicit org/project provided, check if Sentry is already set up - if (!(options.org || options.project)) { - const result = await promptForExistingProject(payload.cwd, options.yes); - if (result) { - return result; - } - } - - // 2. Resolve org — skip interactive resolution if explicitly provided via CLI arg - let orgSlug: string; - if (options.org) { - orgSlug = options.org; - } else { - const orgResult = await resolveOrgSlug(payload.cwd, options.yes); - if (typeof orgResult !== "string") { - return orgResult; - } - orgSlug = orgResult; - } + // org is always set by resolvePreSpinnerOptions before this runs + if (!options.org) { + return { + ok: false, + error: "Internal error: org not resolved before createSentryProject.", + }; + } - // 2.5 Bare slug → project name: check if it already exists in the resolved org. - // When a bare slug was passed (options.project set, options.org unset), - // org was auto-resolved above. Verify the project doesn't already exist - // and prompt the user if it does. - if (options.project && !options.org) { - const existing = await tryGetExistingProject(orgSlug, slug); - if (existing) { - if (options.yes) { - return existing; - } - const choice = await select({ - message: `Found existing project '${slug}' in ${orgSlug}.`, - options: [ - { - value: "existing" as const, - label: `Use existing (${orgSlug}/${slug})`, - hint: "Already configured", - }, - { - value: "create" as const, - label: "Create a new project with this name", - }, - ], - }); - if (isCancel(choice)) { - return { ok: false, error: "Cancelled." }; - } - if (choice === "existing") { - return existing; - } - // Fall through to create a new project - } - } + try { + const orgSlug = options.org; - // 3. If both org and project were provided explicitly, check if the project - // already exists. This avoids a 409 Conflict from the create API when - // re-running init on an existing Sentry project. + // If both org and project are set, check if the project already exists. + // This avoids a 409 Conflict when re-running init on an existing project + // (e.g. `sentry init acme/my-app` run twice). if (options.org && options.project) { const existing = await tryGetExistingProject(orgSlug, slug); if (existing) { diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 9a12ffbaa9..4ec15abc38 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -22,6 +22,7 @@ import { formatBanner } from "../banner.js"; import { CLI_VERSION } from "../constants.js"; import { getAuthToken } from "../db/auth.js"; import { terminalLink } from "../formatters/colors.js"; +import { slugify } from "../utils.js"; import { abortIfCancelled, STEP_LABELS, @@ -42,6 +43,7 @@ import { handleLocalOp, precomputeDirListing, resolveOrgSlug, + tryGetExistingProject, } from "./local-ops.js"; import type { LocalOpResult, @@ -353,6 +355,46 @@ async function resolvePreSpinnerOptions( opts = { ...opts, org: orgResult }; } + // Bare slug case: user ran `sentry init my-app` (project set, org not originally + // provided). Org was just resolved above. Check if this named project already + // exists in the resolved org and prompt the user — must happen before the spinner. + if (opts.project && !options.org && opts.org) { + const slug = slugify(opts.project); + const resolvedOrg = opts.org; + if (slug) { + try { + const existing = await tryGetExistingProject(resolvedOrg, slug); + if (existing && !yes) { + const choice = await select({ + message: `Found existing project '${slug}' in ${resolvedOrg}.`, + options: [ + { + value: "existing" as const, + label: `Use existing (${resolvedOrg}/${slug})`, + hint: "Already configured", + }, + { + value: "create" as const, + label: "Create a new project with this name", + }, + ], + }); + if (isCancel(choice)) { + cancel("Setup cancelled."); + process.exitCode = 0; + return null; + } + if (choice === "create") { + // Clear project so the wizard auto-detects the name from the codebase + opts = { ...opts, project: undefined }; + } + } + } catch { + // API error checking for existing project — proceed and let createSentryProject handle it + } + } + } + return opts; } From aa821aaf56e7bcb4d7435e27da81fd4cd69f97c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 12:50:28 +0200 Subject: [PATCH 6/8] fix(init): fix misleading "Create a new project with this name" label When the project name already exists in the org, selecting "create" clears opts.project so the wizard auto-detects the name from the codebase. The previous label implied the user's name would be kept. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/wizard-runner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 4ec15abc38..ee41ac8930 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -375,7 +375,8 @@ async function resolvePreSpinnerOptions( }, { value: "create" as const, - label: "Create a new project with this name", + label: "Create a new project", + hint: "Wizard will detect the project name from your codebase", }, ], }); From a151f64ab2c53a43d1f9e2a3358890ec2436a676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 12:58:52 +0200 Subject: [PATCH 7/8] fix(init): check options.project not opts in bare-slug guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opts.project gets set when the user picks "Use existing" from the DSN-detection prompt, which caused the bare-slug guard to fire a second time — showing a duplicate "Found existing project" prompt and potentially clearing the user's first choice if they picked "create" on the second prompt. Using options.project (the original CLI args) ensures the guard only activates for actual bare-slug invocations (sentry init my-app). Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/wizard-runner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index ee41ac8930..0af52ee749 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -358,8 +358,8 @@ async function resolvePreSpinnerOptions( // Bare slug case: user ran `sentry init my-app` (project set, org not originally // provided). Org was just resolved above. Check if this named project already // exists in the resolved org and prompt the user — must happen before the spinner. - if (opts.project && !options.org && opts.org) { - const slug = slugify(opts.project); + if (options.project && !options.org && opts.org) { + const slug = slugify(options.project); const resolvedOrg = opts.org; if (slug) { try { From 350c8a4c12bad5a800079fbc938473a85483a25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 31 Mar 2026 13:08:35 +0200 Subject: [PATCH 8/8] fix(init): guard detectExistingProject against filesystem errors detectDsn inside detectExistingProject traverses the filesystem and can throw on permission errors. Previously wrapped by createSentryProject's top-level try-catch; now called directly in resolvePreSpinnerOptions without a handler. Treat any thrown error as "no existing project found", consistent with the inner catch for API errors in detectExistingProject. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/init/wizard-runner.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 0af52ee749..07e2bfdce3 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -292,7 +292,12 @@ async function resolvePreSpinnerOptions( let opts = options; if (!(opts.org || opts.project)) { - const existing = await detectExistingProject(directory); + let existing: Awaited> = null; + try { + existing = await detectExistingProject(directory); + } catch { + // Filesystem error (e.g. permission denied) — treat as no existing project + } if (existing) { if (yes) { opts = {