diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5501596fc..5fdd4310a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -242,17 +242,14 @@ async function resolveTarget(targetArg: string | undefined): Promise<{ // the name for a new project to create. const { projects, orgs } = await findProjectsBySlug(parsed.projectSlug); - // Multiple matches — disambiguation error + // Multiple cross-org matches are not concrete until an organization is + // known. Preserve the requested name and let preflight resolve the org; + // within that org an exact match wins, otherwise this is a new project. if (projects.length > 1) { - const first = projects[0]; - const orgList = projects - .map((p) => ` ${p.orgSlug}/${p.slug}`) - .join("\n"); - throw new ValidationError( - `Project "${parsed.projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry init ${first?.orgSlug ?? ""}/${parsed.projectSlug}` + log.info( + `Project "${parsed.projectSlug}" exists in multiple organizations — the selected organization will determine whether to use it or create it.` ); + return { org: undefined, project: parsed.projectSlug }; } // Exactly one match — use it (wizard handles existing-project flow) @@ -297,6 +294,13 @@ export const initCommand = buildCommand< "Supports org/project syntax and a directory positional. Path-like\n" + "arguments (starting with . / ~) are treated as the directory;\n" + "everything else is treated as the target.\n\n" + + "Without an explicit project, an exact DSN, repository, or directory\n" + + "match is reused automatically. Otherwise creating a new project is the\n" + + "default; selecting from existing projects is a separate choice.\n\n" + + "For project creation, interactive runs let you create a new team or use\n" + + "a team where you are Team Admin. Non-interactive runs use one eligible\n" + + "team automatically; multiple eligible teams require --team. With no\n" + + "eligible team, Sentry creates one when your organization allows it.\n\n" + "Examples:\n" + " sentry init\n" + " sentry init acme/\n" + diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 3faf44c3f..70e9131ce 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -9,8 +9,9 @@ * 1. Parse one or more name:platform pairs and extract any org prefix * 2. Resolve org → positional prefix > env vars > config defaults > DSN auto-detection * (all names must share one org) - * 3. For each name: resolve team + create project (fetch DSN, build URL) - * 4. Display results (one block per project) + * 3. Resolve one team for the batch + * 4. Create each project under that team (fetch DSN, build URL) + * 5. Display results (one block per project) * * Every project is a `name:platform` pair (e.g. `sentry project create * web:javascript api:python-django`). The platform must always be attached @@ -20,9 +21,6 @@ import type { SentryContext } from "../../context.js"; import { - type CreatedProjectDetails, - createProjectWithAutoTeam, - createProjectWithDsn, listTeams, MEMBER_PROJECT_CREATION_DISABLED_DETAIL, } from "../../lib/api-client.js"; @@ -42,6 +40,7 @@ import { type ProjectCreateOutput, } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { interactivePromptsAllowed } from "../../lib/interactive-prompts.js"; import { logger } from "../../lib/logger.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; import { renderPlatformGrid } from "../../lib/platform-grid.js"; @@ -50,17 +49,71 @@ import { isValidPlatform, suggestPlatform, } from "../../lib/platforms.js"; +import { + createProjectWithTeamFallback, + ProjectCreationApiError, +} from "../../lib/project-creation.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { buildOrgNotFoundError, + type ChooseProjectTeam, type ResolvedConcreteTeam, resolveOrCreateTeam, } from "../../lib/resolve-team.js"; +import { chooseProjectTeam } from "../../lib/team-choice.js"; import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); const WHITESPACE_RE = /\s/; +class ProjectTeamChoiceCancelledError extends Error { + constructor() { + super("Project team selection cancelled."); + this.name = "ProjectTeamChoiceCancelledError"; + } +} + +/** Whether this command invocation can safely display a terminal prompt. */ +function canPromptForTeam(context: SentryContext): boolean { + return ( + interactivePromptsAllowed() && + context.stdin.isTTY === true && + context.process.stdout.isTTY === true + ); +} + +/** Adapt the shared team-choice flow to consola's plain terminal prompts. */ +function createTeamChooser( + context: SentryContext +): ChooseProjectTeam | undefined { + if (!canPromptForTeam(context)) { + return; + } + + return async (teams) => + await chooseProjectTeam(teams, async (options) => { + const response = await log.prompt(options.message, { + type: "select", + options: options.options, + initial: options.initialValue, + cancel: "null", + }); + if (response === null) { + throw new ProjectTeamChoiceCancelledError(); + } + if (typeof response !== "string") { + throw new CliError("Team selection returned an invalid response."); + } + const selected = options.options.find( + (option) => option.value === response + ); + if (!selected) { + throw new CliError(`Unknown team selection '${response}'.`); + } + return selected.value; + }); +} + /** Full usage hint shown in errors and help text. */ const USAGE_HINT = "sentry project create [/]:..."; @@ -204,9 +257,8 @@ async function handleCreateProject404(opts: { /** * Resolve the team to show in a --dry-run preview. * - * Mirrors the non-dry-run fallback: if resolveOrCreateTeam 403s (member lacks - * team:read), the real run would use POST /organizations/{org}/projects/ which - * auto-creates a personal team. Show a placeholder instead of failing. + * Mirrors the real resolver without mutating. When the real run would use the + * org-scoped endpoint, show a personal-team placeholder. */ async function resolveDryRunTeam( orgSlug: string, @@ -214,27 +266,18 @@ async function resolveDryRunTeam( team?: string; detectedFrom?: string; autoCreateSlug: string; + chooseTeam?: ChooseProjectTeam; } ): Promise { - try { - return await resolveOrCreateTeam(orgSlug, { - team: opts.team, - detectedFrom: opts.detectedFrom, - usageHint: USAGE_HINT, - autoCreateSlug: opts.autoCreateSlug, - dryRun: true, - }); - } catch (error) { - // 403 from listTeams: member lacks team:read. The real run falls back to the - // org-scoped endpoint which auto-creates a personal team. Preview that outcome. - if (!(error instanceof ApiError && error.status === 403) || opts.team) { - throw error; - } - log.debug( - "403 on listTeams in dry-run — previewing org-scoped fallback outcome" - ); - return { slug: "team-", source: "auto-created" }; - } + const team = await resolveOrCreateTeam(orgSlug, { + team: opts.team, + detectedFrom: opts.detectedFrom, + usageHint: USAGE_HINT, + autoCreateSlug: opts.autoCreateSlug, + dryRun: true, + chooseTeam: opts.chooseTeam, + }); + return team ?? { slug: "team-", source: "auto-created" }; } /** Inputs shared by both project-creation endpoints. */ @@ -247,63 +290,6 @@ type CreateProjectBaseOpts = { platform: string; }; -/** Inputs required by the team-scoped project-creation endpoint. */ -type CreateProjectOpts = CreateProjectBaseOpts & { - /** Team slug that will own the project. */ - teamSlug: string; - /** Source used to resolve the organization, when auto-detected. */ - detectedFrom?: string; -}; - -/** - * Fallback project creation via POST /organizations/{org}/projects/. - * - * Used when the team-scoped flow 403s (member lacks project:write or can't - * create teams). Returns the created project details plus the team slug the - * server auto-created. Surfaces a clear policy error if the org has disabled - * member project creation entirely. - */ -async function createProjectWithAutoTeamFallback( - opts: CreateProjectBaseOpts -): Promise< - CreatedProjectDetails & { - teamSlug: string; - teamSource: ResolvedConcreteTeam["source"]; - } -> { - const { orgSlug, name, platform } = opts; - let result: Awaited>; - try { - result = await createProjectWithAutoTeam(orgSlug, { name, platform }); - } catch (error) { - if (!(error instanceof ApiError)) { - throw error; - } - if ( - error.status === 403 && - error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) - ) { - throw new ApiError( - `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + - "Your organization has disabled project creation for members.\n" + - "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + - "or ask them to create the project and add you to it.", - 403, - error.detail, - error.endpoint - ); - } - return handleCreateApiError(error, opts); - } - return { - project: result.project, - dsn: result.dsn, - url: result.url, - teamSlug: result.team_slug, - teamSource: "auto-created", - }; -} - /** * A project with this name already exists in the org (HTTP 409). Shared by the * team-scoped and org-scoped fallback create paths so the "already exists" @@ -326,6 +312,20 @@ function handleCreateApiError( opts: CreateProjectBaseOpts ): never { const { orgSlug, name, platform } = opts; + if ( + error.status === 403 && + error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) + ) { + throw new ApiError( + `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + + "Your organization has disabled project creation for members.\n" + + "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + + "or ask them to create the project and add you to it.", + 403, + error.detail, + error.endpoint + ); + } if (error.status === 409) { throw projectExistsError(orgSlug, name); } @@ -344,27 +344,6 @@ function handleCreateApiError( ); } -/** - * Create a project (with DSN + URL) with user-friendly error handling. - * Wraps API errors with actionable messages instead of raw HTTP status codes. - */ -async function createProjectWithErrors( - opts: CreateProjectOpts -): Promise { - const { orgSlug, teamSlug, name, platform } = opts; - try { - return await createProjectWithDsn(orgSlug, teamSlug, { name, platform }); - } catch (error) { - if (!(error instanceof ApiError)) { - throw error; - } - if (error.status === 404) { - return await handleCreateProject404(opts); - } - return handleCreateApiError(error, opts); - } -} - /** A validated project specification parsed from the command positionals. */ type ParsedProjectSpec = { /** Explicit organization slug, when the name used org/name syntax. */ @@ -520,17 +499,24 @@ async function createOneProject(opts: { * one team the first project creates — rather than each resolving its own. */ teamAutoCreateSlug?: string; + /** Team already fixed by an earlier project in the same batch. */ + team?: ResolvedConcreteTeam; + /** Interactive choice capability, omitted for JSON and non-TTY runs. */ + chooseTeam?: ChooseProjectTeam; }): Promise { const { orgSlug, name, platform, flags, detectedFrom } = opts; const expectedSlug = slugify(name); const autoCreateSlug = opts.teamAutoCreateSlug ?? expectedSlug; if (flags["dry-run"]) { - const team = await resolveDryRunTeam(orgSlug, { - team: flags.team, - detectedFrom, - autoCreateSlug, - }); + const team = + opts.team ?? + (await resolveDryRunTeam(orgSlug, { + team: flags.team, + detectedFrom, + autoCreateSlug, + chooseTeam: opts.chooseTeam, + })); return { project: { id: "", slug: expectedSlug, name, platform }, orgSlug, @@ -545,51 +531,46 @@ async function createOneProject(opts: { }; } - let teamSlug: string; - let teamSource: ResolvedConcreteTeam["source"]; - let projectDetails: CreatedProjectDetails; - - try { - const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + const team = + opts.team ?? + (await resolveOrCreateTeam(orgSlug, { team: flags.team, detectedFrom, usageHint: USAGE_HINT, autoCreateSlug, - }); - teamSlug = team.slug; - teamSource = team.source; - projectDetails = await createProjectWithErrors({ + chooseTeam: opts.chooseTeam, + })); + + let projectDetails: Awaited>; + try { + projectDetails = await createProjectWithTeamFallback({ orgSlug, - teamSlug, name, platform, - detectedFrom, + team, }); } catch (error) { - // 403 means the user lacks permission to create or access teams, or to - // create projects on the resolved team. Fall back to the org-scoped endpoint - // which requires only project:read and auto-creates a personal team. - // Skip the fallback when --team was explicit: the 403 is meaningful there. - if (!(error instanceof ApiError && error.status === 403) || flags.team) { + if (!(error instanceof ApiError)) { throw error; } - // Policy 403: org has disabled member project creation. The org-scoped - // endpoint enforces the same flag — re-throw to avoid a wasted round-trip. - if (error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { - throw error; + if ( + error instanceof ProjectCreationApiError && + error.status === 404 && + error.route === "team" && + team + ) { + return await handleCreateProject404({ + orgSlug, + teamSlug: team.slug, + name, + platform, + detectedFrom, + }); } - log.debug("403 on team-based flow — falling back to org-scoped endpoint"); - const fallback = await createProjectWithAutoTeamFallback({ - orgSlug, - name, - platform, - }); - teamSlug = fallback.teamSlug; - teamSource = fallback.teamSource; - projectDetails = fallback; + return handleCreateApiError(error, { orgSlug, name, platform }); } - const { project, dsn, url } = projectDetails; + const { project, dsn, url, teamSlug, teamSource } = projectDetails; return { project, orgSlug, @@ -614,8 +595,9 @@ export const createCommand = buildCommand({ "cannot contain whitespace.\n\n" + "Every project is a name:platform pair. Create several projects at once\n" + "by passing multiple pairs as separate arguments. All projects share one org.\n\n" + - "Projects are created under a team. If the org has one team, it is used\n" + - "automatically. If no teams exist, one is created. Otherwise, specify --team.\n\n" + + "Projects are created under a team. In an interactive terminal, choose to\n" + + "create a new team or use a team where you are Team Admin. In non-interactive\n" + + "runs, one eligible team is used automatically; multiple teams require --team.\n\n" + "Examples:\n" + " sentry project create my-app:node\n" + " sentry project create acme-corp/my-app:javascript-nextjs\n" + @@ -676,24 +658,35 @@ export const createCommand = buildCommand({ const teamAutoCreateSlug = parsed .map((p) => slugify(p.name)) .find((slug) => slug !== ""); + const chooseTeam = flags.json ? undefined : createTeamChooser(this); // Create sequentially to respect rate limits. Results are emitted as one // value so --json stays parseable, including partial success before an error. const results: ProjectCreatedResult[] = []; + let batchTeam: ResolvedConcreteTeam | undefined; try { for (const { name, platform } of parsed) { - results.push( - await createOneProject({ - orgSlug, - name, - platform, - flags, - detectedFrom: resolved.detectedFrom, - teamAutoCreateSlug, - }) - ); + const result = await createOneProject({ + orgSlug, + name, + platform, + flags, + detectedFrom: resolved.detectedFrom, + teamAutoCreateSlug, + team: batchTeam, + chooseTeam, + }); + results.push(result); + batchTeam = { + slug: result.teamSlug, + source: result.teamSource, + }; } } catch (error) { + if (error instanceof ProjectTeamChoiceCancelledError) { + log.info("Cancelled."); + return; + } if (results.length > 0) { yield new CommandOutput( buildProjectCreateOutput(results, parsed.length) diff --git a/packages/cli/src/lib/api/projects.ts b/packages/cli/src/lib/api/projects.ts index 65085d5e2..48a052627 100644 --- a/packages/cli/src/lib/api/projects.ts +++ b/packages/cli/src/lib/api/projects.ts @@ -27,7 +27,7 @@ import { setCachedProjectByDsnKey, } from "../db/project-cache.js"; import { getCachedOrganizations } from "../db/regions.js"; -import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; +import { type AuthGuardSuccess, CliError, withAuthGuard } from "../errors.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { isAllDigits } from "../utils.js"; @@ -303,6 +303,11 @@ export async function createProjectWithAutoTeam( result, "Failed to create project" ); + if (typeof data.team_slug !== "string" || data.team_slug.trim() === "") { + throw new CliError( + `Sentry created project '${data.slug}' but did not return its owning team.` + ); + } const dsn = await tryGetPrimaryDsn(orgSlug, data.slug); const url = buildProjectUrl(orgSlug, data.slug); diff --git a/packages/cli/src/lib/api/teams.ts b/packages/cli/src/lib/api/teams.ts index 6106f50cc..72a3a92de 100644 --- a/packages/cli/src/lib/api/teams.ts +++ b/packages/cli/src/lib/api/teams.ts @@ -10,15 +10,14 @@ import { listOrganizationTeams, listProjectTeams as sdkListProjectTeams, } from "@sentry/api"; -// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import -import * as Sentry from "@sentry/node-core/light"; import type { SentryTeam } from "../../types/index.js"; -import { logger } from "../logger.js"; - import { + API_MAX_PER_PAGE, + autoPaginate, getOrgSdkConfig, + MAX_PAGINATION_PAGES, type PaginatedResponse, unwrapPaginatedResult, unwrapResult, @@ -26,17 +25,25 @@ import { /** * List teams in an organization. + * Automatically paginates through all API pages to return the complete list. * Uses region-aware routing for multi-region support. */ export async function listTeams(orgSlug: string): Promise { const config = await getOrgSdkConfig(orgSlug); - const result = await listOrganizationTeams({ - ...config, - path: { organization_id_or_slug: orgSlug }, - }); + const { data: allResults } = await autoPaginate(async (cursor) => { + const result = await listOrganizationTeams({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query: { cursor, per_page: API_MAX_PER_PAGE } as { + cursor?: string; + per_page?: number; + }, + }); + return unwrapPaginatedResult(result, "Failed to list teams"); + }, MAX_PAGINATION_PAGES * API_MAX_PER_PAGE); - return unwrapResult(result, "Failed to list teams"); + return allResults; } /** @@ -91,12 +98,8 @@ export async function listProjectTeams( } /** - * Create a new team in an organization and add the current user as a member. - * - * The Sentry API does not automatically add the creator to a new team, - * so we follow up with an `addMemberToTeam("me")` call. The member-add - * is best-effort — if it fails (e.g., permissions), the team is still - * returned successfully. + * Create a new team in an organization. The Sentry backend adds the creator's + * membership as part of this request. * * @param orgSlug - The organization slug * @param slug - Team slug (also used as display name) @@ -112,21 +115,7 @@ export async function createTeam( path: { organization_id_or_slug: orgSlug }, body: { slug }, }); - const team = unwrapResult(result, "Failed to create team"); - - // Best-effort: add the current user to the team - try { - await addMemberToTeam(orgSlug, team.slug, "me"); - } catch (error) { - Sentry.captureException(error, { - extra: { orgSlug, teamSlug: team.slug, context: "auto-add member" }, - }); - logger.warn( - `Team '${team.slug}' was created but you could not be added as a member.` - ); - } - - return team; + return unwrapResult(result, "Failed to create team"); } /** diff --git a/packages/cli/src/lib/dsn/code-scanner.ts b/packages/cli/src/lib/dsn/code-scanner.ts index f2f81dd42..82e71cf37 100644 --- a/packages/cli/src/lib/dsn/code-scanner.ts +++ b/packages/cli/src/lib/dsn/code-scanner.ts @@ -45,6 +45,8 @@ const log = logger.withTag("dsn-scan"); */ export type CodeScanResult = { dsns: DetectedDsn[]; + /** Every source occurrence, including the same DSN in different files. */ + occurrences?: DetectedDsn[]; /** * Map of source file paths (POSIX, relative to cwd) → mtime. * Only files containing at least one validated DSN. The cache @@ -266,6 +268,7 @@ function scanDirectory(cwd: string): Promise { const sourceMtimes: Record = {}; const dirMtimes: Record = {}; const seen = new Map(); + const occurrences: DetectedDsn[] = []; // Dedup set for mtime recording. `grepFiles` emits one match // per line, so a file with 3 DSN-containing lines would trigger // 3 redundant writes to `sourceMtimes` without this gate. @@ -291,7 +294,12 @@ function scanDirectory(cwd: string): Promise { }); for (const match of matches) { - processMatch(match, { seen, sourceMtimes, filesSeenForMtime }); + processMatch(match, { + seen, + occurrences, + sourceMtimes, + filesSeenForMtime, + }); } span.setAttribute("dsn.files_collected", stats.filesRead); @@ -300,7 +308,12 @@ function scanDirectory(cwd: string): Promise { "dsn.dsns_found": seen.size, }); - return { dsns: [...seen.values()], sourceMtimes, dirMtimes }; + return { + dsns: [...seen.values()], + occurrences, + sourceMtimes, + dirMtimes, + }; } catch (error) { if (error instanceof ConfigError) { throw error; @@ -322,6 +335,7 @@ function scanDirectory(cwd: string): Promise { type MatchProcessingContext = { seen: Map; + occurrences: DetectedDsn[]; sourceMtimes: Record; filesSeenForMtime: Set; }; @@ -355,6 +369,7 @@ function processMatch( continue; } fileHadValidDsn = true; + ctx.occurrences.push(detected); if (!ctx.seen.has(raw)) { ctx.seen.set(raw, detected); } diff --git a/packages/cli/src/lib/dsn/detector.ts b/packages/cli/src/lib/dsn/detector.ts index aa8bf590d..0268c9d16 100644 --- a/packages/cli/src/lib/dsn/detector.ts +++ b/packages/cli/src/lib/dsn/detector.ts @@ -231,6 +231,26 @@ export async function detectAllDsns(cwd: string): Promise { }; } +/** + * Detect every DSN source location without collapsing identical URLs. + * Init uses these occurrences to map each instrumented workspace package. + */ +export async function detectAllDsnOccurrences( + cwd: string +): Promise { + const { projectRoot } = await findProjectRoot(cwd); + const [codeResult, envResult] = await Promise.all([ + scanCodeForDsns(projectRoot), + detectFromAllEnvFiles(projectRoot), + ]); + const envDsn = detectFromEnv(); + return [ + ...(codeResult.occurrences ?? codeResult.dsns), + ...envResult.dsns, + ...(envDsn ? [envDsn] : []), + ]; +} + /** * Check if a higher-priority code DSN exists. * Used to invalidate low-priority cached DSNs when code is added. diff --git a/packages/cli/src/lib/dsn/index.ts b/packages/cli/src/lib/dsn/index.ts index 9a68770ce..5d71394ac 100644 --- a/packages/cli/src/lib/dsn/index.ts +++ b/packages/cli/src/lib/dsn/index.ts @@ -31,6 +31,7 @@ export type { CodeScanResult } from "./code-scanner.js"; export { scanCodeForDsns, scanCodeForFirstDsn } from "./code-scanner.js"; // Main Detection API export { + detectAllDsnOccurrences, detectAllDsns, detectDsn, getDsnSourceDescription, diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index c85af5e00..efd0d39b7 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -1949,7 +1949,7 @@ export type ProjectCreatedResult = { /** Team slug the project was assigned to */ teamSlug: string; /** How the team was resolved */ - teamSource: "explicit" | "auto-selected" | "auto-created"; + teamSource: "explicit" | "selected" | "auto-selected" | "auto-created"; /** The platform the user requested via CLI argument (used as fallback display) */ requestedPlatform: string; /** Primary DSN, if fetched successfully */ @@ -1999,8 +1999,8 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { if (result.teamSource === "auto-created") { lines.push( dry - ? `> **Note:** Would create team '${escapeMarkdownInline(result.teamSlug)}' (org has no teams).` - : `> **Note:** Created team '${escapeMarkdownInline(result.teamSlug)}' (org had no teams).` + ? `> **Note:** Would create team '${escapeMarkdownInline(result.teamSlug)}' for this project.` + : `> **Note:** Created team '${escapeMarkdownInline(result.teamSlug)}' for this project.` ); lines.push(""); } else if (result.teamSource === "auto-selected") { diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index a029e01a6..2e5ee1733 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -412,6 +412,17 @@ export function inferRepositoryName( return; } +/** + * Return the absolute root of the current git worktree. + */ +export function inferRepositoryRoot(cwd?: string): string | undefined { + try { + return git(["rev-parse", "--show-toplevel"], cwd) || undefined; + } catch { + return; + } +} + /** * Infer the default branch from a git remote's HEAD ref. * diff --git a/packages/cli/src/lib/init/clack-utils.ts b/packages/cli/src/lib/init/clack-utils.ts index 8ba347fc2..c74729533 100644 --- a/packages/cli/src/lib/init/clack-utils.ts +++ b/packages/cli/src/lib/init/clack-utils.ts @@ -258,7 +258,7 @@ export const STEP_PROGRESS_MESSAGES: Record = { "Scanning project files...", "Identifying framework and language...", "Analyzing project configuration...", - "Determining SDK compatibility...", + "Matching this app to a Sentry SDK...", ], }; diff --git a/packages/cli/src/lib/init/existing-project.ts b/packages/cli/src/lib/init/existing-project.ts index 438f72ebd..c58b8659a 100644 --- a/packages/cli/src/lib/init/existing-project.ts +++ b/packages/cli/src/lib/init/existing-project.ts @@ -1,4 +1,8 @@ -import { getProject, tryGetPrimaryDsn } from "../api-client.js"; +import { + getProject, + resolveOrgDisplayName, + tryGetPrimaryDsn, +} from "../api-client.js"; import { ApiError } from "../errors.js"; import { buildProjectUrl } from "../sentry-urls.js"; import type { ExistingProjectData } from "./types.js"; @@ -19,10 +23,12 @@ export async function tryGetExistingProjectData( const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); return { orgSlug, + orgDisplay: resolveOrgDisplayName(orgSlug, project.organization?.name), projectSlug: project.slug, + projectDisplay: project.name, projectId: project.id, - dsn: dsn ?? "", url: buildProjectUrl(orgSlug, project.slug), + ...(dsn ? { dsn } : {}), ...(project.platform ? { platform: project.platform } : {}), }; } catch (error) { diff --git a/packages/cli/src/lib/init/interactive.ts b/packages/cli/src/lib/init/interactive.ts index acd8ff66b..a54b5930e 100644 --- a/packages/cli/src/lib/init/interactive.ts +++ b/packages/cli/src/lib/init/interactive.ts @@ -19,6 +19,7 @@ import { } from "./clack-utils.js"; import { REQUIRED_FEATURE } from "./constants.js"; import type { + AppEntry, ConfirmPayload, InteractiveContext, InteractivePayload, @@ -27,6 +28,10 @@ import type { } from "./types.js"; import type { PromptDetail, WizardUI } from "./ui/types.js"; +type InteractiveUiOptions = { + holdPresentationOnSelect?: boolean; +}; + function prependRequiredFeature(features: string[]): string[] { if (features.includes(REQUIRED_FEATURE)) { return features; @@ -89,11 +94,12 @@ function buildFeatureReviewDetails(features: string[]): PromptDetail[] { export async function handleInteractive( payload: InteractivePayload, options: InteractiveContext, - ui: WizardUI + ui: WizardUI, + uiOptions: InteractiveUiOptions = {} ): Promise> { switch (payload.kind) { case "select": - return await handleSelect(payload, options, ui); + return await handleSelect(payload, options, ui, uiOptions); case "multi-select": return await handleMultiSelect(payload, options, ui); case "confirm": @@ -106,28 +112,88 @@ export async function handleInteractive( } } -type AppEntry = { name: string; path: string; framework?: string }; +const APP_ROLE_LABELS: Record, string> = { + application: "Application", + documentation: "Documentation website", + example: "Reference application", + runtime: "Runtime package", +}; + +function canAutoSelectSentrySetup(app: AppEntry): boolean { + return app.sentrySetup === "auto-select"; +} + +function autoSelectTarget( + items: string[], + apps: AppEntry[], + yes: boolean +): Record | undefined { + if (!yes) { + return; + } + if (items.length === 1) { + const onlyApp = apps.find((app) => app.name === items[0]); + if ( + onlyApp?.sentrySetup === "detected" && + !canAutoSelectSentrySetup(onlyApp) + ) { + return; + } + return { selectedApp: items[0] }; + } + const detectedTargets = apps.filter(canAutoSelectSentrySetup); + if (detectedTargets.length !== 1) { + return; + } + const selectedApp = detectedTargets[0]?.name; + if (!selectedApp) { + return; + } + return { selectedApp }; +} 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)); + const labels = items.map( + (name) => apps.find((app) => app.name === name)?.label ?? name + ); + const nameWidth = Math.max(1, ...labels.map((label) => label.length)); return items.map((name) => { const meta = apps.find((a) => a.name === name); - const fw = meta?.framework ? ` (${meta.framework})` : ""; + const label = meta?.label ?? name; + const hint = meta ? appHint(meta) : ""; + const formattedHint = hint ? ` (${hint})` : ""; const path = meta?.path ? ` ${meta.path}` : ""; - return ` ${name.padEnd(nameWidth)}${fw}${path}`; + return ` ${label.padEnd(nameWidth)}${formattedHint}${path}`; }); } +function appHint(app: AppEntry): string { + const parts: string[] = []; + if (app.role) { + parts.push(APP_ROLE_LABELS[app.role]); + } + if (app.framework) { + parts.push(app.framework); + } + if ( + app.sentrySetup && + !parts.some((part) => part.toLowerCase().includes("sentry detected")) + ) { + parts.push("Sentry detected"); + } + return parts.join(" · "); +} + 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:`, + `This monorepo has ${items.length} targets. Use --app to specify which one to initialize:`, "", ` sentry init --yes --features --app ${exampleApp}`, "", - "Available apps:", + "Available targets:", ...formatAppList(apps, items), "", "Or run without --yes to pick interactively:", @@ -144,7 +210,7 @@ function buildAppNotFoundMessage( return [ `App "${requested}" not found in this monorepo.`, "", - "Available apps:", + "Available targets:", ...formatAppList(apps, items), "", "Re-run with --app , for example:", @@ -155,7 +221,8 @@ function buildAppNotFoundMessage( async function handleSelect( payload: SelectPayload, options: InteractiveContext, - ui: WizardUI + ui: WizardUI, + uiOptions: InteractiveUiOptions ): Promise> { const apps = payload.apps ?? []; const items = payload.options ?? apps.map((a) => a.name); @@ -167,21 +234,21 @@ async function handleSelect( } if (options.app && payload.apps && payload.apps.length > 0) { - const match = items.find( - (item) => item.toLowerCase() === options.app?.toLowerCase() + const match = apps.find( + (app) => app.name.toLowerCase() === options.app?.toLowerCase() ); 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 }; + ui.log.info(`Using app: ${match.label ?? match.name}`); + return { selectedApp: match.name }; } - if (options.yes && items.length === 1) { - ui.log.info(`Auto-selected: ${items[0]}`); - return { selectedApp: items[0] }; + const autoSelected = autoSelectTarget(items, apps, options.yes); + if (autoSelected) { + return autoSelected; } if (options.yes && payload.apps && payload.apps.length > 0) { @@ -196,10 +263,13 @@ async function handleSelect( const app = apps.find((a) => a.name === item); return { value: item, - label: item, - ...(app?.framework ? { hint: app.framework } : {}), + label: app?.label ?? item, + ...(app && appHint(app) ? { hint: appHint(app) } : {}), }; }), + ...(uiOptions.holdPresentationOnSelect + ? { holdPresentationOnResolve: true } + : {}), }); return { selectedApp: abortIfCancelled(selected) }; @@ -215,18 +285,28 @@ async function handleMultiSelect( (feature) => !UNSUPPORTED_INIT_FEATURES.has(feature) ) ); + const detectedExisting = (payload.initialFeatures ?? []).filter((feature) => + available.includes(feature) + ); if (options.yes) { + const defaults = normalizeFeatureSelection( + available.filter( + (feature) => + DEFAULT_FEATURES.has(feature) || detectedExisting.includes(feature) + ) + ); ui.log.info( - `Auto-selected all features: ${available.map(featureLabel).join(", ")}` + `Auto-selected default features: ${defaults.map(featureLabel).join(", ")}` ); - return { features: available }; + return { features: defaults }; } const sorted = sortFeatureOptions(available); setTag("wizard.features.offered", available.join(",")); - let initialValues: string[] = sorted.filter((feature) => - DEFAULT_FEATURES.has(feature) + let initialValues: string[] = sorted.filter( + (feature) => + DEFAULT_FEATURES.has(feature) || detectedExisting.includes(feature) ); while (true) { diff --git a/packages/cli/src/lib/init/preflight.ts b/packages/cli/src/lib/init/preflight.ts index 3b4167306..a01c5c581 100644 --- a/packages/cli/src/lib/init/preflight.ts +++ b/packages/cli/src/lib/init/preflight.ts @@ -1,18 +1,20 @@ -import type { SentryTeam } from "../../types/index.js"; -import { - getOrganization, - listOrganizations, - listTeams, -} from "../api-client.js"; +import type { SentryProject } from "../../types/index.js"; +import { listOrganizations, listProjects } from "../api-client.js"; import { getAuthToken } from "../db/auth.js"; +import { parseDsn } from "../dsn/index.js"; import { ApiError, AuthError, HostScopeError, WizardError } from "../errors.js"; -import { buildOrgNotFoundError, resolveOrCreateTeam } from "../resolve-team.js"; +import { logger } from "../logger.js"; +import { resolveAllTargets } from "../resolve-target.js"; import { captureOAuthScopeRecoveryGate } from "../scope-recovery.js"; +import { getSentryBaseUrl, isSentrySaasUrl } from "../sentry-urls.js"; import { slugify } from "../utils.js"; import { WizardCancelledError } from "./clack-utils.js"; import { tryGetExistingProjectData } from "./existing-project.js"; import { resolveOrgPrefetched } from "./org-prefetch.js"; -import { formatMemberProjectCreationDisabledError } from "./project-creation-errors.js"; +import { + detectSentrySetup, + type ExistingSentryDetection, +} from "./tools/detect-sentry.js"; import type { ExistingProjectData, ResolvedInitContext, @@ -21,51 +23,57 @@ import type { import { isCancelled, type WizardUI } from "./ui/types.js"; const NUMERIC_ORG_ID_RE = /^\d+$/; +const log = logger.withTag("init-preflight"); -type ExistingProjectChoice = { - project?: string; - existingProject?: ExistingProjectData; - shouldAbort?: boolean; -}; - -type InitContextSeed = { - org?: string; - project?: string; - existingProject?: ExistingProjectData; +type CanonicalProjectCandidate = { + org: string; + project: string; + detectedDsn?: string; }; type ProjectSelection = Pick< ResolvedInitContext, - "project" | "existingProject" + "project" | "existingProject" | "setupIntent" >; +function markExistingSetupForImprovement( + selection: ProjectSelection, + setup: ExistingSentryDetection +): ProjectSelection { + return setup.status === "none" + ? selection + : { ...selection, setupIntent: "improve-existing" }; +} + /** - * Resolve org, project, team, and auth state before the init workflow starts. + * Resolve organization and authentication before the remote workflow starts. + * Project resolution is deliberately deferred until the workflow has selected + * the concrete app in a monorepo. */ export async function resolveInitContext( initial: WizardOptions, ui: WizardUI ): Promise { return await withPreflightHandling(ui, async () => { - const seed = await resolveInitContextSeed(initial, ui); - if (!seed) { - return null; - } - - const org = await ensureOrg(seed.org, initial, ui); - const projectSelection = await resolveProjectSelection( - org, - initial, - seed, - ui - ); - if (!projectSelection) { - return null; - } - - const team = await resolveTeam(org, initial, ui); - - return buildResolvedInitContext(initial, org, team, projectSelection); + const codebaseCandidates = initial.org + ? [] + : await resolveCanonicalProjects(initial.directory); + const candidateOrgs = [ + ...new Set(codebaseCandidates.map((candidate) => candidate.org)), + ]; + const inferredOrg = + candidateOrgs.length === 1 ? candidateOrgs[0] : undefined; + const preferredOrg = + initial.org ?? + inferredOrg ?? + (await resolvePreferredOrg(initial.directory)); + const org = await ensureOrg(preferredOrg, initial, ui); + + const team = initial.team + ? ({ slug: initial.team, source: "explicit" } as const) + : undefined; + + return buildResolvedInitContext(initial, org, team); }); } @@ -103,8 +111,7 @@ async function withPreflightHandling( function buildResolvedInitContext( initial: WizardOptions, org: string, - team: string | undefined, - selection: ProjectSelection + team: ResolvedInitContext["team"] ): ResolvedInitContext { return { directory: initial.directory, @@ -113,28 +120,18 @@ function buildResolvedInitContext( features: initial.features, org, team, - isExplicitTeam: Boolean(initial.team), - project: selection.project, + project: initial.project, app: initial.app, authToken: getAuthToken(), - existingProject: selection.existingProject, }; } -async function resolveInitContextSeed( - initial: WizardOptions, - ui: WizardUI -): Promise { - const detected = await resolveDetectedProject(initial, ui); - if (detected?.shouldAbort) { - return null; - } - - return { - org: detected?.org ?? initial.org, - project: detected?.project ?? initial.project, - existingProject: detected?.existingProject, - }; +/** Resolve organization-only context before project inference. */ +async function resolvePreferredOrg(cwd: string): Promise { + const resolved = await resolveOrgPrefetched(cwd); + return resolved && !NUMERIC_ORG_ID_RE.test(resolved.org) + ? resolved.org + : undefined; } async function ensureOrg( @@ -154,340 +151,449 @@ async function ensureOrg( throw new WizardError(orgResult.error ?? "Failed to resolve organization."); } -async function resolveProjectSelection( - org: string, - initial: WizardOptions, - seed: InitContextSeed, - ui: WizardUI -): Promise { - if (!seed.project) { - return { - project: seed.project, - existingProject: seed.existingProject, - }; - } - - const resolved = await resolveExistingProjectChoice({ - org, - project: seed.project, - existingProject: seed.existingProject, - yes: initial.yes, - promptOnExisting: Boolean(initial.project && !initial.org), +/** + * Resolve the Sentry project only after the workflow has selected its concrete + * project directory. This lets monorepos use app-local DSNs, package files, + * repository signals, and cwd inference instead of the workspace root. + */ +export async function resolveInitProjectContext( + context: ResolvedInitContext, + cwd: string, + ui: WizardUI, + options: { + setup?: ExistingSentryDetection; + suggestedProjectName?: string; + supportsExistingSetupImprovement?: boolean; + } = {} +): Promise { + const setup = options.setup ?? (await detectSentrySetup(cwd)); + + if (context.project) { + return await resolveExplicitProjectSelection(context, cwd, setup, options); + } + + const canonicalSelection = await resolveCanonicalProjectSelection({ + context, + cwd, + options, + setup, ui, }); - if (resolved.shouldAbort) { - return null; + if (canonicalSelection) { + return canonicalSelection; } - return mergeProjectSelection(seed, resolved); + return await resolveImplicitProjectSelection(context.org, context.yes, ui); } -function mergeProjectSelection( - seed: InitContextSeed, - resolved: ExistingProjectChoice -): ProjectSelection { - const project = "project" in resolved ? resolved.project : seed.project; - const clearedProject = - "project" in resolved && resolved.project === undefined; +async function resolveExplicitProjectSelection( + context: ResolvedInitContext, + cwd: string, + setup: ExistingSentryDetection, + options: { supportsExistingSetupImprovement?: boolean } +): Promise { + const explicit = await resolveExistingProjectChoice({ + org: context.org, + project: context.project ?? "", + detectedDsn: setup.dsn, + }); + if (!explicit.existingProject || setup.status === "none") { + return explicit; + } + const matchesDetectedSetup = setup.dsn + ? detectedSetupMatchesProject(setup, explicit.existingProject) + : await canonicalProjectMatches( + cwd, + explicit.existingProject.orgSlug, + explicit.existingProject.projectSlug + ); + if (!matchesDetectedSetup) { + return explicit; + } + assertImprovementSupported(setup, options); + return markExistingSetupForImprovement(explicit, setup); +} - return { - project, - existingProject: clearedProject - ? undefined - : (resolved.existingProject ?? seed.existingProject), +async function resolveCanonicalProjectSelection({ + context, + cwd, + options, + setup, + ui, +}: { + context: ResolvedInitContext; + cwd: string; + options: { + suggestedProjectName?: string; + supportsExistingSetupImprovement?: boolean; }; + setup: ExistingSentryDetection; + ui: WizardUI; +}): Promise { + const candidates = await resolveCanonicalProjects(cwd, context.org); + const candidate = candidates.length === 1 ? candidates[0] : undefined; + if (!candidate) { + return; + } + const detected = await resolveExistingProjectChoice(candidate); + if (!detected.existingProject) { + return; + } + if ( + setup.status !== "none" && + setup.dsn && + !detectedSetupMatchesProject(setup, detected.existingProject) + ) { + return await resolveImplicitProjectSelection(context.org, context.yes, ui, { + avoidProjectSlug: detected.existingProject.projectSlug, + suggestedProjectName: options.suggestedProjectName, + }); + } + if (setup.status !== "none" && !(context.yes || context.dryRun)) { + return await resolveDetectedSetupChoice( + { + context, + detected, + setup, + suggestedProjectName: options.suggestedProjectName, + supportsExistingSetupImprovement: + options.supportsExistingSetupImprovement, + }, + ui + ); + } + assertImprovementSupported(setup, options); + return markExistingSetupForImprovement(detected, setup); } -async function resolveDetectedProject( - initial: WizardOptions, - ui: WizardUI -): Promise<{ - org?: string; - project?: string; - existingProject?: ExistingProjectData; - shouldAbort?: boolean; -} | null> { - if (initial.org || initial.project) { - return null; +function detectedSetupMatchesProject( + setup: ExistingSentryDetection, + project: ExistingProjectData +): boolean { + if (setup.status === "none" || !setup.dsn) { + return false; } - - let detectedProject: { orgSlug: string; projectSlug: string } | null = null; - try { - detectedProject = await detectExistingProject(initial.directory); - } catch { - return null; + const parsed = parseDsn(setup.dsn); + if (!parsed || parsed.projectId !== project.projectId) { + return false; } - if (!detectedProject) { - return null; + const configuredOrigin = getSentryBaseUrl(); + if (isSentrySaasUrl(configuredOrigin)) { + return parsed.orgId !== undefined; } + return parsed.host === new URL(configuredOrigin).host; +} - const existingProject = await tryGetExistingProjectData( - detectedProject.orgSlug, - detectedProject.projectSlug - ).catch(() => null); +async function canonicalProjectMatches( + cwd: string, + org: string, + project: string +): Promise { + const candidates = await resolveCanonicalProjects(cwd, org); + return ( + candidates.length === 1 && + candidates[0]?.org === org && + candidates[0]?.project === project + ); +} - if (initial.yes) { - return { - org: detectedProject.orgSlug, - project: detectedProject.projectSlug, - ...(existingProject ? { existingProject } : {}), - }; +function assertImprovementSupported( + setup: ExistingSentryDetection, + options: { supportsExistingSetupImprovement?: boolean } +): void { + if ( + setup.status !== "none" && + options.supportsExistingSetupImprovement !== true + ) { + throw new WizardError( + "This setup service version cannot safely improve an existing Sentry setup. Deploy or update the setup service before using this CLI version.", + { rendered: false } + ); } +} - const choice = await ui.select<"existing" | "create">({ - message: "Found an existing Sentry project in this codebase.", - options: [ - { - value: "existing", - label: `Use existing project (${detectedProject.orgSlug}/${detectedProject.projectSlug})`, - hint: "Sentry is already configured here", - }, - { - value: "create", - label: "Create a new Sentry project", - }, - ], - }); - if (isCancelled(choice)) { - throw new WizardCancelledError(); +async function resolveCanonicalProjects( + cwd: string, + organizationFilter?: string +): Promise { + // Auto-resolution is best-effort: only exact local evidence is safe enough to + // reuse implicitly, and self-hosted targets belong to a different API origin. + let resolved: Awaited>; + try { + resolved = await resolveAllTargets({ + cwd, + resolutionMode: "codebase", + ...(organizationFilter ? { organizationFilter } : {}), + }); + } catch (error) { + log.debug("Could not auto-resolve an init project", error); + return []; } - if (choice === "existing") { - return { - org: detectedProject.orgSlug, - project: detectedProject.projectSlug, - ...(existingProject ? { existingProject } : {}), - }; + + if (resolved.skippedSelfHosted) { + return []; } - return {}; + return resolved.targets + .filter((target) => target.matchStrength !== "fuzzy") + .map((target) => ({ + org: target.org, + project: target.project, + ...(target.detectedDsn ? { detectedDsn: target.detectedDsn.raw } : {}), + })); } async function resolveExistingProjectChoice(opts: { org: string; project: string; - existingProject?: ExistingProjectData; - yes: boolean; - promptOnExisting: boolean; - ui: WizardUI; -}): Promise { + detectedDsn?: string; +}): Promise { const slug = slugify(opts.project); if (!slug) { return { project: opts.project }; } - const existingProject = - opts.existingProject && - opts.existingProject.orgSlug === opts.org && - opts.existingProject.projectSlug === slug - ? opts.existingProject - : await tryGetExistingProjectData(opts.org, slug).catch(() => null); + const existingProject = await tryGetExistingProjectData(opts.org, slug); if (!existingProject) { return { project: opts.project }; } - if (!opts.promptOnExisting || opts.yes) { - return { - project: existingProject.projectSlug, - existingProject, - }; - } + const matchingDetectedDsn = + opts.detectedDsn && + parseDsn(opts.detectedDsn)?.projectId === existingProject.projectId + ? opts.detectedDsn + : undefined; + const resolvedDsn = existingProject.dsn ?? matchingDetectedDsn; - const choice = await opts.ui.select<"existing" | "create">({ - message: `Found existing project '${slug}' in ${opts.org}.`, + return { + project: existingProject.projectSlug, + existingProject: { + ...existingProject, + ...(resolvedDsn ? { dsn: resolvedDsn } : {}), + }, + }; +} + +async function resolveDetectedSetupChoice( + options: { + context: ResolvedInitContext; + detected: ProjectSelection; + setup: ExistingSentryDetection; + suggestedProjectName?: string; + supportsExistingSetupImprovement?: boolean; + }, + ui: WizardUI +): Promise { + const { + context, + detected, + setup, + suggestedProjectName, + supportsExistingSetupImprovement, + } = options; + if (supportsExistingSetupImprovement === false) { + ui.log.warn( + "The current setup service cannot safely improve this existing Sentry setup. Choose another project or create a new one." + ); + return await resolveImplicitProjectSelection(context.org, false, ui, { + avoidProjectSlug: + detected.existingProject?.projectSlug ?? detected.project, + suggestedProjectName, + }); + } + const project = detected.existingProject; + const setupContext = project + ? `Sentry detected for project ${project.projectDisplay ?? project.projectSlug} in organization ${project.orgDisplay ?? project.orgSlug}. What would you like to do?` + : "What would you like to do with this Sentry setup?"; + const intent = await ui.select<"improve" | "other">({ + message: setupContext, options: [ { - value: "existing", - label: `Use existing (${opts.org}/${slug})`, - hint: "Already configured", + value: "improve", + label: "Improve your Sentry setup", + description: "Upgrade your current setup and add more Sentry features.", }, { - value: "create", - label: "Create a new project", - hint: "Wizard will detect the project name from your codebase", + value: "other", + label: "Use or create another Sentry project", + description: "Use another project or create a new one.", }, ], + initialValue: "improve", }); - if (isCancelled(choice)) { + if (isCancelled(intent)) { throw new WizardCancelledError(); } - if (choice === "create") { - return { project: undefined }; + if (intent === "improve") { + assertImprovementSupported(setup, { supportsExistingSetupImprovement }); + return { ...detected, setupIntent: "improve-existing" }; } - - return { - project: existingProject.projectSlug, - existingProject, - }; + return await resolveImplicitProjectSelection(context.org, false, ui, { + avoidProjectSlug: detected.existingProject?.projectSlug ?? detected.project, + suggestedProjectName, + }); } /** - * Normalize a team-resolution failure into a WizardError, preserving an - * ApiError's enriched detail (e.g. 401 `member-disabled-over-limit`) via - * format() instead of collapsing to its bare message + status line. + * Resolve new-project creation versus an existing project after the shared + * resolver found no unique target. Creation is the default and selecting an + * existing project is a separate, deliberate action. */ -function toPreflightWizardError(error: unknown): WizardError { - if (error instanceof AuthError || error instanceof HostScopeError) { - throw error; +async function resolveImplicitProjectSelection( + org: string, + yes: boolean, + ui: WizardUI, + options: { + avoidProjectSlug?: string; + suggestedProjectName?: string; + } = {} +): Promise { + if (yes) { + return options.avoidProjectSlug + ? await resolveAlternativeProjectSelection( + org, + options.avoidProjectSlug, + options.suggestedProjectName + ) + : { project: undefined, existingProject: undefined }; } - if (error instanceof WizardError) { - return error; + + const intent = await ui.select<"create" | "existing">({ + message: "How should Sentry be configured for this codebase?", + options: [ + { + value: "create", + label: "+ Create a new Sentry project", + }, + { + value: "existing", + label: "Use an existing Sentry project", + }, + ], + }); + if (isCancelled(intent)) { + throw new WizardCancelledError(); } - if (error instanceof ApiError) { - return new WizardError(error.format()); + if (intent === "create") { + if (options.avoidProjectSlug) { + const selection = await resolveAlternativeProjectSelection( + org, + options.avoidProjectSlug, + options.suggestedProjectName + ); + const { project } = selection; + ui.log.info(`New project ${project} in organization ${org}`); + return selection; + } + return { project: undefined, existingProject: undefined }; } - return new WizardError( - error instanceof Error ? error.message : String(error) + + return await resolveExistingProjectSelection( + org, + ui, + options.avoidProjectSlug ); } -async function resolveTeam( +async function resolveExistingProjectSelection( org: string, - initial: WizardOptions, - ui: WizardUI -): Promise { - if (!initial.team) { - return await resolveImplicitTeam(org, initial, ui); - } - - const scopeRecovery = captureOAuthScopeRecoveryGate(); + ui: WizardUI, + avoidProjectSlug?: string +): Promise { + let projects: SentryProject[]; try { - const result = await resolveOrCreateTeam(org, { - team: initial.team, - usageHint: "sentry init", - dryRun: initial.dryRun, - deferAutoCreateOnEmptyOrg: true, - }); - return result.source === "deferred" ? undefined : result.slug; + projects = await listProjects(org); } catch (error) { - if (error instanceof WizardCancelledError) { - throw error; - } - if ( - error instanceof ApiError && - (error.status === 401 || error.status === 403) && - (await scopeRecovery.shouldDelegate(error, { - unattended: initial.yes || initial.dryRun, - })) - ) { - throw error; - } - if (error instanceof ApiError && error.status === 403) { - return; - } - throw toPreflightWizardError(error); + const reason = error instanceof ApiError ? error.format() : String(error); + throw new WizardError( + `Could not list existing projects in '${org}'.\n\n${reason}` + ); } -} - -function canCreateProjectInTeam(team: SentryTeam): boolean { - return Array.isArray(team.access) && team.access.includes("team:admin"); -} - -/** - * Whether the user's access scopes indicate they can create projects - * regardless of the org's `allowMemberProjectCreation` flag. - * - * Sentry's role hierarchy (from server.py SENTRY_ROLES): - * - member: project:read only — blocked when flag is disabled - * - admin: project:write, project:admin, team:admin — CAN create projects - * - manager: org:write, project:admin, is_global — CAN create projects - * - owner: org:write, org:admin, is_global — CAN create projects - * - * The previous check only looked for `org:write`, which excluded org admins - * who have `project:write` / `project:admin` but not `org:write`. - */ -function canBypassMemberCreationRestriction(access: unknown): boolean { - if (!Array.isArray(access)) { - return false; + if (avoidProjectSlug) { + const avoided = slugify(avoidProjectSlug); + projects = projects.filter((project) => project.slug !== avoided); } - return ( - access.includes("org:write") || - access.includes("project:admin") || - access.includes("project:write") - ); -} - -async function assertOrgScopedCreationCanProceed(org: string): Promise { - let organization: Awaited>; - try { - organization = await getOrganization(org); - } catch { - // If org details cannot be fetched, let the actual create endpoint surface - // the precise API error during the project-creation step. - return; + if (projects.length === 0) { + throw new WizardError( + `There are no${avoidProjectSlug ? " other" : ""} existing projects in '${org}'. Choose "+ Create a new Sentry project" instead.` + ); } - if ( - organization.allowMemberProjectCreation === false && - !canBypassMemberCreationRestriction(organization.access) - ) { - throw new WizardError(formatMemberProjectCreationDisabledError(org)); + const projectSlug = await ui.select({ + message: "Which existing Sentry project should be used?", + options: projects.map((project) => ({ + value: project.slug, + label: project.name, + ...(project.name !== project.slug ? { hint: project.slug } : {}), + })), + }); + if (isCancelled(projectSlug)) { + throw new WizardCancelledError(); } -} -async function listTeamsForImplicitInit( - org: string, - unattended: boolean -): Promise { - const scopeRecovery = captureOAuthScopeRecoveryGate(); - try { - return await listTeams(org); - } catch (error) { - // 403 from listTeams means the user cannot inspect team access. Continue - // without a team so init mirrors onboarding's org-scoped auto-team path. - if ( - error instanceof ApiError && - (error.status === 401 || error.status === 403) && - (await scopeRecovery.shouldDelegate(error, { unattended })) - ) { - throw error; - } - if (error instanceof ApiError && error.status === 403) { - await assertOrgScopedCreationCanProceed(org); - return; - } - if (error instanceof ApiError && error.status === 404) { - return await buildOrgNotFoundError(org, "sentry init"); - } - throw toPreflightWizardError(error); + const existingProject = await loadExistingProject( + org, + projectSlug, + "your project selection" + ); + if (!existingProject) { + throw new WizardError( + `Project '${org}/${projectSlug}' is no longer available. Run sentry init again to refresh the project list.` + ); } + return { project: existingProject.projectSlug, existingProject }; } -async function resolveImplicitTeam( +async function resolveAlternativeProjectSelection( org: string, - initial: WizardOptions, - ui: WizardUI -): Promise { - const teams = await listTeamsForImplicitInit( + avoidProjectSlug: string, + suggestedProjectName?: string +): Promise { + const project = await findAvailableProjectSlug( org, - initial.yes || initial.dryRun + suggestedProjectName ?? avoidProjectSlug, + avoidProjectSlug ); - if (!teams) { - return; - } + return { project, existingProject: undefined }; +} - const candidateTeams = teams - .filter(canCreateProjectInTeam) - .sort((left, right) => left.slug.localeCompare(right.slug)); - if (candidateTeams.length === 0) { - await assertOrgScopedCreationCanProceed(org); - return; +async function findAvailableProjectSlug( + org: string, + suggestedProjectName: string, + avoidedProjectSlug: string +): Promise { + const base = slugify(suggestedProjectName) || "sentry-project"; + const avoided = slugify(avoidedProjectSlug); + + if (base !== avoided && !(await tryGetExistingProjectData(org, base))) { + return base; } - if (candidateTeams.length === 1 || initial.yes) { - return (candidateTeams[0] as SentryTeam).slug; + + for (let suffix = 2; suffix <= 100; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!(await tryGetExistingProjectData(org, candidate))) { + return candidate; + } } - const selected = await ui.select({ - message: "Which team should own this project?", - options: candidateTeams.map((team) => ({ - value: team.slug, - label: team.slug, - ...(team.name !== team.slug ? { hint: team.name } : {}), - })), - }); - if (isCancelled(selected)) { - throw new WizardCancelledError(); + throw new WizardError( + `Could not find an available project slug based on '${base}'. Choose an existing Sentry project instead.` + ); +} + +async function loadExistingProject( + org: string, + project: string, + detectedFrom: string +): Promise { + try { + return await tryGetExistingProjectData(org, project); + } catch (error) { + const reason = error instanceof ApiError ? error.format() : String(error); + throw new WizardError( + `Found existing project '${org}/${project}' from ${detectedFrom}, but could not load its DSN.\n\n${reason}` + ); } - return selected; } /** @@ -569,7 +675,7 @@ async function resolveOrgSlug( } const selected = await ui.select({ - message: "Which organization should the project be created in?", + message: "Which organization should Sentry use?", options: orgs.map((org) => ({ value: org.slug, label: org.name, @@ -581,27 +687,3 @@ async function resolveOrgSlug( } return selected; } - -async function detectExistingProject( - cwd: string -): Promise<{ orgSlug: string; projectSlug: string } | null> { - const { detectDsn } = await import("../dsn/index.js"); - const dsn = await detectDsn(cwd); - if (!dsn?.publicKey) { - return null; - } - - try { - const { resolveDsnByPublicKey } = await import("../resolve-target.js"); - const resolved = await resolveDsnByPublicKey(dsn); - if (!resolved) { - return null; - } - return { - orgSlug: resolved.org, - projectSlug: resolved.project, - }; - } catch { - return null; - } -} diff --git a/packages/cli/src/lib/init/readiness.ts b/packages/cli/src/lib/init/readiness.ts index cbfdef3bd..db2b436e0 100644 --- a/packages/cli/src/lib/init/readiness.ts +++ b/packages/cli/src/lib/init/readiness.ts @@ -14,16 +14,29 @@ import type { WizardUI } from "./ui/types.js"; /** Timeout for the health check fetch (5 seconds). */ const HEALTH_CHECK_TIMEOUT_MS = 5000; +/** Setup-service behavior negotiated before the remote workflow starts. */ +export type InitServiceCapabilities = { + /** The API preserves and upgrades an already-installed Sentry setup. */ + improveExistingSetup: boolean; +}; + +type ReadinessResult = { + apiOk: boolean; + capabilities: InitServiceCapabilities; +}; + /** * Check whether the setup service is reachable before starting the workflow. * Authentication is resolved before the wizard UI is created so recoverable * OAuth failures can use the CLI's global auto-auth flow. */ -export async function checkReadiness(ui: WizardUI): Promise { +export async function checkReadiness( + ui: WizardUI +): Promise { const spin = ui.spinner(); spin.start("Checking prerequisites..."); - const apiOk = await checkMastraApi(); + const { apiOk, capabilities } = await checkMastraApi(); if (apiOk) { spin.stop(""); @@ -33,9 +46,10 @@ export async function checkReadiness(ui: WizardUI): Promise { "Setup service may be slow or unreachable. The wizard will retry if needed." ); } + return capabilities; } -async function checkMastraApi(): Promise { +async function checkMastraApi(): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); try { @@ -43,10 +57,30 @@ async function checkMastraApi(): Promise { signal: controller.signal, method: "GET", }); - return resp.ok; + if (!resp.ok) { + return { + apiOk: false, + capabilities: { improveExistingSetup: false }, + }; + } + const body = (await resp.json().catch(() => null)) as { + capabilities?: unknown; + } | null; + const advertised = Array.isArray(body?.capabilities) + ? body.capabilities + : []; + return { + apiOk: true, + capabilities: { + improveExistingSetup: advertised.includes("improve-existing-setup"), + }, + }; } catch (error) { logger.withTag("readiness").debug("Mastra API health check failed", error); - return false; + return { + apiOk: false, + capabilities: { improveExistingSetup: false }, + }; } finally { clearTimeout(timer); } diff --git a/packages/cli/src/lib/init/tools/create-sentry-project.ts b/packages/cli/src/lib/init/tools/create-sentry-project.ts index c75e2cc31..ae5c19c95 100644 --- a/packages/cli/src/lib/init/tools/create-sentry-project.ts +++ b/packages/cli/src/lib/init/tools/create-sentry-project.ts @@ -2,22 +2,24 @@ * Sentry project creation tool for the init wizard. * * Implements the `create-sentry-project` and `ensure-sentry-project` wizard - * operations. Uses the team-scoped endpoint for explicit or Team Admin teams; - * otherwise uses POST /organizations/{org}/projects/, the onboarding endpoint - * that auto-creates a personal team for eligible members. + * operations. Resolves team capabilities only after the final project slug is + * known, using the same policy as `sentry project create`. */ import { captureException } from "@sentry/node-core/light"; -import { - createProjectWithAutoTeam, - createProjectWithDsn, - MEMBER_PROJECT_CREATION_DISABLED_DETAIL, -} from "../../api-client.js"; +import { MEMBER_PROJECT_CREATION_DISABLED_DETAIL } from "../../api-client.js"; import { ApiError } from "../../errors.js"; -import { resolveOrCreateTeam } from "../../resolve-team.js"; +import { + createProjectWithTeamFallback, + ProjectCreationApiError, +} from "../../project-creation.js"; +import { + type ResolvedConcreteTeam, + resolveOrCreateTeam, +} from "../../resolve-team.js"; import { captureOAuthScopeRecoveryGate } from "../../scope-recovery.js"; import { slugify } from "../../utils.js"; -import { tryGetExistingProjectData } from "../existing-project.js"; +import { WizardCancelledError } from "../clack-utils.js"; import { formatMemberProjectCreationDisabledError } from "../project-creation-errors.js"; import type { CreateSentryProjectPayload, @@ -25,7 +27,10 @@ import type { ToolResult, } from "../types.js"; import { formatToolError } from "./shared.js"; -import type { InitToolDefinition, ToolContext } from "./types.js"; +import type { + InitToolDefinition, + ProjectCreationToolContext, +} from "./types.js"; type ProjectData = { projectSlug: string; @@ -34,6 +39,27 @@ type ProjectData = { url: string; }; +function existingProjectResult( + context: Pick +): ToolResult | undefined { + if (!context.existingProject) { + return; + } + if ( + !context.existingProject.dsn && + context.setupIntent !== "improve-existing" + ) { + return { + ok: false, + error: `Could not obtain a DSN for existing project '${context.existingProject.orgSlug}/${context.existingProject.projectSlug}'. Check project-key access or choose a project whose keys you can read.`, + }; + } + return { + ok: true, + data: { ...context.existingProject, ensuredVia: "existing" }, + }; +} + type ProjectCreationResponse = { project: { id: string; @@ -52,163 +78,87 @@ function toProjectData(response: ProjectCreationResponse): ProjectData { }; } +/** Preserve user cancellation across the tool-result error boundary. */ +function rethrowWizardCancellation(error: unknown): void { + if (error instanceof WizardCancelledError) { + throw error; + } +} + /** - * Resolve project creation using the frontend onboarding policy. - * - * @param opts.org - Organization slug - * @param opts.name - Project display name - * @param opts.platform - Platform identifier (null/undefined → omitted from request) - * @param opts.team - Pre-resolved team slug (explicit or auto-selected by preflight). - * When undefined, use the org-scoped onboarding endpoint directly. - * @param opts.suppressFallback - When true, a 403 from the team-scoped flow is - * surfaced directly rather than triggering the org-scoped fallback. Set only - * when the team was explicitly named via `--team` — a 403 there is meaningful - * user feedback, not a permission gap. - * @returns Resolved project identifiers and DSN + * Retry a registry platform unknown to the projects API on the same concrete + * route that rejected it. This avoids restarting team-to-organization routing. */ -async function resolveProjectCreation(opts: { +async function createProjectWithPlatformFallback(opts: { org: string; name: string; platform: string | null | undefined; - team: string | undefined; - suppressFallback: boolean; + team: ResolvedConcreteTeam | undefined; }): Promise { - const { org, name, team, suppressFallback } = opts; - // Coerce null → undefined: CreateProjectBody.platform is string | undefined. + const { name, org, team } = opts; const platform = opts.platform ?? undefined; - - const withPlatformFallback = async ( - fn: (p: string | undefined) => Promise - ): Promise => { - try { - return await fn(platform); - } catch (err) { - // The registry may include SDK keys whose derived platform slug (e.g. - // "javascript-hono") is not yet in the Sentry API's allowed platform - // list. Retry without a platform so the project is still created, and - // capture to track which slugs need to be added to the API allowlist. - if ( - err instanceof ApiError && - err.status === 400 && - platform && - err.detail?.includes("Invalid platform") - ) { - captureException(err, { - extra: { - attemptedPlatform: platform, - projectName: name, - apiResponseDetail: err.detail, - apiStatus: err.status, - }, - }); - return await fn(undefined); - } - throw err; - } - }; - - if (!team) { - return await withPlatformFallback(async (p) => { - const result = await createProjectWithAutoTeam(org, { + const create = async ( + selectedPlatform: string | undefined, + selectedTeam: ResolvedConcreteTeam | undefined + ) => + toProjectData( + await createProjectWithTeamFallback({ + orgSlug: org, name, - platform: p, - }); - return toProjectData(result); - }); - } + platform: selectedPlatform, + team: selectedTeam, + }) + ); try { - return await withPlatformFallback(async (p) => { - const result = await createProjectWithDsn(org, team, { - name, - platform: p, - }); - return toProjectData(result); - }); - } catch (innerError) { - // Fall back to org-scoped endpoint on 403, unless the fallback is suppressed - // (explicit --team means the 403 is meaningful feedback, not a permission gap). - // Note: a 403 can originate from either the initial createProjectWithDsn call - // or from the platform-less retry inside withPlatformFallback — both mean the - // caller lacks team:write, so the org-scoped fallback is correct in either case. + return await create(platform, team); + } catch (error) { if ( - !(innerError instanceof ApiError && innerError.status === 403) || - suppressFallback + !(error instanceof ProjectCreationApiError) || + error.status !== 400 || + !platform || + !error.detail?.includes("Invalid platform") ) { - throw innerError; - } - // Policy 403: org has disabled member project creation. The org-scoped - // endpoint enforces the same flag — re-throw immediately so the outer - // catch surfaces the friendly disabled-policy message without a wasted round-trip. - if (innerError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { - throw innerError; + throw error; } - return await withPlatformFallback(async (p) => { - const result = await createProjectWithAutoTeam(org, { - name, - platform: p, - }); - return toProjectData(result); - }); - } -} - -/** - * Validate explicit team access for a dry-run, mirroring preflight.ts:resolveTeam. - * - * When `team` is undefined, preflight intentionally chose the org-scoped - * onboarding endpoint, so there is no local team path to validate. - * - * @throws Non-403 errors from resolveOrCreateTeam (org not found, network, etc.) - */ -async function validateTeamForDryRun( - org: string, - team: string | undefined, - autoCreateSlug: string -): Promise { - if (!team) { - return; - } - try { - await resolveOrCreateTeam(org, { - team, - autoCreateSlug, - usageHint: "sentry init", - dryRun: true, - deferAutoCreateOnEmptyOrg: true, + captureException(error.cause, { + extra: { + attemptedPlatform: platform, + projectName: name, + apiResponseDetail: error.detail, + apiStatus: error.status, + }, }); - } catch (teamErr) { - if (!(teamErr instanceof ApiError && teamErr.status === 403)) { - throw teamErr; - } + return await create(undefined, error.route === "team" ? team : undefined); } } /** * Create a new Sentry project using the org that preflight already resolved. - * When preflight does not resolve a Team Admin team, creation uses the same - * org-scoped auto-team endpoint as Sentry onboarding. + * Team resolution happens here rather than in preflight so existing projects + * never trigger a team prompt or API call, and a new team's slug can be based + * on the final project name selected by the workflow. * * New Sentry orgs have member project creation disabled by default * (Organization.flags.disable_member_project_creation = true). When the org * restricts project creation for members, we surface a clear error with an * escape hatch: the user can pass `sentry init /` once an * admin creates the project, which resolves to an existing project and skips - * creation entirely (preflight.ts:261). + * creation entirely (`resolveInitProjectContext` in preflight). */ export async function createSentryProject( payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, context: Pick< - ToolContext, + ProjectCreationToolContext, | "dryRun" | "existingProject" - | "isExplicitTeam" | "org" | "team" | "project" - | "yes" - > + | "setupIntent" + | "chooseTeam" + > & { yes?: boolean } ): Promise { const name = context.project ?? payload.params.name; const slug = slugify(name); @@ -219,29 +169,22 @@ export async function createSentryProject( }; } - if (context.existingProject) { - return { - ok: true, - message: `Using existing project "${context.existingProject.projectSlug}" in ${context.existingProject.orgSlug}`, - data: context.existingProject, - }; + const existing = existingProjectResult(context); + if (existing) { + return existing; } const scopeRecovery = captureOAuthScopeRecoveryGate(); try { - const existingProject = await tryGetExistingProjectData(context.org, slug); - if (existingProject) { - return { - ok: true, - message: `Using existing project "${existingProject.projectSlug}" in ${existingProject.orgSlug}`, - data: existingProject, - }; - } + const team = await resolveOrCreateTeam(context.org, { + team: context.team?.slug, + autoCreateSlug: slug, + usageHint: "sentry init", + dryRun: context.dryRun, + chooseTeam: context.chooseTeam, + }); if (context.dryRun) { - // Validate team access in dry-run — mirrors preflight.ts:resolveTeam. - // Not needed in real runs: resolveProjectCreation handles its own resolution. - await validateTeamForDryRun(context.org, context.team, slug); return { ok: true, data: { @@ -254,15 +197,11 @@ export async function createSentryProject( }; } - // Use the Team Admin path when preflight found one; otherwise use the - // org-scoped onboarding path, which auto-creates a personal team for - // eligible members. - const projectData = await resolveProjectCreation({ + const projectData = await createProjectWithPlatformFallback({ org: context.org, name, platform: payload.params.platform, - team: context.team, - suppressFallback: Boolean(context.isExplicitTeam), + team, }); return { @@ -273,9 +212,11 @@ export async function createSentryProject( projectId: projectData.projectId, dsn: projectData.dsn, url: projectData.url, + ensuredVia: "created", }, }; } catch (error) { + rethrowWizardCancellation(error); // Org-level policy: member project creation is disabled on this org. // Surface a clear message with the escape hatch. if ( @@ -290,7 +231,7 @@ export async function createSentryProject( } if ( await scopeRecovery.shouldDelegate(error, { - unattended: context.yes || context.dryRun, + unattended: context.yes === true || context.dryRun, }) ) { throw error; diff --git a/packages/cli/src/lib/init/tools/detect-sentry.ts b/packages/cli/src/lib/init/tools/detect-sentry.ts index 8713b41df..c655f1252 100644 --- a/packages/cli/src/lib/init/tools/detect-sentry.ts +++ b/packages/cli/src/lib/init/tools/detect-sentry.ts @@ -1,27 +1,343 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { + collectGlob, + collectGrep, + DEFAULT_SKIP_DIRS, + DSN_ADDITIONAL_SKIP_DIRS, +} from "../../scan/index.js"; import type { DetectSentryPayload, ToolResult } from "../types.js"; import type { InitToolDefinition } from "./types.js"; +const INITIALIZATION_PATTERN = [ + String.raw`\bSentry(?:Sdk|Flutter|SDK|Android)?\.(?:init\s*(?:\(|\bdo\b)|start\s*(?:\(|\{))`, + String.raw`\bsentry_sdk\.init\s*\(`, + String.raw`\bsentry\.Init\s*\(`, + String.raw`\bsentry::init\s*\(`, + String.raw`\bsentry_init\s*\(`, + String.raw`\\Sentry\\init\s*\(`, + String.raw`\[SentrySDK\s+startWithConfigureOptions`, + String.raw`\bconfig\s+:sentry\b`, +].join("|"); + +const SDK_REFERENCE_PATTERN = [ + String.raw`@sentry/(?:angular|astro|aws-serverless|browser|bun|capacitor|cloudflare|core|deno|electron|expo|gatsby|google-cloud-serverless|nestjs|nextjs|node|nuxt|opentelemetry|react|react-native|react-router|remix|solidstart|svelte|sveltekit|tanstackstart-react|vue|wasm)(?:["'/\s]|$)`, + String.raw`\bsentry[-_.]?sdk\b`, + String.raw`\bio\.sentry\b`, + String.raw`\bsentry_flutter\b`, + String.raw`\bsentry-ruby\b`, + String.raw`\bsentry-go\b`, + String.raw`\bsentry/sentry\b`, + String.raw`\{:sentry\s*,`, + String.raw`PackageReference[^>]+Include=["']Sentry(?:\.|["'])`, + "getsentry/sentry-cocoa", +].join("|"); + +const CONFIGURED_FEATURE_MARKERS = [ + { + feature: "performanceMonitoring", + markers: [ + "tracesSampleRate", + "tracesSampler", + "traces_sample_rate", + "traces_sampler", + "browserTracingIntegration", + ], + }, + { + feature: "sessionReplay", + markers: [ + "replaysSessionSampleRate", + "replaysOnErrorSampleRate", + "replayIntegration", + "new Sentry.Replay", + ], + }, + { + feature: "profiling", + markers: [ + "profilesSampleRate", + "profilesSampler", + "profileSessionSampleRate", + "profileLifecycle", + "profiles_sample_rate", + "profiles_sampler", + "profile_session_sample_rate", + "profile_lifecycle", + "nodeProfilingIntegration", + "browserProfilingIntegration", + "@sentry/profiling-node", + ], + }, + { feature: "logs", markers: ["enableLogs", "enable_logs"] }, + { + feature: "aiMonitoring", + markers: [ + "openAIIntegration", + "vercelAIIntegration", + "anthropicAIIntegration", + ], + }, + { + feature: "mcpObservability", + markers: ["wrapMcpServerWithSentry", "MCPIntegration"], + }, + { + feature: "userFeedback", + markers: ["showReportDialog", "feedbackIntegration"], + }, + { + feature: "reactFeatures", + markers: [ + "Sentry.ErrorBoundary", + "withErrorBoundary", + "reactErrorHandler", + "captureReactException", + ], + }, +] as const; + +const FEATURE_SIGNAL_PATTERN = CONFIGURED_FEATURE_MARKERS.flatMap( + ({ markers }) => markers +) + .map((marker) => marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + +const SDK_CONFIG_PATTERNS = [ + "**/sentry.*.config.*", + "**/sentry.config.*", + "**/config/sentry.php", +] as const; + +const AUXILIARY_CONFIG_PATTERNS = [ + "**/sentry.properties", + "**/sentry.yml", + "**/sentry.yaml", +] as const; + +const DETECTION_EXCLUDES = [ + "**/{test,tests,__tests__,spec,specs,fixture,fixtures,__fixtures__}/**", + "**/*.{test,spec}.*", +] as const; + +const SDK_CONFIG_FILE_RE = /(?:^|\/)sentry(?:\.[^./]+)?\.config\./; +const LARAVEL_CONFIG_FILE_RE = /(?:^|\/)config\/sentry\.php$/; +const COMMENT_ONLY_LINE_RE = /^\s*(?:\/\/|#|\/\*|\*|