diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 2b1fa5287..dee79ae7c 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -19,8 +19,10 @@ import type { SentryContext } from "../../context.js"; import { type CreatedProjectDetails, + createProjectWithAutoTeam, createProjectWithDsn, listTeams, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; @@ -56,7 +58,7 @@ import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); -/** Usage hint template — base command without positionals */ +/** Full usage hint shown in errors and help text. */ const USAGE_HINT = "sentry project create / "; type CreateFlags = { @@ -224,6 +226,99 @@ 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. + */ +async function resolveDryRunTeam( + orgSlug: string, + opts: { + team?: string; + detectedFrom?: string; + autoCreateSlug: string; + } +): 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" }; + } +} + +/** + * 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: { + orgSlug: string; + name: string; + platform: string; +}): Promise< + CreatedProjectDetails & { + teamSlug: string; + teamSource: ResolvedConcreteTeam["source"]; + } +> { + const { orgSlug, name, platform } = opts; + let result: Awaited>; + try { + result = await createProjectWithAutoTeam(orgSlug, { name, platform }); + } catch (expError) { + if (expError instanceof ApiError) { + if ( + expError.status === 403 && + expError.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, + expError.detail, + expError.endpoint + ); + } + if (expError.status === 409) { + const slug = slugify(name); + throw new CliError( + `A project named '${name}' already exists in ${orgSlug}.\n\n` + + `View it: sentry project view ${orgSlug}/${slug}` + ); + } + } + throw expError; + } + return { + project: result.project, + dsn: result.dsn, + url: result.url, + teamSlug: result.team_slug, + teamSource: "auto-created", + }; +} + /** * Create a project (with DSN + URL) with user-friendly error handling. * Wraps API errors with actionable messages instead of raw HTTP status codes. @@ -389,19 +484,15 @@ export const createCommand = buildCommand({ } const orgSlug = resolved.org; - // Resolve team — auto-creates a team if the org has none - const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { - team: flags.team, - detectedFrom: resolved.detectedFrom, - usageHint: USAGE_HINT, - autoCreateSlug: slugify(name), - dryRun: flags["dry-run"], - }); - const expectedSlug = slugify(name); - // Dry-run mode: show what would be created without creating it + // Dry-run mode: resolve team (or preview auto-create) without hitting create APIs if (flags["dry-run"]) { + const team = await resolveDryRunTeam(orgSlug, { + team: flags.team, + detectedFrom: resolved.detectedFrom, + autoCreateSlug: expectedSlug, + }); const result: ProjectCreatedResult = { project: { id: "", slug: expectedSlug, name, platform }, orgSlug, @@ -417,20 +508,60 @@ export const createCommand = buildCommand({ return yield new CommandOutput(result); } - // Create the project, fetch DSN, and build URL - const { project, dsn, url } = await createProjectWithErrors({ - orgSlug, - teamSlug: team.slug, - name, - platform, - detectedFrom: resolved.detectedFrom, - }); + // If either step 403s (member can't create/see teams, or lacks project:write on + // the team), fall back to POST /organizations/{org}/projects/ which mirrors + // what the Sentry onboarding UI uses: auto-creates a personal team for the + // caller and only requires project:read scope. + let teamSlug: string; + let teamSource: ResolvedConcreteTeam["source"]; + let projectDetails: CreatedProjectDetails; + + try { + const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + team: flags.team, + detectedFrom: resolved.detectedFrom, + usageHint: USAGE_HINT, + autoCreateSlug: expectedSlug, + }); + teamSlug = team.slug; + teamSource = team.source; + projectDetails = await createProjectWithErrors({ + orgSlug, + teamSlug, + name, + platform, + detectedFrom: resolved.detectedFrom, + }); + } 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) { + 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; + } + 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; + } + const { project, dsn, url } = projectDetails; const result: ProjectCreatedResult = { project, orgSlug, - teamSlug: team.slug, - teamSource: team.source, + teamSlug, + teamSource, requestedPlatform: platform, dsn, url, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 02d37c76e..87f750b87 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -93,6 +93,7 @@ export { export { type CreatedProjectDetails, createProject, + createProjectWithAutoTeam, createProjectWithDsn, deleteProject, findProjectByDsnKey, @@ -102,6 +103,7 @@ export { getProjectKeys, listProjects, listProjectsPaginated, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, matchesWordBoundary, type ProjectSearchResult, type ProjectWithOrg, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 86c369ae2..b7f69506d 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -27,6 +27,7 @@ import { } from "../db/project-cache.js"; import { getCachedOrganizations } from "../db/regions.js"; import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; +import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { isAllDigits } from "../utils.js"; @@ -164,6 +165,45 @@ export type CreatedProjectDetails = { url: string; }; +/** + * Seed both project caches after a successful creation. + * + * Best-effort: cache failures are silently swallowed so they never break + * project creation. Called by both `createProjectWithDsn` (team-scoped) + * and `createProjectWithAutoTeam` (org-scoped) to keep cache behaviour + * consistent across both creation paths. + */ +function seedProjectCaches( + orgSlug: string, + project: SentryProject, + dsn: string | null +): void { + try { + const orgName = resolveOrgDisplayName(orgSlug, project.organization?.name); + cacheProjectsForOrg(orgSlug, orgName, [ + { id: project.id, slug: project.slug, name: project.name }, + ]); + } catch { + // Best-effort — don't let cache failures break project creation + } + if (dsn) { + try { + const publicKey = extractPublicKeyFromDsn(dsn); + if (publicKey) { + setCachedProjectByDsnKey(publicKey, { + orgSlug, + orgName: resolveOrgDisplayName(orgSlug, project.organization?.name), + projectSlug: project.slug, + projectName: project.name, + projectId: project.id, + }); + } + } catch { + // Best-effort — don't let cache failures break project creation + } + } +} + /** * Extract the public key from a Sentry DSN URL. * DSN format: https://@/ @@ -195,35 +235,71 @@ export async function createProjectWithDsn( const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); const url = buildProjectUrl(orgSlug, project.slug); - // Seed project cache so subsequent commands skip redundant API lookups - try { - const orgName = resolveOrgDisplayName(orgSlug, project.organization?.name); - cacheProjectsForOrg(orgSlug, orgName, [ - { id: project.id, slug: project.slug, name: project.name }, - ]); - } catch { - // Best-effort — don't let cache failures break project creation - } + seedProjectCaches(orgSlug, project, dsn); + return { project, dsn, url }; +} - // Also seed the DSN-based project cache for DSN resolution - if (dsn) { - try { - const publicKey = extractPublicKeyFromDsn(dsn); - if (publicKey) { - setCachedProjectByDsnKey(publicKey, { - orgSlug, - orgName: resolveOrgDisplayName(orgSlug, project.organization?.name), - projectSlug: project.slug, - projectName: project.name, - projectId: project.id, - }); - } - } catch { - // Best-effort — don't let cache failures break project creation - } - } +/** Raw response shape from the org-scoped project creation endpoint. */ +type ProjectWithAutoTeam = SentryProject & { + /** The personal team auto-created by the server for the requesting user. */ + team_slug: string; +}; - return { project, dsn, url }; +/** + * Result of creating a project via the org-scoped member-accessible endpoint. + * Parallel to {@link CreatedProjectDetails} for the team-scoped endpoint. + */ +type CreatedAutoTeamProjectDetails = CreatedProjectDetails & { + /** The personal team auto-created by the server for the requesting user. */ + team_slug: string; +}; + +/** + * Substring present in the 403 detail when the org has disabled member project + * creation. Callers match against this to distinguish a policy 403 from an auth + * 403, so they can surface a clear "ask your admin" message instead of a generic + * permission error or a re-auth prompt. + */ +export const MEMBER_PROJECT_CREATION_DISABLED_DETAIL = "disabled this feature"; + +/** + * Create a new project via the org-scoped member-accessible endpoint. + * + * Unlike `createProject` (which posts to `/teams/{org}/{team}/projects/` and + * requires `project:write`), this endpoint only requires `project:read` scope + * and is accessible to org members. The server auto-creates a personal team + * named `team-{username}` for the caller with Team Admin role, then creates + * the project under it. + * + * The org must have `allowMemberProjectCreation = true` (i.e. the org flag + * `disable_member_project_creation` must be false). A 403 is returned + * otherwise — callers should surface that as an org policy error, not an + * auth issue. + * + * This mirrors the endpoint called by the Sentry onboarding UI when a member + * selects a platform for the first time. + * + * @param orgSlug - The organization slug + * @param body - Project creation parameters (name required, platform optional) + * @returns The created project with the auto-created team slug + * @throws {ApiError} 403 if member project creation is disabled for the org + * @throws {ApiError} 409 if a project with the same slug already exists + */ +export async function createProjectWithAutoTeam( + orgSlug: string, + body: CreateProjectBody +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/projects/`, + { method: "POST", body } + ); + const dsn = await tryGetPrimaryDsn(orgSlug, data.slug); + const url = buildProjectUrl(orgSlug, data.slug); + + seedProjectCaches(orgSlug, data, dsn); + return { project: data, dsn, url, team_slug: data.team_slug }; } /** diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index 2d02c43e4..d54577c75 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -98,6 +98,7 @@ function buildResolvedInitContext( features: initial.features, org, team, + isExplicitTeam: Boolean(initial.team), project: selection.project, app: initial.app, authToken: getAuthToken(), @@ -337,6 +338,12 @@ async function resolveTeam( if (error instanceof WizardCancelledError) { throw error; } + // 403 from listTeams: member lacks team:read. Return undefined (same as the + // "deferred" path) so the wizard continues to the project creation tool, + // where resolveProjectCreation has the createProjectWithAutoTeam fallback. + if (error instanceof ApiError && error.status === 403) { + return; + } throw error instanceof WizardError ? error : new WizardError(error instanceof Error ? error.message : String(error)); diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts index 431d3af05..f708759bd 100644 --- a/src/lib/init/tools/create-sentry-project.ts +++ b/src/lib/init/tools/create-sentry-project.ts @@ -1,4 +1,17 @@ -import { createProjectWithDsn } from "../../api-client.js"; +/** + * Sentry project creation tool for the init wizard. + * + * Implements the `create-sentry-project` and `ensure-sentry-project` wizard + * operations. Uses the team-scoped endpoint when the caller has team access, + * falling back to POST /organizations/{org}/projects/ for org members who + * lack team:write. + */ + +import { + createProjectWithAutoTeam, + createProjectWithDsn, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, +} from "../../api-client.js"; import { ApiError } from "../../errors.js"; import { resolveOrCreateTeam } from "../../resolve-team.js"; import { slugify } from "../../utils.js"; @@ -11,6 +24,113 @@ import type { import { formatToolError } from "./shared.js"; import type { InitToolDefinition, ToolContext } from "./types.js"; +type ProjectData = { + projectSlug: string; + projectId: string; + dsn: string; + url: string; +}; + +/** + * Resolve project creation using the team-based flow, falling back to the + * org-scoped endpoint on 403 (member lacks team creation permission). + * + * @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 the team is resolved fresh via resolveOrCreateTeam. + * @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. + * @param opts.slugHint - Slug used for auto-creating a team when org has none + * @returns Resolved project identifiers and DSN + */ +async function resolveProjectCreation(opts: { + org: string; + name: string; + platform: string | null | undefined; + team: string | undefined; + suppressFallback: boolean; + slugHint: string; +}): Promise { + const { org, name, team, suppressFallback, slugHint } = opts; + // Coerce null → undefined: CreateProjectBody.platform is string | undefined. + const platform = opts.platform ?? undefined; + try { + const teamSlug = team + ? team + : ( + await resolveOrCreateTeam(org, { + autoCreateSlug: slugHint, + usageHint: "sentry init", + }) + ).slug; + const result = await createProjectWithDsn(org, teamSlug, { + name, + platform, + }); + return { + projectSlug: result.project.slug, + projectId: result.project.id, + dsn: result.dsn ?? "", + url: result.url, + }; + } 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). + if ( + !(innerError instanceof ApiError && innerError.status === 403) || + suppressFallback + ) { + 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; + } + const result = await createProjectWithAutoTeam(org, { name, platform }); + return { + projectSlug: result.project.slug, + projectId: result.project.id, + url: result.url, + dsn: result.dsn ?? "", + }; + } +} + +/** + * Validate team access for a dry-run, mirroring preflight.ts:resolveTeam. + * + * Calls resolveOrCreateTeam with dryRun=true and deferAutoCreateOnEmptyOrg=true + * so no real teams are created. A 403 is swallowed — the real run falls back + * to the org-scoped endpoint. + * + * @throws Non-403 errors from resolveOrCreateTeam (org not found, network, etc.) + */ +async function validateTeamForDryRun( + org: string, + team: string | undefined, + autoCreateSlug: string +): Promise { + try { + await resolveOrCreateTeam(org, { + team, + autoCreateSlug, + usageHint: "sentry init", + dryRun: true, + deferAutoCreateOnEmptyOrg: true, + }); + } catch (teamErr) { + if (!(teamErr instanceof ApiError && teamErr.status === 403)) { + throw teamErr; + } + } +} + /** * Create a new Sentry project using the org that preflight already resolved. * Team creation is deferred here for empty-org init flows so the final project @@ -27,7 +147,7 @@ export async function createSentryProject( payload: CreateSentryProjectPayload | EnsureSentryProjectPayload, context: Pick< ToolContext, - "dryRun" | "existingProject" | "org" | "team" | "project" + "dryRun" | "existingProject" | "isExplicitTeam" | "org" | "team" | "project" > ): Promise { const name = context.project ?? payload.params.name; @@ -57,17 +177,10 @@ export async function createSentryProject( }; } - const teamSlug = context.team - ? context.team - : ( - await resolveOrCreateTeam(context.org, { - autoCreateSlug: slug, - usageHint: "sentry init", - dryRun: context.dryRun, - }) - ).slug; - 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: { @@ -80,35 +193,35 @@ export async function createSentryProject( }; } - const { project, dsn, url } = await createProjectWithDsn( - context.org, - teamSlug, - { - name, - platform: payload.params.platform, - } - ); + // Try the normal team-based flow. If the user is an org member who can't + // create or see teams (403), fall back to POST /organizations/{org}/projects/ + // which requires only project:read scope and auto-creates a personal team. + const projectData = await resolveProjectCreation({ + org: context.org, + name, + platform: payload.params.platform, + team: context.team, + suppressFallback: Boolean(context.isExplicitTeam), + slugHint: slug, + }); return { ok: true, data: { orgSlug: context.org, - projectSlug: project.slug, - projectId: project.id, - dsn: dsn ?? "", - url, + projectSlug: projectData.projectSlug, + projectId: projectData.projectId, + dsn: projectData.dsn, + url: projectData.url, }, }; } catch (error) { - // Org-level policy: members cannot create projects. The generic 403 - // enrichment would suggest re-authentication, which is wrong here. - // Surface a clear message with the escape hatch: once an admin creates - // the project, `sentry init /` resolves to the existing - // project and skips creation entirely. + // Org-level policy: member project creation is disabled on this org. + // Surface a clear message with the escape hatch. if ( error instanceof ApiError && error.status === 403 && - error.detail?.includes("disabled this feature") + error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) ) { return { ok: false, @@ -119,6 +232,17 @@ export async function createSentryProject( ` sentry init ${context.org}/`, }; } + // 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. + if (error instanceof ApiError && error.status === 409) { + return { + ok: false, + error: + `A project named "${name}" already exists in "${context.org}".\n` + + `View it: sentry project view ${context.org}/${slugify(name)}`, + }; + } return { ok: false, error: formatToolError(error) }; } } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 36a76c1a0..e59b2a549 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -47,6 +47,13 @@ export type ResolvedInitContext = { * 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). + */ + isExplicitTeam?: boolean; project?: string; /** Pre-selected app name for monorepo runs. Passed through from `--app`. */ app?: string; diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index ee6022ab2..dcca1883d 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -111,6 +111,42 @@ export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam; * @throws {ContextError} When team cannot be resolved * @throws {ResolutionError} When org slug returns 404 */ + +/** + * 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. + * - other → generic ResolutionError (5xx, network, etc.) + */ +async function handleListTeamsError( + error: unknown, + orgSlug: string, + options: ResolveTeamOptions +): Promise { + if (error instanceof ApiError) { + if (error.status === 404) { + return await buildOrgNotFoundError( + orgSlug, + options.usageHint, + options.detectedFrom + ); + } + if (error.status === 403) { + throw error; + } + throw new ResolutionError( + `Organization '${orgSlug}'`, + `could not be accessed (${error.status})`, + `${options.usageHint} --team `, + ["The organization may not exist, or you may lack access"] + ); + } + throw error; +} + export async function resolveOrCreateTeam( orgSlug: string, options: ResolveTeamOptions & { @@ -133,23 +169,7 @@ export async function resolveOrCreateTeam( try { teams = await listTeams(orgSlug); } catch (error) { - if (error instanceof ApiError) { - if (error.status === 404) { - return await buildOrgNotFoundError( - orgSlug, - options.usageHint, - options.detectedFrom - ); - } - // 403, 5xx, etc. — can't determine if org is wrong or something else - throw new ResolutionError( - `Organization '${orgSlug}'`, - `could not be accessed (${error.status})`, - `${options.usageHint} --team `, - ["The organization may not exist, or you may lack access"] - ); - } - throw error; + return await handleListTeamsError(error, orgSlug, options); } // No teams — auto-create one if a slug was provided @@ -234,6 +254,12 @@ async function autoCreateTeam( 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; + } // 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` + diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 96d571c7d..b77268d9e 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -82,6 +82,9 @@ describe("project create", () => { // With vitest auto-mock, createProjectWithDsn is a vi.fn() stub that // doesn't internally call createProject, so we assert on this spy. const createProjectWithDsnSpy = vi.mocked(projectsApi.createProjectWithDsn); + const createProjectWithAutoTeamSpy = vi.mocked( + projectsApi.createProjectWithAutoTeam + ); const createTeamSpy = vi.mocked(teamsApi.createTeam); const tryGetPrimaryDsnSpy = vi.mocked(projectsApi.tryGetPrimaryDsn); const listOrgsSpy = vi.mocked(orgsApi.listOrganizations); @@ -102,6 +105,14 @@ describe("project create", () => { dsn: "https://abc@o123.ingest.us.sentry.io/999", url: "https://sentry.io/organizations/acme-corp/projects/my-app/", }); + // Default: org-scoped fallback is disabled (matches the common org config). + createProjectWithAutoTeamSpy.mockRejectedValue( + new ApiError( + "Forbidden", + 403, + "Your organization has disabled this feature for members." + ) + ); createTeamSpy.mockResolvedValue(sampleTeam); tryGetPrimaryDsnSpy.mockResolvedValue( "https://abc@o123.ingest.us.sentry.io/999" @@ -115,6 +126,7 @@ describe("project create", () => { afterEach(() => { listTeamsSpy.mockReset(); createProjectWithDsnSpy.mockReset(); + createProjectWithAutoTeamSpy.mockReset(); createTeamSpy.mockReset(); tryGetPrimaryDsnSpy.mockReset(); listOrgsSpy.mockReset(); @@ -444,8 +456,10 @@ describe("project create", () => { }); test("wraps other API errors with context, preserving ApiError type", async () => { + // createProjectWithDsn fails with a non-403 server error (e.g. 500). + // The fallback is only attempted on 403; this should surface directly. createProjectWithDsnSpy.mockRejectedValue( - new ApiError("API request failed: 403 Forbidden", 403, "No permission") + new ApiError("Internal Server Error", 500, "Something went wrong") ); const { context } = createMockContext(); @@ -454,17 +468,55 @@ describe("project create", () => { const err = (await func .call(context, { json: false }, "my-app", "node") .catch((e: Error) => e)) as ApiError; - // Stays ApiError (not a plain CliError wrapper) so the 401–499 - // user-error silencing in error-reporting.ts still applies. + // Stays ApiError (not a plain CliError wrapper) so 5xx errors are + // captured for error reporting. expect(err).toBeInstanceOf(ApiError); - expect(err.status).toBe(403); - expect(err.detail).toBe("No permission"); + expect(err.status).toBe(500); expect(err.message).toContain("Failed to create project"); - expect(err.message).toContain("403"); - // Detail is NOT duplicated in message — ApiError.format() appends it. - expect(err.message).not.toContain("No permission"); - // But format() surfaces it for the user - expect(err.format()).toContain("No permission"); + expect(err.message).toContain("500"); + expect(err.format()).toContain("Something went wrong"); + }); + + test("falls back to org-scoped endpoint when team-based creation 403s", async () => { + // Simulate: member has a team (via listTeams) but can't create projects on it. + // The fallback to POST /organizations/{org}/projects/ should kick in. + createProjectWithDsnSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + createProjectWithAutoTeamSpy.mockResolvedValue({ + project: sampleProject, + dsn: "https://abc@o123.ingest.us.sentry.io/999", + url: "https://sentry.io/organizations/acme-corp/projects/my-app/", + team_slug: "team-testuser", + }); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "node"); + + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme-corp", { + name: "my-app", + platform: "node", + }); + }); + + 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"). + createProjectWithDsnSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + // createProjectWithAutoTeamSpy already defaults to "disabled this feature" 403 + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = (await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(403); + expect(err.message).toContain("disabled project creation for members"); }); test("outputs JSON when --json flag is set", async () => { @@ -620,8 +672,11 @@ describe("project create", () => { expect(err.message).toContain("Your organizations"); }); - test("resolveOrCreateTeam with non-404 listTeams failure shows generic error", async () => { - // listTeams returns 403 — org may exist, but user lacks access + test("listTeams 403 triggers org-scoped fallback, surfaces policy error if fallback also blocked", async () => { + // listTeams returns 403 (member lacks team:read). handleListTeamsError re-throws + // the raw ApiError so the outer catch can route to the org-scoped fallback. + // The default beforeEach mock has createProjectWithAutoTeam reject with + // "disabled this feature", so the final error is the policy-disabled message. listTeamsSpy.mockRejectedValue( new ApiError("API request failed: 403 Forbidden", 403) ); @@ -629,15 +684,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); - expect(err).toBeInstanceOf(CliError); - expect(err.message).toContain("could not be accessed"); - expect(err.message).toContain("403"); - expect(err.message).toContain("may not exist, or you may lack access"); - // Should NOT say "Organization is required" — we don't know that - expect(err.message).not.toContain("is required"); + .catch((e: Error) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(403); + expect(err.message).toContain("disabled project creation for members"); }); test("auto-corrects dot-separated platform to hyphen-separated", async () => { diff --git a/test/lib/api-client.coverage.test.ts b/test/lib/api-client.coverage.test.ts index 5127ebe5b..6dcdca63c 100644 --- a/test/lib/api-client.coverage.test.ts +++ b/test/lib/api-client.coverage.test.ts @@ -14,6 +14,7 @@ import { apiRequest, apiRequestToRegion, createProject, + createProjectWithAutoTeam, createTeam, getCurrentUser, getDetailedTrace, @@ -35,6 +36,7 @@ import { listTeamsPaginated, listTraceLogs, listTransactions, + MEMBER_PROJECT_CREATION_DISABLED_DETAIL, rawApiRequest, tryGetPrimaryDsn, updateIssueStatus, @@ -680,6 +682,84 @@ describe("projects.ts", () => { }); }); + describe("createProjectWithAutoTeam", () => { + const autoTeamProject = { + id: "99", + slug: "auto-proj", + name: "Auto Project", + platform: "node", + dateCreated: "2026-01-01T00:00:00Z", + team_slug: "team-testuser", + }; + const dsnKeys = [ + { + id: "k1", + isActive: true, + dsn: { public: "https://key@o1.ingest.sentry.io/99", secret: "" }, + }, + ]; + + test("POSTs to /organizations/{org}/projects/ and returns project with DSN and team_slug", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + if (req.url.includes("/projects/") && req.method === "POST") { + return new Response(JSON.stringify(autoTeamProject), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + // DSN key fetch + return new Response(JSON.stringify(dsnKeys), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProjectWithAutoTeam("test-org", { + name: "Auto Project", + }); + expect(result.project.slug).toBe("auto-proj"); + expect(result.team_slug).toBe("team-testuser"); + expect(result.dsn).toContain("key"); + expect(result.url).toContain("auto-proj"); + }); + + test("propagates 403 when org has disabled member project creation", async () => { + globalThis.fetch = mockFetch( + async () => + new Response( + JSON.stringify({ detail: MEMBER_PROJECT_CREATION_DISABLED_DETAIL }), + { status: 403, headers: { "Content-Type": "application/json" } } + ) + ); + + await expect( + createProjectWithAutoTeam("test-org", { name: "Blocked" }) + ).rejects.toMatchObject({ status: 403 }); + }); + + test("returns dsn:null when DSN fetch fails", async () => { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + if (req.method === "POST") { + return new Response(JSON.stringify(autoTeamProject), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const result = await createProjectWithAutoTeam("test-org", { + name: "Auto Project", + }); + expect(result.dsn).toBeNull(); + }); + }); + describe("getProjectKeys", () => { test("returns project client keys", async () => { const keys = [ diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index a067dc18e..5c330dc00 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -437,4 +437,40 @@ describe("resolveInitContext", () => { 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"); + }); + + test("sets isExplicitTeam:false when no --team flag is provided", async () => { + 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 () => { + resolveOrCreateTeamSpy.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(); + }); }); diff --git a/test/lib/init/tools/create-sentry-project.test.ts b/test/lib/init/tools/create-sentry-project.test.ts index bb1789d34..a249ba94d 100644 --- a/test/lib/init/tools/create-sentry-project.test.ts +++ b/test/lib/init/tools/create-sentry-project.test.ts @@ -64,7 +64,21 @@ function makeEnsurePayload( }; } +const sampleAutoTeamResult = { + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any, + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + team_slug: "team-testuser", +}; + let createProjectWithDsnSpy: ReturnType; +let createProjectWithAutoTeamSpy: ReturnType; let getProjectSpy: ReturnType; let tryGetPrimaryDsnSpy: ReturnType; let resolveOrCreateTeamSpy: ReturnType; @@ -83,6 +97,9 @@ beforeEach(() => { dsn: "https://abc@o1.ingest.sentry.io/42", url: "https://sentry.io/settings/acme/projects/my-app/", }); + createProjectWithAutoTeamSpy = vi + .spyOn(apiClient, "createProjectWithAutoTeam") + .mockResolvedValue(sampleAutoTeamResult); getProjectSpy = vi.spyOn(apiClient, "getProject").mockResolvedValue({ id: "42", slug: "my-app", @@ -103,6 +120,7 @@ beforeEach(() => { afterEach(() => { createProjectWithDsnSpy.mockRestore(); + createProjectWithAutoTeamSpy.mockRestore(); getProjectSpy.mockRestore(); tryGetPrimaryDsnSpy.mockRestore(); resolveOrCreateTeamSpy.mockRestore(); @@ -249,7 +267,6 @@ describe("createSentryProject", () => { expect.objectContaining({ autoCreateSlug: "my-app", usageHint: "sentry init", - dryRun: false, }) ); expect(createProjectWithDsnSpy).toHaveBeenCalledWith( @@ -298,6 +315,89 @@ describe("createSentryProject", () => { expect(createSentryProjectTool.describe(makePayload())).toContain("my-app"); }); + test("falls back to org-scoped endpoint on 403 from team-based creation", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme", { + name: "my-app", + platform: "javascript-react", + }); + }); + + test("suppresses fallback when team was set via --team (isExplicitTeam)", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: "backend", + isExplicitTeam: true, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(createProjectWithAutoTeamSpy).not.toHaveBeenCalled(); + }); + + test("does not fall back on policy 403 — avoids wasted round-trip to org-scoped endpoint", async () => { + getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError( + "Forbidden", + 403, + "Your organization has disabled this feature for members." + ) + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + 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)); + createProjectWithDsnSpy.mockRejectedValueOnce( + new ApiError("Forbidden", 403, "No project:write access") + ); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("already exists"); + createProjectWithAutoTeamSpy.mockRestore(); + }); + + // ── dry-run ────────────────────────────────────────────────────────────── + test("uses the final project slug for deferred team resolution in dry-run mode", async () => { getProjectSpy.mockRejectedValueOnce(new ApiError("Not found", 404));