From e3fb15396fe31829515b04b4fa768948a2943a3e Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 21:13:19 +0100 Subject: [PATCH 01/27] 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 | 100 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f881078e..09143007 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -38,7 +38,9 @@ import { type SentryOrganization, type SentryProject, type SentryRepository, + SentryRepositorySchema, type SentryTeam, + SentryTeamSchema, type SentryUser, SentryUserSchema, type TraceSpan, @@ -60,7 +62,53 @@ import { } from "./sentry-client.js"; import { isAllDigits } from "./utils.js"; +<<<<<<< HEAD // Helpers +======= +/** + * Control silo URL - handles OAuth, user accounts, and region routing. + * This is always sentry.io for SaaS, or the base URL for self-hosted. + */ +const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; + +/** Request timeout in milliseconds */ +const REQUEST_TIMEOUT_MS = 30_000; + +/** Maximum retry attempts for failed requests */ +const MAX_RETRIES = 2; + +/** Maximum backoff delay between retries in milliseconds */ +const MAX_BACKOFF_MS = 10_000; + +/** HTTP status codes that trigger automatic retry */ +const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; + +/** Regex to extract org slug from /organizations/{slug}/... endpoints */ +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. + */ +function getApiBaseUrl(): string { + const baseUrl = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; + return `${baseUrl}/api/0/`; +} + +/** + * Normalize endpoint path for use with ky's prefixUrl. + * Removes leading slash since ky handles URL joining. + */ +function normalizePath(endpoint: string): string { + return endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; +} +>>>>>>> 82d0ad4 (feat(api): add SentryTeam type, listTeams, and createProject endpoints) type ApiRequestOptions = { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -478,6 +526,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; } @@ -779,6 +833,52 @@ export function listRepositoriesPaginated( ); } +/** + * 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. * From 3248e641df61c96ec059f9ba38f198861aa76c3e Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 21:13:26 +0100 Subject: [PATCH 02/27] 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 3664e6265ee1f1407d69762678f5045b6dcfc315 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Feb 2026 20:14:07 +0000 Subject: [PATCH 03/27] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 27ced2f4..b1d506c3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -145,7 +145,15 @@ sentry org view my-org -w Work with Sentry projects -#### `sentry project list ` +#### `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 566fda3bb539752f009f71e9d8723af3755d481b Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 22:12:33 +0100 Subject: [PATCH 04/27] 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 16242342dc1be3f340604c6d993021f050ec0fb0 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 22:18:30 +0100 Subject: [PATCH 05/27] 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 e05c7e68d800fc205b9b5c7d3a52d9c31434c4fb Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Feb 2026 22:31:45 +0100 Subject: [PATCH 06/27] 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 | 70 ++++----- src/lib/arg-parsing.ts | 51 +++++++ src/lib/resolve-team.ts | 115 +++++++++++++++ test/commands/project/create.test.ts | 19 +-- 6 files changed, 250 insertions(+), 247 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 8aed1b5f..9c20cdde 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, @@ -27,7 +27,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; @@ -77,33 +77,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; @@ -119,12 +92,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 09143007..40b5ba5a 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -62,53 +62,7 @@ import { } from "./sentry-client.js"; import { isAllDigits } from "./utils.js"; -<<<<<<< HEAD // Helpers -======= -/** - * Control silo URL - handles OAuth, user accounts, and region routing. - * This is always sentry.io for SaaS, or the base URL for self-hosted. - */ -const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; - -/** Request timeout in milliseconds */ -const REQUEST_TIMEOUT_MS = 30_000; - -/** Maximum retry attempts for failed requests */ -const MAX_RETRIES = 2; - -/** Maximum backoff delay between retries in milliseconds */ -const MAX_BACKOFF_MS = 10_000; - -/** HTTP status codes that trigger automatic retry */ -const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; - -/** Regex to extract org slug from /organizations/{slug}/... endpoints */ -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. - */ -function getApiBaseUrl(): string { - const baseUrl = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; - return `${baseUrl}/api/0/`; -} - -/** - * Normalize endpoint path for use with ky's prefixUrl. - * Removes leading slash since ky handles URL joining. - */ -function normalizePath(endpoint: string): string { - return endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; -} ->>>>>>> 82d0ad4 (feat(api): add SentryTeam type, listTeams, and createProject endpoints) type ApiRequestOptions = { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -1095,6 +1049,30 @@ export async function getProjectKeys( // Issue functions +/** + * 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 with pagination control. * Returns a single page of results with cursor metadata for manual pagination. diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index adeab430..8ffc17ac 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -235,6 +235,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 ab2532dd49086b2ed10f5a7f67e88346ed70c2f1 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:33:51 +0100 Subject: [PATCH 07/27] 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 8a832062134b40b45a20e5ff72d0019d2209ef24 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:38:05 +0100 Subject: [PATCH 08/27] 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 12879e0351bffd8fab4b4d28b344e42d4eda53d3 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:44:09 +0100 Subject: [PATCH 09/27] 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 befe561747930f176d78c59859a4ae5417a7c140 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Feb 2026 19:57:59 +0100 Subject: [PATCH 10/27] 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"); + }); }); From cadc763f2dfc362b4c5c0a9bf0e2997b867ac5b9 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 18 Feb 2026 21:34:08 +0100 Subject: [PATCH 11/27] refactor(project): extract fetchOrgListHint and clean up create command - Extract shared fetchOrgListHint() in resolve-team.ts to deduplicate org-list fetching logic (used by both resolve-team and create 404 handler) - Use Writer type instead of inline { write } in writeKeyValue - Simplify Awaited> to SentryTeam[] - Add fragility comment to isPlatformError (relies on API message wording) - Fix test import to use barrel (types/index.js) --- src/commands/project/create.ts | 28 ++++++++++------------- src/lib/resolve-team.ts | 33 +++++++++++++++++++--------- test/commands/project/create.test.ts | 2 +- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index f35d2969..7955c058 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -8,7 +8,6 @@ import type { SentryContext } from "../../context.js"; import { createProject, - listOrganizations, listTeams, tryGetPrimaryDsn, } from "../../lib/api-client.js"; @@ -17,9 +16,9 @@ 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 { resolveTeam } from "../../lib/resolve-team.js"; +import { fetchOrgListHint, resolveTeam } from "../../lib/resolve-team.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; -import type { SentryProject } from "../../types/index.js"; +import type { SentryProject, SentryTeam, Writer } from "../../types/index.js"; /** Usage hint template — base command without positionals */ const USAGE_HINT = "sentry project create / "; @@ -73,7 +72,10 @@ function slugify(name: string): string { .replace(/^-|-$/g, ""); } -/** Check whether an API error is about an invalid platform value */ +/** + * Check whether an API error is about an invalid platform value. + * Relies on Sentry's error message wording — may need updating if the API changes. + */ function isPlatformError(error: ApiError): boolean { const detail = error.detail ?? error.message; return detail.includes("platform") && detail.includes("Invalid"); @@ -115,7 +117,7 @@ async function handleCreateProject404( name: string, platform: string ): Promise { - let teams: Awaited> | null = null; + let teams: SentryTeam[] | null = null; let listTeamsError: unknown = null; try { @@ -143,17 +145,9 @@ async function handleCreateProject404( // 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 - } - + const orgHint = await fetchOrgListHint( + `Specify org explicitly: ${USAGE_HINT}` + ); throw new CliError(`Organization '${orgSlug}' not found.\n\n${orgHint}`); } @@ -207,7 +201,7 @@ async function createProjectWithErrors( * Used for human-readable output after resource creation. */ function writeKeyValue( - stdout: { write: (s: string) => void }, + stdout: Writer, pairs: [label: string, value: string][] ): void { const maxLabel = Math.max(...pairs.map(([l]) => l.length)); diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index 8102b621..2c1f591d 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -10,6 +10,26 @@ import { listOrganizations, listTeams } from "./api-client.js"; import { ApiError, CliError, ContextError } from "./errors.js"; import { getSentryBaseUrl } from "./sentry-urls.js"; +/** + * Best-effort fetch the user's organizations and format as a hint string. + * Returns a fallback hint if the API call fails or no orgs are found. + * + * @param fallbackHint - Shown when the org list can't be fetched + * @returns Formatted org list like "Your organizations:\n\n acme-corp\n other-org" + */ +export async function fetchOrgListHint(fallbackHint: string): Promise { + try { + const orgs = await listOrganizations(); + if (orgs.length > 0) { + const orgList = orgs.map((o) => ` ${o.slug}`).join("\n"); + return `Your organizations:\n\n${orgList}`; + } + } catch { + // Best-effort — if this also fails, use the fallback + } + return fallbackHint; +} + /** Options for resolving a team within an organization */ export type ResolveTeamOptions = { /** Explicit team slug from --team flag */ @@ -94,16 +114,9 @@ async function buildOrgFailureError( 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 orgHint = await fetchOrgListHint( + `Specify org explicitly: ${options.usageHint}` + ); const alternatives = [ `Could not list teams for org '${orgSlug}' (${error.status})`, diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index d7b21340..8835e64e 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -21,7 +21,7 @@ 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"; +import type { SentryProject, SentryTeam } from "../../../src/types/index.js"; const sampleTeam: SentryTeam = { id: "1", From 93e5fcdf847637f49340b121d2ec9c0c7037aeb7 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 18 Feb 2026 21:34:20 +0100 Subject: [PATCH 12/27] feat(project): show note when Sentry adjusts the project slug When a project slug is already taken, Sentry silently appends a random suffix (e.g., 'test1' becomes 'test1-0g'). This was confusing because the user had no indication why the slug differed from the name. Now shows: Note: Slug 'test1-0g' was assigned because 'test1' is already taken. --- src/commands/project/create.ts | 12 +++++++++++- test/commands/project/create.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 7955c058..6da18458 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -336,7 +336,17 @@ export const createCommand = buildCommand({ } fields.push(["URL", url]); - stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n\n`); + stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n`); + + // Sentry may adjust the slug to avoid collisions (e.g., "my-app" → "my-app-0g") + const expectedSlug = slugify(name); + if (project.slug !== expectedSlug) { + stdout.write( + `Note: Slug '${project.slug}' was assigned because '${expectedSlug}' is already taken.\n` + ); + } + + stdout.write("\n"); writeKeyValue(stdout, fields); writeFooter( diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 8835e64e..1cf8de30 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -357,6 +357,32 @@ describe("project create", () => { expect(output).toContain("/settings/acme-corp/projects/my-app/"); }); + test("shows slug divergence note when Sentry adjusts the slug", async () => { + // Sentry may append a random suffix when the desired slug is taken + createProjectSpy.mockResolvedValue({ + ...sampleProject, + slug: "my-app-0g", + name: "my-app", + }); + + 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("Slug 'my-app-0g' was assigned"); + expect(output).toContain("'my-app' is already taken"); + }); + + test("does not show slug note when slug matches name", 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).not.toContain("was assigned"); + }); + test("shows helpful error when name is missing", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); From d337517a400a89ba6a1d37bbf62b36e7a38c0195 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 19 Feb 2026 17:30:56 +0100 Subject: [PATCH 13/27] feat(project): auto-select team based on membership in multi-team orgs Previously, project create errored whenever an org had 2+ teams, requiring --team in every non-trivial org. Now filters teams by isMember and auto-selects when the user belongs to exactly one team. When multiple member teams exist, only those are shown in the error (not all org teams). Falls back to the full list when isMember data is unavailable (self-hosted, old API). --- src/lib/resolve-team.ts | 22 +++++---- test/commands/project/create.test.ts | 69 ++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index 2c1f591d..7585cce5 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -90,18 +90,24 @@ export async function resolveTeam( ]); } - if (teams.length === 1) { - return (teams[0] as SentryTeam).slug; + // Prefer teams the user belongs to — avoids requiring --team in multi-team orgs + const memberTeams = teams.filter((t) => t.isMember === true); + const candidates = memberTeams.length > 0 ? memberTeams : teams; + + if (candidates.length === 1) { + return (candidates[0] as SentryTeam).slug; } - // Multiple teams — user must specify - const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); + // Multiple candidates — user must specify + const teamList = candidates.map((t) => ` ${t.slug}`).join("\n"); + const label = + memberTeams.length > 0 + ? `You belong to ${candidates.length} teams in ${orgSlug}` + : `Multiple teams found in ${orgSlug}`; throw new ContextError( "Team", - `${options.usageHint} --team ${(teams[0] as SentryTeam).slug}`, - [ - `Multiple teams found in ${orgSlug}. Specify one with --team:\n\n${teamList}`, - ] + `${options.usageHint} --team ${(candidates[0] as SentryTeam).slug}`, + [`${label}. Specify one with --team:\n\n${teamList}`] ); } diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 1cf8de30..20c7f193 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -28,6 +28,7 @@ const sampleTeam: SentryTeam = { slug: "engineering", name: "Engineering", memberCount: 5, + isMember: true, }; const sampleTeam2: SentryTeam = { @@ -35,6 +36,7 @@ const sampleTeam2: SentryTeam = { slug: "mobile", name: "Mobile Team", memberCount: 3, + isMember: true, }; const sampleProject: SentryProject = { @@ -150,20 +152,77 @@ describe("project create", () => { }); }); - test("errors when multiple teams exist without --team", async () => { + test("auto-selects team when user is member of exactly one among many", async () => { + const nonMemberTeam = { ...sampleTeam2, isMember: false }; + listTeamsSpy.mockResolvedValue([nonMemberTeam, sampleTeam]); + + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-app", "node"); + + // Should auto-select the one team the user is a member of + expect(createProjectSpy).toHaveBeenCalledWith("acme-corp", "engineering", { + name: "my-app", + platform: "node", + }); + }); + + test("errors when user is member of multiple teams 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); + const err = await func + .call(context, { json: false }, "my-app", "node") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ContextError); + expect(err.message).toContain("You belong to 2 teams"); + expect(err.message).toContain("engineering"); + expect(err.message).toContain("mobile"); - // Should not call createProject expect(createProjectSpy).not.toHaveBeenCalled(); }); + test("shows only member teams in error, not all org teams", async () => { + const nonMemberTeam = { + id: "3", + slug: "infra", + name: "Infrastructure", + isMember: false, + }; + listTeamsSpy.mockResolvedValue([sampleTeam, sampleTeam2, nonMemberTeam]); + + 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("engineering"); + expect(err.message).toContain("mobile"); + // Non-member team should NOT appear + expect(err.message).not.toContain("infra"); + }); + + test("falls back to all teams when isMember is not available", async () => { + const teamNoMembership1 = { id: "1", slug: "alpha", name: "Alpha" }; + const teamNoMembership2 = { id: "2", slug: "beta", name: "Beta" }; + listTeamsSpy.mockResolvedValue([teamNoMembership1, teamNoMembership2]); + + 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("Multiple teams found"); + expect(err.message).toContain("alpha"); + expect(err.message).toContain("beta"); + }); + test("errors when no teams exist", async () => { listTeamsSpy.mockResolvedValue([]); From ef14a6e3c3f393c6fff8937258524f61c0e5e49b Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 20 Feb 2026 18:22:24 +0100 Subject: [PATCH 14/27] fix(project): avoid contradictory 'team not found' error when team exists handleCreateProject404 now checks if the teamSlug is actually present in the returned teams list before claiming it's not found. When the team exists (e.g., auto-selected by resolveTeam), the error correctly reports a permission issue instead of the contradictory message. --- src/commands/project/create.ts | 13 ++++++++++++- test/commands/project/create.test.ts | 29 ++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 6da18458..68d378e2 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -126,8 +126,19 @@ async function handleCreateProject404( listTeamsError = error; } - // listTeams succeeded → org is valid, team is wrong + // listTeams succeeded → org is valid, diagnose the team if (teams !== null) { + const teamExists = teams.some((t) => t.slug === teamSlug); + if (teamExists) { + // Team is in the list but the create endpoint still returned 404 — + // likely a permissions issue (rare; Sentry usually returns 403) + throw new CliError( + `Failed to create project '${name}' in ${orgSlug}.\n\n` + + `Team '${teamSlug}' exists but the request was rejected. ` + + "You may lack permission to create projects in this team." + ); + } + if (teams.length > 0) { const teamList = teams.map((t) => ` ${t.slug}`).join("\n"); throw new CliError( diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index 20c7f193..1894c6f6 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -273,16 +273,41 @@ describe("project create", () => { const { context } = createMockContext(); const func = await createCommand.loader(); + // Use --team with a slug that doesn't match any team in the org const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { team: "nonexistent", 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 'nonexistent' not found"); expect(err.message).toContain("Available teams:"); expect(err.message).toContain("engineering"); expect(err.message).toContain("--team "); }); + test("handles 404 when auto-selected team exists — shows permission error", async () => { + // createProject returns 404 but the auto-selected team IS in the org. + // This used to produce a contradictory "Team 'engineering' not found" + // while listing "engineering" as an available team. + createProjectSpy.mockRejectedValue( + new ApiError("API request failed: 404 Not Found", 404) + ); + // Default listTeams returns [sampleTeam] (slug: "engineering") + // resolveTeam auto-selects "engineering", then handleCreateProject404 + // calls listTeams again and finds "engineering" in the list. + + 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("exists but the request was rejected"); + expect(err.message).toContain("permission"); + // Must NOT say "not found" — the team clearly exists + expect(err.message).not.toContain("not found"); + }); + test("handles 404 from createProject with bad org — shows user's orgs", async () => { createProjectSpy.mockRejectedValue( new ApiError("API request failed: 404 Not Found", 404) From 7ee96faadc42c749b6a669da9c6862024d7d786a Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 20 Feb 2026 19:06:32 +0100 Subject: [PATCH 15/27] fix(arg-parsing): trim whitespace in parseOrgPrefixedArg for consistency --- src/lib/arg-parsing.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 8ffc17ac..25362c8e 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -267,13 +267,15 @@ export function parseOrgPrefixedArg( resourceLabel: string, usageHint: string ): ParsedOrgPrefixed { - if (!arg.includes("/")) { - return { name: arg }; + const trimmed = arg.trim(); + + if (!trimmed.includes("/")) { + return { name: trimmed }; } - const slashIndex = arg.indexOf("/"); - const org = arg.slice(0, slashIndex); - const name = arg.slice(slashIndex + 1); + const slashIndex = trimmed.indexOf("/"); + const org = trimmed.slice(0, slashIndex); + const name = trimmed.slice(slashIndex + 1); if (!(org && name)) { throw new ContextError( From b226b2d22e1c62198a25d2747a3fe19ff42a6a42 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 23 Feb 2026 17:31:24 +0530 Subject: [PATCH 16/27] fix: shifted 2 functions to use the @sentry/api package --- src/lib/api-client.ts | 52 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 40b5ba5a..07f3c99e 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -9,6 +9,9 @@ */ import { + createANewProject, + listAnOrganization_sIssues, + listAnOrganization_sRepositories, listAnOrganization_sTeams, listAProject_sClientKeys, listAProject_sTeams, @@ -690,13 +693,13 @@ export type ProjectWithOrg = SentryProject & { export async function listRepositories( orgSlug: string ): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); - - const { data } = await apiRequestToRegion( - regionUrl, - `/organizations/${orgSlug}/repos/` - ); - return data; + const config = await getOrgSdkConfig(orgSlug); + const result = await listAnOrganization_sRepositories({ + ...config, + path: { organization_id_or_slug: orgSlug }, + }); + const data = unwrapResult(result, "Failed to list repositories"); + return data as unknown as SentryRepository[]; } /** @@ -715,6 +718,41 @@ export async function listTeams(orgSlug: string): Promise { return data as unknown as SentryTeam[]; } +/** 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. + * + * @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 async function createProject( + orgSlug: string, + teamSlug: string, + body: CreateProjectBody +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await createANewProject({ + ...config, + path: { + organization_id_or_slug: orgSlug, + team_id_or_slug: teamSlug, + }, + body, + }); + const data = unwrapResult(result, "Failed to create project"); + return data as unknown as SentryProject; +} + + /** * List teams in an organization with pagination control. * Returns a single page of results with cursor metadata. From 1c80d6785368eb4a89496dee0508da385e3f8b4a Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 26 Feb 2026 21:14:51 +0100 Subject: [PATCH 17/27] fix(project): align slugify with Sentry's canonical implementation Add NFKD normalization to handle accented characters and ligatures, and preserve underscores to match Sentry's MIXED_SLUG_PATTERN. --- src/commands/project/create.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 68d378e2..836d72d2 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -60,15 +60,20 @@ const PLATFORMS = [ /** * Convert a project name to its expected Sentry slug. - * Sentry slugs are lowercase, with non-alphanumeric runs replaced by hyphens. + * Aligned with Sentry's canonical implementation: + * https://github.com/getsentry/sentry/blob/master/static/app/utils/slugify.tsx * * @example slugify("My Cool App") // "my-cool-app" * @example slugify("my-app") // "my-app" + * @example slugify("Café Project") // "cafe-project" + * @example slugify("my_app") // "my_app" */ function slugify(name: string): string { return name + .normalize("NFKD") .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") + .replace(/[^a-z0-9_\s-]/g, "") + .replace(/[-\s]+/g, "-") .replace(/^-|-$/g, ""); } From 5519c45a18582403e73c9cd7ffff2dcde64aad8a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 21:28:41 +0000 Subject: [PATCH 18/27] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index b1d506c3..3be8e8f1 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -153,7 +153,7 @@ Create a new project - `-t, --team - Team to create the project under` - `--json - Output as JSON` -#### `sentry project list ` +#### `sentry project list ` List projects From dfdd3d0f0015fd1e243c95adfe349dbedad77caa Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 26 Feb 2026 22:36:54 +0100 Subject: [PATCH 19/27] refactor(project): replace parseOrgPrefixedArg with parseOrgProjectArg Reuse the more capable parseOrgProjectArg parser instead of the custom parseOrgPrefixedArg, which was the only consumer. This removes duplicated parsing logic and adds URL support for free. --- src/commands/project/create.ts | 30 +++++++++++++++---- src/lib/arg-parsing.ts | 53 ---------------------------------- 2 files changed, 24 insertions(+), 59 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 836d72d2..04d97d49 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -11,7 +11,7 @@ import { listTeams, tryGetPrimaryDsn, } from "../../lib/api-client.js"; -import { parseOrgPrefixedArg } from "../../lib/arg-parsing.js"; +import { parseOrgProjectArg } 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"; @@ -297,11 +297,29 @@ export const createCommand = buildCommand({ throw new CliError(buildPlatformError(nameArg)); } - const { org: explicitOrg, name } = parseOrgPrefixedArg( - nameArg, - "Project name", - USAGE_HINT - ); + const parsed = parseOrgProjectArg(nameArg); + + let explicitOrg: string | undefined; + let name: string; + + switch (parsed.type) { + case "explicit": + explicitOrg = parsed.org; + name = parsed.project; + break; + case "project-search": + name = parsed.projectSlug; + break; + case "org-all": + throw new ContextError("Project name", USAGE_HINT); + case "auto-detect": + // Shouldn't happen — nameArg is a required positional + throw new ContextError("Project name", USAGE_HINT); + default: { + const _exhaustive: never = parsed; + throw new ContextError("Project name", String(_exhaustive)); + } + } // Resolve organization const resolved = await resolveOrg({ org: explicitOrg, cwd }); diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 25362c8e..adeab430 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -235,59 +235,6 @@ 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 { - const trimmed = arg.trim(); - - if (!trimmed.includes("/")) { - return { name: trimmed }; - } - - const slashIndex = trimmed.indexOf("/"); - const org = trimmed.slice(0, slashIndex); - const name = trimmed.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. * From 9749e6787707131f6f90d44651ef2e45e2d33070 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 26 Feb 2026 22:41:16 +0100 Subject: [PATCH 20/27] fix(api-client): remove duplicate listTeams and createProject definitions Delete the old orgScopedRequest-based implementations that were left behind after migrating to @sentry/api. These duplicates referenced undefined identifiers (orgScopedRequest, SentryTeamSchema, SentryProjectSchema) and would crash at runtime. Also add the missing TEAM_ENDPOINT_REGEX constant and clean up unused imports. --- src/lib/api-client.ts | 51 +------------------------------------------ 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 07f3c99e..f30ea229 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -10,7 +10,6 @@ import { createANewProject, - listAnOrganization_sIssues, listAnOrganization_sRepositories, listAnOrganization_sTeams, listAProject_sClientKeys, @@ -41,9 +40,7 @@ import { type SentryOrganization, type SentryProject, type SentryRepository, - SentryRepositorySchema, type SentryTeam, - SentryTeamSchema, type SentryUser, SentryUserSchema, type TraceSpan, @@ -465,6 +462,7 @@ export async function listOrganizationsInRegion( /** Regex patterns for extracting org slugs from endpoint paths */ const ORG_ENDPOINT_REGEX = /\/organizations\/([^/]+)\//; const PROJECT_ENDPOINT_REGEX = /\/projects\/([^/]+)\//; +const TEAM_ENDPOINT_REGEX = /\/teams\/([^/]+)\//; /** * Extract organization slug from an endpoint path. @@ -752,7 +750,6 @@ export async function createProject( return data as unknown as SentryProject; } - /** * List teams in an organization with pagination control. * Returns a single page of results with cursor metadata. @@ -825,52 +822,6 @@ export function listRepositoriesPaginated( ); } -/** - * 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. * From 0a5949cfc63d9eac23346945b6f588e2b1a3d8bc Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 09:32:54 +0100 Subject: [PATCH 21/27] refactor: extract writeKeyValue to shared formatter utility Move writeKeyValue from project/create.ts to lib/formatters/output.ts so it can be reused across commands. Adopt it in team/view.ts to replace the missing formatTeamDetails with inline human-readable output. --- src/commands/project/create.ts | 22 +-- src/commands/team/view.ts | 236 +++++++++++++++++++++++++++++++++ src/lib/formatters/output.ts | 14 ++ 3 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 src/commands/team/view.ts diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 04d97d49..f9bcc7a3 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -14,11 +14,15 @@ import { import { parseOrgProjectArg } 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 { + writeFooter, + writeJson, + writeKeyValue, +} from "../../lib/formatters/index.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { fetchOrgListHint, resolveTeam } from "../../lib/resolve-team.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; -import type { SentryProject, SentryTeam, Writer } from "../../types/index.js"; +import type { SentryProject, SentryTeam } from "../../types/index.js"; /** Usage hint template — base command without positionals */ const USAGE_HINT = "sentry project create / "; @@ -212,20 +216,6 @@ async function createProjectWithErrors( } } -/** - * Write key-value pairs with aligned columns. - * Used for human-readable output after resource creation. - */ -function writeKeyValue( - stdout: Writer, - 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`); - } -} - export const createCommand = buildCommand({ docs: { brief: "Create a new project", diff --git a/src/commands/team/view.ts b/src/commands/team/view.ts new file mode 100644 index 00000000..e06b2604 --- /dev/null +++ b/src/commands/team/view.ts @@ -0,0 +1,236 @@ +/** + * sentry team view + * + * View detailed information about a Sentry team. + */ + +import type { SentryContext } from "../../context.js"; +import { getTeam } from "../../lib/api-client.js"; +import { + type ParsedOrgProject, + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; +import { getDefaultOrganization } from "../../lib/db/defaults.js"; +import { ApiError, ContextError } from "../../lib/errors.js"; +import { + writeFooter, + writeJson, + writeKeyValue, +} from "../../lib/formatters/index.js"; +import { resolveAllTargets } from "../../lib/resolve-target.js"; +import { buildTeamUrl } from "../../lib/sentry-urls.js"; +import type { SentryTeam } from "../../types/index.js"; + +type ViewFlags = { + readonly json: boolean; + readonly web: boolean; +}; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry team view /"; + +/** + * Resolve org and team slugs from the positional argument. + * + * Supports: + * - `/` — explicit org and team + * - `` — resolve org from config defaults or DSN auto-detection + * + * @param parsed - Parsed positional argument + * @param cwd - Current working directory (for DSN auto-detection) + * @returns Resolved org slug and team slug + */ +/** Whether the org was explicitly provided or auto-resolved */ +type ResolvedOrgAndTeam = { + orgSlug: string; + teamSlug: string; + /** True when org was inferred from DSN/config, not user input */ + orgAutoResolved: boolean; +}; + +/** + * Resolve the organization slug when only a team slug is provided. + * + * Uses config defaults first (fast), then falls back to DSN auto-detection + * via `resolveAllTargets` (same approach as `team list`). + */ +async function resolveOrgForTeam(cwd: string): Promise { + // 1. Config defaults (fast, no file scanning) + const defaultOrg = await getDefaultOrganization(); + if (defaultOrg) { + return defaultOrg; + } + + // 2. DSN auto-detection (same as team list) + try { + const { targets } = await resolveAllTargets({ cwd }); + if (targets.length > 0 && targets[0]) { + return targets[0].org; + } + } catch { + // Fall through — DSN detection is best-effort + } + + return null; +} + +async function resolveOrgAndTeam( + parsed: ParsedOrgProject, + cwd: string +): Promise { + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + return { + orgSlug: parsed.org, + teamSlug: parsed.project, + orgAutoResolved: false, + }; + + case ProjectSpecificationType.ProjectSearch: { + const orgSlug = await resolveOrgForTeam(cwd); + if (!orgSlug) { + throw new ContextError("Organization", USAGE_HINT, [ + "Specify the org explicitly: sentry team view /", + ]); + } + return { + orgSlug, + teamSlug: parsed.projectSlug, + orgAutoResolved: true, + }; + } + + case ProjectSpecificationType.OrgAll: + throw new ContextError("Team slug", USAGE_HINT, [ + "Specify the team: sentry team view /", + ]); + + case ProjectSpecificationType.AutoDetect: + throw new ContextError("Team slug", USAGE_HINT); + + default: + throw new ContextError("Team slug", USAGE_HINT); + } +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View details of a team", + fullDescription: + "View detailed information about a Sentry team, including associated projects.\n\n" + + "Target specification:\n" + + " sentry team view / # explicit org and team\n" + + " sentry team view # auto-detect org from config or DSN", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "team", + brief: "Target: / or (if org is auto-detected)", + parse: String, + optional: true, + }, + ], + }, + flags: { + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async func( + this: SentryContext, + flags: ViewFlags, + teamArg?: string + ): Promise { + const { stdout, cwd } = this; + + const parsed = parseOrgProjectArg(teamArg); + const { orgSlug, teamSlug, orgAutoResolved } = await resolveOrgAndTeam( + parsed, + cwd + ); + + if (flags.web) { + await openInBrowser(stdout, buildTeamUrl(orgSlug, teamSlug), "team"); + return; + } + + let team: SentryTeam; + try { + team = await getTeam(orgSlug, teamSlug); + } catch (error: unknown) { + // When org was auto-resolved, any API failure likely means wrong org + if (orgAutoResolved) { + throw new ContextError(`Team "${teamSlug}"`, USAGE_HINT, [ + `Auto-detected organization "${orgSlug}" may be incorrect`, + "Specify the org explicitly: sentry team view /", + ]); + } + if (error instanceof ApiError && error.status === 404) { + throw new ContextError( + `Team "${teamSlug}" in organization "${orgSlug}"`, + USAGE_HINT, + [ + "Check that the team slug is correct", + "Check that you have access to this team", + `Try: sentry team list ${orgSlug}`, + ] + ); + } + throw error; + } + + // JSON output + if (flags.json) { + writeJson(stdout, team); + return; + } + + // Human-readable output + stdout.write(`\n${team.name}\n\n`); + + const fields: [string, string][] = [ + ["Slug:", team.slug], + ["ID:", team.id], + ]; + if (team.memberCount !== undefined) { + fields.push(["Members:", String(team.memberCount)]); + } + if (team.teamRole) { + fields.push(["Role:", team.teamRole]); + } + writeKeyValue(stdout, fields); + + // Projects section (only when present) + const projects = (team as Record).projects as + | { slug: string; platform?: string }[] + | undefined; + if (projects && projects.length > 0) { + stdout.write("\nProjects:\n"); + const projectPairs: [string, string][] = projects.map((p) => [ + p.slug, + p.platform || "", + ]); + writeKeyValue(stdout, projectPairs); + } + + writeFooter( + stdout, + `Tip: Use 'sentry team list ${orgSlug}' to see all teams` + ); + }, +}); diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index 3b293362..fa764d28 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -55,3 +55,17 @@ export function writeFooter(stdout: Writer, text: string): void { stdout.write("\n"); stdout.write(`${muted(text)}\n`); } + +/** + * Write key-value pairs with aligned columns. + * Used for human-readable output after resource creation. + */ +export function writeKeyValue( + stdout: Writer, + 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`); + } +} From 917c9d327a863dcdcda87e077ca1ab502a911b29 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 09:42:08 +0100 Subject: [PATCH 22/27] fix(project): remove misleading "set a default org" hint The error hint referenced `sentry org view ` for setting a default org, but no such mechanism exists yet. Remove it to avoid confusing users. Tracked in #304. --- src/commands/project/create.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index f9bcc7a3..1c44da66 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -316,7 +316,6 @@ export const createCommand = buildCommand({ if (!resolved) { 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", ]); } From 20ce565b0b733a95c377c45b4f1c8f394cfeeb1b Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 09:48:12 +0100 Subject: [PATCH 23/27] fix(project): remove misleading DSN hint from project create DSN detection is confusing in the project create context since you typically don't have a DSN when creating a new project. Remove DSN mentions from the error hint and description while keeping DSN detection functional as a silent fallback. --- src/commands/project/create.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 1c44da66..b0ec4d4a 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -222,7 +222,7 @@ export const createCommand = buildCommand({ 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" + + "If omitted, the org is auto-detected from config defaults.\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" + @@ -316,7 +316,6 @@ export const createCommand = buildCommand({ if (!resolved) { throw new ContextError("Organization", USAGE_HINT, [ `Include org in name: ${USAGE_HINT}`, - "Run from a directory with a Sentry DSN configured", ]); } const orgSlug = resolved.org; From 2da8900771ed7fbc4a0c3f020f7a8ad2c9f43b98 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 09:56:02 +0100 Subject: [PATCH 24/27] feat(api-client): add getTeam function for retrieving a single team The team view command imports getTeam but the function was missing from api-client, causing a runtime TypeError. Add getTeam using the same pattern as getProject, backed by the SDK's retrieveATeam. --- src/lib/api-client.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f30ea229..aebedba9 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -21,6 +21,7 @@ import { retrieveAnIssueEvent, retrieveAnOrganization, retrieveAProject, + retrieveATeam, retrieveSeerIssueFixState, listYourOrganizations as sdkListOrganizations, resolveAnEventId as sdkResolveAnEventId, @@ -716,6 +717,28 @@ export async function listTeams(orgSlug: string): Promise { return data as unknown as SentryTeam[]; } +/** + * Get a single team by slug. + * Uses region-aware routing for multi-region support. + */ +export async function getTeam( + orgSlug: string, + teamSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + + const result = await retrieveATeam({ + ...config, + path: { + organization_id_or_slug: orgSlug, + team_id_or_slug: teamSlug, + }, + }); + + const data = unwrapResult(result, "Failed to get team"); + return data as unknown as SentryTeam; +} + /** Request body for creating a new project */ type CreateProjectBody = { name: string; From eed8fd605c96b95ff3c09452487445014c074a6f Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 10:20:52 +0100 Subject: [PATCH 25/27] fix: remove out-of-scope team view code to fix CI typecheck Remove team view command and getTeam API function that were accidentally included in this branch. The code imported a non-existent buildTeamUrl, breaking typecheck. This work is preserved on feat/team-view-command. Co-Authored-By: Claude Opus 4.6 --- src/commands/team/view.ts | 236 -------------------------------------- src/lib/api-client.ts | 23 ---- 2 files changed, 259 deletions(-) delete mode 100644 src/commands/team/view.ts diff --git a/src/commands/team/view.ts b/src/commands/team/view.ts deleted file mode 100644 index e06b2604..00000000 --- a/src/commands/team/view.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * sentry team view - * - * View detailed information about a Sentry team. - */ - -import type { SentryContext } from "../../context.js"; -import { getTeam } from "../../lib/api-client.js"; -import { - type ParsedOrgProject, - ProjectSpecificationType, - parseOrgProjectArg, -} from "../../lib/arg-parsing.js"; -import { openInBrowser } from "../../lib/browser.js"; -import { buildCommand } from "../../lib/command.js"; -import { getDefaultOrganization } from "../../lib/db/defaults.js"; -import { ApiError, ContextError } from "../../lib/errors.js"; -import { - writeFooter, - writeJson, - writeKeyValue, -} from "../../lib/formatters/index.js"; -import { resolveAllTargets } from "../../lib/resolve-target.js"; -import { buildTeamUrl } from "../../lib/sentry-urls.js"; -import type { SentryTeam } from "../../types/index.js"; - -type ViewFlags = { - readonly json: boolean; - readonly web: boolean; -}; - -/** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry team view /"; - -/** - * Resolve org and team slugs from the positional argument. - * - * Supports: - * - `/` — explicit org and team - * - `` — resolve org from config defaults or DSN auto-detection - * - * @param parsed - Parsed positional argument - * @param cwd - Current working directory (for DSN auto-detection) - * @returns Resolved org slug and team slug - */ -/** Whether the org was explicitly provided or auto-resolved */ -type ResolvedOrgAndTeam = { - orgSlug: string; - teamSlug: string; - /** True when org was inferred from DSN/config, not user input */ - orgAutoResolved: boolean; -}; - -/** - * Resolve the organization slug when only a team slug is provided. - * - * Uses config defaults first (fast), then falls back to DSN auto-detection - * via `resolveAllTargets` (same approach as `team list`). - */ -async function resolveOrgForTeam(cwd: string): Promise { - // 1. Config defaults (fast, no file scanning) - const defaultOrg = await getDefaultOrganization(); - if (defaultOrg) { - return defaultOrg; - } - - // 2. DSN auto-detection (same as team list) - try { - const { targets } = await resolveAllTargets({ cwd }); - if (targets.length > 0 && targets[0]) { - return targets[0].org; - } - } catch { - // Fall through — DSN detection is best-effort - } - - return null; -} - -async function resolveOrgAndTeam( - parsed: ParsedOrgProject, - cwd: string -): Promise { - switch (parsed.type) { - case ProjectSpecificationType.Explicit: - return { - orgSlug: parsed.org, - teamSlug: parsed.project, - orgAutoResolved: false, - }; - - case ProjectSpecificationType.ProjectSearch: { - const orgSlug = await resolveOrgForTeam(cwd); - if (!orgSlug) { - throw new ContextError("Organization", USAGE_HINT, [ - "Specify the org explicitly: sentry team view /", - ]); - } - return { - orgSlug, - teamSlug: parsed.projectSlug, - orgAutoResolved: true, - }; - } - - case ProjectSpecificationType.OrgAll: - throw new ContextError("Team slug", USAGE_HINT, [ - "Specify the team: sentry team view /", - ]); - - case ProjectSpecificationType.AutoDetect: - throw new ContextError("Team slug", USAGE_HINT); - - default: - throw new ContextError("Team slug", USAGE_HINT); - } -} - -export const viewCommand = buildCommand({ - docs: { - brief: "View details of a team", - fullDescription: - "View detailed information about a Sentry team, including associated projects.\n\n" + - "Target specification:\n" + - " sentry team view / # explicit org and team\n" + - " sentry team view # auto-detect org from config or DSN", - }, - parameters: { - positional: { - kind: "tuple", - parameters: [ - { - placeholder: "team", - brief: "Target: / or (if org is auto-detected)", - parse: String, - optional: true, - }, - ], - }, - flags: { - json: { - kind: "boolean", - brief: "Output as JSON", - default: false, - }, - web: { - kind: "boolean", - brief: "Open in browser", - default: false, - }, - }, - aliases: { w: "web" }, - }, - async func( - this: SentryContext, - flags: ViewFlags, - teamArg?: string - ): Promise { - const { stdout, cwd } = this; - - const parsed = parseOrgProjectArg(teamArg); - const { orgSlug, teamSlug, orgAutoResolved } = await resolveOrgAndTeam( - parsed, - cwd - ); - - if (flags.web) { - await openInBrowser(stdout, buildTeamUrl(orgSlug, teamSlug), "team"); - return; - } - - let team: SentryTeam; - try { - team = await getTeam(orgSlug, teamSlug); - } catch (error: unknown) { - // When org was auto-resolved, any API failure likely means wrong org - if (orgAutoResolved) { - throw new ContextError(`Team "${teamSlug}"`, USAGE_HINT, [ - `Auto-detected organization "${orgSlug}" may be incorrect`, - "Specify the org explicitly: sentry team view /", - ]); - } - if (error instanceof ApiError && error.status === 404) { - throw new ContextError( - `Team "${teamSlug}" in organization "${orgSlug}"`, - USAGE_HINT, - [ - "Check that the team slug is correct", - "Check that you have access to this team", - `Try: sentry team list ${orgSlug}`, - ] - ); - } - throw error; - } - - // JSON output - if (flags.json) { - writeJson(stdout, team); - return; - } - - // Human-readable output - stdout.write(`\n${team.name}\n\n`); - - const fields: [string, string][] = [ - ["Slug:", team.slug], - ["ID:", team.id], - ]; - if (team.memberCount !== undefined) { - fields.push(["Members:", String(team.memberCount)]); - } - if (team.teamRole) { - fields.push(["Role:", team.teamRole]); - } - writeKeyValue(stdout, fields); - - // Projects section (only when present) - const projects = (team as Record).projects as - | { slug: string; platform?: string }[] - | undefined; - if (projects && projects.length > 0) { - stdout.write("\nProjects:\n"); - const projectPairs: [string, string][] = projects.map((p) => [ - p.slug, - p.platform || "", - ]); - writeKeyValue(stdout, projectPairs); - } - - writeFooter( - stdout, - `Tip: Use 'sentry team list ${orgSlug}' to see all teams` - ); - }, -}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index aebedba9..f30ea229 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -21,7 +21,6 @@ import { retrieveAnIssueEvent, retrieveAnOrganization, retrieveAProject, - retrieveATeam, retrieveSeerIssueFixState, listYourOrganizations as sdkListOrganizations, resolveAnEventId as sdkResolveAnEventId, @@ -717,28 +716,6 @@ export async function listTeams(orgSlug: string): Promise { return data as unknown as SentryTeam[]; } -/** - * Get a single team by slug. - * Uses region-aware routing for multi-region support. - */ -export async function getTeam( - orgSlug: string, - teamSlug: string -): Promise { - const config = await getOrgSdkConfig(orgSlug); - - const result = await retrieveATeam({ - ...config, - path: { - organization_id_or_slug: orgSlug, - team_id_or_slug: teamSlug, - }, - }); - - const data = unwrapResult(result, "Failed to get team"); - return data as unknown as SentryTeam; -} - /** Request body for creating a new project */ type CreateProjectBody = { name: string; From 70bb1b964acf012bef18e1ebfcb2f7cdabe797bf Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 13:07:30 +0100 Subject: [PATCH 26/27] fix(project): re-throw AuthError and guard empty writeKeyValue Re-throw AuthError in handleCreateProject404 so auth errors propagate instead of being swallowed. Guard writeKeyValue against empty pairs to avoid Math.max(...[]) returning -Infinity. Co-Authored-By: Claude Opus 4.6 --- src/commands/project/create.ts | 10 +++++++++- src/lib/formatters/output.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index b0ec4d4a..35e427db 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -13,7 +13,12 @@ import { } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { ApiError, CliError, ContextError } from "../../lib/errors.js"; +import { + ApiError, + AuthError, + CliError, + ContextError, +} from "../../lib/errors.js"; import { writeFooter, writeJson, @@ -132,6 +137,9 @@ async function handleCreateProject404( try { teams = await listTeams(orgSlug); } catch (error) { + if (error instanceof AuthError) { + throw error; + } listTeamsError = error; } diff --git a/src/lib/formatters/output.ts b/src/lib/formatters/output.ts index fa764d28..db6d544c 100644 --- a/src/lib/formatters/output.ts +++ b/src/lib/formatters/output.ts @@ -64,6 +64,9 @@ export function writeKeyValue( stdout: Writer, pairs: [label: string, value: string][] ): void { + if (pairs.length === 0) { + return; + } const maxLabel = Math.max(...pairs.map(([l]) => l.length)); for (const [label, value] of pairs) { stdout.write(` ${label.padEnd(maxLabel + 2)}${value}\n`); From 55b9cdec3b0d7a1910b127913b10abafd5c68f83 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 27 Feb 2026 13:31:33 +0100 Subject: [PATCH 27/27] fix(project): add return before Promise await calls Replace `await` with `return` on `handleCreateProject404` and `buildOrgFailureError` calls so the `never` control flow is explicit and subsequent code is clearly unreachable. Co-Authored-By: Claude Opus 4.6 --- src/commands/project/create.ts | 2 +- src/lib/resolve-team.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 35e427db..6e348ae4 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -213,7 +213,7 @@ async function createProjectWithErrors( throw new CliError(buildPlatformError(`${orgSlug}/${name}`, platform)); } if (error.status === 404) { - await handleCreateProject404(orgSlug, teamSlug, name, platform); + return handleCreateProject404(orgSlug, teamSlug, name, platform); } throw new CliError( `Failed to create project '${name}' in ${orgSlug}.\n\n` + diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index 7585cce5..f4805279 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -70,7 +70,7 @@ export async function resolveTeam( } catch (error) { if (error instanceof ApiError) { if (error.status === 404) { - await buildOrgFailureError(orgSlug, error, options); + return buildOrgFailureError(orgSlug, error, options); } // 403, 5xx, etc. — can't determine if org is wrong or something else throw new CliError(