From a794d558478975ce1b4091fdd93d65244293dbcd Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 6 Aug 2026 21:48:51 +0200 Subject: [PATCH] fix(init): default to creation and share team resolution --- packages/cli/src/commands/init.ts | 22 +- packages/cli/src/commands/project/create.ts | 299 +++--- packages/cli/src/lib/api-scope.ts | 14 + packages/cli/src/lib/api/projects.ts | 7 +- packages/cli/src/lib/api/teams.ts | 49 +- packages/cli/src/lib/formatters/human.ts | 6 +- packages/cli/src/lib/git.ts | 11 + packages/cli/src/lib/init/preflight.ts | 456 ++++----- .../lib/init/tools/create-sentry-project.ts | 227 ++--- packages/cli/src/lib/init/tools/registry.ts | 37 +- packages/cli/src/lib/init/tools/types.ts | 24 +- packages/cli/src/lib/init/types.ts | 16 +- packages/cli/src/lib/init/ui/ink-ui.ts | 1 + packages/cli/src/lib/init/ui/types.ts | 3 + packages/cli/src/lib/init/wizard-runner.ts | 43 +- packages/cli/src/lib/project-creation.ts | 192 ++++ packages/cli/src/lib/resolve-target.ts | 447 +++++++-- packages/cli/src/lib/resolve-team.ts | 328 ++++--- packages/cli/src/lib/team-choice.ts | 78 ++ packages/cli/test/commands/init.test.ts | 9 +- .../project/create-team-choice.test.ts | 146 +++ .../cli/test/commands/project/create.test.ts | 117 ++- .../cli/test/lib/api-client.coverage.test.ts | 104 +- packages/cli/test/lib/api-scope.test.ts | 10 + packages/cli/test/lib/init/preflight.test.ts | 918 +++++++----------- .../create-sentry-project.component.test.ts | 127 +++ .../init/tools/create-sentry-project.test.ts | 140 +-- packages/cli/test/lib/init/ui/mock-ui.ts | 1 + .../cli/test/lib/init/wizard-runner.test.ts | 84 ++ .../cli/test/lib/project-creation.test.ts | 215 ++++ .../test/lib/resolve-target.mocked.test.ts | 536 +++++++++- packages/cli/test/lib/resolve-team.test.ts | 277 +++++- packages/cli/test/lib/team-choice.test.ts | 88 ++ 33 files changed, 3496 insertions(+), 1536 deletions(-) create mode 100644 packages/cli/src/lib/project-creation.ts create mode 100644 packages/cli/src/lib/team-choice.ts create mode 100644 packages/cli/test/commands/project/create-team-choice.test.ts create mode 100644 packages/cli/test/lib/init/tools/create-sentry-project.component.test.ts create mode 100644 packages/cli/test/lib/project-creation.test.ts create mode 100644 packages/cli/test/lib/team-choice.test.ts diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 96a2501cda..61048412f8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -243,17 +243,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) @@ -298,6 +295,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 3faf44c3f5..ff81c70a22 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 = 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-scope.ts b/packages/cli/src/lib/api-scope.ts index a5049d5f1d..3228669c09 100644 --- a/packages/cli/src/lib/api-scope.ts +++ b/packages/cli/src/lib/api-scope.ts @@ -61,6 +61,11 @@ export function extractRequiredScopes(detail: unknown): string[] { if (!detail) { return []; } + const serializedDetail = + typeof detail === "string" ? detail : JSON.stringify(detail); + if (isMemberProjectCreationPolicy(serializedDetail)) { + return []; + } if (typeof detail === "object") { const fromFields = extractFromRecord(detail as Record); if (fromFields.length > 0) { @@ -75,6 +80,15 @@ export function extractRequiredScopes(detail: unknown): string[] { return []; } +/** A role/policy denial can mention scope names without a token lacking them. */ +function isMemberProjectCreationPolicy(detail: string): boolean { + const normalized = detail.toLowerCase(); + return ( + normalized.includes("disabled this feature for members") || + normalized.includes("org-level policy setting, not an auth issue") + ); +} + function extractFromRecord(record: Record): string[] { for (const field of SCOPE_FIELD_NAMES) { const value = record[field]; diff --git a/packages/cli/src/lib/api/projects.ts b/packages/cli/src/lib/api/projects.ts index 65085d5e2a..48a0526278 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 6106f50cc9..72a3a92dec 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/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index 32f4f08721..72f782e046 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -1946,7 +1946,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 */ @@ -1996,8 +1996,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 a029e01a67..2e5ee17335 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/preflight.ts b/packages/cli/src/lib/init/preflight.ts index 347174ab70..8e7b330c98 100644 --- a/packages/cli/src/lib/init/preflight.ts +++ b/packages/cli/src/lib/init/preflight.ts @@ -1,17 +1,14 @@ -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 { ApiError, WizardError } from "../errors.js"; -import { buildOrgNotFoundError, resolveOrCreateTeam } from "../resolve-team.js"; +import { resolveAllTargets } from "../resolve-target.js"; +import type { ResolvedConcreteTeam } from "../resolve-team.js"; +import { buildProjectUrl } 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 type { ExistingProjectData, ResolvedInitContext, @@ -24,13 +21,19 @@ const NUMERIC_ORG_ID_RE = /^\d+$/; type ExistingProjectChoice = { project?: string; existingProject?: ExistingProjectData; - shouldAbort?: boolean; +}; + +type CanonicalProjectCandidate = { + org: string; + project: string; + existingProject?: ExistingProjectData; }; type InitContextSeed = { org?: string; project?: string; existingProject?: ExistingProjectData; + detectedProjects?: CanonicalProjectCandidate[]; }; type ProjectSelection = Pick< @@ -46,10 +49,7 @@ export async function resolveInitContext( ui: WizardUI ): Promise { return await withPreflightHandling(ui, async () => { - const seed = await resolveInitContextSeed(initial, ui); - if (!seed) { - return null; - } + const seed = await resolveInitContextSeed(initial); const org = await ensureOrg(seed.org, initial, ui); const projectSelection = await resolveProjectSelection( @@ -62,7 +62,9 @@ export async function resolveInitContext( return null; } - const team = await resolveTeam(org, initial, ui); + const team = initial.team + ? ({ slug: initial.team, source: "explicit" } as const) + : undefined; return buildResolvedInitContext(initial, org, team, projectSelection); }); @@ -93,7 +95,7 @@ async function withPreflightHandling( function buildResolvedInitContext( initial: WizardOptions, org: string, - team: string | undefined, + team: ResolvedConcreteTeam | undefined, selection: ProjectSelection ): ResolvedInitContext { return { @@ -103,7 +105,6 @@ function buildResolvedInitContext( features: initial.features, org, team, - isExplicitTeam: Boolean(initial.team), project: selection.project, app: initial.app, authToken: getAuthToken(), @@ -112,21 +113,37 @@ function buildResolvedInitContext( } async function resolveInitContextSeed( - initial: WizardOptions, - ui: WizardUI -): Promise { - const detected = await resolveDetectedProject(initial, ui); - if (detected?.shouldAbort) { - return null; - } - + initial: WizardOptions +): Promise { + const preferredOrg = + initial.org ?? (await resolvePreferredOrg(initial.directory)); + const detected = await resolveCanonicalProjects(initial, preferredOrg); + const candidates = preferredOrg + ? detected.filter((candidate) => candidate.org === preferredOrg) + : detected; + const concrete = candidates.length === 1 ? candidates[0] : undefined; + const candidateOrgs = [ + ...new Set(candidates.map((candidate) => candidate.org)), + ]; return { - org: detected?.org ?? initial.org, - project: detected?.project ?? initial.project, - existingProject: detected?.existingProject, + org: + preferredOrg ?? + concrete?.org ?? + (candidateOrgs.length === 1 ? candidateOrgs[0] : undefined), + project: concrete?.project ?? initial.project, + existingProject: concrete?.existingProject, + detectedProjects: candidates, }; } +/** 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( org: string | undefined, initial: WizardOptions, @@ -150,26 +167,30 @@ async function resolveProjectSelection( seed: InitContextSeed, ui: WizardUI ): Promise { - if (!seed.project) { - return { + if (seed.project) { + const resolved = await resolveExistingProjectChoice({ + org, project: seed.project, existingProject: seed.existingProject, - }; + }); + return mergeProjectSelection(seed, resolved); } - const resolved = await resolveExistingProjectChoice({ - org, - project: seed.project, - existingProject: seed.existingProject, - yes: initial.yes, - promptOnExisting: Boolean(initial.project && !initial.org), - ui, - }); - if (resolved.shouldAbort) { - return null; + const candidates = seed.detectedProjects?.filter( + (candidate) => candidate.org === org + ); + if (candidates?.length === 1) { + const candidate = candidates[0]; + if (candidate) { + const resolved = await resolveExistingProjectChoice(candidate); + return { + project: resolved.project ?? candidate.project, + existingProject: resolved.existingProject ?? candidate.existingProject, + }; + } } - return mergeProjectSelection(seed, resolved); + return await resolveImplicitProjectSelection(org, initial, ui); } function mergeProjectSelection( @@ -188,77 +209,64 @@ function mergeProjectSelection( }; } -async function resolveDetectedProject( +/** + * Reuse the CLI-wide org/project resolver instead of maintaining an init-only + * detection policy. Init auto-selects only a single concrete target; an empty + * or ambiguous result continues to the create-first flow. + */ +async function resolveCanonicalProjects( initial: WizardOptions, - ui: WizardUI -): Promise<{ - org?: string; - project?: string; - existingProject?: ExistingProjectData; - shouldAbort?: boolean; -} | null> { - if (initial.org || initial.project) { - return null; + organizationFilter?: string +): Promise { + if (initial.project) { + return []; } - let detectedProject: { orgSlug: string; projectSlug: string } | null = null; + let resolved: Awaited>; try { - detectedProject = await detectExistingProject(initial.directory); + resolved = await resolveAllTargets({ + cwd: initial.directory, + resolutionMode: "codebase", + ...(organizationFilter ? { organizationFilter } : {}), + }); } catch { - return null; - } - if (!detectedProject) { - return null; - } - - const existingProject = await tryGetExistingProjectData( - detectedProject.orgSlug, - detectedProject.projectSlug - ).catch(() => null); - - if (initial.yes) { - return { - org: detectedProject.orgSlug, - project: detectedProject.projectSlug, - ...(existingProject ? { existingProject } : {}), - }; - } - - 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(); - } - if (choice === "existing") { - return { - org: detectedProject.orgSlug, - project: detectedProject.projectSlug, - ...(existingProject ? { existingProject } : {}), - }; - } - - return {}; + return []; + } + + if (resolved.skippedSelfHosted) { + return []; + } + + return resolved.targets + .filter((target) => target.matchStrength !== "fuzzy") + .map((target) => { + // Preserve the exact DSN provenance carried by the shared resolver so a + // partial multi-DSN resolution can never attach another target's DSN. + const existingProject = target.detectedDsn + ? { + orgSlug: target.org, + projectSlug: target.project, + projectId: String(target.projectId ?? target.detectedDsn.projectId), + dsn: target.detectedDsn.raw, + url: buildProjectUrl(target.org, target.project), + ...(target.projectData?.platform + ? { platform: target.projectData.platform } + : {}), + } + : undefined; + + return { + org: target.org, + project: target.project, + existingProject, + }; + }); } async function resolveExistingProjectChoice(opts: { org: string; project: string; existingProject?: ExistingProjectData; - yes: boolean; - promptOnExisting: boolean; - ui: WizardUI; }): Promise { const slug = slugify(opts.project); if (!slug) { @@ -270,40 +278,11 @@ async function resolveExistingProjectChoice(opts: { opts.existingProject.orgSlug === opts.org && opts.existingProject.projectSlug === slug ? opts.existingProject - : await tryGetExistingProjectData(opts.org, slug).catch(() => null); + : await tryGetExistingProjectData(opts.org, slug); if (!existingProject) { return { project: opts.project }; } - if (!opts.promptOnExisting || opts.yes) { - return { - project: existingProject.projectSlug, - existingProject, - }; - } - - const choice = await opts.ui.select<"existing" | "create">({ - message: `Found existing project '${slug}' in ${opts.org}.`, - options: [ - { - value: "existing", - label: `Use existing (${opts.org}/${slug})`, - hint: "Already configured", - }, - { - value: "create", - label: "Create a new project", - hint: "Wizard will detect the project name from your codebase", - }, - ], - }); - if (isCancelled(choice)) { - throw new WizardCancelledError(); - } - if (choice === "create") { - return { project: undefined }; - } - return { project: existingProject.projectSlug, existingProject, @@ -311,146 +290,93 @@ async function resolveExistingProjectChoice(opts: { } /** - * 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 WizardError) { - return error; - } - if (error instanceof ApiError) { - return new WizardError(error.format()); - } - return new WizardError( - error instanceof Error ? error.message : String(error) - ); -} - -async function resolveTeam( +async function resolveImplicitProjectSelection( org: string, initial: WizardOptions, ui: WizardUI -): Promise { - if (!initial.team) { - return await resolveImplicitTeam(org, initial, ui); +): Promise { + if (initial.yes) { + return {}; } - try { - const result = await resolveOrCreateTeam(org, { - team: initial.team, - usageHint: "sentry init", - dryRun: initial.dryRun, - deferAutoCreateOnEmptyOrg: true, - }); - return result.source === "deferred" ? undefined : result.slug; - } catch (error) { - if (error instanceof WizardCancelledError) { - throw error; - } - if (error instanceof ApiError && error.status === 403) { - return; - } - throw toPreflightWizardError(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", + hint: "Recommended — no matching project was found", + }, + { + value: "existing", + label: "Use an existing Sentry project", + }, + ], + }); + if (isCancelled(intent)) { + throw new WizardCancelledError(); } -} - -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 (intent === "create") { + return {}; } - return ( - access.includes("org:write") || - access.includes("project:admin") || - access.includes("project:write") - ); -} -async function assertOrgScopedCreationCanProceed(org: string): Promise { - let organization: Awaited>; + let projects: SentryProject[]; 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; + projects = await listProjects(org); + } catch (error) { + const reason = error instanceof ApiError ? error.format() : String(error); + throw new WizardError( + `Could not list existing projects in '${org}'.\n\n${reason}` + ); + } + if (projects.length === 0) { + throw new WizardError( + `There are no 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 -): Promise { - 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 === 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 loadExistingProject( org: string, - initial: WizardOptions, - ui: WizardUI -): Promise { - const teams = await listTeamsForImplicitInit(org); - if (!teams) { - return; - } - - const candidateTeams = teams.filter(canCreateProjectInTeam); - if (candidateTeams.length === 0) { - await assertOrgScopedCreationCanProceed(org); - return; - } - if (candidateTeams.length === 1 || initial.yes) { - return (candidateTeams[0] as SentryTeam).slug; - } - - 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(); + 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; } /** @@ -524,7 +450,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, @@ -536,27 +462,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/tools/create-sentry-project.ts b/packages/cli/src/lib/init/tools/create-sentry-project.ts index 4328d9666a..d1e8b9f121 100644 --- a/packages/cli/src/lib/init/tools/create-sentry-project.ts +++ b/packages/cli/src/lib/init/tools/create-sentry-project.ts @@ -2,21 +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 { extractRequiredScopes } from "../../api-scope.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 { 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, @@ -24,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; @@ -51,143 +57,67 @@ 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 @@ -199,8 +129,8 @@ async function validateTeamForDryRun( export async function createSentryProject( payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, context: Pick< - ToolContext, - "dryRun" | "existingProject" | "isExplicitTeam" | "org" | "team" | "project" + ProjectCreationToolContext, + "dryRun" | "existingProject" | "org" | "team" | "project" | "chooseTeam" > ): Promise { const name = context.project ?? payload.params.name; @@ -221,19 +151,15 @@ export async function createSentryProject( } 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: { @@ -246,15 +172,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 { @@ -268,6 +190,7 @@ export async function createSentryProject( }, }; } catch (error) { + rethrowWizardCancellation(error); // Org-level policy: member project creation is disabled on this org. // Surface a clear message with the escape hatch. if ( @@ -280,6 +203,16 @@ export async function createSentryProject( error: formatMemberProjectCreationDisabledError(context.org), }; } + // Existing OAuth grants may predate a newly standard scope. Let this + // machine-readable 403 reach the CLI-wide reauthorization middleware, + // which refreshes the grant once and retries the command. + if ( + error instanceof ApiError && + error.status === 403 && + extractRequiredScopes(error.detail).length > 0 + ) { + throw error; + } // 409: project already exists (from either the team-scoped or org-scoped // endpoint — both propagate here). Surface a friendly message with a view // hint rather than the raw API error text. diff --git a/packages/cli/src/lib/init/tools/registry.ts b/packages/cli/src/lib/init/tools/registry.ts index c2ca1a92d2..1a65f5b4b2 100644 --- a/packages/cli/src/lib/init/tools/registry.ts +++ b/packages/cli/src/lib/init/tools/registry.ts @@ -1,4 +1,12 @@ -import type { ToolOperation, ToolPayload, ToolResult } from "../types.js"; +import { extractRequiredScopes } from "../../api-scope.js"; +import { ApiError } from "../../errors.js"; +import { WizardCancelledError } from "../clack-utils.js"; +import type { + ResolvedInitContext, + ToolOperation, + ToolPayload, + ToolResult, +} from "../types.js"; import { applyPatchsetTool } from "./apply-patchset.js"; import { createSentryProjectTool, @@ -12,7 +20,12 @@ import { listDirTool } from "./list-dir.js"; import { readFilesTool } from "./read-files.js"; import { runCommandsTool } from "./run-commands.js"; import { formatToolError, validateToolSandbox } from "./shared.js"; -import type { AnyInitToolDefinition, ToolContext } from "./types.js"; +import type { AnyInitToolDefinition, ToolCapabilities } from "./types.js"; + +const PROJECT_CREATION_OPERATIONS = new Set([ + "create-sentry-project", + "ensure-sentry-project", +]); const toolDefinitions = [ listDirTool, @@ -44,7 +57,8 @@ export function describeTool(payload: ToolPayload): string { */ export async function executeTool( payload: ToolPayload, - context: ToolContext + context: ResolvedInitContext, + capabilities: ToolCapabilities = {} ): Promise { const sandboxError = validateToolSandbox(payload, context.directory); if (sandboxError) { @@ -60,8 +74,23 @@ export async function executeTool( } try { - return await tool.execute(payload as never, context); + const executionContext = PROJECT_CREATION_OPERATIONS.has(payload.operation) + ? { ...context, chooseTeam: capabilities.chooseTeam } + : context; + return await tool.execute(payload as never, executionContext); } catch (error) { + if (error instanceof WizardCancelledError) { + throw error; + } + // Scope-bearing 403s must reach the top-level CLI middleware so existing + // OAuth grants can be refreshed and the full init command retried. + if ( + error instanceof ApiError && + error.status === 403 && + extractRequiredScopes(error.detail).length > 0 + ) { + throw error; + } return { ok: false, error: formatToolError(error) }; } } diff --git a/packages/cli/src/lib/init/tools/types.ts b/packages/cli/src/lib/init/tools/types.ts index 0d608f1017..a0ca18a217 100644 --- a/packages/cli/src/lib/init/tools/types.ts +++ b/packages/cli/src/lib/init/tools/types.ts @@ -1,3 +1,4 @@ +import type { ChooseProjectTeam } from "../../resolve-team.js"; import type { ResolvedInitContext, ToolOperation, @@ -5,11 +6,26 @@ import type { ToolResult, } from "../types.js"; -/** - * Client-side context available to init tools while the workflow is suspended. - */ +/** Client-side context shared by every init tool. */ export type ToolContext = ResolvedInitContext; +/** Narrow interactive capabilities supplied by the local wizard runner. */ +export type ToolCapabilities = { + chooseTeam?: ChooseProjectTeam; +}; + +export type ProjectCreationToolOperation = + | "create-sentry-project" + | "ensure-sentry-project"; + +/** Extra local capabilities visible only to project-creation tools. */ +export type ProjectCreationToolContext = ToolContext & ToolCapabilities; + +type ToolContextFor = + TOperation extends ProjectCreationToolOperation + ? ProjectCreationToolContext + : ToolContext; + /** * A single init tool implementation plus its user-facing spinner copy. */ @@ -23,7 +39,7 @@ export type InitToolDefinition = { /** Execute the tool and return a resumable payload result. */ execute: ( payload: Extract, - context: ToolContext + context: ToolContextFor ) => Promise; }; diff --git a/packages/cli/src/lib/init/types.ts b/packages/cli/src/lib/init/types.ts index 7ea6a87219..218afe37ad 100644 --- a/packages/cli/src/lib/init/types.ts +++ b/packages/cli/src/lib/init/types.ts @@ -1,3 +1,5 @@ +import type { ResolvedConcreteTeam } from "../resolve-team.js"; + export type DirEntry = { name: string; path: string; @@ -43,17 +45,11 @@ export type ResolvedInitContext = { features?: string[]; org: string; /** - * Resolved team slug for init operations. - * Omitted when init defers empty-org auto-creation until project creation. - */ - team?: string; - /** - * True only when `team` was supplied via the `--team` CLI flag. - * False/absent when the team was auto-selected by preflight. - * Used by project creation tools to decide whether to suppress the - * org-scoped fallback on 403 (only suppress for explicitly named teams). + * Explicit team requested with `--team`. + * Implicit team resolution is deferred until project creation so existing + * projects never require a team choice. */ - isExplicitTeam?: boolean; + team?: ResolvedConcreteTeam; project?: string; /** Pre-selected app name for monorepo runs. Passed through from `--app`. */ app?: string; diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 266c8839a3..e4b6b6714a 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -360,6 +360,7 @@ type InkInstance = { * re-renders. */ export class InkUI implements WizardUI { + readonly supportsInteractivePrompts = true; private readonly instance: InkInstance; private readonly store: WizardStore; /** diff --git a/packages/cli/src/lib/init/ui/types.ts b/packages/cli/src/lib/init/ui/types.ts index 6e78200110..0ecb3dc392 100644 --- a/packages/cli/src/lib/init/ui/types.ts +++ b/packages/cli/src/lib/init/ui/types.ts @@ -155,6 +155,9 @@ export type WizardSummary = { * the main screen buffer, and release any held TTY resources. */ export type WizardUI = AsyncDisposable & { + /** Whether this implementation can render and resolve interactive prompts. */ + readonly supportsInteractivePrompts?: boolean; + // ── Lifecycle messages ──────────────────────────────────────────── /** diff --git a/packages/cli/src/lib/init/wizard-runner.ts b/packages/cli/src/lib/init/wizard-runner.ts index 10aa98c733..4432abc3a9 100644 --- a/packages/cli/src/lib/init/wizard-runner.ts +++ b/packages/cli/src/lib/init/wizard-runner.ts @@ -21,6 +21,7 @@ import { getTraceData, setTag, } from "@sentry/node-core/light"; +import { extractRequiredScopes } from "../api-scope.js"; import { formatBanner } from "../banner.js"; import { CLI_VERSION } from "../constants.js"; import { customFetch } from "../custom-ca.js"; @@ -31,6 +32,7 @@ import { stripColorTags, } from "../formatters/markdown.js"; import { logger } from "../logger.js"; +import { chooseProjectTeam } from "../team-choice.js"; import { abortIfCancelled, PROGRESS_ROTATE_INTERVAL_MS, @@ -83,6 +85,7 @@ import { type SpinState = { running: boolean }; const INIT_SERVICE_AUTH_FAILED_LABEL = "Authentication failed"; +const INIT_SCOPE_UPDATE_REQUIRED_LABEL = "Authorization update required"; const APPLY_CODEMODS_STEP = "apply-codemods"; @@ -103,6 +106,10 @@ type StepContext = { ui: WizardUI; }; +function supportsInteractiveTeamChoice(ui: WizardUI): boolean { + return ui.supportsInteractivePrompts === true; +} + function nextPhase( stepPhases: Map, stepId: string, @@ -358,7 +365,26 @@ async function handleSuspendedStep( ui.recordFilesReading?.(payload.params.paths); } - const toolResult = await executeTool(payload, context); + const canChooseTeam = + (payload.operation === "create-sentry-project" || + payload.operation === "ensure-sentry-project") && + !context.yes && + !context.dryRun && + supportsInteractiveTeamChoice(ui); + const toolResult = canChooseTeam + ? await executeTool(payload, context, { + chooseTeam: async (teams) => { + spin.stop("Found available teams"); + spinState.running = false; + const choice = await chooseProjectTeam(teams, async (options) => + abortIfCancelled(await ui.select(options)) + ); + spin.start("Creating Sentry project..."); + spinState.running = true; + return choice; + }, + }) + : await executeTool(payload, context); if (toolResult.message) { spin.stop(renderInlineMarkdown(toolResult.message)); @@ -1093,6 +1119,10 @@ export async function runWizard(initialOptions: WizardOptions): Promise { } } catch (err) { const isAuthFailure = err instanceof ApiError && err.status === 401; + const isScopeFailure = + err instanceof ApiError && + err.status === 403 && + extractRequiredScopes(err.detail).length > 0; // A running spinner owns a live interval, so stop it before any early // return or rethrow to avoid leaving the event loop artificially busy. if (spinState.running) { @@ -1103,6 +1133,8 @@ export async function runWizard(initialOptions: WizardOptions): Promise { code = 0; } else if (isAuthFailure) { label = INIT_SERVICE_AUTH_FAILED_LABEL; + } else if (isScopeFailure) { + label = INIT_SCOPE_UPDATE_REQUIRED_LABEL; } spin.stop(label, code); spinState.running = false; @@ -1120,8 +1152,13 @@ export async function runWizard(initialOptions: WizardOptions): Promise { if (activeStepId) { ui.setStep?.(activeStepId, "failed"); } - if (isAuthFailure) { - showFailedFeedback(ui, INIT_SERVICE_AUTH_FAILED_LABEL); + if (isAuthFailure || isScopeFailure) { + showFailedFeedback( + ui, + isAuthFailure + ? INIT_SERVICE_AUTH_FAILED_LABEL + : INIT_SCOPE_UPDATE_REQUIRED_LABEL + ); setTag("wizard.outcome", "errored"); throw err; } diff --git a/packages/cli/src/lib/project-creation.ts b/packages/cli/src/lib/project-creation.ts new file mode 100644 index 0000000000..fcef8b2939 --- /dev/null +++ b/packages/cli/src/lib/project-creation.ts @@ -0,0 +1,192 @@ +/** + * Shared project-creation routing for CLI commands. + * + * A resolved team uses the team-scoped endpoint. When an implicitly resolved + * team rejects creation, the org-scoped onboarding endpoint can create a + * personal team instead. Explicit or interactively selected teams and org + * policy failures never fall back, preserving the user's choice and the + * organization's restriction. + */ + +import { + type CreatedProjectDetails, + createProjectWithAutoTeam, + createProjectWithDsn, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, +} from "./api-client.js"; +import { ApiError } from "./errors.js"; +import { + buildTeamAdminAuthorizationError, + type ResolvedConcreteTeam, +} from "./resolve-team.js"; + +/** Project details plus the owning team selected by the creation route. */ +export type ProjectCreationResult = CreatedProjectDetails & { + /** Slug of the existing or newly created team that owns the project. */ + teamSlug: string; + /** How the owning team was selected. */ + teamSource: ResolvedConcreteTeam["source"]; +}; + +/** Inputs for shared project-creation endpoint selection. */ +export type ProjectCreationOptions = { + /** Project display name. */ + name: string; + /** Organization that will own the project. */ + orgSlug: string; + /** Optional Sentry platform identifier. */ + platform?: string; + /** Resolved team, or undefined to use org-scoped onboarding creation. */ + team?: ResolvedConcreteTeam; +}; + +/** API route selected by the shared project-creation resolver. */ +export type ProjectCreationRoute = "organization" | "team"; + +/** ApiError annotated with the concrete project-creation route that failed. */ +export class ProjectCreationApiError extends ApiError { + /** Original API error before route annotation. */ + override readonly cause: ApiError; + /** Concrete endpoint family that produced the error. */ + readonly route: ProjectCreationRoute; + + /** + * @param cause - Original API error + * @param route - Project-creation route that failed + */ + constructor(cause: ApiError, route: ProjectCreationRoute) { + super( + cause.message, + cause.status, + cause.detail, + cause.endpoint, + cause.enriched403 + ); + this.name = "ProjectCreationApiError"; + this.cause = cause; + this.route = route; + } +} + +/** + * Execute one concrete creation route and retain that provenance on API errors. + * The caller can use the route tag without inferring it from mutable resolver state. + */ +async function createOnRoute( + requestedPlatform: string | undefined, + route: ProjectCreationRoute, + create: ( + selectedPlatform: string | undefined + ) => Promise +): Promise { + try { + return await create(requestedPlatform); + } catch (error) { + if (error instanceof ApiError) { + throw new ProjectCreationApiError(error, route); + } + throw error; + } +} + +/** Match the policy detail shared by both project-creation endpoints. */ +function isMemberCreationDisabled403( + error: unknown +): error is ProjectCreationApiError { + return ( + error instanceof ProjectCreationApiError && + error.status === 403 && + error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) === true + ); +} + +/** Whether team choice must be preserved instead of using a personal team. */ +function isUserSelectedTeam(team: ResolvedConcreteTeam): boolean { + return team.source === "explicit" || team.source === "selected"; +} + +/** + * Create a project through the same endpoint-selection policy used by the UI. + */ +export async function createProjectWithTeamFallback( + options: ProjectCreationOptions +): Promise { + const { name, orgSlug, platform, team } = options; + + if (!team) { + return await createOnRoute( + platform, + "organization", + async (selectedPlatform) => { + const result = await createProjectWithAutoTeam(orgSlug, { + name, + platform: selectedPlatform, + }); + return { + project: result.project, + dsn: result.dsn, + url: result.url, + teamSlug: result.team_slug, + teamSource: "auto-created", + }; + } + ); + } + + try { + return await createOnRoute(platform, "team", async (selectedPlatform) => { + const result = await createProjectWithDsn(orgSlug, team.slug, { + name, + platform: selectedPlatform, + }); + return { + ...result, + teamSlug: team.slug, + teamSource: team.source, + }; + }); + } catch (error) { + if (!(error instanceof ProjectCreationApiError && error.status === 403)) { + throw error; + } + + // TeamProjectsEndpoint uses the member-creation-disabled detail when its + // `has_team_scope(team, "team:admin")` check fails. Since this team was + // selected from serialized Team Admin access, that response means the + // token's OAuth upper bound is stale, not that another route can work. + if (team.source !== "explicit" && isMemberCreationDisabled403(error)) { + throw buildTeamAdminAuthorizationError(orgSlug, team.slug); + } + + // A user-selected team is authoritative just like --team: never replace + // it with a personal team through the organization route. + if (isUserSelectedTeam(team)) { + throw error; + } + + try { + return await createOnRoute( + platform, + "organization", + async (selectedPlatform) => { + const result = await createProjectWithAutoTeam(orgSlug, { + name, + platform: selectedPlatform, + }); + return { + project: result.project, + dsn: result.dsn, + url: result.url, + teamSlug: result.team_slug, + teamSource: "auto-created", + }; + } + ); + } catch (fallbackError) { + if (isMemberCreationDisabled403(fallbackError)) { + throw buildTeamAdminAuthorizationError(orgSlug, team.slug); + } + throw fallbackError; + } + } +} diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 0372c78e7b..b8f62b1fb5 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -11,10 +11,10 @@ * 3. `.sentryclirc` config file (walked up from CWD, merged with global) * 4. Config defaults (SQLite) * 5. DSN auto-detection (source code, .env files, environment variables) - * 6. Directory name inference (matches project slugs with word boundaries) + * 6. Codebase-name inference (specific project root/cwd, git remote, fuzzy root fallback) */ -import { basename } from "node:path"; +import { basename, resolve as resolvePath } from "node:path"; import { isatty } from "node:tty"; import pLimit from "p-limit"; import type { SentryOrganization, SentryProject } from "../types/index.js"; @@ -66,12 +66,17 @@ import { withAuthGuard, } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; +import { inferRepositoryName, inferRepositoryRoot } from "./git.js"; import { interactivePromptsAllowed } from "./interactive-prompts.js"; import { logger } from "./logger.js"; import { resolveEffectiveOrg } from "./region.js"; -import { CONFIG_FILENAME, loadSentryCliRc } from "./sentryclirc.js"; +import { + CONFIG_FILENAME, + getGlobalPaths, + loadSentryCliRc, +} from "./sentryclirc.js"; import { setOrgProjectContext, withTracingSpan } from "./telemetry.js"; -import { isAllDigits } from "./utils.js"; +import { isAllDigits, slugify } from "./utils.js"; const log = logger.withTag("resolve-target"); @@ -123,6 +128,10 @@ export type ResolvedTarget = { packagePath?: string; /** Full project data when already fetched (avoids redundant getProject re-fetch) */ projectData?: SentryProject; + /** Exact detected DSN that produced this target, when DSN-resolved. */ + detectedDsn?: DetectedDsn; + /** Whether a name-based signal matched the project slug exactly or fuzzily. */ + matchStrength?: "exact" | "fuzzy"; }; /** @@ -168,8 +177,29 @@ export type ResolveOptions = { * invocations never block on a prompt). */ interactive?: boolean; + /** + * Resolution policy. `codebase` ignores account-wide defaults and accepts + * `.sentryclirc` only when its project came from a file in the cwd ancestry. + * Use it for create-first workflows that must not reuse an unrelated global + * default when the checked-out code has no concrete project signal. + */ + resolutionMode?: "standard" | "codebase"; + /** + * Restrict auto-detected targets to one organization while still allowing + * lower-priority signals to run when a higher-priority signal points at a + * different organization. Explicit `org` + `project` inputs still win. + */ + organizationFilter?: string; }; +/** Whether a resolved `.sentryclirc` project came from the cwd ancestry. */ +function hasLocalRcProject( + config: Awaited> +) { + const source = config.sources.project; + return source !== undefined && !getGlobalPaths().has(source); +} + /** * Options for resolving org only. */ @@ -207,6 +237,7 @@ export async function resolveFromDsn( orgDisplay: cached.orgName, projectDisplay: cached.projectName, detectedFrom, + detectedDsn: dsn, }; } @@ -233,6 +264,7 @@ export async function resolveFromDsn( orgDisplay: orgName, projectDisplay: projectInfo.name, detectedFrom, + detectedDsn: dsn, }; } @@ -244,6 +276,7 @@ export async function resolveFromDsn( orgDisplay: dsn.orgId, projectDisplay: projectInfo.name, detectedFrom, + detectedDsn: dsn, }; } @@ -346,6 +379,7 @@ export async function resolveDsnByPublicKey( projectDisplay: cached.projectName, detectedFrom, packagePath: dsn.packagePath, + detectedDsn: dsn, }; } @@ -378,6 +412,7 @@ export async function resolveDsnByPublicKey( projectDisplay: projectInfo.name, detectedFrom, packagePath: dsn.packagePath, + detectedDsn: dsn, }; } @@ -423,6 +458,7 @@ async function resolveDsnToTarget( projectDisplay: cached.projectName, detectedFrom, packagePath, + detectedDsn: dsn, }; } @@ -451,6 +487,7 @@ async function resolveDsnToTarget( projectDisplay: projectInfo.name, detectedFrom, packagePath, + detectedDsn: dsn, }; } @@ -463,6 +500,7 @@ async function resolveDsnToTarget( projectDisplay: projectInfo.name, detectedFrom, packagePath, + detectedDsn: dsn, }; }); return result.ok ? result.value : null; @@ -488,18 +526,27 @@ export function isValidDirNameForInference(dirName: string): boolean { return true; } +/** Classify a directory signal against one concrete project slug. */ +function projectNameMatchStrength( + projectSlug: string, + directoryName: string +): "exact" | "fuzzy" { + return projectSlug === slugify(directoryName) ? "exact" : "fuzzy"; +} + /** - * Infer project(s) from directory name when DSN detection fails. + * Infer project(s) from the discovered project-root name. * Uses word-boundary matching (`\b`) against all accessible projects. * * Caches results in dsn_cache with source: "inferred" for performance. * Cache is invalidated when directory mtime changes or after 24h TTL. * - * @param cwd - Current working directory + * @param projectRoot - Project root selected by local project discovery * @returns Resolved targets, or empty if no matches found */ -async function inferFromDirectoryName(cwd: string): Promise { - const { projectRoot } = await findProjectRoot(cwd); +async function inferFromProjectRootName( + projectRoot: string +): Promise { const dirName = basename(projectRoot); // Skip inference for invalid directory names @@ -514,12 +561,13 @@ async function inferFromDirectoryName(cwd: string): Promise { // Return all cached targets if available if (cached.allResolved && cached.allResolved.length > 0) { - const targets = cached.allResolved.map((r) => ({ + const targets: ResolvedTarget[] = cached.allResolved.map((r) => ({ org: r.orgSlug, project: r.projectSlug, orgDisplay: r.orgName, projectDisplay: r.projectName, detectedFrom, + matchStrength: projectNameMatchStrength(r.projectSlug, dirName), })); return { targets, @@ -540,6 +588,10 @@ async function inferFromDirectoryName(cwd: string): Promise { orgDisplay: cached.resolved.orgName, projectDisplay: cached.resolved.projectName, detectedFrom, + matchStrength: projectNameMatchStrength( + cached.resolved.projectSlug, + dirName + ), }, ], }; @@ -586,6 +638,7 @@ async function inferFromDirectoryName(cwd: string): Promise { orgDisplay: m.organization?.name ?? m.orgSlug, projectDisplay: m.name, detectedFrom, + matchStrength: projectNameMatchStrength(m.slug, dirName), })); return { @@ -597,6 +650,193 @@ async function inferFromDirectoryName(cwd: string): Promise { }; } +/** + * Resolve all accessible projects for an exact slug signal. + */ +async function inferFromExactProjectSlug(options: { + projectSlug: string; + detectedFrom: string; + matchDescription: string; + logDescription: string; +}): Promise { + try { + const { projects } = await findProjectsBySlug(options.projectSlug); + const targets: ResolvedTarget[] = projects.map((project) => ({ + org: project.orgSlug, + project: project.slug, + projectId: toNumericId(project.id), + orgDisplay: project.organization?.name ?? project.orgSlug, + projectDisplay: project.name, + projectData: project, + detectedFrom: options.detectedFrom, + matchStrength: "exact", + })); + return { + targets, + footer: + targets.length > 1 + ? `Found ${targets.length} projects matching ${options.matchDescription}` + : undefined, + }; + } catch (error) { + log.debug(`${options.logDescription} project inference failed`, error); + return { targets: [] }; + } +} + +/** + * Infer projects from the exact leaf name of the local git remote. + * + * Unlike directory inference this does not use fuzzy matching: a repository + * named `owner/junior` only resolves projects whose slug is exactly `junior`. + * Multiple cross-organization matches are preserved so callers can decide + * whether the result is concrete enough for their workflow. + */ +async function inferFromRepositoryName(cwd: string): Promise { + const repository = inferRepositoryName(cwd); + const repositoryName = repository?.name.split("/").at(-1); + const projectSlug = repositoryName ? slugify(repositoryName) : ""; + if (!(repository && projectSlug)) { + return { targets: [] }; + } + + return await inferFromExactProjectSlug({ + projectSlug, + detectedFrom: `git ${repository.remote} remote "${repository.name}"`, + matchDescription: `git repository "${repository.name}"`, + logDescription: "Git repository", + }); +} + +/** + * Infer projects from the exact current working-directory name. + * + * This is intentionally exact because nested directory names such as `src` + * are common and must not trigger broad fuzzy matches. Project-root inference + * remains the final word-boundary fallback. + */ +async function inferFromWorkingDirectoryName( + cwd: string +): Promise { + const directoryName = basename(cwd); + if (!isValidDirNameForInference(directoryName)) { + return { targets: [] }; + } + + const projectSlug = slugify(directoryName); + if (!projectSlug) { + return { targets: [] }; + } + return await inferFromExactProjectSlug({ + projectSlug, + detectedFrom: `working directory name "${directoryName}"`, + matchDescription: `working directory "${directoryName}"`, + logDescription: "Working-directory", + }); +} + +/** Resolve an exact project slug from the root selected by project discovery. */ +async function inferFromExactProjectRoot( + projectRoot: string +): Promise { + const directoryName = basename(projectRoot); + if (!isValidDirNameForInference(directoryName)) { + return { targets: [] }; + } + const projectSlug = slugify(directoryName); + if (!projectSlug) { + return { targets: [] }; + } + return await inferFromExactProjectSlug({ + projectSlug, + detectedFrom: `project root name "${directoryName}"`, + matchDescription: `project root "${directoryName}"`, + logDescription: "Project-root", + }); +} + +/** + * Resolve name-based codebase signals without letting a repository-root name + * override a more specific monorepo app, or an arbitrary checkout directory + * override the canonical remote name. + */ +async function inferFromCodebaseNames( + cwd: string, + organizationFilter?: string +): Promise { + const projectRootInfo = await findProjectRoot(cwd); + const repositoryRoot = inferRepositoryRoot(cwd); + const resolvedCwd = resolvePath(cwd); + const resolvedProjectRoot = resolvePath(projectRootInfo.projectRoot); + const resolvedRepositoryRoot = repositoryRoot + ? resolvePath(repositoryRoot) + : undefined; + + const workingDirectoryResult = filterTargetsByOrganization( + await inferFromWorkingDirectoryName(resolvedCwd), + organizationFilter + ); + const projectRootResult = + resolvedProjectRoot === resolvedCwd + ? workingDirectoryResult + : filterTargetsByOrganization( + await inferFromExactProjectRoot(resolvedProjectRoot), + organizationFilter + ); + + const projectRootIsSpecific = + resolvedRepositoryRoot !== undefined && + resolvedProjectRoot !== resolvedRepositoryRoot; + if (projectRootIsSpecific && projectRootResult.targets.length > 0) { + return projectRootResult; + } + + if (projectRootIsSpecific && workingDirectoryResult.targets.length > 0) { + return workingDirectoryResult; + } + + const repositoryResult = filterTargetsByOrganization( + await inferFromRepositoryName(cwd), + organizationFilter + ); + if (repositoryResult.targets.length > 0) { + return repositoryResult; + } + if (projectRootResult.targets.length > 0) { + return projectRootResult; + } + if (workingDirectoryResult.targets.length > 0) { + return workingDirectoryResult; + } + + return filterTargetsByOrganization( + await inferFromProjectRootName(resolvedProjectRoot), + organizationFilter + ); +} + +/** Keep auto-detected targets inside an already resolved organization. */ +function filterTargetsByOrganization( + result: ResolvedTargets, + organizationFilter?: string +): ResolvedTargets { + if (!organizationFilter) { + return result; + } + const targets = result.targets.filter( + (target) => target.org === organizationFilter + ); + if (targets.length === result.targets.length) { + return result; + } + return { + ...result, + targets, + footer: + targets.length > 1 ? formatMultipleProjectsFooter(targets) : undefined, + }; +} + /** * Read org/project from SENTRY_ORG and SENTRY_PROJECT environment variables. * @@ -1072,7 +1312,7 @@ async function resolveDsnsWithTimeout( * 3. `.sentryclirc` config file - returns single target * 4. Config defaults - returns single target * 5. DSN auto-detection - may return multiple targets - * 6. Directory name inference - matches project slugs with word boundaries + * 6. Codebase-name inference (specific project root/cwd, git remote, fuzzy root fallback) * * @param options - Resolution options with org, project, and cwd * @returns All resolved targets and optional footer message @@ -1086,7 +1326,8 @@ export async function resolveAllTargets( "resolve", // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Priority-based resolution cascade requires sequential checks. async (span) => { - const { org, project, cwd } = options; + const { org, project, cwd, organizationFilter } = options; + const codebaseMode = options.resolutionMode === "codebase"; // 1. CLI flags take priority (both must be provided together) if (org && project) { @@ -1117,7 +1358,10 @@ export async function resolveAllTargets( // 2. SENTRY_ORG / SENTRY_PROJECT environment variables const envVars = resolveFromEnvVars(); - if (envVars?.project) { + if ( + envVars?.project && + (!organizationFilter || envVars.org === organizationFilter) + ) { span.setAttribute("resolve.method", "env_vars"); setOrgProjectContext([envVars.org], [envVars.project]); return { @@ -1139,7 +1383,12 @@ export async function resolveAllTargets( // 3. .sentryclirc config file (walked up from cwd, merged with global) const rcConfig = await loadSentryCliRc(cwd); - if (rcConfig.org && rcConfig.project) { + if ( + rcConfig.org && + rcConfig.project && + (!organizationFilter || rcConfig.org === organizationFilter) && + (!codebaseMode || hasLocalRcProject(rcConfig)) + ) { span.setAttribute("resolve.method", "sentryclirc"); setOrgProjectContext([rcConfig.org], [rcConfig.project]); return { @@ -1155,24 +1404,34 @@ export async function resolveAllTargets( }; } - log.debug(`No ${CONFIG_FILENAME} org/project, trying config defaults`); + log.debug( + codebaseMode + ? `No local ${CONFIG_FILENAME} org/project, skipping account defaults` + : `No ${CONFIG_FILENAME} org/project, trying config defaults` + ); // 4. Config defaults - const defaultOrg = getDefaultOrganization(); - const defaultProject = getDefaultProject(); - if (defaultOrg && defaultProject) { - span.setAttribute("resolve.method", "defaults"); - setOrgProjectContext([defaultOrg], [defaultProject]); - return { - targets: [ - { - org: defaultOrg, - project: defaultProject, - orgDisplay: defaultOrg, - projectDisplay: defaultProject, - }, - ], - }; + if (!codebaseMode) { + const defaultOrg = getDefaultOrganization(); + const defaultProject = getDefaultProject(); + if ( + defaultOrg && + defaultProject && + (!organizationFilter || defaultOrg === organizationFilter) + ) { + span.setAttribute("resolve.method", "defaults"); + setOrgProjectContext([defaultOrg], [defaultProject]); + return { + targets: [ + { + org: defaultOrg, + project: defaultProject, + orgDisplay: defaultOrg, + projectDisplay: defaultProject, + }, + ], + }; + } } log.debug("No config defaults set, trying DSN auto-detection"); @@ -1180,30 +1439,38 @@ export async function resolveAllTargets( // 5. DSN auto-detection (may find multiple in monorepos) const detection = await detectAllDsns(cwd); - if (detection.all.length === 0) { - log.debug( - "No DSNs found in source code or env files, trying directory name inference" + if (detection.all.length > 0) { + const dsnResult = filterTargetsByOrganization( + await resolveDetectedDsns(detection), + organizationFilter ); - // 6. Fallback: infer from directory name - const result = await inferFromDirectoryName(cwd); - if (result.targets.length === 0) { - span.setAttribute("resolve.method", "none"); - log.debug( - "Directory name inference found no matching projects — auto-detection failed" - ); - } else { - span.setAttribute("resolve.method", "inference"); - const uniqueOrgs = [...new Set(result.targets.map((t) => t.org))]; - const uniqueProjects = [ - ...new Set(result.targets.map((t) => t.project)), - ]; - setOrgProjectContext(uniqueOrgs, uniqueProjects); + if (dsnResult.targets.length > 0 || dsnResult.skippedSelfHosted) { + span.setAttribute("resolve.method", "dsn"); + return dsnResult; } - return result; + log.debug( + organizationFilter + ? `Detected DSNs did not match organization '${organizationFilter}', trying codebase name inference` + : "Detected DSNs could not be resolved, trying codebase name inference" + ); } - span.setAttribute("resolve.method", "dsn"); - return resolveDetectedDsns(detection); + log.debug("No matching DSNs found, trying codebase name inference"); + const result = await inferFromCodebaseNames(cwd, organizationFilter); + if (result.targets.length === 0) { + span.setAttribute("resolve.method", "none"); + log.debug( + "Codebase name inference found no matching projects — auto-detection failed" + ); + } else { + span.setAttribute("resolve.method", "codebase-name"); + const uniqueOrgs = [...new Set(result.targets.map((t) => t.org))]; + const uniqueProjects = [ + ...new Set(result.targets.map((t) => t.project)), + ]; + setOrgProjectContext(uniqueOrgs, uniqueProjects); + } + return result; }, { "resolve.mode": "multi" } ); @@ -1296,7 +1563,7 @@ async function resolveDetectedDsns( * 3. `.sentryclirc` config file * 4. Config defaults * 5. DSN auto-detection - * 6. Directory name inference - matches project slugs with word boundaries + * 6. Codebase-name inference (specific project root/cwd, git remote, fuzzy root fallback) * * @param options - Resolution options with org, project, and cwd * @returns Resolved target, or null if resolution failed @@ -1310,7 +1577,8 @@ export async function resolveOrgAndProject( "resolve", // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Priority-based resolution cascade requires sequential checks. async (span) => { - const { org, project, cwd } = options; + const { org, project, cwd, organizationFilter } = options; + const codebaseMode = options.resolutionMode === "codebase"; // 1. CLI flags take priority (both must be provided together) if (org && project) { @@ -1334,7 +1602,10 @@ export async function resolveOrgAndProject( // 2. SENTRY_ORG / SENTRY_PROJECT environment variables const envVars = resolveFromEnvVars(); - if (envVars?.project) { + if ( + envVars?.project && + (!organizationFilter || envVars.org === organizationFilter) + ) { span.setAttribute("resolve.method", "env_vars"); return withTelemetryContext({ org: envVars.org, @@ -1347,7 +1618,12 @@ export async function resolveOrgAndProject( // 3. .sentryclirc config file const rcConfig = await loadSentryCliRc(cwd); - if (rcConfig.org && rcConfig.project) { + if ( + rcConfig.org && + rcConfig.project && + (!organizationFilter || rcConfig.org === organizationFilter) && + (!codebaseMode || hasLocalRcProject(rcConfig)) + ) { span.setAttribute("resolve.method", "sentryclirc"); return withTelemetryContext({ org: rcConfig.org, @@ -1359,22 +1635,31 @@ export async function resolveOrgAndProject( } // 4. Config defaults - const defaultOrg = getDefaultOrganization(); - const defaultProject = getDefaultProject(); - if (defaultOrg && defaultProject) { - span.setAttribute("resolve.method", "defaults"); - return withTelemetryContext({ - org: defaultOrg, - project: defaultProject, - orgDisplay: defaultOrg, - projectDisplay: defaultProject, - }); + if (!codebaseMode) { + const defaultOrg = getDefaultOrganization(); + const defaultProject = getDefaultProject(); + if ( + defaultOrg && + defaultProject && + (!organizationFilter || defaultOrg === organizationFilter) + ) { + span.setAttribute("resolve.method", "defaults"); + return withTelemetryContext({ + org: defaultOrg, + project: defaultProject, + orgDisplay: defaultOrg, + projectDisplay: defaultProject, + }); + } } // 5. DSN auto-detection try { const dsnResult = await resolveFromDsn(cwd); - if (dsnResult) { + if ( + dsnResult && + (!organizationFilter || dsnResult.org === organizationFilter) + ) { span.setAttribute("resolve.method", "dsn"); return withTelemetryContext(dsnResult); } @@ -1382,35 +1667,37 @@ export async function resolveOrgAndProject( // Fall through to directory inference } - // 6. Fallback: infer from directory name - const inferred = await inferFromDirectoryName(cwd); + // 6-8. Resolve cwd, project-root, and git-remote names as one policy. + const inferred = await inferFromCodebaseNames(cwd, organizationFilter); const [first] = inferred.targets; + if (inferred.targets.length > 1) { + span.setAttribute("resolve.method", "codebase-name-ambiguous"); + return null; + } if (!first) { - // 7. Authenticated last resort: if the account has exactly one + // 9. Authenticated last resort: if the account has exactly one // accessible org with exactly one project, that pair is the only // possible target — use it instead of failing. This removes the // "Could not auto-detect organization and project" dead-end for // single-org/single-project accounts (CLI-3B). Callers that rely on // a null return (e.g. event view's cross-org search) are unaffected: // this only ever turns a null into a uniquely-determined target. - const sole = await resolveSoleAccountTarget(); - if (sole) { - span.setAttribute("resolve.method", "account_sole"); - return withTelemetryContext(sole); + if (!codebaseMode) { + const sole = await resolveSoleAccountTarget(); + if ( + sole && + (!organizationFilter || sole.org === organizationFilter) + ) { + span.setAttribute("resolve.method", "account_sole"); + return withTelemetryContext(sole); + } } span.setAttribute("resolve.method", "none"); return null; } - span.setAttribute("resolve.method", "inference"); - // If multiple matches, note it in detectedFrom - return withTelemetryContext({ - ...first, - detectedFrom: - inferred.targets.length > 1 - ? `${first.detectedFrom} (1 of ${inferred.targets.length} matches)` - : first.detectedFrom, - }); + span.setAttribute("resolve.method", "codebase-name"); + return withTelemetryContext(first); }, { "resolve.mode": "single" } ); diff --git a/packages/cli/src/lib/resolve-team.ts b/packages/cli/src/lib/resolve-team.ts index 1a15b94da5..f79d20c5cf 100644 --- a/packages/cli/src/lib/resolve-team.ts +++ b/packages/cli/src/lib/resolve-team.ts @@ -1,8 +1,8 @@ /** * Team Resolution * - * Resolves which team to use for operations that require one (e.g., project creation). - * Shared across create commands that need a team in the API path. + * Resolves which team to use for project creation. + * Shared by `sentry project create` and `sentry init`. * * ## Resolution flow * @@ -10,19 +10,31 @@ * 2. Fetch org teams via `listTeams` * - On 404: org doesn't exist → resolve effective org via cache, show org list * - On other errors: surface status + generic hint - * 3. If zero teams → auto-create a team named after the project (slug-based), - * or defer init-specific creation until the final slug is known - * 4. If exactly one team → auto-select it - * 5. Filter to teams the user belongs to (`isMember === true`) - * - If exactly one member team → auto-select it - * 6. Multiple candidate teams → error with team list and `--team` hint + * 3. Filter to teams on which the caller has effective `team:admin` access. + * - One eligible team is the non-interactive default. + * - Interactive callers offer create-new first, then existing-team choice. + * - Multiple eligible teams require an interactive choice or `--team`. + * 4. If the user chooses create-new or no eligible team exists, inspect the + * organization policy. When member project creation is allowed or the + * caller has `org:write`, return no team so the org-scoped onboarding + * endpoint can atomically create the project and its personal Team Admin + * team. + * 5. For a restricted organization, create a new project-owning team only when + * the caller has both `project:admin` and `team:admin`. The latter is needed + * to administer the team after creating it and create its project. * - * The auto-created team (step 3) mirrors the Sentry UI behavior where new - * organizations always have at least one team. + * The resolver owns capability and fallback policy. Callers own presentation + * through the narrow `chooseTeam` callback so Ink and plain CLI prompts can + * share the same decision flow without leaking UI dependencies here. */ import type { SentryTeam } from "../types/index.js"; -import { createTeam, listOrganizations, listTeams } from "./api-client.js"; +import { + createTeam, + getOrganization, + listOrganizations, + listTeams, +} from "./api-client.js"; import { ApiError, AuthError, @@ -31,7 +43,7 @@ import { ResolutionError, } from "./errors.js"; import { resolveEffectiveOrg } from "./region.js"; -import { getSentryBaseUrl } from "./sentry-urls.js"; +import type { ProjectTeamChoice, ProjectTeamOption } from "./team-choice.js"; /** * Best-effort fetch the user's organizations and format as a hint string. @@ -54,6 +66,10 @@ async function fetchOrgListHint(fallbackHint: string): Promise { } /** Options for resolving a team within an organization */ +export type ChooseProjectTeam = ( + teams: readonly ProjectTeamOption[] +) => Promise; + export type ResolveTeamOptions = { /** Explicit team slug from --team flag */ team?: string; @@ -61,11 +77,7 @@ export type ResolveTeamOptions = { detectedFrom?: string; /** Usage hint shown in errors (e.g., "sentry project create /:") */ usageHint: string; - /** - * Slug to use when auto-creating a team in an empty org. - * If not provided and the org has zero teams, an error is thrown instead - * unless empty-org auto-creation is being deferred. - */ + /** Slug to use when auto-creating a team for a project admin. */ autoCreateSlug?: string; /** * When true, skip the actual team creation API call and return what @@ -73,16 +85,8 @@ export type ResolveTeamOptions = { * with the autoCreateSlug value. */ dryRun?: boolean; - /** - * When true, an empty org returns a deferred result instead of auto-creating - * a team immediately. This lets callers wait until they know the final slug. - */ - deferAutoCreateOnEmptyOrg?: boolean; - /** - * Called when multiple candidate teams remain after membership filtering. - * Return the selected team slug. If not provided, a ContextError is thrown. - */ - onAmbiguous?: (candidates: SentryTeam[]) => Promise; + /** Ask an interactive user whether to create or select an eligible team. */ + chooseTeam?: ChooseProjectTeam; }; /** Result of team resolution that produced a concrete team slug. */ @@ -90,35 +94,37 @@ export type ResolvedConcreteTeam = { /** The resolved team slug */ slug: string; /** How the team was determined */ - source: "explicit" | "auto-selected" | "auto-created"; + source: "explicit" | "selected" | "auto-selected" | "auto-created"; }; -/** Result of init-specific deferred team resolution for empty organizations. */ -export type DeferredResolvedTeam = { - /** Indicates that team creation should happen later once the final slug is known. */ - source: "deferred"; -}; - -/** Result of team resolution, including deferred empty-org handling for init. */ -export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam; - /** - * Resolve which team to use for an operation. - * - * @param orgSlug - Organization to list teams from - * @param options - Resolution options (team flag, usage hint, detection source) - * @returns Resolved team slug with source info - * @throws {ContextError} When team cannot be resolved - * @throws {ResolutionError} When org slug returns 404 + * Build the actionable authorization error used when account permissions and + * token scopes disagree. This commonly happens to OAuth sessions issued before + * `team:admin` became part of the CLI's standard scope set. Keeping the scope + * name in an `ApiError` detail lets the global scope-recovery middleware offer + * a one-time OAuth refresh and retry the command for those existing grants. */ +export function buildTeamAdminAuthorizationError( + orgSlug: string, + teamSlug?: string +): ApiError { + const target = teamSlug ? `team '${teamSlug}'` : "a new project-owning team"; + return new ApiError( + `Cannot create the project through ${target} in '${orgSlug}' without the 'team:admin' authorization scope.`, + 403, + [ + "This operation requires the 'team:admin' authorization scope.", + "Your Sentry role may already grant Team Admin access, but the current CLI authorization may predate that standard scope.", + "Re-authorize the CLI, or use an auth token with team:admin.", + ].join("\n") + ); +} /** * Handle errors from `listTeams` during team resolution. * * - 404 → org not found (builds a rich error with org list) - * - 403 → member lacks team:read; re-thrown as `ApiError` so callers that - * implement a member-accessible fallback can detect it and use - * POST /organizations/{org}/projects/ instead. + * - 403 is handled by the caller as an org-scoped creation fallback. * - 401 → re-thrown as `ApiError` so the enriched detail (expired session, * member-disabled-over-limit, etc.) survives instead of being flattened. * - other → generic ResolutionError (5xx, network, etc.) @@ -152,123 +158,201 @@ async function handleListTeamsError( throw error; } -export async function resolveOrCreateTeam( - orgSlug: string, - options: ResolveTeamOptions & { - deferAutoCreateOnEmptyOrg?: false | undefined; - } -): Promise; -export async function resolveOrCreateTeam( - orgSlug: string, - options: ResolveTeamOptions & { deferAutoCreateOnEmptyOrg: true } -): Promise; -export async function resolveOrCreateTeam( +/** + * List visible teams. A 403 is not fatal: the organization-scoped onboarding + * route may still be available without permission to enumerate teams. + */ +async function listTeamsForResolution( orgSlug: string, options: ResolveTeamOptions -): Promise { - if (options.team) { - return { slug: options.team, source: "explicit" }; - } - - let teams: SentryTeam[]; +): Promise { try { - teams = await listTeams(orgSlug); + return await listTeams(orgSlug); } catch (error) { + if (error instanceof ApiError && error.status === 403) { + return; + } return await handleListTeamsError(error, orgSlug, options); } +} - // No teams — auto-create one if a slug was provided - if (teams.length === 0) { - return resolveEmptyTeams(orgSlug, options); - } +type EligibleTeamDecision = + | { kind: "create" } + | { kind: "team"; team: ResolvedConcreteTeam }; + +/** Resolve the existing-team side of the policy without creating anything. */ +async function resolveEligibleTeam( + orgSlug: string, + teams: readonly SentryTeam[], + options: ResolveTeamOptions +): Promise { + const eligibleTeams = teams.filter( + (team) => Array.isArray(team.access) && team.access.includes("team:admin") + ); - // Single team — auto-select - if (teams.length === 1) { - return { slug: (teams[0] as SentryTeam).slug, source: "auto-selected" }; + if (eligibleTeams.length === 0) { + return { kind: "create" }; } - // Multiple teams — prefer teams the user belongs to - const memberTeams = teams.filter((t) => t.isMember === true); - const candidates = memberTeams.length > 0 ? memberTeams : teams; + if (options.chooseTeam) { + const eligibleBySlug = new Map( + eligibleTeams.map((team) => [team.slug, team] as const) + ); + const choice = await options.chooseTeam( + eligibleTeams.map(({ slug, name }) => ({ slug, name })) + ); + if (choice.kind === "create") { + return choice; + } + const selected = eligibleBySlug.get(choice.slug); + if (!selected) { + throw new CliError( + `Selected team '${choice.slug}' is not an eligible Team Admin team in '${orgSlug}'.` + ); + } + return { + kind: "team", + team: { slug: selected.slug, source: "selected" }, + }; + } - if (candidates.length === 1) { + const [onlyTeam] = eligibleTeams; + if (eligibleTeams.length === 1 && onlyTeam) { return { - slug: (candidates[0] as SentryTeam).slug, - source: "auto-selected", + kind: "team", + team: { slug: onlyTeam.slug, source: "auto-selected" }, }; } - // Multiple candidates — let caller choose or throw - if (options.onAmbiguous) { - const slug = await options.onAmbiguous(candidates); - return { slug, source: "auto-selected" }; + const shown = eligibleTeams.slice(0, 10); + const remaining = eligibleTeams.length - shown.length; + throw new ContextError("Team", `${options.usageHint} --team `, [ + `You are a Team Admin of ${eligibleTeams.length} teams in '${orgSlug}'. Choose one explicitly with --team.`, + ...shown.map((team) => `Available: ${team.slug}`), + ...(remaining > 0 ? [`...and ${remaining} more`] : []), + ]); +} + +/** Resolve the create-new path after existing-team selection is exhausted. */ +async function resolveNewTeam( + orgSlug: string, + options: ResolveTeamOptions +): Promise { + if (!options.autoCreateSlug) { + return; } - const label = - memberTeams.length > 0 - ? `You belong to ${candidates.length} teams in ${orgSlug}` - : `Multiple teams found in ${orgSlug}`; - throw new ContextError( - "Team", - `${options.usageHint} --team ${(candidates[0] as SentryTeam).slug}`, - [ - `${label}. Specify one with --team`, - ...candidates.map((t) => `Available: ${t.slug}`), - ] - ); + let organization: Awaited>; + try { + organization = await getOrganization(orgSlug); + } catch { + // Team listing already proved the org exists. If its detail endpoint is + // unavailable, let the org-scoped project endpoint produce the precise + // creation error instead of turning a best-effort capability check into a + // blocker. + return; + } + + const access = Array.isArray(organization.access) ? organization.access : []; + if ( + organization.allowMemberProjectCreation !== false || + access.includes("org:write") + ) { + return; + } + if (!access.includes("project:admin")) { + return; + } + if (!access.includes("team:admin")) { + throw buildTeamAdminAuthorizationError(orgSlug); + } + if (options.dryRun) { + return { slug: options.autoCreateSlug, source: "auto-created" }; + } + return await autoCreateTeam(orgSlug, options.autoCreateSlug); } /** - * Handle the case when an org has zero teams. - * Either defers init-specific creation, auto-creates a team, returns a dry-run - * preview, or throws. + * Resolve which team to use for project creation. + * + * @param orgSlug - Organization to list teams from + * @param options - Resolution options (team flag, usage hint, detection source) + * @returns Resolved team slug with source info, or undefined for the + * org-scoped onboarding route + * @throws {ResolutionError} When org slug returns 404 */ -function resolveEmptyTeams( +export async function resolveOrCreateTeam( orgSlug: string, options: ResolveTeamOptions -): Promise | ResolvedTeam { - if (options.deferAutoCreateOnEmptyOrg) { - return { source: "deferred" }; +): Promise { + if (options.team) { + return { slug: options.team, source: "explicit" }; } - if (!options.autoCreateSlug) { - const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; - throw new ContextError("Team", `${options.usageHint} --team `, [ - `No teams found in org '${orgSlug}'`, - `Create a team at ${teamsUrl}`, - ]); + + const teams = await listTeamsForResolution(orgSlug, options); + if (!teams) { + return; } - if (options.dryRun) { - return { slug: options.autoCreateSlug, source: "auto-created" }; + + const decision = await resolveEligibleTeam(orgSlug, teams, options); + if (decision.kind === "team") { + return decision.team; } - return autoCreateTeam(orgSlug, options.autoCreateSlug); + return await resolveNewTeam(orgSlug, options); } /** - * Auto-create a team in an org that has no teams. - * Uses the provided slug as the team name. + * Auto-create a project-owning team, retrying deterministic suffixes when a + * non-admin team already owns the preferred slug. */ async function autoCreateTeam( orgSlug: string, slug: string -): Promise { +): Promise { + const candidates = [ + slug, + `${slug}-team`, + ...[2, 3, 4].map((n) => `${slug}-team-${n}`), + ]; + + for (const candidate of candidates) { + const result = await tryCreateTeamCandidate(orgSlug, candidate, slug); + if (result === "conflict") { + continue; + } + return result; + } + + throw new CliError( + `Could not create a unique team for project '${slug}' in '${orgSlug}'.` + ); +} + +/** + * Attempt one candidate slug. A conflict asks the outer bounded retry loop for + * another slug; a permission failure reports the stale authorization without + * mutating further. + */ +async function tryCreateTeamCandidate( + orgSlug: string, + candidate: string, + projectSlug: string +): Promise { try { - const team = await createTeam(orgSlug, slug); + const team = await createTeam(orgSlug, candidate); return { slug: team.slug, source: "auto-created" }; } catch (error) { - // Let auth errors propagate so the central handler can trigger auto-login if (error instanceof AuthError) { throw error; } - // 403 means the user lacks permission to create teams (e.g., org member role). - // Re-throw as ApiError so callers can fall back to the org-scoped endpoint - // (POST /organizations/{org}/projects/) instead of showing a dead-end error. if (error instanceof ApiError && error.status === 403) { - throw error; + throw buildTeamAdminAuthorizationError(orgSlug, candidate); + } + if (error instanceof ApiError && error.status === 409) { + return "conflict"; } - // Other failures (permissions, network, etc.) — surface with manual fallback throw new CliError( - `No teams found in org '${orgSlug}' and automatic team creation failed.\n\n` + - `Create a team manually at ${getSentryBaseUrl()}/settings/${orgSlug}/teams/` + + `Could not create a team for project '${projectSlug}' in '${orgSlug}'.` + (error instanceof ApiError ? `\n\nAPI error (${error.status}): ${error.detail ?? error.message}` : "") diff --git a/packages/cli/src/lib/team-choice.ts b/packages/cli/src/lib/team-choice.ts new file mode 100644 index 0000000000..f326ec6cd8 --- /dev/null +++ b/packages/cli/src/lib/team-choice.ts @@ -0,0 +1,78 @@ +/** + * Interactive project-team choice shared by `sentry init` and + * `sentry project create`. + * + * Creating a team is a top-level action, never an item appended to the team + * selector. This keeps the action visible even when the organization has a + * long team list. + */ + +export type ProjectTeamChoice = + | { kind: "create" } + | { kind: "existing"; slug: string }; + +/** Team fields needed by the project-creation prompt. */ +export type ProjectTeamOption = Readonly<{ + slug: string; + name: string; +}>; + +export type ProjectTeamSelectOptions = { + message: string; + options: { value: T; label: string; hint?: string }[]; + initialValue?: T; +}; + +/** Narrow prompt capability required by the shared team-choice flow. */ +export type ProjectTeamSelect = ( + options: ProjectTeamSelectOptions +) => Promise; + +/** + * Ask whether to create a team or use an existing Team Admin team. + * + * With one eligible team, that team is shown directly in the top-level + * decision. With several, choosing the existing-team action opens a second + * prompt containing only teams. + */ +export async function chooseProjectTeam( + teams: readonly ProjectTeamOption[], + select: ProjectTeamSelect +): Promise { + const onlyTeam = teams.length === 1 ? teams[0] : undefined; + const existingLabel = onlyTeam + ? `Use #${onlyTeam.slug}` + : "Select an existing team"; + + const intent = await select<"create" | "existing">({ + message: "Choose a team for the new project", + options: [ + { + value: "create", + label: "+ Create a new team", + }, + { + value: "existing", + label: existingLabel, + }, + ], + ...(onlyTeam ? { initialValue: "existing" } : {}), + }); + + if (intent === "create") { + return { kind: "create" }; + } + if (onlyTeam) { + return { kind: "existing", slug: onlyTeam.slug }; + } + + const slug = await select({ + message: "Select an existing team", + options: teams.map((team) => ({ + value: team.slug, + label: `#${team.slug}`, + ...(team.name !== team.slug ? { hint: team.name } : {}), + })), + }); + return { kind: "existing", slug }; +} diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts index e8925ede66..40df351c28 100644 --- a/packages/cli/test/commands/init.test.ts +++ b/packages/cli/test/commands/init.test.ts @@ -377,7 +377,7 @@ describe("init command func", () => { expect(capturedArgs?.project).toBeUndefined(); }); - test("bare slug in multiple orgs → throws ValidationError", async () => { + test("bare slug in multiple orgs → defers the decision until org resolution", async () => { findProjectsSpy.mockImplementation(async (slug: string) => ({ projects: [mockProject(slug, "org-a"), mockProject(slug, "org-b")], orgs: [ @@ -386,9 +386,10 @@ describe("init command func", () => { ], })); const ctx = makeContext(); - await expect(func.call(ctx, DEFAULT_FLAGS, "my-app")).rejects.toThrow( - ValidationError - ); + await func.call(ctx, DEFAULT_FLAGS, "my-app"); + + expect(capturedArgs?.org).toBeUndefined(); + expect(capturedArgs?.project).toBe("my-app"); }); }); diff --git a/packages/cli/test/commands/project/create-team-choice.test.ts b/packages/cli/test/commands/project/create-team-choice.test.ts new file mode 100644 index 0000000000..dd45234e81 --- /dev/null +++ b/packages/cli/test/commands/project/create-team-choice.test.ts @@ -0,0 +1,146 @@ +/** Interactive team-choice adapter coverage for `sentry project create`. */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { fakeLog, mockPrompt } = vi.hoisted(() => { + const prompt = vi.fn<() => Promise>(); + const noop = vi.fn(); + const log = { + prompt, + info: noop, + warn: noop, + error: noop, + debug: noop, + success: noop, + withTag: () => log, + }; + return { fakeLog: log, mockPrompt: prompt }; +}); + +vi.mock("../../../src/lib/logger.js", async (importOriginal) => ({ + ...(await importOriginal()), + logger: fakeLog, +})); +vi.mock("../../../src/lib/api/projects.js"); +vi.mock("../../../src/lib/api/teams.js"); +vi.mock("../../../src/lib/api/organizations.js"); +vi.mock("../../../src/lib/resolve-target.js"); + +import { createCommand } from "../../../src/commands/project/create.js"; +import type { SentryContext } from "../../../src/context.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as projectsApi from "../../../src/lib/api/projects.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as teamsApi from "../../../src/lib/api/teams.js"; +import { CliError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +function createInteractiveContext(): SentryContext { + return { + process: { stdout: { isTTY: true } }, + env: {}, + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + stdin: { isTTY: true }, + cwd: "/tmp", + homeDir: "/tmp", + configDir: "/tmp", + } as unknown as SentryContext; +} + +describe("project create interactive team choice", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveTarget.resolveOrg).mockResolvedValue({ org: "acme" }); + vi.mocked(teamsApi.listTeams).mockResolvedValue([ + { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + { + id: "2", + slug: "mobile", + name: "Mobile", + access: ["team:admin"], + }, + ]); + vi.mocked(projectsApi.createProjectWithDsn).mockResolvedValue({ + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "node", + }, + dsn: "https://key@example.com/42", + url: "https://acme.sentry.io/projects/my-app/", + }); + mockPrompt + .mockResolvedValueOnce("existing") + .mockResolvedValueOnce("mobile"); + }); + + test("keeps create first, outside the team selector, and prompts once per batch", async () => { + const context = createInteractiveContext(); + const func = await createCommand.loader(); + + await func.call(context, { json: false }, "my-app:node", "worker:node"); + + expect(mockPrompt).toHaveBeenCalledTimes(2); + const firstOptions = mockPrompt.mock.calls[0]?.[1]?.options; + expect(firstOptions).toEqual([ + { + value: "create", + label: "+ Create a new team", + }, + { value: "existing", label: "Select an existing team" }, + ]); + const secondOptions = mockPrompt.mock.calls[1]?.[1]?.options; + expect(secondOptions).toEqual([ + expect.objectContaining({ value: "platform", label: "#platform" }), + expect.objectContaining({ value: "mobile", label: "#mobile" }), + ]); + expect( + secondOptions?.some( + (option: { value: string }) => option.value === "create" + ) + ).toBe(false); + expect(projectsApi.createProjectWithDsn).toHaveBeenCalledWith( + "acme", + "mobile", + { name: "my-app", platform: "node" } + ); + expect(projectsApi.createProjectWithDsn).toHaveBeenCalledWith( + "acme", + "mobile", + { name: "worker", platform: "node" } + ); + }); + + test("cancels cleanly before creating a project", async () => { + mockPrompt.mockReset().mockResolvedValueOnce(null); + const context = createInteractiveContext(); + const func = await createCommand.loader(); + + await func.call(context, { json: false }, "my-app:node"); + + expect(projectsApi.createProjectWithDsn).not.toHaveBeenCalled(); + expect(projectsApi.createProjectWithAutoTeam).not.toHaveBeenCalled(); + expect(fakeLog.info).toHaveBeenCalledWith("Cancelled."); + }); + + test("surfaces an invalid prompt result instead of treating it as cancellation", async () => { + mockPrompt.mockReset().mockResolvedValueOnce("not-an-option"); + const context = createInteractiveContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { json: false }, "my-app:node") + ).rejects.toBeInstanceOf(CliError); + + expect(projectsApi.createProjectWithDsn).not.toHaveBeenCalled(); + expect(fakeLog.info).not.toHaveBeenCalledWith("Cancelled."); + }); +}); diff --git a/packages/cli/test/commands/project/create.test.ts b/packages/cli/test/commands/project/create.test.ts index 20ca056bad..229f994557 100644 --- a/packages/cli/test/commands/project/create.test.ts +++ b/packages/cli/test/commands/project/create.test.ts @@ -50,6 +50,8 @@ const sampleTeam: SentryTeam = { name: "Engineering", memberCount: 5, isMember: true, + teamRole: "admin", + access: ["team:admin"], }; const sampleTeam2: SentryTeam = { @@ -58,6 +60,8 @@ const sampleTeam2: SentryTeam = { name: "Mobile Team", memberCount: 3, isMember: true, + teamRole: "admin", + access: ["team:admin"], }; const sampleProject: SentryProject = { @@ -114,6 +118,7 @@ describe("project create", () => { const createTeamSpy = vi.mocked(teamsApi.createTeam); const tryGetPrimaryDsnSpy = vi.mocked(projectsApi.tryGetPrimaryDsn); const listOrgsSpy = vi.mocked(orgsApi.listOrganizations); + const getOrganizationSpy = vi.mocked(orgsApi.getOrganization); const resolveOrgSpy = vi.mocked(resolveTarget.resolveOrg); beforeEach(() => { @@ -147,6 +152,13 @@ describe("project create", () => { { slug: "acme-corp", name: "Acme Corp" }, { slug: "other-org", name: "Other Org" }, ]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + access: ["project:admin", "team:admin"], + allowMemberProjectCreation: false, + }); }); afterEach(() => { @@ -156,6 +168,7 @@ describe("project create", () => { createTeamSpy.mockReset(); tryGetPrimaryDsnSpy.mockReset(); listOrgsSpy.mockReset(); + getOrganizationSpy.mockReset(); resolveOrgSpy.mockReset(); }); @@ -293,14 +306,19 @@ describe("project create", () => { }); test("auto-selects team when user is member of exactly one among many", async () => { - const nonMemberTeam = { ...sampleTeam2, isMember: false }; + const nonMemberTeam = { + ...sampleTeam2, + isMember: false, + teamRole: null, + access: ["team:read"], + }; listTeamsSpy.mockResolvedValue([nonMemberTeam, sampleTeam]); const { context } = createMockContext(); const func = await createCommand.loader(); await func.call(context, { json: false }, "my-app:node"); - // Should auto-select the one team the user is a member of + // Only teams with effective Team Admin access are eligible. expect(createProjectWithDsnSpy).toHaveBeenCalledWith( "acme-corp", "engineering", @@ -311,60 +329,63 @@ describe("project create", () => { ); }); - test("errors when user is member of multiple teams without --team", async () => { + test("requires --team when several Team Admin teams are ambiguous non-interactively", async () => { listTeamsSpy.mockResolvedValue([sampleTeam, sampleTeam2]); const { context } = createMockContext(); const func = await createCommand.loader(); - - const err = await func + const error = await func .call(context, { json: false }, "my-app:node") - .catch((e: Error) => e); - expect(err).toBeInstanceOf(ContextError); - expect(err.message).toContain("You belong to 2 teams"); - expect(err.message).toContain("engineering"); - expect(err.message).toContain("mobile"); + .catch((cause) => cause); + expect(error).toBeInstanceOf(ContextError); + expect(error.message).toContain("Choose one explicitly with --team"); expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); - test("shows only member teams in error, not all org teams", async () => { + test("ignores teams without Team Admin access", async () => { const nonMemberTeam = { id: "3", slug: "infra", name: "Infrastructure", isMember: false, + access: ["team:read"], }; - listTeamsSpy.mockResolvedValue([sampleTeam, sampleTeam2, nonMemberTeam]); + listTeamsSpy.mockResolvedValue([nonMemberTeam, sampleTeam2]); const { context } = createMockContext(); const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app:node"); - const err = await func - .call(context, { json: false }, "my-app:node") - .catch((e: Error) => e); - expect(err).toBeInstanceOf(ContextError); - expect(err.message).toContain("engineering"); - expect(err.message).toContain("mobile"); - // Non-member team should NOT appear - expect(err.message).not.toContain("infra"); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "mobile", + expect.objectContaining({ name: "my-app" }) + ); }); - test("falls back to all teams when isMember is not available", async () => { + test("uses org-scoped creation for a member with no eligible team", async () => { const teamNoMembership1 = { id: "1", slug: "alpha", name: "Alpha" }; const teamNoMembership2 = { id: "2", slug: "beta", name: "Beta" }; listTeamsSpy.mockResolvedValue([teamNoMembership1, teamNoMembership2]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + access: ["project:read"], + allowMemberProjectCreation: true, + }); + createProjectWithAutoTeamSpy.mockResolvedValue({ + ...createdProjectDetails("my-app"), + team_slug: "team-user", + }); const { context } = createMockContext(); const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app:node"); - const err = await func - .call(context, { json: false }, "my-app:node") - .catch((e: Error) => e); - expect(err).toBeInstanceOf(ContextError); - expect(err.message).toContain("Multiple teams found"); - expect(err.message).toContain("alpha"); - expect(err.message).toContain("beta"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(createProjectWithAutoTeamSpy).toHaveBeenCalled(); }); test("auto-creates team when org has no teams", async () => { @@ -391,7 +412,8 @@ describe("project create", () => { const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Created team 'my-app'"); - expect(output).toContain("org had no teams"); + expect(output).toContain("for this project"); + expect(output).not.toContain("org had no teams"); }); test("errors when org cannot be resolved", async () => { @@ -468,6 +490,24 @@ describe("project create", () => { expect(err.message).not.toContain("not found"); }); + test("does not diagnose an org-scoped fallback 404 as a team failure", async () => { + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError("Not found", 404, "Endpoint unavailable") + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + const error = await func + .call(context, { json: false }, "my-app:node") + .catch((cause) => cause); + + expect(error.message).toContain("Failed to create project"); + expect(error.message).not.toContain("Team 'engineering'"); + }); + test("handles 404 from createProject with bad org — shows user's orgs", async () => { createProjectWithDsnSpy.mockRejectedValue( new ApiError("API request failed: 404 Not Found", 404) @@ -546,6 +586,7 @@ describe("project create", () => { expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Invalid platform 'node'"); expect(err.message).toContain("Common platforms:"); + expect(createProjectWithDsnSpy).toHaveBeenCalledOnce(); }); test("wraps other API errors with context, preserving ApiError type", async () => { @@ -655,11 +696,14 @@ describe("project create", () => { expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Invalid platform 'node'"); expect(err.message).toContain("Common platforms:"); + expect(createProjectWithDsnSpy).toHaveBeenCalledOnce(); + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledOnce(); }); - test("surfaces policy error when org has disabled member project creation", async () => { - // Both paths 403: team-based creation fails, and the fallback returns - // the org-level policy error ("disabled this feature"). + test("identifies a missing team:admin scope when only the team route could work", async () => { + // The serialized team looks eligible, but an old OAuth token can still + // reject team creation. The restricted org fallback confirms that this is + // an authorization-scope mismatch rather than a viable alternate route. createProjectWithDsnSpy.mockRejectedValue( new ApiError("Forbidden", 403, "You do not have permission") ); @@ -668,12 +712,12 @@ describe("project create", () => { const { context } = createMockContext(); const func = await createCommand.loader(); - const err = (await func + const err = await func .call(context, { json: false }, "my-app:node") - .catch((e: Error) => e)) as ApiError; + .catch((e: Error) => e); expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(403); - expect(err.message).toContain("disabled project creation for members"); + expect(err).toMatchObject({ status: 403 }); + expect((err as ApiError).detail).toContain("team:admin"); }); test("outputs JSON when --json flag is set", async () => { @@ -940,6 +984,7 @@ describe("project create", () => { ); expect(createProjectWithDsnSpy).toHaveBeenCalledTimes(3); + expect(listTeamsSpy).toHaveBeenCalledOnce(); for (const [name, platform] of [ ["web", "javascript"], ["api", "python-django"], diff --git a/packages/cli/test/lib/api-client.coverage.test.ts b/packages/cli/test/lib/api-client.coverage.test.ts index 7266cedb3d..fabc70b4cd 100644 --- a/packages/cli/test/lib/api-client.coverage.test.ts +++ b/packages/cli/test/lib/api-client.coverage.test.ts @@ -45,6 +45,7 @@ import { import { setAuthToken } from "../../src/lib/db/auth.js"; import { setOrgRegion } from "../../src/lib/db/regions.js"; import { ApiError, AuthError } from "../../src/lib/errors.js"; +import { resolveOrCreateTeam } from "../../src/lib/resolve-team.js"; import { mockFetch, useTestConfigDir } from "../helpers.js"; // --- Shared test setup --- @@ -507,6 +508,57 @@ describe("teams.ts", () => { const result = await listTeams("test-org"); expect(result).toHaveLength(2); }); + + test("lets team resolution select a Team Admin from a later page", async () => { + let requestCount = 0; + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + requestCount += 1; + if (requestCount === 1) { + expect(new URL(req.url).searchParams.get("cursor")).toBeNull(); + return new Response( + JSON.stringify([ + mockTeam({ + id: "1", + slug: "contributors", + access: ["team:read"], + }), + ]), + { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkHeader("next-teams-page", true), + }, + } + ); + } + + expect(new URL(req.url).searchParams.get("cursor")).toBe( + "next-teams-page" + ); + return new Response( + JSON.stringify([ + mockTeam({ + id: "2", + slug: "platform", + access: ["team:read", "team:admin"], + }), + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }); + + const result = await resolveOrCreateTeam("test-org", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + expect(result).toEqual({ slug: "platform", source: "auto-selected" }); + expect(requestCount).toBe(2); + }); }); describe("listProjectTeams", () => { @@ -529,7 +581,7 @@ describe("teams.ts", () => { }); describe("createTeam", () => { - test("creates team and adds current user as member", async () => { + test("creates a team in one request", async () => { const team = mockTeam({ slug: "new-team" }); const requestUrls: string[] = []; @@ -546,45 +598,12 @@ describe("teams.ts", () => { headers: { "Content-Type": "application/json" }, }); } - if (req.url.includes("/member")) { - return new Response(JSON.stringify({}), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } return new Response("Not found", { status: 404 }); }); const result = await createTeam("test-org", "new-team"); expect(result.slug).toBe("new-team"); - // Should have made at least 2 calls (create + add member) - expect(requestUrls.length).toBeGreaterThanOrEqual(2); - }); - - test("returns team even when member-add fails", async () => { - const team = mockTeam({ slug: "new-team" }); - - globalThis.fetch = mockFetch(async (input, init) => { - const req = new Request(input!, init); - - if ( - req.method === "POST" && - req.url.includes("/organizations/test-org/teams/") - ) { - return new Response(JSON.stringify(team), { - status: 201, - headers: { "Content-Type": "application/json" }, - }); - } - // addMemberToTeam fails - return new Response(JSON.stringify({ detail: "Permission denied" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - }); - - const result = await createTeam("test-org", "new-team"); - expect(result.slug).toBe("new-team"); + expect(requestUrls).toHaveLength(1); }); }); @@ -770,6 +789,21 @@ describe("projects.ts", () => { ).rejects.toMatchObject({ status: 403 }); }); + test("rejects a successful response without an owning team slug", async () => { + const { team_slug: _missing, ...projectWithoutTeam } = autoTeamProject; + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify(projectWithoutTeam), { + status: 201, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect( + createProjectWithAutoTeam("test-org", { name: "Auto Project" }) + ).rejects.toThrow("did not return its owning team"); + }); + test("returns dsn:null when DSN fetch fails", async () => { globalThis.fetch = mockFetch(async (input, init) => { const req = new Request(input!, init); diff --git a/packages/cli/test/lib/api-scope.test.ts b/packages/cli/test/lib/api-scope.test.ts index db2e7d50a6..e983e843ec 100644 --- a/packages/cli/test/lib/api-scope.test.ts +++ b/packages/cli/test/lib/api-scope.test.ts @@ -21,6 +21,16 @@ describe("extractRequiredScopes", () => { ).toEqual([]); }); + test("ignores role scopes mentioned in member-project policy guidance", () => { + expect( + extractRequiredScopes( + "Your organization has disabled this feature for members. " + + "This is an org-level policy setting, not an auth issue. " + + "You need org:admin/manager/owner role, or team:admin role on the team." + ) + ).toEqual([]); + }); + test("extracts a single scope from a detail string", () => { expect( extractRequiredScopes( diff --git a/packages/cli/test/lib/init/preflight.test.ts b/packages/cli/test/lib/init/preflight.test.ts index 8d82c3734e..a1899d2354 100644 --- a/packages/cli/test/lib/init/preflight.test.ts +++ b/packages/cli/test/lib/init/preflight.test.ts @@ -1,6 +1,5 @@ /** - * Tests for `resolveInitContext`. Stubs API and DSN-detection layers - * with `spyOn` and uses `MockUI` to drive prompts deterministically. + * Tests for init preflight project intent and organization resolution. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -9,120 +8,97 @@ vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { const actual = await importOriginal(); return Object.fromEntries( - Object.entries(actual).map(([k, v]) => [ - k, - typeof v === "function" ? vi.fn(v) : v, + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, ]) ); }); -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as apiClient from "../../../src/lib/api-client.js"; - vi.mock("../../../src/lib/db/auth.js", async (importOriginal) => { const actual = await importOriginal(); return Object.fromEntries( - Object.entries(actual).map(([k, v]) => [ - k, - typeof v === "function" ? vi.fn(v) : v, - ]) - ); -}); - -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as auth from "../../../src/lib/db/auth.js"; - -vi.mock("../../../src/lib/dsn/index.js", async (importOriginal) => { - const actual = - await importOriginal(); - return Object.fromEntries( - Object.entries(actual).map(([k, v]) => [ - k, - typeof v === "function" ? vi.fn(v) : v, + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, ]) ); }); -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as dsnIndex from "../../../src/lib/dsn/index.js"; -import { ApiError, WizardError } from "../../../src/lib/errors.js"; - vi.mock("../../../src/lib/init/org-prefetch.js", async (importOriginal) => { const actual = await importOriginal< typeof import("../../../src/lib/init/org-prefetch.js") >(); return Object.fromEntries( - Object.entries(actual).map(([k, v]) => [ - k, - typeof v === "function" ? vi.fn(v) : v, + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, ]) ); }); -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as prefetch from "../../../src/lib/init/org-prefetch.js"; -import { resolveInitContext } from "../../../src/lib/init/preflight.js"; -import type { WizardOptions } from "../../../src/lib/init/types.js"; -import { CANCELLED } from "../../../src/lib/init/ui/types.js"; - vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { const actual = await importOriginal(); return Object.fromEntries( - Object.entries(actual).map(([k, v]) => [ - k, - typeof v === "function" ? vi.fn(v) : v, + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, ]) ); }); // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as resolveTarget from "../../../src/lib/resolve-target.js"; - -vi.mock("../../../src/lib/resolve-team.js", async (importOriginal) => { - const actual = - await importOriginal(); - return Object.fromEntries( - Object.entries(actual).map(([k, v]) => [ - k, - typeof v === "function" ? vi.fn(v) : v, - ]) - ); -}); - +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as auth from "../../../src/lib/db/auth.js"; +import { ApiError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as prefetch from "../../../src/lib/init/org-prefetch.js"; +import { resolveInitContext } from "../../../src/lib/init/preflight.js"; +import type { WizardOptions } from "../../../src/lib/init/types.js"; +import { CANCELLED } from "../../../src/lib/init/ui/types.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as resolveTeam from "../../../src/lib/resolve-team.js"; +import * as resolveTarget from "../../../src/lib/resolve-target.js"; import { createMockUI, type MockCall } from "./ui/mock-ui.js"; function makeOptions(overrides?: Partial): WizardOptions { return { - directory: "/tmp/test", + directory: "/work/checkout", yes: true, dryRun: false, ...overrides, }; } +function makeProject(slug: string, name = slug) { + return { + id: `id-${slug}`, + slug, + name, + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any; +} + function feedbackOutcomes(calls: MockCall[]): string[] { return calls .filter( - (c): c is Extract => c.kind === "feedback" + (call): call is Extract => + call.kind === "feedback" ) - .map((c) => c.outcome); + .map((call) => call.outcome); } let resolveOrgPrefetchedSpy: ReturnType; let listOrganizationsSpy: ReturnType; -let listTeamsSpy: ReturnType; -let getOrganizationSpy: ReturnType; +let listProjectsSpy: ReturnType; let getProjectSpy: ReturnType; let tryGetPrimaryDsnSpy: ReturnType; let getAuthTokenSpy: ReturnType; -let resolveOrCreateTeamSpy: ReturnType; -let detectDsnSpy: ReturnType; -let resolveDsnByPublicKeySpy: ReturnType; +let resolveAllTargetsSpy: ReturnType; beforeEach(() => { resolveOrgPrefetchedSpy = vi @@ -131,635 +107,485 @@ beforeEach(() => { listOrganizationsSpy = vi .spyOn(apiClient, "listOrganizations") .mockResolvedValue([{ id: "1", slug: "acme", name: "Acme" }]); - listTeamsSpy = vi.spyOn(apiClient, "listTeams").mockResolvedValue([ - { - id: "1", - slug: "platform", - name: "Platform", - access: ["team:admin"], - isMember: true, - } as any, - ]); - getOrganizationSpy = vi - .spyOn(apiClient, "getOrganization") - .mockResolvedValue({ - id: "1", - slug: "acme", - name: "Acme", - access: ["project:read"], - allowMemberProjectCreation: true, - } as any); - getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ - id: "42", - slug: "my-app", - name: "my-app", - platform: "javascript-react", - dateCreated: "2026-04-16T00:00:00Z", - } as any); + listProjectsSpy = vi.spyOn(apiClient, "listProjects").mockResolvedValue([]); + getProjectSpy = vi + .spyOn(apiClient, "getProject") + .mockImplementation(async (_org, slug) => { + if (slug === "junior") { + return makeProject("junior"); + } + throw new ApiError("not found", 404); + }); tryGetPrimaryDsnSpy = vi .spyOn(apiClient, "tryGetPrimaryDsn") .mockResolvedValue("https://abc@o1.ingest.sentry.io/42"); getAuthTokenSpy = vi .spyOn(auth, "getAuthToken") .mockReturnValue("sntrys_test"); - resolveOrCreateTeamSpy = vi - .spyOn(resolveTeam, "resolveOrCreateTeam") - .mockResolvedValue({ - slug: "platform", - source: "auto-selected", - }); - detectDsnSpy = vi.spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); - resolveDsnByPublicKeySpy = vi - .spyOn(resolveTarget, "resolveDsnByPublicKey") - .mockResolvedValue(null); + resolveAllTargetsSpy = vi + .spyOn(resolveTarget, "resolveAllTargets") + .mockResolvedValue({ targets: [] }); }); afterEach(() => { resolveOrgPrefetchedSpy.mockRestore(); listOrganizationsSpy.mockRestore(); - listTeamsSpy.mockRestore(); - getOrganizationSpy.mockRestore(); + listProjectsSpy.mockRestore(); getProjectSpy.mockRestore(); tryGetPrimaryDsnSpy.mockRestore(); getAuthTokenSpy.mockRestore(); - resolveOrCreateTeamSpy.mockRestore(); - detectDsnSpy.mockRestore(); - resolveDsnByPublicKeySpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); process.exitCode = 0; }); describe("resolveInitContext", () => { - test("uses an existing detected project in --yes mode", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "abc", - protocol: "https", - host: "o1.ingest.sentry.io", - projectId: "42", - raw: "https://abc@o1.ingest.sentry.io/42", - source: "env_file" as const, - }); - resolveDsnByPublicKeySpy.mockResolvedValue({ - org: "acme", - project: "my-app", + test("automatically uses the shared resolver's concrete project", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "acme", + project: "junior", + projectId: 42, + orgDisplay: "Acme", + projectDisplay: "Junior", + detectedFrom: 'git origin remote "getsentry/junior"', + }, + ], }); - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); + const { ui, calls } = createMockUI(); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); expect(context).toEqual( expect.objectContaining({ org: "acme", - project: "my-app", - team: "platform", - authToken: "sntrys_test", - existingProject: expect.objectContaining({ - orgSlug: "acme", - projectSlug: "my-app", - }), + project: "junior", + team: undefined, + existingProject: expect.objectContaining({ projectSlug: "junior" }), }) ); - expect(getProjectSpy).toHaveBeenCalledTimes(1); - expect(tryGetPrimaryDsnSpy).toHaveBeenCalledTimes(1); + expect(calls.filter((call) => call.kind === "select")).toHaveLength(0); + expect(listProjectsSpy).not.toHaveBeenCalled(); + expect(resolveAllTargetsSpy).toHaveBeenCalledWith({ + cwd: "/work/checkout", + resolutionMode: "codebase", + organizationFilter: "acme", + }); }); - test("keeps a detected DSN project even when project enrichment fails", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "abc", - protocol: "https", - host: "o1.ingest.sentry.io", - projectId: "42", - raw: "https://abc@o1.ingest.sentry.io/42", - source: "env_file" as const, - }); - resolveDsnByPublicKeySpy.mockResolvedValue({ - org: "acme", - project: "my-app", + test("keeps a canonical DSN when metadata enrichment is unavailable", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "acme", + project: "junior", + projectId: 42, + orgDisplay: "Acme", + projectDisplay: "Junior", + detectedDsn: { + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + }, + }, + ], }); getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); const { ui } = createMockUI(); const context = await resolveInitContext(makeOptions(), ui); - expect(context).toEqual( + expect(context?.existingProject).toEqual( expect.objectContaining({ - org: "acme", - project: "my-app", - team: "platform", + projectSlug: "junior", + projectId: "42", + dsn: "https://abc@o1.ingest.sentry.io/42", }) ); - expect(context?.existingProject).toBeUndefined(); + expect(getProjectSpy).not.toHaveBeenCalled(); }); - test("retries detected project enrichment during project selection when the first lookup yields no metadata", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "abc", + test("keeps target DSN provenance instead of correlating by array position", async () => { + const correctDsn = { + publicKey: "correct", protocol: "https", host: "o1.ingest.sentry.io", projectId: "42", - raw: "https://abc@o1.ingest.sentry.io/42", - source: "env_file" as const, + raw: "https://correct@o1.ingest.sentry.io/42", + source: "code" as const, + }; + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "acme", + project: "junior", + orgDisplay: "Acme", + projectDisplay: "Junior", + detectedDsn: correctDsn, + }, + ], + detectedDsns: [ + { + publicKey: "unresolved", + protocol: "https", + host: "self-hosted.example.com", + projectId: "42", + raw: "https://unresolved@self-hosted.example.com/42", + source: "code" as const, + }, + correctDsn, + ], + }); + + const { ui } = createMockUI(); + const context = await resolveInitContext(makeOptions(), ui); + + expect(context?.existingProject?.dsn).toBe(correctDsn.raw); + expect(context?.existingProject?.projectId).toBe("42"); + }); + + test("does not auto-select a partial multi-DSN resolution", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "acme", + project: "junior", + orgDisplay: "Acme", + projectDisplay: "Junior", + }, + ], + skippedSelfHosted: 1, }); - resolveDsnByPublicKeySpy.mockResolvedValue({ + const { ui, respond } = createMockUI(); + respond.select("create"); + + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(context?.org).toBe("acme"); + expect(context?.project).toBeUndefined(); + expect(context?.existingProject).toBeUndefined(); + }); + + test("resolves organization before filtering project candidates", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue({ org: "acme", - project: "my-app", + detectedFrom: "SENTRY_ORG env var", + }); + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "other", + project: "junior", + orgDisplay: "Other", + projectDisplay: "Junior", + }, + ], + }); + const { ui, respond } = createMockUI(); + respond.select("create"); + + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(resolveAllTargetsSpy).toHaveBeenCalledWith({ + cwd: "/work/checkout", + resolutionMode: "codebase", + organizationFilter: "acme", + }); + expect(context?.org).toBe("acme"); + expect(context?.project).toBeUndefined(); + }); + + test("filters shared resolver matches by an explicit organization", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "other", + project: "junior", + orgDisplay: "Other", + projectDisplay: "Junior", + }, + { + org: "acme", + project: "junior", + orgDisplay: "Acme", + projectDisplay: "Junior", + }, + ], }); - getProjectSpy - .mockRejectedValueOnce(new ApiError("not found", 404)) - .mockResolvedValue({ - id: "42", - slug: "my-app", - name: "my-app", - platform: "javascript-react", - dateCreated: "2026-04-16T00:00:00Z", - } as any); const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); + const context = await resolveInitContext(makeOptions({ org: "acme" }), ui); - expect(context?.existingProject).toEqual( - expect.objectContaining({ - orgSlug: "acme", - projectSlug: "my-app", - }) - ); - expect(getProjectSpy).toHaveBeenCalledTimes(2); - expect(tryGetPrimaryDsnSpy).toHaveBeenCalledTimes(1); + expect(context?.existingProject?.projectSlug).toBe("junior"); }); - test("falls back to listing organizations when prefetch misses", async () => { + test("filters ambiguous cross-org matches after organization resolution", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "other", + project: "junior", + orgDisplay: "Other", + projectDisplay: "Junior", + }, + { + org: "acme", + project: "junior", + orgDisplay: "Acme", + projectDisplay: "Junior", + }, + ], + }); + + const { ui, calls } = createMockUI(); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); + + expect(context?.org).toBe("acme"); + expect(context?.existingProject?.projectSlug).toBe("junior"); + expect(calls.filter((call) => call.kind === "select")).toHaveLength(0); + }); + + test("falls back to the only listed organization when detection misses", async () => { resolveOrgPrefetchedSpy.mockResolvedValue(null); listOrganizationsSpy.mockResolvedValue([ { id: "1", slug: "solo-org", name: "Solo Org" }, ]); - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions({ yes: false }), ui); + const { ui, respond } = createMockUI(); + respond.select("create"); + const context = await resolveInitContext( + makeOptions({ yes: false, directory: "/work/no-match" }), + ui + ); expect(context?.org).toBe("solo-org"); }); - test("lets the user choose an existing bare-slug project", async () => { - const { ui, respond } = createMockUI(); - respond.select("existing"); - + test("uses an explicitly named existing project without prompting", async () => { + const { ui, calls } = createMockUI(); const context = await resolveInitContext( - makeOptions({ yes: false, project: "my-app" }), + makeOptions({ yes: false, project: "junior" }), ui ); - expect(context?.project).toBe("my-app"); - expect(context?.existingProject?.projectSlug).toBe("my-app"); + expect(context?.existingProject?.projectSlug).toBe("junior"); + expect(calls.filter((call) => call.kind === "select")).toHaveLength(0); }); - test("keeps the bare slug when the existence lookup fails", async () => { - getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); + test("keeps an explicitly named new project when no exact project exists", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("not found", 404)); const { ui } = createMockUI(); const context = await resolveInitContext( - makeOptions({ yes: false, project: "my-app" }), + makeOptions({ project: "brand-new" }), ui ); - expect(context?.project).toBe("my-app"); + expect(context?.project).toBe("brand-new"); expect(context?.existingProject).toBeUndefined(); }); - test("uses org-scoped creation when no Team Admin team is available", async () => { - listTeamsSpy.mockResolvedValueOnce([ - { - id: "1", - slug: "frontend", - name: "Frontend", - access: ["team:read"], - isMember: true, - } as any, - ]); - + test("surfaces transient failures while checking an explicitly named project", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("unavailable", 503)); const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - expect(context?.team).toBeUndefined(); - expect(getOrganizationSpy).toHaveBeenCalledWith("acme"); - expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + await expect( + resolveInitContext(makeOptions({ project: "junior" }), ui) + ).rejects.toThrow("unavailable"); }); - test("clears the project when the user chooses to create new", async () => { - const { ui, respond } = createMockUI(); + test("continues create-first flow when shared resolution fails", async () => { + resolveAllTargetsSpy.mockRejectedValue(new ApiError("unavailable", 503)); + const { ui, respond, calls } = createMockUI(); respond.select("create"); const context = await resolveInitContext( - makeOptions({ yes: false, project: "my-app" }), + makeOptions({ yes: false, directory: "/work/local-checkout" }), ui ); expect(context?.project).toBeUndefined(); - expect(context?.existingProject).toBeUndefined(); + expect(calls.filter((call) => call.kind === "select")).toHaveLength(1); }); - test("resolves an explicit team during preflight", async () => { - resolveOrCreateTeamSpy.mockImplementation(async (_org, options) => ({ - slug: options.team ?? "platform", - source: options.team ? "explicit" : "auto-selected", - })); + test("does not auto-select an ambiguous shared resolution", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "acme", + project: "junior", + orgDisplay: "Acme", + projectDisplay: "Junior", + }, + { + org: "acme", + project: "backend", + orgDisplay: "Acme", + projectDisplay: "Backend", + }, + ], + }); + const { ui, respond } = createMockUI(); + respond.select("create"); - const { ui } = createMockUI(); const context = await resolveInitContext( - makeOptions({ team: "backend", yes: false }), + makeOptions({ yes: false, directory: "/work/local-checkout" }), ui ); - expect(context?.team).toBe("backend"); - expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( - "acme", - expect.objectContaining({ - team: "backend", - deferAutoCreateOnEmptyOrg: true, - }) - ); - }); - - test("selects a Team Admin team over non-admin member teams", async () => { - listTeamsSpy.mockResolvedValueOnce([ - { - id: "1", - slug: "frontend", - name: "Frontend", - access: ["team:read"], - isMember: true, - } as any, - { - id: "2", - slug: "platform", - name: "Platform", - access: ["team:admin"], - isMember: true, - } as any, - ]); - - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - - expect(context?.team).toBe("platform"); - expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + expect(context?.project).toBeUndefined(); + expect(context?.existingProject).toBeUndefined(); }); - test("prompts when multiple Team Admin teams are available", async () => { + test("does not reuse a fuzzy-only project-root match", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "acme", + project: "checkout-service", + orgDisplay: "Acme", + projectDisplay: "Checkout Service", + matchStrength: "fuzzy", + }, + ], + }); const { ui, respond } = createMockUI(); - respond.select("mobile"); - listTeamsSpy.mockResolvedValueOnce([ - { - id: "1", - slug: "mobile", - name: "Mobile", - access: ["team:admin"], - isMember: true, - } as any, - { - id: "2", - slug: "platform", - name: "Platform", - access: ["team:admin"], - isMember: true, - } as any, - ]); + respond.select("create"); const context = await resolveInitContext(makeOptions({ yes: false }), ui); - expect(context?.team).toBe("mobile"); + expect(context?.project).toBeUndefined(); + expect(context?.existingProject).toBeUndefined(); }); - test("selects the first Team Admin team in --yes mode", async () => { - listTeamsSpy.mockResolvedValueOnce([ - { - id: "1", - slug: "mobile", - name: "Mobile", - access: ["team:admin"], - isMember: true, - } as any, - { - id: "2", - slug: "platform", - name: "Platform", - access: ["team:admin"], - isMember: true, - } as any, + test("offers create first when no likely project matches", async () => { + listProjectsSpy.mockResolvedValue([ + makeProject("backend"), + makeProject("frontend"), ]); + const { ui, calls, respond } = createMockUI(); + respond.select("create"); - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions({ yes: true }), ui); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); - expect(context?.team).toBe("mobile"); + expect(context?.project).toBeUndefined(); + expect(calls).toContainEqual({ + kind: "select", + message: "How should Sentry be configured for this codebase?", + options: ["create", "existing"], + }); + expect(listProjectsSpy).not.toHaveBeenCalled(); }); - test("returns null when the user cancels an org selection", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrganizationsSpy.mockResolvedValue([ - { id: "1", slug: "acme", name: "Acme" }, - { id: "2", slug: "beta", name: "Beta" }, + test("opens the project list only after choosing the existing-project path", async () => { + listProjectsSpy.mockResolvedValue([ + makeProject("backend", "Backend"), + makeProject("frontend", "Frontend"), ]); - + getProjectSpy.mockImplementation(async (_org, slug) => { + if (slug === "frontend") { + return makeProject("frontend", "Frontend"); + } + throw new ApiError("not found", 404); + }); const { ui, calls, respond } = createMockUI(); - respond.select(CANCELLED); + respond.select("existing"); + respond.select("frontend"); const context = await resolveInitContext(makeOptions({ yes: false }), ui); - expect(context).toBeNull(); - const cancelCall = calls.find((c) => c.kind === "cancel"); - expect(cancelCall?.kind === "cancel" && cancelCall.message).toBe( - "Setup cancelled." - ); - expect(feedbackOutcomes(calls)).toEqual(["cancelled"]); - }); - - test("surfaces 403 guidance when listOrganizations is forbidden", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrganizationsSpy.mockRejectedValueOnce( - new ApiError( - "Failed to list organizations", - 403, - "You do not have permission." - ) - ); - - const { ui, calls } = createMockUI(); - await expect( - resolveInitContext(makeOptions({ yes: true }), ui) - ).rejects.toThrow("403 Forbidden"); - - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toContain("403 Forbidden"); - expect(errorCall?.message).toContain("sentry init /"); + expect(context?.existingProject?.projectSlug).toBe("frontend"); + expect(calls.filter((call) => call.kind === "select")).toEqual([ + { + kind: "select", + message: "How should Sentry be configured for this codebase?", + options: ["create", "existing"], + }, + { + kind: "select", + message: "Which existing Sentry project should be used?", + options: ["backend", "frontend"], + }, + ]); }); - test("surfaces 401 guidance when listOrganizations is unauthorized", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrganizationsSpy.mockRejectedValueOnce( - new ApiError("Failed to list organizations", 401, "Token expired") - ); - + test("does not ask for a team and preserves an explicit --team", async () => { const { ui, calls } = createMockUI(); - await expect( - resolveInitContext(makeOptions({ yes: true }), ui) - ).rejects.toThrow("401 Unauthorized"); - - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toContain("401 Unauthorized"); - expect(errorCall?.message).toContain("Token expired"); - }); - - test("includes the auth token in the resolved context", async () => { - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - - expect(context?.authToken).toBe("sntrys_test"); - }); - - test("sets isExplicitTeam:true when --team flag is provided", async () => { - resolveOrCreateTeamSpy.mockResolvedValue({ - slug: "backend", - source: "explicit", - } as any); - - const { ui } = createMockUI(); const context = await resolveInitContext( makeOptions({ team: "backend" }), ui ); - expect(context?.isExplicitTeam).toBe(true); - expect(context?.team).toBe("backend"); + expect(context?.team).toEqual({ slug: "backend", source: "explicit" }); + expect(calls.filter((call) => call.kind === "select")).toHaveLength(0); }); - test("sets isExplicitTeam:false when no --team flag is provided", async () => { + test("does not list every project in non-interactive create mode", async () => { + listProjectsSpy.mockRejectedValueOnce(new ApiError("unavailable", 503)); const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - expect(context?.isExplicitTeam).toBe(false); - }); - - test("swallows 403 from listTeams and resolves context with team:undefined", async () => { - listTeamsSpy.mockRejectedValueOnce( - new ApiError("Forbidden", 403, "No team:read access") - ); - - const { ui } = createMockUI(); const context = await resolveInitContext(makeOptions(), ui); - // 403 is swallowed so the wizard can proceed to the org-scoped fallback - expect(context).not.toBeNull(); - expect(context?.team).toBeUndefined(); - expect(getOrganizationSpy).toHaveBeenCalledWith("acme"); - expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); - }); - - test("preserves rich org-not-found guidance when implicit team lookup returns 404", async () => { - resolveOrgPrefetchedSpy.mockResolvedValueOnce({ org: "missing-org" }); - listOrganizationsSpy.mockResolvedValueOnce([ - { id: "1", slug: "acme", name: "Acme" }, - { id: "2", slug: "beta", name: "Beta" }, - ]); - listTeamsSpy.mockRejectedValueOnce( - new ApiError("Not found", 404, "Organization not found") - ); - - const { ui, calls } = createMockUI(); - await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( - "Organization 'missing-org'" - ); - - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toContain("Your organizations:"); - expect(errorCall?.message).toContain("acme"); - expect(errorCall?.message).toContain("beta"); - }); - - test("surfaces the enriched detail when implicit listTeams returns 401", async () => { - // member-disabled-over-limit: a 401 from listTeams must reach the user with - // its actionable detail, not a bare "Failed to list teams" + status line. - listTeamsSpy.mockRejectedValueOnce( - new ApiError( - "Failed to list teams", - 401, - "Your account is disabled in this organization because it is over its member limit." - ) - ); - - const { ui, calls } = createMockUI(); - await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow(); - - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toContain("over its member limit"); + expect(context?.project).toBeUndefined(); + expect(listProjectsSpy).not.toHaveBeenCalled(); }); - test("surfaces the enriched detail when explicit --team listTeams returns 401", async () => { - resolveOrCreateTeamSpy.mockRejectedValueOnce( - new ApiError( - "Failed to list teams", - 401, - "Your account is disabled in this organization because it is over its member limit." - ) - ); + test("surfaces project-list failures after the user chooses existing", async () => { + listProjectsSpy.mockRejectedValueOnce(new ApiError("unavailable", 503)); + const { ui, respond } = createMockUI(); + respond.select("existing"); - const { ui, calls } = createMockUI(); await expect( - resolveInitContext(makeOptions({ team: "backend" }), ui) - ).rejects.toThrow(); - - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toContain("over its member limit"); + resolveInitContext(makeOptions({ yes: false }), ui) + ).rejects.toThrow("Could not list existing projects"); }); - test("passes a pre-rendered WizardError through team resolution unchanged", async () => { - listTeamsSpy.mockRejectedValueOnce( - new WizardError("custom preflight failure") - ); - - const { ui, calls } = createMockUI(); - await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( - "custom preflight failure" - ); + test("does not turn a stale existing-project selection into creation", async () => { + listProjectsSpy.mockResolvedValue([makeProject("backend")]); + const { ui, respond } = createMockUI(); + respond.select("existing"); + respond.select("backend"); - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toBe("custom preflight failure"); + await expect( + resolveInitContext(makeOptions({ yes: false }), ui) + ).rejects.toThrow("Project 'acme/backend' is no longer available"); }); - test("surfaces a non-API error message from implicit team resolution", async () => { - listTeamsSpy.mockRejectedValueOnce(new Error("network down")); + test("returns null when the user cancels project intent selection", async () => { + listProjectsSpy.mockResolvedValue([makeProject("backend")]); + const { ui, calls, respond } = createMockUI(); + respond.select(CANCELLED); - const { ui, calls } = createMockUI(); - await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( - "network down" - ); + const context = await resolveInitContext(makeOptions({ yes: false }), ui); - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" - ); - expect(errorCall?.message).toContain("network down"); + expect(context).toBeNull(); + expect(feedbackOutcomes(calls)).toEqual(["cancelled"]); }); - test("fails early when listTeams is forbidden and member project creation is disabled", async () => { - listTeamsSpy.mockRejectedValueOnce( - new ApiError("Forbidden", 403, "No team:read access") - ); - getOrganizationSpy.mockResolvedValueOnce({ - id: "1", - slug: "acme", - name: "Acme", - access: ["project:read"], - allowMemberProjectCreation: false, - } as any); - - const { ui } = createMockUI(); - await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( - "Project creation is disabled for members" + test("surfaces 403 guidance when organizations cannot be listed", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockRejectedValueOnce( + new ApiError("Failed to list organizations", 403, "Missing org:read") ); - }); - - test("fails early when member project creation is disabled and no Team Admin team exists", async () => { - listTeamsSpy.mockResolvedValueOnce([]); - getOrganizationSpy.mockResolvedValueOnce({ - id: "1", - slug: "acme", - name: "Acme", - access: ["project:read"], - allowMemberProjectCreation: false, - } as any); - const { ui, calls } = createMockUI(); + await expect(resolveInitContext(makeOptions(), ui)).rejects.toThrow( - "Project creation is disabled for members" + "403 Forbidden" ); - - const errorCall = calls.find( - (c): c is Extract => - c.kind === "log.error" + expect(calls.find((call) => call.kind === "log.error")).toEqual( + expect.objectContaining({ + message: expect.stringContaining("sentry init /"), + }) ); - expect(errorCall?.message).toContain("sentry init acme/"); - }); - - test("allows org-scoped creation when member creation is disabled but token has org:write", async () => { - listTeamsSpy.mockResolvedValueOnce([]); - getOrganizationSpy.mockResolvedValueOnce({ - id: "1", - slug: "acme", - name: "Acme", - access: ["org:write"], - allowMemberProjectCreation: false, - } as any); - - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - - expect(context?.team).toBeUndefined(); }); - test("allows org-scoped creation when member creation is disabled but user is an admin (project:admin scope)", async () => { - listTeamsSpy.mockResolvedValueOnce([]); - getOrganizationSpy.mockResolvedValueOnce({ - id: "1", - slug: "acme", - name: "Acme", - access: ["project:read", "project:write", "project:admin", "team:admin"], - allowMemberProjectCreation: false, - } as any); - - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - - expect(context?.team).toBeUndefined(); - }); - - test("allows org-scoped creation when member creation is disabled but user has project:write scope", async () => { - listTeamsSpy.mockResolvedValueOnce([]); - getOrganizationSpy.mockResolvedValueOnce({ - id: "1", - slug: "acme", - name: "Acme", - access: ["project:read", "project:write"], - allowMemberProjectCreation: false, - } as any); - - const { ui } = createMockUI(); - const context = await resolveInitContext(makeOptions(), ui); - - expect(context?.team).toBeUndefined(); - }); - - test("allows org-scoped creation when listTeams returns 403 and user has project:admin scope", async () => { - listTeamsSpy.mockRejectedValueOnce( - new ApiError("Forbidden", 403, "No team:read access") - ); - getOrganizationSpy.mockResolvedValueOnce({ - id: "1", - slug: "acme", - name: "Acme", - access: ["project:read", "project:write", "project:admin"], - allowMemberProjectCreation: false, - } as any); - + test("includes the auth token in the resolved context", async () => { const { ui } = createMockUI(); const context = await resolveInitContext(makeOptions(), ui); - expect(context?.team).toBeUndefined(); + expect(context?.authToken).toBe("sntrys_test"); }); }); diff --git a/packages/cli/test/lib/init/tools/create-sentry-project.component.test.ts b/packages/cli/test/lib/init/tools/create-sentry-project.component.test.ts new file mode 100644 index 0000000000..3c16aa8205 --- /dev/null +++ b/packages/cli/test/lib/init/tools/create-sentry-project.component.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: assertions need module spies +import * as apiClient from "../../../../src/lib/api-client.js"; +import { ApiError } from "../../../../src/lib/errors.js"; +import { WizardCancelledError } from "../../../../src/lib/init/clack-utils.js"; +import { createSentryProject } from "../../../../src/lib/init/tools/create-sentry-project.js"; +import { executeTool } from "../../../../src/lib/init/tools/registry.js"; +import type { CreateSentryProjectPayload } from "../../../../src/lib/init/types.js"; + +const payload: CreateSentryProjectPayload = { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, +}; + +const context = { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, +}; + +describe("createSentryProject with the real team resolver", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.listTeams).mockResolvedValue([ + { + id: "1", + slug: "contributors", + name: "Contributors", + access: ["team:read"], + }, + ]); + vi.mocked(apiClient.getOrganization).mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:admin", "team:admin"], + allowMemberProjectCreation: false, + }); + vi.mocked(apiClient.createTeam).mockResolvedValue({ + id: "2", + slug: "my-app", + name: "my-app", + }); + vi.mocked(apiClient.createProjectWithDsn).mockResolvedValue({ + project: { id: "42", slug: "my-app" } as never, + dsn: "https://key@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + }); + }); + + test("creates a team and then its project in a restricted organization", async () => { + const result = await createSentryProject(payload, context); + + expect(result.ok).toBe(true); + expect(apiClient.createTeam).toHaveBeenCalledWith("acme", "my-app"); + expect(apiClient.createProjectWithDsn).toHaveBeenCalledWith( + "acme", + "my-app", + { name: "my-app", platform: "javascript-react" } + ); + expect(apiClient.createProjectWithAutoTeam).not.toHaveBeenCalled(); + }); + + test("does not try an impossible org fallback if team creation loses permission", async () => { + vi.mocked(apiClient.createTeam).mockRejectedValueOnce( + new ApiError("Forbidden", 403, "Missing team permission") + ); + + await expect( + executeTool(payload, { + directory: "/tmp/test", + yes: true, + ...context, + }) + ).rejects.toMatchObject({ + status: 403, + detail: expect.stringContaining("team:admin"), + }); + expect(apiClient.createTeam).toHaveBeenCalledOnce(); + expect(apiClient.createProjectWithDsn).not.toHaveBeenCalled(); + expect(apiClient.createProjectWithAutoTeam).not.toHaveBeenCalled(); + }); + + test("propagates cancellation from the interactive team chooser", async () => { + vi.mocked(apiClient.listTeams).mockResolvedValueOnce([ + { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + ]); + + await expect( + executeTool( + payload, + { + directory: "/tmp/test", + yes: false, + ...context, + }, + { + chooseTeam: async () => { + throw new WizardCancelledError(); + }, + } + ) + ).rejects.toBeInstanceOf(WizardCancelledError); + expect(apiClient.createProjectWithDsn).not.toHaveBeenCalled(); + expect(apiClient.createProjectWithAutoTeam).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/init/tools/create-sentry-project.test.ts b/packages/cli/test/lib/init/tools/create-sentry-project.test.ts index 64680db448..215e4db8be 100644 --- a/packages/cli/test/lib/init/tools/create-sentry-project.test.ts +++ b/packages/cli/test/lib/init/tools/create-sentry-project.test.ts @@ -77,10 +77,13 @@ const sampleAutoTeamResult = { team_slug: "team-testuser", }; +const autoSelectedTeam = { + slug: "platform", + source: "auto-selected" as const, +}; + let createProjectWithDsnSpy: ReturnType; let createProjectWithAutoTeamSpy: ReturnType; -let getProjectSpy: ReturnType; -let tryGetPrimaryDsnSpy: ReturnType; let resolveOrCreateTeamSpy: ReturnType; beforeEach(() => { @@ -100,29 +103,16 @@ beforeEach(() => { createProjectWithAutoTeamSpy = vi .spyOn(apiClient, "createProjectWithAutoTeam") .mockResolvedValue(sampleAutoTeamResult); - getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ - id: "42", - slug: "my-app", - name: "my-app", - platform: "javascript-react", - dateCreated: "2026-04-16T00:00:00Z", - } as any); - tryGetPrimaryDsnSpy = vi - .spyOn(apiClient, "tryGetPrimaryDsn") - .mockResolvedValue("https://abc@o1.ingest.sentry.io/42"); resolveOrCreateTeamSpy = vi .spyOn(resolveTeam, "resolveOrCreateTeam") - .mockResolvedValue({ - slug: "generated-team", - source: "auto-created", - } as any); + .mockImplementation(async (_org, options) => + options.team ? { slug: options.team, source: "explicit" } : undefined + ); }); afterEach(() => { createProjectWithDsnSpy.mockRestore(); createProjectWithAutoTeamSpy.mockRestore(); - getProjectSpy.mockRestore(); - tryGetPrimaryDsnSpy.mockRestore(); resolveOrCreateTeamSpy.mockRestore(); }); @@ -181,13 +171,11 @@ describe("createSentryProject", () => { expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); - test("creates a new project with the pre-resolved org and team", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); - + test("creates a new project with the explicit preflight team", async () => { const result = await createSentryProject(makePayload(), { dryRun: false, org: "acme", - team: "platform", + team: { slug: "platform", source: "explicit" }, project: undefined, }); @@ -202,42 +190,24 @@ describe("createSentryProject", () => { ); }); - test("re-checks for an existing project before creating when the slug is known", async () => { + test("does not silently reuse a project after creation was selected", async () => { const result = await createSentryProject(makePayload(), { dryRun: false, org: "acme", - team: "platform", + team: { slug: "platform", source: "explicit" }, project: undefined, }); expect(result.ok).toBe(true); - expect(result.message).toContain("Using existing project"); - expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); - expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); - }); - - test("surfaces lookup failures before creating when a known slug cannot be verified", async () => { - getProjectSpy.mockRejectedValueOnce(new Error("temporary failure")); - - const result = await createSentryProject(makePayload(), { - dryRun: false, - org: "acme", - team: "platform", - project: undefined, - }); - - expect(result.ok).toBe(false); - expect(result.error).toContain("temporary failure"); - expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(createProjectWithDsnSpy).toHaveBeenCalledOnce(); + expect(apiClient.getProject).not.toHaveBeenCalled(); }); test("returns dry-run placeholder project data", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); - const result = await createSentryProject(makePayload(), { dryRun: true, org: "acme", - team: "platform", + team: { slug: "platform", source: "explicit" }, project: undefined, }); @@ -252,8 +222,6 @@ describe("createSentryProject", () => { }); test("uses org-scoped auto-team creation when preflight did not resolve a team", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); - const result = await createSentryProject(makePayload(), { dryRun: false, org: "acme", @@ -262,7 +230,10 @@ describe("createSentryProject", () => { }); expect(result.ok).toBe(true); - expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ autoCreateSlug: "my-app" }) + ); expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", { name: "my-app", @@ -271,7 +242,6 @@ describe("createSentryProject", () => { }); test("returns clear error with sentry-init guidance when org disables member creation", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); createProjectWithAutoTeamSpy.mockRejectedValueOnce( new ApiError( "Failed to create project: 403 Forbidden", @@ -307,7 +277,7 @@ describe("createSentryProject", () => { }); test("falls back to org-scoped endpoint on 403 from team-based creation", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + resolveOrCreateTeamSpy.mockResolvedValueOnce(autoSelectedTeam); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError("Forbidden", 403, "No project:write access") ); @@ -315,7 +285,7 @@ describe("createSentryProject", () => { const result = await createSentryProject(makePayload(), { dryRun: false, org: "acme", - team: "platform", + team: undefined, project: undefined, }); @@ -326,17 +296,45 @@ describe("createSentryProject", () => { }); }); - test("suppresses fallback when team was set via --team (isExplicitTeam)", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + test("retries an invalid platform on the concrete fallback route", async () => { + resolveOrCreateTeamSpy.mockResolvedValueOnce(autoSelectedTeam); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError("Forbidden", 403, "No project:write access") ); + createProjectWithAutoTeamSpy + .mockRejectedValueOnce( + new ApiError("Bad request", 400, "Invalid platform") + ) + .mockResolvedValueOnce(sampleAutoTeamResult); const result = await createSentryProject(makePayload(), { dryRun: false, org: "acme", - team: "backend", - isExplicitTeam: true, + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(createProjectWithDsnSpy).toHaveBeenCalledOnce(); + expect(createProjectWithAutoTeamSpy).toHaveBeenNthCalledWith(1, "acme", { + name: "my-app", + platform: "javascript-react", + }); + expect(createProjectWithAutoTeamSpy).toHaveBeenNthCalledWith(2, "acme", { + name: "my-app", + platform: undefined, + }); + }); + + test("suppresses fallback for an explicitly resolved team", async () => { + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "You do not have permission") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: { slug: "backend", source: "explicit" }, project: undefined, }); @@ -344,8 +342,8 @@ describe("createSentryProject", () => { expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); }); - test("does not fall back on team-scoped policy 403", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + test("identifies the team scope hidden by a team-scoped policy 403", async () => { + resolveOrCreateTeamSpy.mockResolvedValueOnce(autoSelectedTeam); createProjectWithDsnSpy.mockRejectedValueOnce( new ApiError( "Forbidden", @@ -354,23 +352,24 @@ describe("createSentryProject", () => { ) ); - const result = await createSentryProject(makePayload(), { - dryRun: false, - org: "acme", - team: "platform", - project: undefined, + await expect( + createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }) + ).rejects.toMatchObject({ + status: 403, + detail: expect.stringContaining("team:admin"), }); - - expect(result.ok).toBe(false); expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); - expect(result.error).toContain("disabled for members"); }); test("surfaces friendly 409 error when fallback project already exists", async () => { createProjectWithAutoTeamSpy.mockRejectedValueOnce( new ApiError("Conflict", 409, "Slug already in use") ); - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); const result = await createSentryProject(makePayload(), { dryRun: false, @@ -385,9 +384,7 @@ describe("createSentryProject", () => { // ── dry-run ────────────────────────────────────────────────────────────── - test("does not resolve a team for org-scoped dry-run mode", async () => { - getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); - + test("resolves team policy without mutating during dry-run", async () => { const result = await createSentryProject(makePayload(), { dryRun: true, org: "acme", @@ -396,7 +393,10 @@ describe("createSentryProject", () => { }); expect(result.ok).toBe(true); - expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ dryRun: true }) + ); expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/test/lib/init/ui/mock-ui.ts b/packages/cli/test/lib/init/ui/mock-ui.ts index 460bf5553b..05283e4971 100644 --- a/packages/cli/test/lib/init/ui/mock-ui.ts +++ b/packages/cli/test/lib/init/ui/mock-ui.ts @@ -126,6 +126,7 @@ export function createMockUI(options: MockUIOptions = {}): { } const ui: WizardUI = { + supportsInteractivePrompts: true, banner: (art) => calls.push({ kind: "banner", art }), intro: (title) => calls.push({ kind: "intro", title }), summary: (summary) => calls.push({ kind: "summary", summary }), diff --git a/packages/cli/test/lib/init/wizard-runner.test.ts b/packages/cli/test/lib/init/wizard-runner.test.ts index 47346a619a..0faa044eb4 100644 --- a/packages/cli/test/lib/init/wizard-runner.test.ts +++ b/packages/cli/test/lib/init/wizard-runner.test.ts @@ -616,6 +616,58 @@ describe("runWizard", () => { expect(spinnerMock.message).toHaveBeenCalledWith("Running tool..."); }); + test("gives interactive project creation a narrow team-choice capability", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("continue"); + respond.select("existing"); + useMockUI(ui, calls); + const payload: ToolPayload = { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, + }; + const context = makeContext({ yes: false, team: undefined }); + resolveInitContextSpy.mockResolvedValue(context); + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { suspendPayload: payload }, + }, + }; + mockResumeResults = [{ status: "success" }]; + executeToolSpy.mockImplementation( + async (_payload, _context, capabilities) => { + const choice = await capabilities?.chooseTeam?.([ + { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + ]); + expect(choice).toEqual({ kind: "existing", slug: "platform" }); + return { ok: true, data: { results: [] } }; + } + ); + + await forceStdinTty(() => runWizard(makeOptions({ yes: false }))); + + expect(executeToolSpy).toHaveBeenCalledWith(payload, context, { + chooseTeam: expect.any(Function), + }); + expect(calls).toContainEqual({ + kind: "select", + message: "Choose a team for the new project", + options: ["create", "existing"], + }); + expect(spinnerMock.stop).toHaveBeenCalledWith("Found available teams"); + expect(spinnerMock.start).toHaveBeenCalledWith( + "Creating Sentry project..." + ); + }); + test("dispatches interactive payloads to the prompt handler", async () => { mockStartResult = { status: "suspended", @@ -721,6 +773,38 @@ describe("runWizard", () => { expect(lastFeedbackOutcome()).toBe("failed"); }); + test("preserves a missing-scope 403 for global OAuth recovery", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, + }; + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { suspendPayload: payload }, + }, + }; + const scopeError = new ApiError( + "Cannot create project", + 403, + "This operation requires the 'team:admin' authorization scope." + ); + executeToolSpy.mockRejectedValue(scopeError); + + const error = await runWizard(makeOptions()).catch((cause) => cause); + + expect(error).toBe(scopeError); + expect(error).not.toBeInstanceOf(WizardError); + expect(spinnerMock.stop).toHaveBeenCalledWith( + "Authorization update required", + 1 + ); + expect(lastCancelMessage()).toBe("Authorization update required"); + }); + test("tears down forwarding and stops the spinner on cancellation", async () => { const captureSpy = vi.spyOn(Sentry, "captureException"); const payload: ToolPayload = { diff --git a/packages/cli/test/lib/project-creation.test.ts b/packages/cli/test/lib/project-creation.test.ts new file mode 100644 index 0000000000..f7fe354a4e --- /dev/null +++ b/packages/cli/test/lib/project-creation.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/api/projects.js"); + +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as projectsApi from "../../src/lib/api/projects.js"; +import { MEMBER_PROJECT_CREATION_DISABLED_DETAIL } from "../../src/lib/api-client.js"; +import { ApiError } from "../../src/lib/errors.js"; +import { + createProjectWithTeamFallback, + ProjectCreationApiError, +} from "../../src/lib/project-creation.js"; + +const projectDetails = { + project: { + id: "42", + slug: "my-project", + name: "My Project", + platform: "javascript", + }, + dsn: "https://public@example.com/42", + url: "https://acme.sentry.io/projects/my-project/", +}; + +const autoTeamDetails = { + ...projectDetails, + team_slug: "my-project-team", +}; + +describe("createProjectWithTeamFallback", () => { + const createProjectWithDsnSpy = vi.mocked(projectsApi.createProjectWithDsn); + const createProjectWithAutoTeamSpy = vi.mocked( + projectsApi.createProjectWithAutoTeam + ); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("uses org-scoped creation when no team is resolved", async () => { + createProjectWithAutoTeamSpy.mockResolvedValueOnce(autoTeamDetails); + + const result = await createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + platform: "javascript", + }); + + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", { + name: "My Project", + platform: "javascript", + }); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + teamSlug: "my-project-team", + teamSource: "auto-created", + }); + }); + + test("uses the resolved team when team-scoped creation succeeds", async () => { + createProjectWithDsnSpy.mockResolvedValueOnce(projectDetails); + + const result = await createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "auto-selected" }, + }); + + expect(createProjectWithDsnSpy).toHaveBeenCalledWith("acme", "platform", { + name: "My Project", + platform: undefined, + }); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + teamSlug: "platform", + teamSource: "auto-selected", + }); + }); + + test("falls back to org-scoped creation after an implicit team 403", async () => { + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "You do not have permission") + ); + createProjectWithAutoTeamSpy.mockResolvedValueOnce(autoTeamDetails); + + const result = await createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "auto-selected" }, + }); + + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledOnce(); + expect(result.teamSource).toBe("auto-created"); + }); + + test("does not replace an explicitly requested team after a 403", async () => { + const error = new ApiError("Forbidden", 403, "You do not have permission"); + createProjectWithDsnSpy.mockRejectedValueOnce(error); + + await expect( + createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "explicit" }, + }) + ).rejects.toMatchObject({ cause: error, route: "team" }); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("does not replace an interactively selected team after a 403", async () => { + const error = new ApiError("Forbidden", 403, "You do not have permission"); + createProjectWithDsnSpy.mockRejectedValueOnce(error); + + await expect( + createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "selected" }, + }) + ).rejects.toMatchObject({ cause: error, route: "team" }); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("identifies stale authorization for an interactively selected Team Admin team", async () => { + const error = new ApiError( + "Forbidden", + 403, + `This organization has ${MEMBER_PROJECT_CREATION_DISABLED_DETAIL} for members` + ); + createProjectWithDsnSpy.mockRejectedValueOnce(error); + + await expect( + createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "selected" }, + }) + ).rejects.toThrow("team:admin"); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("identifies the real team-scope failure hidden by the policy detail", async () => { + const error = new ApiError( + "Forbidden", + 403, + `This organization has ${MEMBER_PROJECT_CREATION_DISABLED_DETAIL} for members` + ); + createProjectWithDsnSpy.mockRejectedValueOnce(error); + + await expect( + createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "auto-selected" }, + }) + ).rejects.toThrow("team:admin"); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("does not reinterpret a policy 403 for an explicit team", async () => { + const error = new ApiError( + "Forbidden", + 403, + `This organization has ${MEMBER_PROJECT_CREATION_DISABLED_DETAIL} for members` + ); + createProjectWithDsnSpy.mockRejectedValueOnce(error); + + await expect( + createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "explicit" }, + }) + ).rejects.toMatchObject({ cause: error, route: "team" }); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("preserves organization-route provenance after a team fallback", async () => { + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "You do not have permission") + ); + const orgError = new ApiError("Not found", 404, "Endpoint unavailable"); + createProjectWithAutoTeamSpy.mockRejectedValueOnce(orgError); + + const error = await createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "auto-selected" }, + }).catch((cause) => cause); + + expect(error).toBeInstanceOf(ProjectCreationApiError); + expect(error).toMatchObject({ cause: orgError, route: "organization" }); + }); + + test("identifies an old OAuth scope when the org fallback confirms restriction", async () => { + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "You do not have permission") + ); + createProjectWithAutoTeamSpy.mockRejectedValueOnce( + new ApiError( + "Forbidden", + 403, + `This organization has ${MEMBER_PROJECT_CREATION_DISABLED_DETAIL} for members` + ) + ); + + await expect( + createProjectWithTeamFallback({ + orgSlug: "acme", + name: "My Project", + team: { slug: "platform", source: "auto-selected" }, + }) + ).rejects.toThrow("team:admin"); + }); +}); diff --git a/packages/cli/test/lib/resolve-target.mocked.test.ts b/packages/cli/test/lib/resolve-target.mocked.test.ts index 047310a312..a9c5606dc8 100644 --- a/packages/cli/test/lib/resolve-target.mocked.test.ts +++ b/packages/cli/test/lib/resolve-target.mocked.test.ts @@ -41,6 +41,11 @@ const { mockGetProject, mockFindProjectByDsnKey, mockFindProjectsByPattern, + mockFindProjectsBySlug, + mockInferRepositoryName, + mockInferRepositoryRoot, + mockLoadSentryCliRc, + mockGetGlobalPaths, mockListOrganizationsUncached, mockGetOrgByNumericId, } = vi.hoisted(() => ({ @@ -77,6 +82,15 @@ const { mockGetProject: vi.fn(() => Promise.resolve({ slug: "test", name: "Test" })), mockFindProjectByDsnKey: vi.fn(() => Promise.resolve(null)), mockFindProjectsByPattern: vi.fn(() => Promise.resolve([])), + mockFindProjectsBySlug: vi.fn(() => + Promise.resolve({ projects: [], orgs: [] }) + ), + mockInferRepositoryName: vi.fn( + () => undefined as { name: string; remote: string } | undefined + ), + mockInferRepositoryRoot: vi.fn(() => undefined as string | undefined), + mockLoadSentryCliRc: vi.fn(() => Promise.resolve({ sources: {} })), + mockGetGlobalPaths: vi.fn(() => new Set(["/global/.sentryclirc"])), mockListOrganizationsUncached: vi.fn(() => Promise.resolve([])), mockGetOrgByNumericId: vi.fn( () => undefined as { slug: string; regionUrl: string } | undefined @@ -114,6 +128,17 @@ vi.mock("../../src/lib/db/dsn-cache.js", () => ({ setCachedDsn: mockSetCachedDsn, })); +vi.mock("../../src/lib/git.js", () => ({ + inferRepositoryName: mockInferRepositoryName, + inferRepositoryRoot: mockInferRepositoryRoot, +})); + +vi.mock("../../src/lib/sentryclirc.js", () => ({ + CONFIG_FILENAME: ".sentryclirc", + getGlobalPaths: mockGetGlobalPaths, + loadSentryCliRc: mockLoadSentryCliRc, +})); + vi.mock("../../src/lib/db/regions.js", () => ({ getOrgByNumericId: mockGetOrgByNumericId, getOrgRegion: vi.fn(() => { @@ -145,7 +170,7 @@ vi.mock("../../src/lib/api-client.js", () => ({ getProject: mockGetProject, findProjectByDsnKey: mockFindProjectByDsnKey, findProjectsByPattern: mockFindProjectsByPattern, - findProjectsBySlug: vi.fn(() => Promise.resolve({ projects: [], orgs: [] })), + findProjectsBySlug: mockFindProjectsBySlug, listOrganizations: vi.fn(() => Promise.resolve([])), listOrganizationsUncached: mockListOrganizationsUncached, listProjects: vi.fn(() => Promise.resolve([])), @@ -179,6 +204,11 @@ function resetAllMocks() { mockGetProject.mockReset(); mockFindProjectByDsnKey.mockReset(); mockFindProjectsByPattern.mockReset(); + mockFindProjectsBySlug.mockReset(); + mockInferRepositoryName.mockReset(); + mockInferRepositoryRoot.mockReset(); + mockLoadSentryCliRc.mockReset(); + mockGetGlobalPaths.mockReset(); mockListOrganizationsUncached.mockReset(); mockGetOrgByNumericId.mockReset(); @@ -203,6 +233,11 @@ function resetAllMocks() { mockGetCachedProjectByDsnKey.mockReturnValue(null); mockGetCachedDsn.mockReturnValue(null); mockFindProjectsByPattern.mockResolvedValue([]); + mockFindProjectsBySlug.mockResolvedValue({ projects: [], orgs: [] }); + mockInferRepositoryName.mockReturnValue(undefined); + mockInferRepositoryRoot.mockReturnValue(undefined); + mockLoadSentryCliRc.mockResolvedValue({ sources: {} }); + mockGetGlobalPaths.mockReturnValue(new Set(["/global/.sentryclirc"])); mockListOrganizationsUncached.mockResolvedValue([]); mockGetOrgByNumericId.mockReturnValue(undefined); } @@ -613,6 +648,180 @@ describe("resolveOrgAndProject", () => { expect(result?.project).toBe("dsn-project"); }); + test("resolves an exact project slug from the git repository name", async () => { + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "junior" + ? [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "sentry", + organization: { + id: "1", + slug: "sentry", + name: "Sentry", + }, + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveOrgAndProject({ cwd: "/work/checkout" }); + + expect(result).toEqual( + expect.objectContaining({ + org: "sentry", + project: "junior", + detectedFrom: 'git origin remote "getsentry/junior"', + }) + ); + expect(mockFindProjectsBySlug).toHaveBeenCalledWith("junior"); + expect(mockFindProjectsByPattern).not.toHaveBeenCalled(); + }); + + test("resolves an exact project slug from the working-directory name", async () => { + mockInferRepositoryRoot.mockReturnValue("/work/junior"); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/work/junior", + detectedFrom: "package.json", + }); + mockFindProjectsBySlug.mockResolvedValue({ + projects: [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "sentry", + organization: { id: "1", slug: "sentry", name: "Sentry" }, + }, + ], + orgs: [], + }); + + const result = await resolveOrgAndProject({ cwd: "/work/junior" }); + + expect(result).toEqual( + expect.objectContaining({ + org: "sentry", + project: "junior", + detectedFrom: 'working directory name "junior"', + }) + ); + expect(mockFindProjectsBySlug).toHaveBeenCalledWith("junior"); + expect(mockFindProjectRoot).toHaveBeenCalledWith("/work/junior"); + expect(mockFindProjectsByPattern).not.toHaveBeenCalled(); + }); + + test("prefers the specific working directory over the repository root", async () => { + mockInferRepositoryRoot.mockReturnValue("/work/monorepo"); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/work/monorepo/packages/frontend", + detectedFrom: "package.json", + }); + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/monorepo", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "frontend" + ? [ + { + id: "789", + slug: "frontend", + name: "Frontend", + orgSlug: "sentry", + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveOrgAndProject({ + cwd: "/work/monorepo/packages/frontend", + }); + + expect(result).toEqual( + expect.objectContaining({ org: "sentry", project: "frontend" }) + ); + expect(mockInferRepositoryName).not.toHaveBeenCalled(); + }); + + test("prefers the git remote over a conflicting checkout-root name", async () => { + mockInferRepositoryRoot.mockReturnValue("/work/checkout"); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/work/checkout", + detectedFrom: "vcs", + }); + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: [ + { + id: slug === "junior" ? "789" : "790", + slug, + name: slug, + orgSlug: "sentry", + }, + ], + orgs: [], + }) + ); + + const result = await resolveOrgAndProject({ cwd: "/work/checkout" }); + + expect(result).toEqual( + expect.objectContaining({ org: "sentry", project: "junior" }) + ); + }); + + test("does not treat a common nested cwd as a monorepo app root", async () => { + mockInferRepositoryRoot.mockReturnValue("/work/junior"); + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/work/junior", + detectedFrom: "vcs", + }); + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: [ + { + id: slug === "junior" ? "789" : "790", + slug, + name: slug, + orgSlug: "sentry", + }, + ], + orgs: [], + }) + ); + + const result = await resolveOrgAndProject({ + cwd: "/work/junior/src", + }); + + expect(result).toEqual( + expect.objectContaining({ org: "sentry", project: "junior" }) + ); + }); + test("falls back to directory inference when DSN detection fails", async () => { mockGetDefaultOrganization.mockReturnValue(null); mockGetDefaultProject.mockReturnValue(null); @@ -653,6 +862,39 @@ describe("resolveOrgAndProject", () => { expect(result).toBeNull(); }); + + test("does not choose the first cross-org exact match", async () => { + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "junior" + ? [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "sentry", + }, + { + id: "790", + slug: "junior", + name: "Junior", + orgSlug: "personal", + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveOrgAndProject({ cwd: "/work/checkout" }); + + expect(result).toBeNull(); + }); }); // ============================================================================ @@ -693,6 +935,186 @@ describe("resolveAllTargets", () => { expect(result.targets[0].project).toBe("default-project"); }); + test("codebase mode ignores global config and account defaults in favor of a local DSN", async () => { + mockLoadSentryCliRc.mockResolvedValue({ + org: "global-org", + project: "global-project", + sources: { + org: "/global/.sentryclirc", + project: "/global/.sentryclirc", + }, + }); + mockGetDefaultOrganization.mockReturnValue("default-org"); + mockGetDefaultProject.mockReturnValue("default-project"); + mockDetectAllDsns.mockResolvedValue({ + primary: { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file", + }, + all: [ + { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file", + }, + ], + hasMultiple: false, + fingerprint: "abc", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "local-org", + orgName: "Local Org", + projectSlug: "local-project", + projectName: "Local Project", + projectId: "456", + }); + + const result = await resolveAllTargets({ + cwd: "/work/checkout", + resolutionMode: "codebase", + }); + + expect(result.targets).toEqual([ + expect.objectContaining({ + org: "local-org", + project: "local-project", + }), + ]); + expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); + expect(mockGetDefaultProject).not.toHaveBeenCalled(); + }); + + test("codebase mode accepts a project from a local sentryclirc", async () => { + mockLoadSentryCliRc.mockResolvedValue({ + org: "local-org", + project: "local-project", + sources: { + org: "/global/.sentryclirc", + project: "/work/.sentryclirc", + }, + }); + + const result = await resolveAllTargets({ + cwd: "/work/checkout", + resolutionMode: "codebase", + }); + + expect(result.targets).toEqual([ + expect.objectContaining({ + org: "local-org", + project: "local-project", + }), + ]); + expect(mockDetectAllDsns).not.toHaveBeenCalled(); + }); + + test("organization filter skips an incompatible local config and continues", async () => { + mockLoadSentryCliRc.mockResolvedValue({ + org: "other-org", + project: "other-project", + sources: { + org: "/work/.sentryclirc", + project: "/work/.sentryclirc", + }, + }); + mockInferRepositoryRoot.mockReturnValue("/work/checkout"); + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "junior" + ? [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "acme", + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveAllTargets({ + cwd: "/work/checkout", + resolutionMode: "codebase", + organizationFilter: "acme", + }); + + expect(result.targets).toEqual([ + expect.objectContaining({ org: "acme", project: "junior" }), + ]); + }); + + test("organization filter skips a fully resolved DSN from another org", async () => { + const dsn = { + raw: "https://abc@o123.ingest.sentry.io/456", + protocol: "https", + publicKey: "abc", + host: "o123.ingest.sentry.io", + projectId: "456", + orgId: "123", + source: "env-file" as const, + }; + mockDetectAllDsns.mockResolvedValue({ + primary: dsn, + all: [dsn], + hasMultiple: false, + fingerprint: "abc", + }); + mockGetCachedProject.mockReturnValue({ + orgSlug: "other-org", + orgName: "Other Org", + projectSlug: "other-project", + projectName: "Other Project", + projectId: "456", + }); + mockInferRepositoryRoot.mockReturnValue("/work/checkout"); + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "junior" + ? [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "acme", + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveAllTargets({ + cwd: "/work/checkout", + resolutionMode: "codebase", + organizationFilter: "acme", + }); + + expect(result.targets).toEqual([ + expect.objectContaining({ org: "acme", project: "junior" }), + ]); + }); + test("resolves multiple DSNs in monorepo", async () => { mockGetDefaultOrganization.mockReturnValue(null); mockGetDefaultProject.mockReturnValue(null); @@ -838,6 +1260,82 @@ describe("resolveAllTargets", () => { expect(result.targets).toHaveLength(1); expect(result.targets[0].org).toBe("inferred-org"); expect(result.targets[0].project).toBe("my-app"); + expect(result.targets[0].matchStrength).toBe("exact"); + }); + + test("marks a non-exact project-root match as fuzzy", async () => { + mockFindProjectRoot.mockResolvedValue({ + projectRoot: "/home/user/junior", + detectedFrom: "package.json", + }); + mockFindProjectsByPattern.mockResolvedValue([ + { + id: "789", + slug: "junior-api", + name: "Junior API", + orgSlug: "inferred-org", + organization: { id: "1", slug: "inferred-org", name: "Inferred Org" }, + }, + ]); + + const result = await resolveAllTargets({ cwd: "/work/checkout" }); + + expect(result.targets).toEqual([ + expect.objectContaining({ + project: "junior-api", + matchStrength: "fuzzy", + }), + ]); + }); + + test("preserves ambiguous cross-org git repository matches", async () => { + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "upstream", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "junior" + ? [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "sentry", + organization: { + id: "1", + slug: "sentry", + name: "Sentry", + }, + }, + { + id: "790", + slug: "junior", + name: "Junior", + orgSlug: "personal", + organization: { + id: "2", + slug: "personal", + name: "Personal", + }, + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveAllTargets({ cwd: "/work/checkout" }); + + expect( + result.targets.map(({ org, project }) => ({ org, project })) + ).toEqual([ + { org: "sentry", project: "junior" }, + { org: "personal", project: "junior" }, + ]); + expect(result.footer).toContain("2 projects matching git repository"); + expect(mockFindProjectsByPattern).not.toHaveBeenCalled(); }); test("returns empty targets when all DSN resolutions fail", async () => { @@ -1037,6 +1535,42 @@ describe("env var resolution: SENTRY_ORG + SENTRY_PROJECT", () => { expect(mockGetDefaultOrganization).not.toHaveBeenCalled(); }); + test("resolveAllTargets: organization filter skips incompatible env target", async () => { + process.env.SENTRY_ORG = "other-org"; + process.env.SENTRY_PROJECT = "other-project"; + mockInferRepositoryRoot.mockReturnValue("/work/checkout"); + mockInferRepositoryName.mockReturnValue({ + name: "getsentry/junior", + remote: "origin", + }); + mockFindProjectsBySlug.mockImplementation((slug: string) => + Promise.resolve({ + projects: + slug === "junior" + ? [ + { + id: "789", + slug: "junior", + name: "Junior", + orgSlug: "acme", + }, + ] + : [], + orgs: [], + }) + ); + + const result = await resolveAllTargets({ + cwd: "/work/checkout", + resolutionMode: "codebase", + organizationFilter: "acme", + }); + + expect(result.targets).toEqual([ + expect.objectContaining({ org: "acme", project: "junior" }), + ]); + }); + // --- resolveOrgsForListing --- test("resolveOrgsForListing: returns org from SENTRY_ORG when no flag or defaults", async () => { diff --git a/packages/cli/test/lib/resolve-team.test.ts b/packages/cli/test/lib/resolve-team.test.ts index 5c98c94154..828be924b4 100644 --- a/packages/cli/test/lib/resolve-team.test.ts +++ b/packages/cli/test/lib/resolve-team.test.ts @@ -8,6 +8,8 @@ import { afterEach, describe, expect, test, vi } from "vitest"; vi.mock("../../src/lib/api/teams.js"); vi.mock("../../src/lib/api/organizations.js"); +// biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking +import * as organizationsApi from "../../src/lib/api/organizations.js"; // biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking import * as teamsApi from "../../src/lib/api/teams.js"; import { ApiError, ResolutionError } from "../../src/lib/errors.js"; @@ -15,9 +17,282 @@ import { resolveOrCreateTeam } from "../../src/lib/resolve-team.js"; describe("resolveOrCreateTeam", () => { const listTeamsSpy = vi.mocked(teamsApi.listTeams); + const createTeamSpy = vi.mocked(teamsApi.createTeam); + const getOrganizationSpy = vi.mocked(organizationsApi.getOrganization); afterEach(() => { - listTeamsSpy.mockReset(); + vi.resetAllMocks(); + }); + + test("preserves an explicit team without listing teams", async () => { + const result = await resolveOrCreateTeam("acme", { + team: "backend", + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(result).toEqual({ slug: "backend", source: "explicit" }); + expect(listTeamsSpy).not.toHaveBeenCalled(); + }); + + test("uses the team selected by an interactive caller", async () => { + listTeamsSpy.mockResolvedValue([ + { + id: "1", + slug: "contributors", + name: "Contributors", + access: ["team:read"], + }, + { + id: "2", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + { + id: "3", + slug: "web", + name: "Web", + access: ["team:admin"], + }, + ]); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + chooseTeam: async () => ({ kind: "existing", slug: "web" }), + }); + + expect(result).toEqual({ slug: "web", source: "selected" }); + expect(getOrganizationSpy).not.toHaveBeenCalled(); + }); + + test("auto-selects one eligible team without an interactive caller", async () => { + listTeamsSpy.mockResolvedValue([ + { + id: "2", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + ]); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init --yes", + autoCreateSlug: "my-app", + }); + + expect(result).toEqual({ slug: "platform", source: "auto-selected" }); + }); + + test("requires --team for multiple eligible teams without a prompt", async () => { + listTeamsSpy.mockResolvedValue([ + { + id: "2", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + { + id: "3", + slug: "web", + name: "Web", + access: ["team:admin"], + }, + ]); + + await expect( + resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }) + ).rejects.toThrow("Choose one explicitly with --team"); + }); + + test("creates a new team when the interactive caller chooses create", async () => { + listTeamsSpy.mockResolvedValue([ + { + id: "2", + slug: "platform", + name: "Platform", + access: ["team:admin"], + }, + ]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read"], + allowMemberProjectCreation: true, + }); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + chooseTeam: async () => ({ kind: "create" }), + }); + + expect(result).toBeUndefined(); + expect(getOrganizationSpy).toHaveBeenCalledWith("acme"); + }); + + test("creates a team for a project admin with no eligible team", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:admin", "team:admin"], + allowMemberProjectCreation: false, + }); + createTeamSpy.mockResolvedValue({ + id: "2", + slug: "my-app", + name: "my-app", + }); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(createTeamSpy).toHaveBeenCalledWith("acme", "my-app"); + expect(result).toEqual({ slug: "my-app", source: "auto-created" }); + }); + + test("retries a unique team slug after a conflict", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:admin", "team:admin"], + allowMemberProjectCreation: false, + }); + createTeamSpy + .mockRejectedValueOnce(new ApiError("conflict", 409)) + .mockResolvedValueOnce({ + id: "2", + slug: "my-app-team", + name: "my-app-team", + }); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(createTeamSpy).toHaveBeenNthCalledWith(1, "acme", "my-app"); + expect(createTeamSpy).toHaveBeenNthCalledWith(2, "acme", "my-app-team"); + expect(result?.slug).toBe("my-app-team"); + }); + + test("surfaces team:admin when restricted team creation returns 403", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:admin", "team:admin"], + allowMemberProjectCreation: false, + }); + createTeamSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "You do not have permission") + ); + + await expect( + resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }) + ).rejects.toThrow("team:admin"); + }); + + test("leaves team undefined for the org-scoped member route", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:read"], + allowMemberProjectCreation: true, + }); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(result).toBeUndefined(); + expect(createTeamSpy).not.toHaveBeenCalled(); + }); + + test("prefers atomic org-scoped creation when member creation is allowed", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:admin"], + allowMemberProjectCreation: true, + }); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(result).toBeUndefined(); + expect(createTeamSpy).not.toHaveBeenCalled(); + }); + + test("prefers org-scoped creation for an org manager", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["org:write", "project:admin"], + allowMemberProjectCreation: false, + }); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(result).toBeUndefined(); + expect(createTeamSpy).not.toHaveBeenCalled(); + }); + + test("does not create an unusable team when OAuth lacks team:admin", async () => { + listTeamsSpy.mockResolvedValue([]); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme", + name: "Acme", + access: ["project:admin"], + allowMemberProjectCreation: false, + }); + + await expect( + resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }) + ).rejects.toThrow("team:admin"); + expect(createTeamSpy).not.toHaveBeenCalled(); + }); + + test("uses the org-scoped route when teams cannot be listed", async () => { + listTeamsSpy.mockRejectedValueOnce(new ApiError("Forbidden", 403)); + + const result = await resolveOrCreateTeam("acme", { + usageHint: "sentry init", + autoCreateSlug: "my-app", + }); + + expect(result).toBeUndefined(); }); test("re-throws the original ApiError when listTeams returns 401", async () => { diff --git a/packages/cli/test/lib/team-choice.test.ts b/packages/cli/test/lib/team-choice.test.ts new file mode 100644 index 0000000000..1e24b7cd7a --- /dev/null +++ b/packages/cli/test/lib/team-choice.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test, vi } from "vitest"; +import { + chooseProjectTeam, + type ProjectTeamSelect, +} from "../../src/lib/team-choice.js"; +import type { SentryTeam } from "../../src/types/index.js"; + +const platform: SentryTeam = { + id: "1", + slug: "platform", + name: "Platform", + access: ["team:admin"], +}; +const web: SentryTeam = { + id: "2", + slug: "web", + name: "Web", + access: ["team:admin"], +}; + +function makeSelect(...answers: string[]): { + calls: Parameters[0][]; + select: ProjectTeamSelect; +} { + const calls: Parameters[0][] = []; + const select: ProjectTeamSelect = vi.fn(async (options) => { + calls.push(options); + const answer = answers.shift(); + const selected = options.options.find((option) => option.value === answer); + if (!selected) { + throw new Error(`Missing test option: ${answer}`); + } + return selected.value; + }); + return { calls, select }; +} + +describe("chooseProjectTeam", () => { + test("shows create first and the only eligible team directly", async () => { + const prompt = makeSelect("existing"); + + const result = await chooseProjectTeam([platform], prompt.select); + + expect(result).toEqual({ kind: "existing", slug: "platform" }); + expect(prompt.calls).toHaveLength(1); + expect(prompt.calls[0]?.options).toEqual([ + { + value: "create", + label: "+ Create a new team", + }, + { + value: "existing", + label: "Use #platform", + }, + ]); + expect(prompt.calls[0]?.initialValue).toBe("existing"); + }); + + test("keeps create outside the team selector when several teams exist", async () => { + const prompt = makeSelect("existing", "web"); + + const result = await chooseProjectTeam([platform, web], prompt.select); + + expect(result).toEqual({ kind: "existing", slug: "web" }); + expect(prompt.calls).toHaveLength(2); + expect(prompt.calls[0]?.options).toEqual([ + { value: "create", label: "+ Create a new team" }, + { value: "existing", label: "Select an existing team" }, + ]); + expect(prompt.calls[1]?.message).toBe("Select an existing team"); + expect(prompt.calls[1]?.options).toEqual([ + { value: "platform", label: "#platform", hint: "Platform" }, + { value: "web", label: "#web", hint: "Web" }, + ]); + expect( + prompt.calls[1]?.options.some((option) => option.value === "create") + ).toBe(false); + }); + + test("does not open the team selector after create is chosen", async () => { + const prompt = makeSelect("create"); + + const result = await chooseProjectTeam([platform, web], prompt.select); + + expect(result).toEqual({ kind: "create" }); + expect(prompt.calls).toHaveLength(1); + }); +});