From 82d0ad4964ea181b8005c7cf507e9bbf2c3b489f Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 21:13:19 +0100 Subject: [PATCH 01/10] feat(api): add SentryTeam type, listTeams, and createProject endpoints Add team schema/type and two new API functions needed for project creation. Also adds TEAM_ENDPOINT_REGEX so /teams/{org}/... endpoints route to the correct region. --- src/lib/api-client.ts | 57 +++++++++++++++++++++++++++++++++++++++++++ src/types/index.ts | 2 ++ src/types/sentry.ts | 19 +++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e2146845..ece649aa 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -27,6 +27,8 @@ import { SentryProjectSchema, type SentryRepository, SentryRepositorySchema, + type SentryTeam, + SentryTeamSchema, type SentryUser, SentryUserSchema, type TraceSpan, @@ -67,6 +69,9 @@ const ORG_ENDPOINT_REGEX = /^\/?organizations\/([^/]+)/; /** Regex to extract org slug from /projects/{org}/{project}/... endpoints */ const PROJECT_ENDPOINT_REGEX = /^\/?projects\/([^/]+)\/[^/]+/; +/** Regex to extract org slug from /teams/{org}/{team}/... endpoints */ +const TEAM_ENDPOINT_REGEX = /^\/?teams\/([^/]+)/; + /** * Get the Sentry API base URL. * Supports self-hosted instances via SENTRY_URL env var. @@ -478,6 +483,12 @@ function extractOrgSlugFromEndpoint(endpoint: string): string | null { return projectMatch[1]; } + // Try team path: /teams/{org}/{team}/... + const teamMatch = endpoint.match(TEAM_ENDPOINT_REGEX); + if (teamMatch?.[1]) { + return teamMatch[1]; + } + return null; } @@ -602,6 +613,52 @@ export function listRepositories(orgSlug: string): Promise { ); } +/** + * List teams in an organization. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - The organization slug + * @returns Array of teams in the organization + */ +export function listTeams(orgSlug: string): Promise { + return orgScopedRequest(`/organizations/${orgSlug}/teams/`, { + params: { detailed: "0" }, + schema: z.array(SentryTeamSchema), + }); +} + +/** Request body for creating a new project */ +type CreateProjectBody = { + name: string; + platform?: string; + default_rules?: boolean; +}; + +/** + * Create a new project in an organization under a team. + * Uses region-aware routing via the /teams/ endpoint regex. + * + * @param orgSlug - The organization slug + * @param teamSlug - The team slug to create the project under + * @param body - Project creation parameters (name is required) + * @returns The created project + * @throws {ApiError} 409 if a project with the same slug already exists + */ +export function createProject( + orgSlug: string, + teamSlug: string, + body: CreateProjectBody +): Promise { + return orgScopedRequest( + `/teams/${orgSlug}/${teamSlug}/projects/`, + { + method: "POST", + body, + schema: SentryProjectSchema, + } + ); +} + /** * Search for projects matching a slug across all accessible organizations. * diff --git a/src/types/index.ts b/src/types/index.ts index 229f66a1..ccb9fd16 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -74,6 +74,7 @@ export type { SentryOrganization, SentryProject, SentryRepository, + SentryTeam, SentryUser, Span, StackFrame, @@ -114,6 +115,7 @@ export { SentryOrganizationSchema, SentryProjectSchema, SentryRepositorySchema, + SentryTeamSchema, SentryUserSchema, SpanSchema, StackFrameSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 28b7be60..cb36645c 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -75,6 +75,25 @@ export const SentryUserSchema = z export type SentryUser = z.infer; +// Team + +/** A Sentry team within an organization */ +export const SentryTeamSchema = z + .object({ + // Core identifiers (required) + id: z.string(), + slug: z.string(), + name: z.string(), + // Optional metadata + memberCount: z.number().optional(), + isMember: z.boolean().optional(), + teamRole: z.string().nullable().optional(), + dateCreated: z.string().nullable().optional(), + }) + .passthrough(); + +export type SentryTeam = z.infer; + // Project export const SentryProjectSchema = z From 0ff0135f70155905864564bec9a09baa8805cc0f Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 21:13:26 +0100 Subject: [PATCH 02/10] feat(project): add `project create` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `sentry project create [--team] [--json]`. Supports org/name syntax (like gh repo create owner/repo), auto-detects org from config/DSN, and auto-selects team when the org has exactly one. Fetches the DSN after creation so users can start sending events immediately. All error paths are actionable — wrong org lists your orgs, wrong team lists available teams, 409 links to the existing project. --- src/commands/project/create.ts | 358 ++++++++++++++++++++++++++ src/commands/project/index.ts | 2 + test/commands/project/create.test.ts | 364 +++++++++++++++++++++++++++ 3 files changed, 724 insertions(+) create mode 100644 src/commands/project/create.ts create mode 100644 test/commands/project/create.test.ts diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts new file mode 100644 index 00000000..06b406d2 --- /dev/null +++ b/src/commands/project/create.ts @@ -0,0 +1,358 @@ +/** + * sentry project create + * + * Create a new Sentry project. + * Supports org/name positional syntax (like `gh repo create owner/repo`). + */ + +import type { SentryContext } from "../../context.js"; +import { + createProject, + getProjectKeys, + listOrganizations, + listTeams, +} from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ApiError, CliError, ContextError } from "../../lib/errors.js"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; +import { buildProjectUrl, getSentryBaseUrl } from "../../lib/sentry-urls.js"; +import type { SentryProject, SentryTeam } from "../../types/index.js"; + +type CreateFlags = { + readonly team?: string; + readonly json: boolean; +}; + +/** Common Sentry platform strings, shown when platform arg is missing */ +const PLATFORMS = [ + "javascript", + "javascript-react", + "javascript-nextjs", + "javascript-vue", + "javascript-angular", + "javascript-svelte", + "javascript-remix", + "javascript-astro", + "node", + "node-express", + "python", + "python-django", + "python-flask", + "python-fastapi", + "go", + "ruby", + "ruby-rails", + "php", + "php-laravel", + "java", + "android", + "dotnet", + "react-native", + "apple-ios", + "rust", + "elixir", +] as const; + +/** + * Parse the name positional argument. + * Supports `org/name` syntax for explicit org, or bare `name` for auto-detect. + * + * @returns Parsed org (if explicit) and project name + */ +function parseNameArg(arg: string): { org?: string; name: string } { + if (arg.includes("/")) { + const slashIndex = arg.indexOf("/"); + const org = arg.slice(0, slashIndex); + const name = arg.slice(slashIndex + 1); + + if (!(org && name)) { + throw new ContextError( + "Project name", + "sentry project create / \n\n" + + 'Both org and name are required when using "/" syntax.' + ); + } + + return { org, name }; + } + + return { name: arg }; +} + +/** + * Resolve which team to create the project under. + * + * Priority: + * 1. Explicit --team flag + * 2. Auto-detect: if org has exactly one team, use it + * 3. Error with list of available teams + * + * @param orgSlug - Organization to list teams from + * @param teamFlag - Explicit team slug from --team flag + * @param detectedFrom - Source of auto-detected org (shown in error messages) + * @returns Team slug to use + */ +async function resolveTeam( + orgSlug: string, + teamFlag?: string, + detectedFrom?: string +): Promise { + if (teamFlag) { + return teamFlag; + } + + let teams: SentryTeam[]; + try { + teams = await listTeams(orgSlug); + } catch (error) { + if (error instanceof ApiError) { + // Try to list the user's actual orgs to help them fix the command + let orgHint = + "Specify org explicitly: sentry project create / "; + try { + const orgs = await listOrganizations(); + if (orgs.length > 0) { + const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); + orgHint = `Your organizations:\n\n${orgList}`; + } + } catch { + // Best-effort — if this also fails, use the generic hint + } + + const alternatives = [ + `Could not list teams for org '${orgSlug}' (${error.status})`, + ]; + if (detectedFrom) { + alternatives.push( + `Org '${orgSlug}' was auto-detected from ${detectedFrom}` + ); + } + alternatives.push(orgHint); + throw new ContextError( + "Organization", + "sentry project create / --team ", + alternatives + ); + } + throw error; + } + + if (teams.length === 0) { + const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; + throw new ContextError( + "Team", + `sentry project create ${orgSlug}/ --team `, + [`No teams found in org '${orgSlug}'`, `Create a team at ${teamsUrl}`] + ); + } + + if (teams.length === 1) { + return (teams[0] as SentryTeam).slug; + } + + // Multiple teams — user must specify + const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); + throw new ContextError( + "Team", + `sentry project create --team ${(teams[0] as SentryTeam).slug}`, + [ + `Multiple teams found in ${orgSlug}. Specify one with --team:\n\n${teamList}`, + ] + ); +} + +/** + * Create a project with user-friendly error handling. + * Wraps API errors with actionable messages instead of raw HTTP status codes. + */ +async function createProjectWithErrors( + orgSlug: string, + teamSlug: string, + name: string, + platform: string +): Promise { + try { + return await createProject(orgSlug, teamSlug, { name, platform }); + } catch (error) { + if (error instanceof ApiError) { + if (error.status === 409) { + throw new CliError( + `A project named '${name}' already exists in ${orgSlug}.\n\n` + + `View it: sentry project view ${orgSlug}/${name}` + ); + } + if (error.status === 404) { + throw new CliError( + `Team '${teamSlug}' not found in ${orgSlug}.\n\n` + + "Check the team slug and try again:\n" + + ` sentry project create ${orgSlug}/${name} ${platform} --team ` + ); + } + throw new CliError( + `Failed to create project '${name}' in ${orgSlug}.\n\n` + + `API error (${error.status}): ${error.detail ?? error.message}` + ); + } + throw error; + } +} + +/** + * Try to fetch the primary DSN for a newly created project. + * Returns null on any error — DSN display is best-effort. + */ +async function tryGetPrimaryDsn( + orgSlug: string, + projectSlug: string +): Promise { + try { + const keys = await getProjectKeys(orgSlug, projectSlug); + const activeKey = keys.find((k) => k.isActive); + return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null; + } catch { + return null; + } +} + +export const createCommand = buildCommand({ + docs: { + brief: "Create a new project", + fullDescription: + "Create a new Sentry project in an organization.\n\n" + + "The name supports org/name syntax to specify the organization explicitly.\n" + + "If omitted, the org is auto-detected from config defaults or DSN.\n\n" + + "Projects are created under a team. If the org has one team, it is used\n" + + "automatically. Otherwise, specify --team.\n\n" + + "Examples:\n" + + " sentry project create my-app node\n" + + " sentry project create acme-corp/my-app javascript-nextjs\n" + + " sentry project create my-app python-django --team backend\n" + + " sentry project create my-app go --json", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "name", + brief: "Project name (supports org/name syntax)", + parse: String, + optional: true, + }, + { + placeholder: "platform", + brief: "Project platform (e.g., node, python, javascript-nextjs)", + parse: String, + optional: true, + }, + ], + }, + flags: { + team: { + kind: "parsed", + parse: String, + brief: "Team to create the project under", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + }, + aliases: { t: "team" }, + }, + async func( + this: SentryContext, + flags: CreateFlags, + nameArg?: string, + platformArg?: string + ): Promise { + const { stdout, cwd } = this; + + if (!nameArg) { + throw new ContextError( + "Project name", + "sentry project create ", + [ + "Use org/name syntax: sentry project create / ", + "Specify team: sentry project create --team ", + ] + ); + } + + if (!platformArg) { + const list = PLATFORMS.map((p) => ` ${p}`).join("\n"); + throw new ContextError( + "Platform", + `sentry project create ${nameArg} `, + [ + `Available platforms:\n\n${list}`, + "Full list: https://docs.sentry.io/platforms/", + ] + ); + } + + // Parse name (may include org/ prefix) + const { org: explicitOrg, name } = parseNameArg(nameArg); + + // Resolve organization + const resolved = await resolveOrg({ org: explicitOrg, cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry project create / ", + [ + "Include org in name: sentry project create / ", + "Set a default: sentry org view ", + "Run from a directory with a Sentry DSN configured", + ] + ); + } + const orgSlug = resolved.org; + + // Resolve team + const teamSlug = await resolveTeam( + orgSlug, + flags.team, + resolved.detectedFrom + ); + + // Create the project + const project = await createProjectWithErrors( + orgSlug, + teamSlug, + name, + platformArg + ); + + // Fetch DSN (best-effort, non-blocking for output) + const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); + + // JSON output + if (flags.json) { + writeJson(stdout, { ...project, dsn }); + return; + } + + // Human-readable output + const url = buildProjectUrl(orgSlug, project.slug); + + stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n\n`); + stdout.write(` Project ${project.name}\n`); + stdout.write(` Slug ${project.slug}\n`); + stdout.write(` Org ${orgSlug}\n`); + stdout.write(` Team ${teamSlug}\n`); + stdout.write(` Platform ${project.platform || platformArg}\n`); + if (dsn) { + stdout.write(` DSN ${dsn}\n`); + } + stdout.write(` URL ${url}\n`); + + writeFooter( + stdout, + `Tip: Use 'sentry project view ${orgSlug}/${project.slug}' for details` + ); + }, +}); diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts index 54f2ccac..9e344340 100644 --- a/src/commands/project/index.ts +++ b/src/commands/project/index.ts @@ -1,9 +1,11 @@ import { buildRouteMap } from "@stricli/core"; +import { createCommand } from "./create.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; export const projectRoute = buildRouteMap({ routes: { + create: createCommand, list: listCommand, view: viewCommand, }, diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts new file mode 100644 index 00000000..8f87accc --- /dev/null +++ b/test/commands/project/create.test.ts @@ -0,0 +1,364 @@ +/** + * Project Create Command Tests + * + * Tests for the project create command in src/commands/project/create.ts. + * Uses spyOn to mock api-client and resolve-target to test + * the func() body without real HTTP calls or database access. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { createCommand } from "../../../src/commands/project/create.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ApiError, CliError, ContextError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { SentryProject, SentryTeam } from "../../../src/types/sentry.js"; + +const sampleTeam: SentryTeam = { + id: "1", + slug: "engineering", + name: "Engineering", + memberCount: 5, +}; + +const sampleTeam2: SentryTeam = { + id: "2", + slug: "mobile", + name: "Mobile Team", + memberCount: 3, +}; + +const sampleProject: SentryProject = { + id: "999", + slug: "my-app", + name: "my-app", + platform: "python", + dateCreated: "2026-02-12T10:00:00Z", +}; + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + }; +} + +describe("project create", () => { + let listTeamsSpy: ReturnType; + let createProjectSpy: ReturnType; + let getProjectKeysSpy: ReturnType; + let listOrgsSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + listTeamsSpy = spyOn(apiClient, "listTeams"); + createProjectSpy = spyOn(apiClient, "createProject"); + getProjectKeysSpy = spyOn(apiClient, "getProjectKeys"); + listOrgsSpy = spyOn(apiClient, "listOrganizations"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + + // Default mocks + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + listTeamsSpy.mockResolvedValue([sampleTeam]); + createProjectSpy.mockResolvedValue(sampleProject); + getProjectKeysSpy.mockResolvedValue([ + { + id: "key1", + name: "Default", + dsn: { public: "https://abc@o123.ingest.us.sentry.io/999" }, + isActive: true, + }, + ]); + listOrgsSpy.mockResolvedValue([ + { slug: "acme-corp", name: "Acme Corp" }, + { slug: "other-org", name: "Other Org" }, + ]); + }); + + afterEach(() => { + listTeamsSpy.mockRestore(); + createProjectSpy.mockRestore(); + getProjectKeysSpy.mockRestore(); + listOrgsSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("creates project with auto-detected org and single team", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "node"); + + expect(createProjectSpy).toHaveBeenCalledWith("acme-corp", "engineering", { + name: "my-app", + platform: "node", + }); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Created project 'my-app'"); + expect(output).toContain("acme-corp"); + expect(output).toContain("engineering"); + expect(output).toContain("https://abc@o123.ingest.us.sentry.io/999"); + }); + + test("parses org/name positional syntax", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-org/my-app", "python"); + + // resolveOrg should receive the explicit org + expect(resolveOrgSpy).toHaveBeenCalledWith({ + org: "my-org", + cwd: "/tmp", + }); + }); + + test("passes platform positional to createProject", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "python-flask"); + + expect(createProjectSpy).toHaveBeenCalledWith("acme-corp", "engineering", { + name: "my-app", + platform: "python-flask", + }); + }); + + test("passes --team to skip team auto-detection", async () => { + listTeamsSpy.mockResolvedValue([sampleTeam, sampleTeam2]); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { team: "mobile", json: false }, "my-app", "go"); + + // listTeams should NOT be called when --team is explicit + expect(listTeamsSpy).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith("acme-corp", "mobile", { + name: "my-app", + platform: "go", + }); + }); + + test("errors when multiple teams exist without --team", async () => { + listTeamsSpy.mockResolvedValue([sampleTeam, sampleTeam2]); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { json: false }, "my-app", "node") + ).rejects.toThrow(ContextError); + + // Should not call createProject + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("errors when no teams exist", async () => { + listTeamsSpy.mockResolvedValue([]); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { json: false }, "my-app", "node") + ).rejects.toThrow(ContextError); + }); + + test("errors when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await expect( + func.call(context, { json: false }, "my-app", "node") + ).rejects.toThrow(ContextError); + }); + + test("handles 409 conflict with friendly error", async () => { + createProjectSpy.mockRejectedValue( + new ApiError( + "API request failed: 409 Conflict", + 409, + "Project already exists" + ) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("already exists"); + expect(err.message).toContain("sentry project view"); + }); + + test("handles 404 from createProject as team-not-found", async () => { + createProjectSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Team 'engineering' not found"); + expect(err.message).toContain("--team "); + }); + + test("wraps other API errors with context", async () => { + createProjectSpy.mockRejectedValue( + new ApiError("API request failed: 403 Forbidden", 403, "No permission") + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Failed to create project"); + expect(err.message).toContain("403"); + }); + + test("outputs JSON when --json flag is set", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: true }, "my-app", "node"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.slug).toBe("my-app"); + expect(parsed.dsn).toBe("https://abc@o123.ingest.us.sentry.io/999"); + }); + + test("handles DSN fetch failure gracefully", async () => { + getProjectKeysSpy.mockRejectedValue(new Error("network error")); + + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "node"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // Should still show project info without DSN + expect(output).toContain("Created project 'my-app'"); + expect(output).not.toContain("ingest.us.sentry.io"); + }); + + test("errors on invalid org/name syntax", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + // Missing name after slash + await expect( + func.call(context, { json: false }, "acme-corp/", "node") + ).rejects.toThrow(ContextError); + }); + + test("shows platform in human output", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "python-django"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("python"); + }); + + test("shows project URL in human output", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "node"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("/settings/acme-corp/projects/my-app/"); + }); + + test("shows helpful error when name is missing", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }) + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ContextError); + expect(err.message).toContain("Project name is required"); + expect(err.message).toContain("sentry project create "); + }); + + test("shows helpful error when platform is missing", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ContextError); + expect(err.message).toContain("Platform is required"); + expect(err.message).toContain("Available platforms"); + expect(err.message).toContain("javascript-nextjs"); + expect(err.message).toContain("python"); + expect(err.message).toContain("docs.sentry.io/platforms"); + }); + + test("wraps listTeams API failure with org list", async () => { + listTeamsSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ContextError); + expect(err.message).toContain("acme-corp"); + expect(err.message).toContain("404"); + // Should show the user's actual orgs to help them pick the right one + expect(err.message).toContain("Your organizations"); + expect(err.message).toContain("other-org"); + }); + + test("shows auto-detected org source when listTeams fails", async () => { + resolveOrgSpy.mockResolvedValue({ + org: "123", + detectedFrom: "test/mocks/routes.ts", + }); + listTeamsSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ContextError); + expect(err.message).toContain("auto-detected from test/mocks/routes.ts"); + expect(err.message).toContain("123"); + expect(err.message).toContain("Your organizations"); + }); +}); From 62fc00c55ba7ee01513afb080af5ccc5071c76eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Feb 2026 20:14:07 +0000 Subject: [PATCH 03/10] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 0a00b3c8..f598ecc5 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -136,6 +136,14 @@ sentry org view my-org -w Work with Sentry projects +#### `sentry project create ` + +Create a new project + +**Flags:** +- `-t, --team - Team to create the project under` +- `--json - Output as JSON` + #### `sentry project list ` List projects From f747f3bda2ea0ac5de8f6a63cbc87f9db87dea2f Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 22:12:33 +0100 Subject: [PATCH 04/10] fix(project): show platform list on invalid platform API error When the API returns 400 for an invalid platform string, show the same helpful platform list instead of a raw JSON error body. --- src/commands/project/create.ts | 17 +++++++++++++++++ test/commands/project/create.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 06b406d2..cf9d379b 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -162,6 +162,12 @@ async function resolveTeam( ); } +/** Check whether an API error is about an invalid platform value */ +function isPlatformError(error: ApiError): boolean { + const detail = error.detail ?? error.message; + return detail.includes("platform") && detail.includes("Invalid"); +} + /** * Create a project with user-friendly error handling. * Wraps API errors with actionable messages instead of raw HTTP status codes. @@ -182,6 +188,17 @@ async function createProjectWithErrors( `View it: sentry project view ${orgSlug}/${name}` ); } + if (error.status === 400 && isPlatformError(error)) { + const list = PLATFORMS.map((p) => ` ${p}`).join("\n"); + throw new CliError( + `Invalid platform '${platform}'.\n\n` + + "Specify it using:\n" + + ` sentry project create ${orgSlug}/${name} \n\n` + + "Or:\n" + + ` - Available platforms:\n\n${list}\n` + + " - Full list: https://docs.sentry.io/platforms/" + ); + } if (error.status === 404) { throw new CliError( `Team '${teamSlug}' not found in ${orgSlug}.\n\n` + diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 8f87accc..865d45bd 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -227,6 +227,28 @@ describe("project create", () => { expect(err.message).toContain("--team "); }); + test("handles 400 invalid platform with platform list", async () => { + createProjectSpy.mockRejectedValue( + new ApiError( + "API request failed: 400 Bad Request", + 400, + '{"platform":["Invalid platform"]}' + ) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Invalid platform 'node'"); + expect(err.message).toContain("Available platforms:"); + expect(err.message).toContain("javascript-nextjs"); + expect(err.message).toContain("docs.sentry.io/platforms"); + }); + test("wraps other API errors with context", async () => { createProjectSpy.mockRejectedValue( new ApiError("API request failed: 403 Forbidden", 403, "No permission") From 4f7258f7af49dbe8a0d8098ff6ab44507f6d591a Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 22:18:30 +0100 Subject: [PATCH 05/10] fix(project): improve platform error message formatting Replace the confusing 'Or: - Available platforms:' pattern with a cleaner 'Usage: ... Available platforms:' layout. Applies to both missing platform and invalid platform errors. --- src/commands/project/create.ts | 41 ++++++++++++++++------------ test/commands/project/create.test.ts | 4 +-- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index cf9d379b..91bc8de9 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -168,6 +168,27 @@ function isPlatformError(error: ApiError): boolean { return detail.includes("platform") && detail.includes("Invalid"); } +/** + * Build a user-friendly error message for missing or invalid platform. + * + * @param nameArg - The name arg (used in the usage example) + * @param platform - The invalid platform string, if provided + */ +function buildPlatformError(nameArg: string, platform?: string): string { + const list = PLATFORMS.map((p) => ` ${p}`).join("\n"); + const heading = platform + ? `Invalid platform '${platform}'.` + : "Platform is required."; + + return ( + `${heading}\n\n` + + "Usage:\n" + + ` sentry project create ${nameArg} \n\n` + + `Available platforms:\n\n${list}\n\n` + + "Full list: https://docs.sentry.io/platforms/" + ); +} + /** * Create a project with user-friendly error handling. * Wraps API errors with actionable messages instead of raw HTTP status codes. @@ -189,15 +210,7 @@ async function createProjectWithErrors( ); } if (error.status === 400 && isPlatformError(error)) { - const list = PLATFORMS.map((p) => ` ${p}`).join("\n"); - throw new CliError( - `Invalid platform '${platform}'.\n\n` + - "Specify it using:\n" + - ` sentry project create ${orgSlug}/${name} \n\n` + - "Or:\n" + - ` - Available platforms:\n\n${list}\n` + - " - Full list: https://docs.sentry.io/platforms/" - ); + throw new CliError(buildPlatformError(`${orgSlug}/${name}`, platform)); } if (error.status === 404) { throw new CliError( @@ -300,15 +313,7 @@ export const createCommand = buildCommand({ } if (!platformArg) { - const list = PLATFORMS.map((p) => ` ${p}`).join("\n"); - throw new ContextError( - "Platform", - `sentry project create ${nameArg} `, - [ - `Available platforms:\n\n${list}`, - "Full list: https://docs.sentry.io/platforms/", - ] - ); + throw new CliError(buildPlatformError(nameArg)); } // Parse name (may include org/ prefix) diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 865d45bd..6e55c3bf 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -336,9 +336,9 @@ describe("project create", () => { const err = await func .call(context, { json: false }, "my-app") .catch((e: Error) => e); - expect(err).toBeInstanceOf(ContextError); + expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Platform is required"); - expect(err.message).toContain("Available platforms"); + expect(err.message).toContain("Available platforms:"); expect(err.message).toContain("javascript-nextjs"); expect(err.message).toContain("python"); expect(err.message).toContain("docs.sentry.io/platforms"); From 62948622004b02c401bda63198f73993a1808bae Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 22:31:45 +0100 Subject: [PATCH 06/10] refactor: extract shared helpers for reuse across create commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tryGetPrimaryDsn() → api-client.ts (was duplicated in view + create) - resolveTeam() → resolve-team.ts (reusable for future team-dependent commands) - parseOrgPrefixedArg() → arg-parsing.ts (reusable org/name parsing) - writeKeyValue() for aligned key-value output in create.ts - project/view.ts now uses shared tryGetPrimaryDsn instead of local copy --- src/commands/project/create.ts | 203 +++++++-------------------- src/commands/project/view.ts | 39 +---- src/lib/api-client.ts | 24 ++++ src/lib/arg-parsing.ts | 52 +++++++ src/lib/resolve-team.ts | 115 +++++++++++++++ test/commands/project/create.test.ts | 19 +-- 6 files changed, 251 insertions(+), 201 deletions(-) create mode 100644 src/lib/resolve-team.ts diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 91bc8de9..317d07b6 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -6,25 +6,25 @@ */ import type { SentryContext } from "../../context.js"; -import { - createProject, - getProjectKeys, - listOrganizations, - listTeams, -} from "../../lib/api-client.js"; +import { createProject, tryGetPrimaryDsn } from "../../lib/api-client.js"; +import { parseOrgPrefixedArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError, CliError, ContextError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { buildProjectUrl, getSentryBaseUrl } from "../../lib/sentry-urls.js"; -import type { SentryProject, SentryTeam } from "../../types/index.js"; +import { resolveTeam } from "../../lib/resolve-team.js"; +import { buildProjectUrl } from "../../lib/sentry-urls.js"; +import type { SentryProject } from "../../types/index.js"; + +/** Usage hint template — base command without positionals */ +const USAGE_HINT = "sentry project create / "; type CreateFlags = { readonly team?: string; readonly json: boolean; }; -/** Common Sentry platform strings, shown when platform arg is missing */ +/** Common Sentry platform strings, shown when platform arg is missing or invalid */ const PLATFORMS = [ "javascript", "javascript-react", @@ -54,114 +54,6 @@ const PLATFORMS = [ "elixir", ] as const; -/** - * Parse the name positional argument. - * Supports `org/name` syntax for explicit org, or bare `name` for auto-detect. - * - * @returns Parsed org (if explicit) and project name - */ -function parseNameArg(arg: string): { org?: string; name: string } { - if (arg.includes("/")) { - const slashIndex = arg.indexOf("/"); - const org = arg.slice(0, slashIndex); - const name = arg.slice(slashIndex + 1); - - if (!(org && name)) { - throw new ContextError( - "Project name", - "sentry project create / \n\n" + - 'Both org and name are required when using "/" syntax.' - ); - } - - return { org, name }; - } - - return { name: arg }; -} - -/** - * Resolve which team to create the project under. - * - * Priority: - * 1. Explicit --team flag - * 2. Auto-detect: if org has exactly one team, use it - * 3. Error with list of available teams - * - * @param orgSlug - Organization to list teams from - * @param teamFlag - Explicit team slug from --team flag - * @param detectedFrom - Source of auto-detected org (shown in error messages) - * @returns Team slug to use - */ -async function resolveTeam( - orgSlug: string, - teamFlag?: string, - detectedFrom?: string -): Promise { - if (teamFlag) { - return teamFlag; - } - - let teams: SentryTeam[]; - try { - teams = await listTeams(orgSlug); - } catch (error) { - if (error instanceof ApiError) { - // Try to list the user's actual orgs to help them fix the command - let orgHint = - "Specify org explicitly: sentry project create / "; - try { - const orgs = await listOrganizations(); - if (orgs.length > 0) { - const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); - orgHint = `Your organizations:\n\n${orgList}`; - } - } catch { - // Best-effort — if this also fails, use the generic hint - } - - const alternatives = [ - `Could not list teams for org '${orgSlug}' (${error.status})`, - ]; - if (detectedFrom) { - alternatives.push( - `Org '${orgSlug}' was auto-detected from ${detectedFrom}` - ); - } - alternatives.push(orgHint); - throw new ContextError( - "Organization", - "sentry project create / --team ", - alternatives - ); - } - throw error; - } - - if (teams.length === 0) { - const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; - throw new ContextError( - "Team", - `sentry project create ${orgSlug}/ --team `, - [`No teams found in org '${orgSlug}'`, `Create a team at ${teamsUrl}`] - ); - } - - if (teams.length === 1) { - return (teams[0] as SentryTeam).slug; - } - - // Multiple teams — user must specify - const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); - throw new ContextError( - "Team", - `sentry project create --team ${(teams[0] as SentryTeam).slug}`, - [ - `Multiple teams found in ${orgSlug}. Specify one with --team:\n\n${teamList}`, - ] - ); -} - /** Check whether an API error is about an invalid platform value */ function isPlatformError(error: ApiError): boolean { const detail = error.detail ?? error.message; @@ -229,19 +121,16 @@ async function createProjectWithErrors( } /** - * Try to fetch the primary DSN for a newly created project. - * Returns null on any error — DSN display is best-effort. + * Write key-value pairs with aligned columns. + * Used for human-readable output after resource creation. */ -async function tryGetPrimaryDsn( - orgSlug: string, - projectSlug: string -): Promise { - try { - const keys = await getProjectKeys(orgSlug, projectSlug); - const activeKey = keys.find((k) => k.isActive); - return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null; - } catch { - return null; +function writeKeyValue( + stdout: { write: (s: string) => void }, + pairs: [label: string, value: string][] +): void { + const maxLabel = Math.max(...pairs.map(([l]) => l.length)); + for (const [label, value] of pairs) { + stdout.write(` ${label.padEnd(maxLabel + 2)}${value}\n`); } } @@ -306,7 +195,7 @@ export const createCommand = buildCommand({ "Project name", "sentry project create ", [ - "Use org/name syntax: sentry project create / ", + `Use org/name syntax: ${USAGE_HINT}`, "Specify team: sentry project create --team ", ] ); @@ -316,30 +205,29 @@ export const createCommand = buildCommand({ throw new CliError(buildPlatformError(nameArg)); } - // Parse name (may include org/ prefix) - const { org: explicitOrg, name } = parseNameArg(nameArg); + const { org: explicitOrg, name } = parseOrgPrefixedArg( + nameArg, + "Project name", + USAGE_HINT + ); // Resolve organization const resolved = await resolveOrg({ org: explicitOrg, cwd }); if (!resolved) { - throw new ContextError( - "Organization", - "sentry project create / ", - [ - "Include org in name: sentry project create / ", - "Set a default: sentry org view ", - "Run from a directory with a Sentry DSN configured", - ] - ); + throw new ContextError("Organization", USAGE_HINT, [ + `Include org in name: ${USAGE_HINT}`, + "Set a default: sentry org view ", + "Run from a directory with a Sentry DSN configured", + ]); } const orgSlug = resolved.org; // Resolve team - const teamSlug = await resolveTeam( - orgSlug, - flags.team, - resolved.detectedFrom - ); + const teamSlug = await resolveTeam(orgSlug, { + team: flags.team, + detectedFrom: resolved.detectedFrom, + usageHint: USAGE_HINT, + }); // Create the project const project = await createProjectWithErrors( @@ -349,7 +237,7 @@ export const createCommand = buildCommand({ platformArg ); - // Fetch DSN (best-effort, non-blocking for output) + // Fetch DSN (best-effort) const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); // JSON output @@ -360,17 +248,20 @@ export const createCommand = buildCommand({ // Human-readable output const url = buildProjectUrl(orgSlug, project.slug); - - stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n\n`); - stdout.write(` Project ${project.name}\n`); - stdout.write(` Slug ${project.slug}\n`); - stdout.write(` Org ${orgSlug}\n`); - stdout.write(` Team ${teamSlug}\n`); - stdout.write(` Platform ${project.platform || platformArg}\n`); + const fields: [string, string][] = [ + ["Project", project.name], + ["Slug", project.slug], + ["Org", orgSlug], + ["Team", teamSlug], + ["Platform", project.platform || platformArg], + ]; if (dsn) { - stdout.write(` DSN ${dsn}\n`); + fields.push(["DSN", dsn]); } - stdout.write(` URL ${url}\n`); + fields.push(["URL", url]); + + stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n\n`); + writeKeyValue(stdout, fields); writeFooter( stdout, diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 8d24db38..e007d173 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,7 +6,7 @@ */ import type { SentryContext } from "../../context.js"; -import { getProject, getProjectKeys } from "../../lib/api-client.js"; +import { getProject, tryGetPrimaryDsn } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -26,7 +26,7 @@ import { resolveProjectBySlug, } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; -import type { ProjectKey, SentryProject } from "../../types/index.js"; +import type { SentryProject } from "../../types/index.js"; type ViewFlags = { readonly json: boolean; @@ -76,33 +76,6 @@ async function handleWebView( ); } -/** - * Try to fetch project keys, returning null on any error. - * Non-blocking - if keys fetch fails, we still display project info. - */ -async function tryGetProjectKeys( - orgSlug: string, - projectSlug: string -): Promise { - try { - return await getProjectKeys(orgSlug, projectSlug); - } catch { - return null; - } -} - -/** - * Get the primary DSN from project keys. - * Returns the first active key's public DSN, or null if none found. - */ -function getPrimaryDsn(keys: ProjectKey[] | null): string | null { - if (!keys || keys.length === 0) { - return null; - } - const activeKey = keys.find((k) => k.isActive); - return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null; -} - /** Result of fetching a single project with its DSN */ type ProjectWithDsn = { project: SentryProject; @@ -118,12 +91,12 @@ async function fetchProjectDetails( target: ResolvedTarget ): Promise { try { - // Fetch project and keys in parallel - const [project, keys] = await Promise.all([ + // Fetch project and DSN in parallel + const [project, dsn] = await Promise.all([ getProject(target.org, target.project), - tryGetProjectKeys(target.org, target.project), + tryGetPrimaryDsn(target.org, target.project), ]); - return { project, dsn: getPrimaryDsn(keys) }; + return { project, dsn }; } catch (error) { // Rethrow auth errors - user needs to know they're not authenticated if (error instanceof AuthError) { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index ece649aa..1b118bb5 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -858,6 +858,30 @@ export function getProjectKeys( ); } +/** + * Fetch the primary DSN for a project. + * Returns the public DSN of the first active key, or null on any error. + * + * Best-effort: failures are silently swallowed so callers can treat + * DSN display as optional (e.g., after project creation or in views). + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @returns Public DSN string, or null if unavailable + */ +export async function tryGetPrimaryDsn( + orgSlug: string, + projectSlug: string +): Promise { + try { + const keys = await getProjectKeys(orgSlug, projectSlug); + const activeKey = keys.find((k) => k.isActive); + return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null; + } catch { + return null; + } +} + /** * List issues for a project. * Uses region-aware routing for multi-region support. diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 1a6c073f..2addece5 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,6 +6,7 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { ContextError } from "./errors.js"; import { isAllDigits } from "./utils.js"; /** Default span depth when no value is provided */ @@ -151,6 +152,57 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { return { type: "project-search", projectSlug: trimmed }; } +/** Parsed result from an `org/name` positional argument */ +export type ParsedOrgPrefixed = { + /** Organization slug, if an explicit `org/` prefix was provided */ + org?: string; + /** The resource name (the part after the slash, or the full arg) */ + name: string; +}; + +/** + * Parse a positional argument that supports optional `org/name` syntax. + * + * Used by create commands where the user can either provide a bare name + * (and org is auto-detected) or prefix it with `org/` for explicit targeting. + * + * @param arg - Raw CLI argument (e.g., "my-app" or "acme-corp/my-app") + * @param resourceLabel - Human-readable resource label for error messages (e.g., "Project name") + * @param usageHint - Usage example shown in error (e.g., "sentry project create / ") + * @returns Parsed org (if explicit) and resource name + * @throws {ContextError} If slash is present but org or name is empty + * + * @example + * parseOrgPrefixedArg("my-app", "Project name", "sentry project create /") + * // { name: "my-app" } + * + * parseOrgPrefixedArg("acme/my-app", "Project name", "sentry project create /") + * // { org: "acme", name: "my-app" } + */ +export function parseOrgPrefixedArg( + arg: string, + resourceLabel: string, + usageHint: string +): ParsedOrgPrefixed { + if (!arg.includes("/")) { + return { name: arg }; + } + + const slashIndex = arg.indexOf("/"); + const org = arg.slice(0, slashIndex); + const name = arg.slice(slashIndex + 1); + + if (!(org && name)) { + throw new ContextError( + resourceLabel, + `${usageHint}\n\n` + + 'Both org and name are required when using "/" syntax.' + ); + } + + return { org, name }; +} + /** * Parsed issue argument types - flattened for ergonomics. * diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts new file mode 100644 index 00000000..c829aeda --- /dev/null +++ b/src/lib/resolve-team.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +import type { SentryTeam } from "../types/index.js"; +import { listOrganizations, listTeams } from "./api-client.js"; +import { ApiError, ContextError } from "./errors.js"; +import { getSentryBaseUrl } from "./sentry-urls.js"; + +/** Options for resolving a team within an organization */ +export type ResolveTeamOptions = { + /** Explicit team slug from --team flag */ + team?: string; + /** Source of the auto-detected org, shown in error messages */ + detectedFrom?: string; + /** Usage hint shown in error messages (e.g., "sentry project create / ") */ + usageHint: string; +}; + +/** + * Resolve which team to use for an operation. + * + * Priority: + * 1. Explicit --team flag — returned as-is, no validation + * 2. Auto-detect: if org has exactly one team, use it + * 3. Error with list of available teams + * + * When listTeams fails (e.g., bad org slug from auto-detection), the error + * includes the user's actual organizations so they can fix the command. + * + * @param orgSlug - Organization to list teams from + * @param options - Resolution options (team flag, usage hint, detection source) + * @returns Team slug to use + * @throws {ContextError} When team cannot be resolved + */ +export async function resolveTeam( + orgSlug: string, + options: ResolveTeamOptions +): Promise { + if (options.team) { + return options.team; + } + + let teams: SentryTeam[]; + try { + teams = await listTeams(orgSlug); + } catch (error) { + if (error instanceof ApiError) { + await buildOrgFailureError(orgSlug, error, options); + } + throw error; + } + + if (teams.length === 0) { + const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; + throw new ContextError("Team", `${options.usageHint} --team `, [ + `No teams found in org '${orgSlug}'`, + `Create a team at ${teamsUrl}`, + ]); + } + + if (teams.length === 1) { + return (teams[0] as SentryTeam).slug; + } + + // Multiple teams — user must specify + const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); + throw new ContextError( + "Team", + `${options.usageHint} --team ${(teams[0] as SentryTeam).slug}`, + [ + `Multiple teams found in ${orgSlug}. Specify one with --team:\n\n${teamList}`, + ] + ); +} + +/** + * Build an error for when listTeams fails (usually a bad org slug). + * Best-effort fetches the user's actual organizations to help them fix it. + */ +async function buildOrgFailureError( + orgSlug: string, + error: ApiError, + options: ResolveTeamOptions +): Promise { + let orgHint = `Specify org explicitly: ${options.usageHint}`; + try { + const orgs = await listOrganizations(); + if (orgs.length > 0) { + const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); + orgHint = `Your organizations:\n\n${orgList}`; + } + } catch { + // Best-effort — if this also fails, use the generic hint + } + + const alternatives = [ + `Could not list teams for org '${orgSlug}' (${error.status})`, + ]; + if (options.detectedFrom) { + alternatives.push( + `Org '${orgSlug}' was auto-detected from ${options.detectedFrom}` + ); + } + alternatives.push(orgHint); + + throw new ContextError( + "Organization", + `${options.usageHint} --team `, + alternatives + ); +} diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 6e55c3bf..0e67e526 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -63,14 +63,14 @@ function createMockContext() { describe("project create", () => { let listTeamsSpy: ReturnType; let createProjectSpy: ReturnType; - let getProjectKeysSpy: ReturnType; + let tryGetPrimaryDsnSpy: ReturnType; let listOrgsSpy: ReturnType; let resolveOrgSpy: ReturnType; beforeEach(() => { listTeamsSpy = spyOn(apiClient, "listTeams"); createProjectSpy = spyOn(apiClient, "createProject"); - getProjectKeysSpy = spyOn(apiClient, "getProjectKeys"); + tryGetPrimaryDsnSpy = spyOn(apiClient, "tryGetPrimaryDsn"); listOrgsSpy = spyOn(apiClient, "listOrganizations"); resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); @@ -78,14 +78,9 @@ describe("project create", () => { resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); listTeamsSpy.mockResolvedValue([sampleTeam]); createProjectSpy.mockResolvedValue(sampleProject); - getProjectKeysSpy.mockResolvedValue([ - { - id: "key1", - name: "Default", - dsn: { public: "https://abc@o123.ingest.us.sentry.io/999" }, - isActive: true, - }, - ]); + tryGetPrimaryDsnSpy.mockResolvedValue( + "https://abc@o123.ingest.us.sentry.io/999" + ); listOrgsSpy.mockResolvedValue([ { slug: "acme-corp", name: "Acme Corp" }, { slug: "other-org", name: "Other Org" }, @@ -95,7 +90,7 @@ describe("project create", () => { afterEach(() => { listTeamsSpy.mockRestore(); createProjectSpy.mockRestore(); - getProjectKeysSpy.mockRestore(); + tryGetPrimaryDsnSpy.mockRestore(); listOrgsSpy.mockRestore(); resolveOrgSpy.mockRestore(); }); @@ -277,7 +272,7 @@ describe("project create", () => { }); test("handles DSN fetch failure gracefully", async () => { - getProjectKeysSpy.mockRejectedValue(new Error("network error")); + tryGetPrimaryDsnSpy.mockResolvedValue(null); const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); From 8766e4c631a39845d76f600209b0a90a9998e8d1 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:33:51 +0100 Subject: [PATCH 07/10] fix(project): disambiguate 404 errors from create endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /teams/{org}/{team}/projects/ endpoint returns 404 for both a bad org and a bad team. Previously we always blamed the team, which was misleading when --team was explicit and the org was auto-detected wrong. Now on 404 we call listTeams(orgSlug) to check: - If it succeeds → team is wrong, show available teams - If it fails → org is wrong, show user's actual organizations Only adds an API call on the error path, never on the happy path. --- src/commands/project/create.ts | 62 +++++++++++++++++++++++++--- test/commands/project/create.test.ts | 25 ++++++++++- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 317d07b6..f929ae2a 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -6,7 +6,12 @@ */ import type { SentryContext } from "../../context.js"; -import { createProject, tryGetPrimaryDsn } from "../../lib/api-client.js"; +import { + createProject, + listOrganizations, + listTeams, + tryGetPrimaryDsn, +} from "../../lib/api-client.js"; import { parseOrgPrefixedArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError, CliError, ContextError } from "../../lib/errors.js"; @@ -81,6 +86,55 @@ function buildPlatformError(nameArg: string, platform?: string): string { ); } +/** + * Disambiguate a 404 from the create project endpoint. + * + * The `/teams/{org}/{team}/projects/` endpoint returns 404 for both + * a bad org and a bad team. This helper calls `listTeams` to determine + * which is wrong, then throws an actionable error. + * + * Only called on the error path — no cost to the happy path. + */ +async function handleCreateProject404( + orgSlug: string, + teamSlug: string, + name: string, + platform: string +): Promise { + // If listTeams succeeds, the org is valid and the team is wrong + const teams = await listTeams(orgSlug).catch(() => null); + + if (teams !== null) { + if (teams.length > 0) { + const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); + throw new CliError( + `Team '${teamSlug}' not found in ${orgSlug}.\n\n` + + `Available teams:\n\n${teamList}\n\n` + + "Try:\n" + + ` sentry project create ${orgSlug}/${name} ${platform} --team ` + ); + } + throw new CliError( + `No teams found in ${orgSlug}.\n\n` + + "Create a team first, then try again." + ); + } + + // listTeams also failed — org is likely wrong + let orgHint = `Specify org explicitly: ${USAGE_HINT}`; + try { + const orgs = await listOrganizations(); + if (orgs.length > 0) { + const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); + orgHint = `Your organizations:\n\n${orgList}`; + } + } catch { + // Best-effort — if this also fails, use the generic hint + } + + throw new CliError(`Organization '${orgSlug}' not found.\n\n${orgHint}`); +} + /** * Create a project with user-friendly error handling. * Wraps API errors with actionable messages instead of raw HTTP status codes. @@ -105,11 +159,7 @@ async function createProjectWithErrors( throw new CliError(buildPlatformError(`${orgSlug}/${name}`, platform)); } if (error.status === 404) { - throw new CliError( - `Team '${teamSlug}' not found in ${orgSlug}.\n\n` + - "Check the team slug and try again:\n" + - ` sentry project create ${orgSlug}/${name} ${platform} --team ` - ); + await handleCreateProject404(orgSlug, teamSlug, name, platform); } throw new CliError( `Failed to create project '${name}' in ${orgSlug}.\n\n` + diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 0e67e526..4a6c60ed 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -206,7 +206,7 @@ describe("project create", () => { expect(err.message).toContain("sentry project view"); }); - test("handles 404 from createProject as team-not-found", async () => { + test("handles 404 from createProject as team-not-found with available teams", async () => { createProjectSpy.mockRejectedValue( new ApiError("API request failed: 404 Not Found", 404) ); @@ -219,9 +219,32 @@ describe("project create", () => { .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Team 'engineering' not found"); + expect(err.message).toContain("Available teams:"); + expect(err.message).toContain("engineering"); expect(err.message).toContain("--team "); }); + test("handles 404 from createProject with bad org — shows user's orgs", async () => { + createProjectSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + // listTeams also fails → org is bad + listTeamsSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false, team: "backend" }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Organization 'acme-corp' not found"); + expect(err.message).toContain("Your organizations"); + expect(err.message).toContain("other-org"); + }); + test("handles 400 invalid platform with platform list", async () => { createProjectSpy.mockRejectedValue( new ApiError( From d4c16701260df8527f1875671819d75df84f10a5 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:38:05 +0100 Subject: [PATCH 08/10] fix(project): slugify name in 409 conflict hint The view command hint on 409 used the raw name ('My Cool App') instead of the expected slug ('my-cool-app'), pointing to a non-existent target. --- src/commands/project/create.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index f929ae2a..a7a4f917 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -59,6 +59,20 @@ const PLATFORMS = [ "elixir", ] as const; +/** + * Convert a project name to its expected Sentry slug. + * Sentry slugs are lowercase, with non-alphanumeric runs replaced by hyphens. + * + * @example slugify("My Cool App") // "my-cool-app" + * @example slugify("my-app") // "my-app" + */ +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + /** Check whether an API error is about an invalid platform value */ function isPlatformError(error: ApiError): boolean { const detail = error.detail ?? error.message; @@ -150,9 +164,10 @@ async function createProjectWithErrors( } catch (error) { if (error instanceof ApiError) { if (error.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}/${name}` + `View it: sentry project view ${orgSlug}/${slug}` ); } if (error.status === 400 && isPlatformError(error)) { From 8777a132388ac38c6ab296210532e2711475227d Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:44:09 +0100 Subject: [PATCH 09/10] fix(project): only diagnose 'org not found' on 404 from listTeams handleCreateProject404 was treating any listTeams failure as proof that the org doesn't exist. Now it checks the status code: only 404 triggers 'Organization not found'. Other failures (403, 5xx, network) get a generic message that doesn't misdiagnose the root cause. --- src/commands/project/create.ts | 41 ++++++++++++++++++++-------- test/commands/project/create.test.ts | 22 +++++++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index a7a4f917..f35d2969 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -115,9 +115,16 @@ async function handleCreateProject404( name: string, platform: string ): Promise { - // If listTeams succeeds, the org is valid and the team is wrong - const teams = await listTeams(orgSlug).catch(() => null); + let teams: Awaited> | null = null; + let listTeamsError: unknown = null; + try { + teams = await listTeams(orgSlug); + } catch (error) { + listTeamsError = error; + } + + // listTeams succeeded → org is valid, team is wrong if (teams !== null) { if (teams.length > 0) { const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); @@ -134,19 +141,29 @@ async function handleCreateProject404( ); } - // listTeams also failed — org is likely wrong - let orgHint = `Specify org explicitly: ${USAGE_HINT}`; - try { - const orgs = await listOrganizations(); - if (orgs.length > 0) { - const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); - orgHint = `Your organizations:\n\n${orgList}`; + // listTeams returned 404 → org doesn't exist + if (listTeamsError instanceof ApiError && listTeamsError.status === 404) { + let orgHint = `Specify org explicitly: ${USAGE_HINT}`; + try { + const orgs = await listOrganizations(); + if (orgs.length > 0) { + const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); + orgHint = `Your organizations:\n\n${orgList}`; + } + } catch { + // Best-effort — if this also fails, use the generic hint } - } catch { - // Best-effort — if this also fails, use the generic hint + + throw new CliError(`Organization '${orgSlug}' not found.\n\n${orgHint}`); } - throw new CliError(`Organization '${orgSlug}' not found.\n\n${orgHint}`); + // listTeams failed for other reasons (403, 5xx, network) — can't disambiguate + throw new CliError( + `Failed to create project '${name}' in ${orgSlug}.\n\n` + + "The organization or team may not exist, or you may lack access.\n\n" + + "Try:\n" + + ` sentry project create ${orgSlug}/${name} ${platform} --team ` + ); } /** diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 4a6c60ed..4c699605 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -245,6 +245,28 @@ describe("project create", () => { expect(err.message).toContain("other-org"); }); + test("handles 404 with non-404 listTeams failure — shows generic error", async () => { + createProjectSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + // listTeams returns 403 (not 404) — can't tell if org or team is wrong + listTeamsSpy.mockRejectedValue( + new ApiError("API request failed: 403 Forbidden", 403) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false, team: "backend" }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Failed to create project"); + expect(err.message).toContain("may not exist, or you may lack access"); + // Should NOT say "Organization not found" — we don't know that + expect(err.message).not.toContain("not found"); + }); + test("handles 400 invalid platform with platform list", async () => { createProjectSpy.mockRejectedValue( new ApiError( From 968ac1abdf9137181000e55abbeaf1bf649b5850 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:57:59 +0100 Subject: [PATCH 10/10] fix(resolve-team): only diagnose 'org not found' on 404 from listTeams Same class of bug as the previous fix in handleCreateProject404: resolveTeam was routing all ApiErrors from listTeams into the 'org not found' path. Now only 404 triggers that diagnosis. Other failures (403, 5xx) get a generic message that doesn't misdiagnose the cause. --- src/lib/resolve-team.ts | 12 ++++++++++-- test/commands/project/create.test.ts | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index c829aeda..8102b621 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -7,7 +7,7 @@ import type { SentryTeam } from "../types/index.js"; import { listOrganizations, listTeams } from "./api-client.js"; -import { ApiError, ContextError } from "./errors.js"; +import { ApiError, CliError, ContextError } from "./errors.js"; import { getSentryBaseUrl } from "./sentry-urls.js"; /** Options for resolving a team within an organization */ @@ -49,7 +49,15 @@ export async function resolveTeam( teams = await listTeams(orgSlug); } catch (error) { if (error instanceof ApiError) { - await buildOrgFailureError(orgSlug, error, options); + if (error.status === 404) { + await buildOrgFailureError(orgSlug, error, options); + } + // 403, 5xx, etc. — can't determine if org is wrong or something else + throw new CliError( + `Could not list teams for org '${orgSlug}' (${error.status}).\n\n` + + "The organization may not exist, or you may lack access.\n\n" + + `Try: ${options.usageHint} --team ` + ); } throw error; } diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 4c699605..d7b21340 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -423,4 +423,24 @@ describe("project create", () => { expect(err.message).toContain("123"); expect(err.message).toContain("Your organizations"); }); + + test("resolveTeam with non-404 listTeams failure shows generic error", async () => { + // listTeams returns 403 — org may exist, but user lacks access + listTeamsSpy.mockRejectedValue( + new ApiError("API request failed: 403 Forbidden", 403) + ); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + + 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 list teams"); + 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"); + }); });