Skip to content
Merged
1 change: 1 addition & 0 deletions plugins/sentry-cli/skills/sentry-cli/references/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Initialize Sentry in your project (experimental)
- `-n, --dry-run - Show what would happen without making changes`
- `--features <value>... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback`
- `-t, --team <value> - Team slug to create the project under`
- `--app <value> - 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:**
Expand Down
9 changes: 9 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
89 changes: 77 additions & 12 deletions src/lib/init/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import chalk from "chalk";
import { WizardError } from "../errors.js";
import {
abortIfCancelled,
featureHint,
Expand Down Expand Up @@ -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] ?? "<app>";
return [
`This monorepo has ${items.length} apps. Use --app to specify which one to initialize:`,
"",
` sentry init --yes --features <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] ?? "<app>";
return [
`App "${requested}" not found in this monorepo.`,
"",
"Available apps:",
...formatAppList(apps, items),
"",
"Re-run with --app <name>, for example:",
` sentry init --yes --features <features> --app ${exampleApp}`,
].join("\n");
}

async function handleSelect(
payload: SelectPayload,
options: InteractiveContext,
Expand All @@ -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,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

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<string>({
Comment thread
betegon marked this conversation as resolved.
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,
Expand Down
1 change: 1 addition & 0 deletions src/lib/init/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function buildResolvedInitContext(
org,
team,
project: selection.project,
app: initial.app,
authToken: getAuthToken(),
existingProject: selection.existingProject,
};
Expand Down
11 changes: 10 additions & 1 deletion src/lib/init/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ResolvedInitContext, "yes" | "dryRun">;
export type InteractiveContext = Pick<
ResolvedInitContext,
"yes" | "dryRun" | "app"
>;

// Tool suspend payloads
export type ToolPayload =
Expand Down
11 changes: 11 additions & 0 deletions src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading