From 820d60d8f15f25d34ddaa8d7ef091d08b84b8176 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 18 May 2026 22:33:25 +0200 Subject: [PATCH 1/7] fix(init): add --app flag and actionable errors for non-interactive monorepo runs When running `sentry init --yes --features errors` against a monorepo, the wizard suspended for app selection, received `{ cancelled: true }` as resume data, and the server returned a bare HTTP 500 because `selectedApp` was required. The root causes: - No flag existed to pre-select an app in non-interactive mode - handleSelect returned { cancelled: true } instead of throwing, so malformed resume data reached the server - The one-liner error didn't name the available apps or show a fix This adds --app to allow non-interactive app selection and rewrites handleSelect to throw with a formatted list of apps and the exact re-run command. The default branch in handleInteractive also now throws instead of returning the sentinel. A guard in handleSuspendedStep catches any future { cancelled: true } return before it reaches the server. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/commands/init.ts | 9 ++++ src/lib/init/interactive.ts | 86 +++++++++++++++++++++++++++++++---- src/lib/init/preflight.ts | 1 + src/lib/init/types.ts | 11 ++++- src/lib/init/wizard-runner.ts | 11 +++++ 5 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 6753abb2b..81c5c896e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -82,6 +82,7 @@ type InitFlags = { readonly "dry-run": boolean; readonly features?: string[]; readonly team?: string; + readonly app?: string; /** * Default `true` — Ink is the default UI on both the Bun binary * and the npm/Node distribution. Stricli auto-generates a negated @@ -336,6 +337,13 @@ export const initCommand = buildCommand< brief: "Team slug to create the project under", optional: true, }, + app: { + kind: "parsed", + parse: String, + brief: + "App to initialize in a monorepo (required with --yes when multiple apps are detected)", + optional: true, + }, tui: { kind: "boolean", brief: @@ -406,6 +414,7 @@ export const initCommand = buildCommand< dryRun: flags["dry-run"], features: featuresList, team: flags.team, + app: flags.app, org: explicitOrg, project: explicitProject, // `flags.tui` defaults to `true`. `--no-tui` (auto-generated diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index f5493be6a..71864207e 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -11,6 +11,7 @@ */ import chalk from "chalk"; +import { WizardError } from "../errors.js"; import { abortIfCancelled, featureHint, @@ -50,10 +51,60 @@ export async function handleInteractive( case "confirm": return await handleConfirm(payload, options, ui); default: - return { cancelled: true }; + throw new WizardError( + `Unsupported interactive prompt kind: "${(payload as { kind: string }).kind}"`, + { rendered: false } + ); } } +type AppEntry = { name: string; path: string; framework?: string }; + +function formatAppList(apps: AppEntry[], items: string[]): string[] { + // Iterate over `items` (the canonical set shown to the user) and look up + // path/framework metadata by name. This stays correct even when `payload.options` + // and `payload.apps` arrive with different lengths. + const nameWidth = Math.max(1, ...items.map((n) => n.length)); + return items.map((name) => { + const meta = apps.find((a) => a.name === name); + const fw = meta?.framework ? ` (${meta.framework})` : ""; + const path = meta?.path ? ` ${meta.path}` : ""; + return ` ${name.padEnd(nameWidth)}${fw}${path}`; + }); +} + +function buildMultiAppMessage(apps: AppEntry[], items: string[]): string { + const exampleApp = items[0] ?? ""; + return [ + `This monorepo has ${items.length} apps. Use --app to specify which one to initialize:`, + "", + ` sentry init --yes --features --app ${exampleApp}`, + "", + "Available apps:", + ...formatAppList(apps, items), + "", + "Or run without --yes to pick interactively:", + " sentry init", + ].join("\n"); +} + +function buildAppNotFoundMessage( + requested: string, + apps: AppEntry[], + items: string[] +): string { + const exampleApp = items[0] ?? ""; + return [ + `App "${requested}" not found in this monorepo.`, + "", + "Available apps:", + ...formatAppList(apps, items), + "", + "Re-run with --app , for example:", + ` sentry init --yes --features --app ${exampleApp}`, + ].join("\n"); +} + async function handleSelect( payload: SelectPayload, options: InteractiveContext, @@ -63,18 +114,33 @@ async function handleSelect( const items = payload.options ?? apps.map((a) => a.name); if (items.length === 0) { - return { cancelled: true }; + throw new WizardError("No apps found in this monorepo.", { + rendered: false, + }); } - if (options.yes) { - if (items.length === 1) { - ui.log.info(`Auto-selected: ${items[0]}`); - return { selectedApp: items[0] }; - } - ui.log.error( - `--yes requires exactly one option for selection, but found ${items.length}. Run interactively to choose.` + if (options.app) { + const match = items.find( + (item) => item.toLowerCase() === options.app?.toLowerCase() ); - return { cancelled: true }; + if (!match) { + const message = buildAppNotFoundMessage(options.app, apps, items); + ui.log.error(message); + throw new WizardError(message, { rendered: true }); + } + ui.log.info(`Using app: ${match}`); + return { selectedApp: match }; + } + + if (options.yes && items.length === 1) { + ui.log.info(`Auto-selected: ${items[0]}`); + return { selectedApp: items[0] }; + } + + if (options.yes) { + const message = buildMultiAppMessage(apps, items); + ui.log.error(message); + throw new WizardError(message, { rendered: true }); } const selected = await ui.select({ diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index ddfa83bd3..2d02c43e4 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -99,6 +99,7 @@ function buildResolvedInitContext( org, team, project: selection.project, + app: initial.app, authToken: getAuthToken(), existingProject: selection.existingProject, }; diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 597ee53fd..72aea0656 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -21,6 +21,10 @@ export type WizardOptions = { team?: string; org?: string; project?: string; + /** Pre-selected app name for monorepo runs. When set, skips the interactive + * app-selection prompt and uses this value directly. Required when `--yes` + * is passed against a monorepo with more than one detected app. */ + app?: string; /** * Force the non-Ink fallback (`LoggingUI`). Mapped from * `--no-tui`. Acts as an escape hatch when the Ink TUI @@ -44,11 +48,16 @@ export type ResolvedInitContext = { */ team?: string; project?: string; + /** Pre-selected app name for monorepo runs. Passed through from `--app`. */ + app?: string; authToken?: string; existingProject?: ExistingProjectData; }; -export type InteractiveContext = Pick; +export type InteractiveContext = Pick< + ResolvedInitContext, + "yes" | "dryRun" | "app" +>; // Tool suspend payloads export type ToolPayload = diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 68f4112c1..f2c812ae1 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -227,6 +227,17 @@ async function handleSuspendedStep( const interactiveResult = await handleInteractive(payload, context, ui); + // Safety net: { cancelled: true } would send malformed resume data to the + // server and produce a cryptic HTTP 500. All interactive handlers should + // throw on unresolvable prompts instead of returning this sentinel, but + // guard here as well so any future regression fails loudly on the CLI side. + if (interactiveResult.cancelled === true) { + throw new WizardError( + "Setup could not complete: interactive step was not resolved.", + { rendered: false } + ); + } + spin.start("Processing..."); spinState.running = true; From 47c96f73366ff75601f878c1005c66978a3cab90 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 18 May 2026 20:34:13 +0000 Subject: [PATCH 2/7] chore: regenerate docs --- plugins/sentry-cli/skills/sentry-cli/references/init.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md index 802a23900..c8ec4ca05 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -20,6 +20,7 @@ Initialize Sentry in your project (experimental) - `-n, --dry-run - Show what would happen without making changes` - `--features ... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback` - `-t, --team - Team slug to create the project under` +- `--app - App to initialize in a monorepo (required with --yes when multiple apps are detected)` - `--tui - Use the Ink-based interactive UI (default). Pass --no-tui to fall back to plain log output.` **Examples:** From 4e491cb26c446032ce136f69b9255853692c988c Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 09:14:31 +0200 Subject: [PATCH 3/7] test(init): update interactive handler tests for throw-instead-of-cancel Three tests expected { cancelled: true } return values that the refactored handlers now throw as WizardError instead. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/lib/init/interactive.test.ts | 68 ++++++++++++++++--------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index a24ecaf1b..aedadeace 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { handleInteractive } from "../../../src/lib/init/interactive.js"; +import { WizardError } from "../../../src/lib/errors.js"; import type { InteractiveContext } from "../../../src/lib/init/types.js"; import { CANCELLED } from "../../../src/lib/init/ui/types.js"; import { createMockUI } from "./ui/mock-ui.js"; @@ -24,14 +25,15 @@ function makeOptions( } describe("handleInteractive dispatcher", () => { - test("returns cancelled for unknown kind", async () => { + test("throws WizardError for unknown kind", async () => { const { ui } = createMockUI(); - const result = await handleInteractive( - { type: "interactive", prompt: "test", kind: "unknown" as "select" }, - makeOptions(), - ui - ); - expect(result).toEqual({ cancelled: true }); + await expect( + handleInteractive( + { type: "interactive", prompt: "test", kind: "unknown" as "select" }, + makeOptions(), + ui + ) + ).rejects.toBeInstanceOf(WizardError); }); }); @@ -55,37 +57,37 @@ describe("handleSelect", () => { ).toBe(true); }); - test("cancels with --yes when multiple options exist", async () => { + test("throws WizardError with app list when --yes and multiple options", async () => { const { ui, calls } = createMockUI(); - const result = await handleInteractive( - { - type: "interactive", - prompt: "Choose app", - kind: "select", - options: ["react", "vue"], - }, - makeOptions({ yes: true }), - ui - ); - - expect(result).toEqual({ cancelled: true }); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: ["react", "vue"], + }, + makeOptions({ yes: true }), + ui + ) + ).rejects.toBeInstanceOf(WizardError); expect(calls.some((c) => c.kind === "log.error")).toBe(true); }); - test("cancels when options list is empty", async () => { + test("throws WizardError when options list is empty", async () => { const { ui } = createMockUI(); - const result = await handleInteractive( - { - type: "interactive", - prompt: "Choose app", - kind: "select", - options: [], - }, - makeOptions(), - ui - ); - - expect(result).toEqual({ cancelled: true }); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Choose app", + kind: "select", + options: [], + }, + makeOptions(), + ui + ) + ).rejects.toBeInstanceOf(WizardError); }); test("uses apps array names when options not provided", async () => { From 81ccb5979ea08358b104cd9069900b6c1975cc80 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 09:25:27 +0200 Subject: [PATCH 4/7] fix(init): scope --app guard to monorepo selects, fix lint, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups for #977: - Guard the --app matching branch on payload.apps being present and non-empty, so the flag only activates for the monorepo app-selection prompt and is silently ignored on any other select-kind payload - Fix import sort order in interactive.test.ts (WizardError before handleInteractive) to satisfy Biome's organizeImports rule - Add five tests covering the --app happy path, case-insensitive matching, not-found error content, non-monorepo passthrough, and multi-app error message content — brings interactive.ts to 100% function coverage and 99% line coverage Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/interactive.ts | 2 +- test/lib/init/interactive.test.ts | 106 +++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 71864207e..ba29ef827 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -119,7 +119,7 @@ async function handleSelect( }); } - if (options.app) { + if (options.app && payload.apps && payload.apps.length > 0) { const match = items.find( (item) => item.toLowerCase() === options.app?.toLowerCase() ); diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index aedadeace..dc299cca3 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -8,8 +8,8 @@ */ import { describe, expect, test } from "bun:test"; -import { handleInteractive } from "../../../src/lib/init/interactive.js"; import { WizardError } from "../../../src/lib/errors.js"; +import { handleInteractive } from "../../../src/lib/init/interactive.js"; import type { InteractiveContext } from "../../../src/lib/init/types.js"; import { CANCELLED } from "../../../src/lib/init/ui/types.js"; import { createMockUI } from "./ui/mock-ui.js"; @@ -144,6 +144,110 @@ describe("handleSelect", () => { }); }); +describe("handleSelect with --app flag", () => { + test("selects matching app by name", async () => { + const { ui, calls } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [ + { name: "web", path: "/repo/apps/web", framework: "Next.js" }, + { name: "api", path: "/repo/apps/api", framework: "Express" }, + ], + }, + makeOptions({ yes: true, app: "web" }), + ui + ); + + expect(result).toEqual({ selectedApp: "web" }); + expect( + calls.some((c) => c.kind === "log.info" && c.message.includes("web")) + ).toBe(true); + }); + + test("matches --app case-insensitively", async () => { + const { ui } = createMockUI(); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [{ name: "Web", path: "/repo/apps/web" }], + }, + makeOptions({ app: "WEB" }), + ui + ); + + expect(result).toEqual({ selectedApp: "Web" }); + }); + + test("throws WizardError when --app name is not found", async () => { + const { ui, calls } = createMockUI(); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [ + { name: "web", path: "/repo/apps/web" }, + { name: "api", path: "/repo/apps/api" }, + ], + }, + makeOptions({ yes: true, app: "missing" }), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + const errorCall = calls.find((c) => c.kind === "log.error"); + expect(errorCall?.message).toContain("missing"); + expect(errorCall?.message).toContain("web"); + }); + + test("ignores --app when payload has no apps array", async () => { + // --app only activates for monorepo app-selection prompts (payload.apps present). + // For other select prompts it must fall through to the normal interactive pick. + const { ui, respond } = createMockUI(); + respond.select("existing"); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Found an existing project.", + kind: "select", + options: ["existing", "create"], + }, + makeOptions({ app: "web" }), + ui + ); + + expect(result).toEqual({ selectedApp: "existing" }); + }); + + test("error message for --yes with multiple apps includes app names and --app hint", async () => { + const { ui, calls } = createMockUI(); + await expect( + handleInteractive( + { + type: "interactive", + prompt: "Select the target application:", + kind: "select", + apps: [ + { name: "web", path: "/repo/apps/web", framework: "Next.js" }, + { name: "api", path: "/repo/apps/api" }, + ], + }, + makeOptions({ yes: true }), + ui + ) + ).rejects.toBeInstanceOf(WizardError); + const errorCall = calls.find((c) => c.kind === "log.error"); + expect(errorCall?.message).toContain("web"); + expect(errorCall?.message).toContain("api"); + expect(errorCall?.message).toContain("--app"); + }); +}); + describe("handleMultiSelect", () => { test("auto-selects all features with --yes", async () => { const { ui } = createMockUI(); From 2445a17d2e4d46686e74de7ca8a3692fa746cad4 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 09:32:45 +0200 Subject: [PATCH 5/7] fix(init): guard --yes multi-app error to monorepo-only selects The if (options.yes) block that throws the "This monorepo has N apps" error was missing the same payload.apps guard added for --app in the previous commit. A non-monorepo select prompt with multiple options and --yes set would hit this path and show a completely wrong error suggesting the user is in a monorepo and needs --app. Guard is now: options.yes && payload.apps && payload.apps.length > 0, symmetric with the --app guard above it. Without apps present the block is skipped and falls through to ui.select() as expected. Updates existing test to include payload.apps so it still covers the monorepo error path. Adds a new test confirming --yes with a plain options-only select falls through without error. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/interactive.ts | 2 +- test/lib/init/interactive.test.ts | 25 +++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index ba29ef827..e5537fe33 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -137,7 +137,7 @@ async function handleSelect( return { selectedApp: items[0] }; } - if (options.yes) { + if (options.yes && payload.apps && payload.apps.length > 0) { const message = buildMultiAppMessage(apps, items); ui.log.error(message); throw new WizardError(message, { rendered: true }); diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index dc299cca3..5bdd26250 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -57,7 +57,7 @@ describe("handleSelect", () => { ).toBe(true); }); - test("throws WizardError with app list when --yes and multiple options", async () => { + test("throws WizardError with app list when --yes and multiple apps", async () => { const { ui, calls } = createMockUI(); await expect( handleInteractive( @@ -65,7 +65,10 @@ describe("handleSelect", () => { type: "interactive", prompt: "Choose app", kind: "select", - options: ["react", "vue"], + apps: [ + { name: "react", path: "/repo/apps/react" }, + { name: "vue", path: "/repo/apps/vue" }, + ], }, makeOptions({ yes: true }), ui @@ -74,6 +77,24 @@ describe("handleSelect", () => { expect(calls.some((c) => c.kind === "log.error")).toBe(true); }); + test("falls through to ui.select when --yes and non-monorepo select", async () => { + // --yes must not throw the monorepo error for select prompts that have + // no payload.apps — only app-selection prompts provide that array. + const { ui, respond } = createMockUI(); + respond.select("create"); + const result = await handleInteractive( + { + type: "interactive", + prompt: "Found an existing project.", + kind: "select", + options: ["existing", "create"], + }, + makeOptions({ yes: true }), + ui + ); + expect(result).toEqual({ selectedApp: "create" }); + }); + test("throws WizardError when options list is empty", async () => { const { ui } = createMockUI(); await expect( From 2d4fb7db27547bbd69619a6bfba2f1c70db60fe7 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 10:51:20 +0200 Subject: [PATCH 6/7] fix(init): use generic message for empty select items "No apps found in this monorepo." was wrong when payload.apps is absent and the empty options list came from a non-monorepo select prompt. Message is now context-neutral. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/interactive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index e5537fe33..8a1a8160c 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -114,7 +114,7 @@ async function handleSelect( const items = payload.options ?? apps.map((a) => a.name); if (items.length === 0) { - throw new WizardError("No apps found in this monorepo.", { + throw new WizardError("No options available for this selection.", { rendered: false, }); } From afcda438d29efc1c32d1ba243cc299cee8e12dd5 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 11:38:51 +0200 Subject: [PATCH 7/7] refactor(init): use name-based lookup in ui.select options, tighten comment The interactive app picker built select options with apps[i] (index-based) while formatAppList already used apps.find(a => a.name === item) after the alignment fix. Consistent name-based lookup throughout so framework hints stay correct regardless of whether payload.options and payload.apps have the same ordering or length. Also trims the formatAppList comment to the why, dropping the redundant "how" sentence. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/interactive.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index 8a1a8160c..a080250f9 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -61,9 +61,8 @@ export async function handleInteractive( type AppEntry = { name: string; path: string; framework?: string }; function formatAppList(apps: AppEntry[], items: string[]): string[] { - // Iterate over `items` (the canonical set shown to the user) and look up - // path/framework metadata by name. This stays correct even when `payload.options` - // and `payload.apps` arrive with different lengths. + // Name-based lookup keeps this correct even when payload.options and + // payload.apps arrive with different lengths. const nameWidth = Math.max(1, ...items.map((n) => n.length)); return items.map((name) => { const meta = apps.find((a) => a.name === name); @@ -145,8 +144,8 @@ async function handleSelect( const selected = await ui.select({ message: payload.prompt, - options: items.map((item, i) => { - const app = apps[i]; + options: items.map((item) => { + const app = apps.find((a) => a.name === item); return { value: item, label: item,