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:** 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..a080250f9 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,59 @@ 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[] { + // 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); + 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,24 +113,39 @@ async function handleSelect( const items = payload.options ?? apps.map((a) => a.name); if (items.length === 0) { - return { cancelled: true }; + throw new WizardError("No options available for this selection.", { + 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 && payload.apps && payload.apps.length > 0) { + 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 && payload.apps && payload.apps.length > 0) { + const message = buildMultiAppMessage(apps, items); + ui.log.error(message); + throw new WizardError(message, { rendered: true }); } 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, 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; diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index a24ecaf1b..5bdd26250 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -8,6 +8,7 @@ */ import { describe, expect, test } from "bun:test"; +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"; @@ -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,58 @@ describe("handleSelect", () => { ).toBe(true); }); - test("cancels with --yes when multiple options exist", async () => { + test("throws WizardError with app list when --yes and multiple apps", 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", + apps: [ + { name: "react", path: "/repo/apps/react" }, + { name: "vue", path: "/repo/apps/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 () => { - const { ui } = createMockUI(); + 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: "Choose app", + prompt: "Found an existing project.", kind: "select", - options: [], + options: ["existing", "create"], }, - makeOptions(), + makeOptions({ yes: true }), ui ); + expect(result).toEqual({ selectedApp: "create" }); + }); - expect(result).toEqual({ cancelled: true }); + test("throws WizardError when options list is empty", async () => { + const { ui } = createMockUI(); + 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 () => { @@ -142,6 +165,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();