diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 12aeb8c3..4fbe3607 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -136,6 +136,14 @@ sentry org view my-org -w Work with Sentry projects +#### `sentry project create ` + +Create a new project + +**Flags:** +- `-t, --team - Team to create the project under` +- `--json - Output as JSON` + #### `sentry project list ` List projects diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts new file mode 100644 index 00000000..f35d2969 --- /dev/null +++ b/src/commands/project/create.ts @@ -0,0 +1,353 @@ +/** + * 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, + 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"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { resolveOrg } from "../../lib/resolve-target.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 or invalid */ +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; + +/** + * 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; + 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/" + ); +} + +/** + * 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 { + 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"); + 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 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 + } + + 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 ` + ); +} + +/** + * 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) { + const slug = slugify(name); + throw new CliError( + `A project named '${name}' already exists in ${orgSlug}.\n\n` + + `View it: sentry project view ${orgSlug}/${slug}` + ); + } + if (error.status === 400 && isPlatformError(error)) { + throw new CliError(buildPlatformError(`${orgSlug}/${name}`, platform)); + } + if (error.status === 404) { + await handleCreateProject404(orgSlug, teamSlug, name, platform); + } + throw new CliError( + `Failed to create project '${name}' in ${orgSlug}.\n\n` + + `API error (${error.status}): ${error.detail ?? error.message}` + ); + } + throw error; + } +} + +/** + * Write key-value pairs with aligned columns. + * Used for human-readable output after resource creation. + */ +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`); + } +} + +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: ${USAGE_HINT}`, + "Specify team: sentry project create --team ", + ] + ); + } + + if (!platformArg) { + throw new CliError(buildPlatformError(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", 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, { + team: flags.team, + detectedFrom: resolved.detectedFrom, + usageHint: USAGE_HINT, + }); + + // Create the project + const project = await createProjectWithErrors( + orgSlug, + teamSlug, + name, + platformArg + ); + + // Fetch DSN (best-effort) + 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); + const fields: [string, string][] = [ + ["Project", project.name], + ["Slug", project.slug], + ["Org", orgSlug], + ["Team", teamSlug], + ["Platform", project.platform || platformArg], + ]; + if (dsn) { + fields.push(["DSN", dsn]); + } + fields.push(["URL", url]); + + stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n\n`); + writeKeyValue(stdout, fields); + + 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/src/commands/project/view.ts b/src/commands/project/view.ts index 8d24db38..e007d173 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,7 +6,7 @@ */ import type { SentryContext } from "../../context.js"; -import { getProject, getProjectKeys } from "../../lib/api-client.js"; +import { getProject, tryGetPrimaryDsn } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -26,7 +26,7 @@ import { resolveProjectBySlug, } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; -import type { ProjectKey, SentryProject } from "../../types/index.js"; +import type { SentryProject } from "../../types/index.js"; type ViewFlags = { readonly json: boolean; @@ -76,33 +76,6 @@ async function handleWebView( ); } -/** - * Try to fetch project keys, returning null on any error. - * Non-blocking - if keys fetch fails, we still display project info. - */ -async function tryGetProjectKeys( - orgSlug: string, - projectSlug: string -): Promise { - try { - return await getProjectKeys(orgSlug, projectSlug); - } catch { - return null; - } -} - -/** - * Get the primary DSN from project keys. - * Returns the first active key's public DSN, or null if none found. - */ -function getPrimaryDsn(keys: ProjectKey[] | null): string | null { - if (!keys || keys.length === 0) { - return null; - } - const activeKey = keys.find((k) => k.isActive); - return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null; -} - /** Result of fetching a single project with its DSN */ type ProjectWithDsn = { project: SentryProject; @@ -118,12 +91,12 @@ async function fetchProjectDetails( target: ResolvedTarget ): Promise { try { - // Fetch project and keys in parallel - const [project, keys] = await Promise.all([ + // Fetch project and DSN in parallel + const [project, dsn] = await Promise.all([ getProject(target.org, target.project), - tryGetProjectKeys(target.org, target.project), + tryGetPrimaryDsn(target.org, target.project), ]); - return { project, dsn: getPrimaryDsn(keys) }; + return { project, dsn }; } catch (error) { // Rethrow auth errors - user needs to know they're not authenticated if (error instanceof AuthError) { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 38f3528f..ad41475e 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -69,6 +69,9 @@ const ORG_ENDPOINT_REGEX = /^\/?organizations\/([^/]+)/; /** Regex to extract org slug from /projects/{org}/{project}/... endpoints */ const PROJECT_ENDPOINT_REGEX = /^\/?projects\/([^/]+)\/[^/]+/; +/** Regex to extract org slug from /teams/{org}/{team}/... endpoints */ +const TEAM_ENDPOINT_REGEX = /^\/?teams\/([^/]+)/; + /** * Get the Sentry API base URL. * Supports self-hosted instances via SENTRY_URL env var. @@ -480,6 +483,12 @@ function extractOrgSlugFromEndpoint(endpoint: string): string | null { return projectMatch[1]; } + // Try team path: /teams/{org}/{team}/... + const teamMatch = endpoint.match(TEAM_ENDPOINT_REGEX); + if (teamMatch?.[1]) { + return teamMatch[1]; + } + return null; } @@ -607,6 +616,9 @@ export function listRepositories(orgSlug: string): Promise { /** * List teams in an organization. * Uses region-aware routing for multi-region support. + * + * @param orgSlug - The organization slug + * @returns Array of teams in the organization */ export function listTeams(orgSlug: string): Promise { return orgScopedRequest(`/organizations/${orgSlug}/teams/`, { @@ -614,6 +626,38 @@ export function listTeams(orgSlug: string): Promise { }); } +/** 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. * @@ -813,6 +857,30 @@ export function getProjectKeys( ); } +/** + * Fetch the primary DSN for a project. + * Returns the public DSN of the first active key, or null on any error. + * + * Best-effort: failures are silently swallowed so callers can treat + * DSN display as optional (e.g., after project creation or in views). + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @returns Public DSN string, or null if unavailable + */ +export async function tryGetPrimaryDsn( + orgSlug: string, + projectSlug: string +): Promise { + try { + const keys = await getProjectKeys(orgSlug, projectSlug); + const activeKey = keys.find((k) => k.isActive); + return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null; + } catch { + return null; + } +} + /** * List issues for a project. * Uses region-aware routing for multi-region support. diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 1a6c073f..2addece5 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,6 +6,7 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { ContextError } from "./errors.js"; import { isAllDigits } from "./utils.js"; /** Default span depth when no value is provided */ @@ -151,6 +152,57 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { return { type: "project-search", projectSlug: trimmed }; } +/** Parsed result from an `org/name` positional argument */ +export type ParsedOrgPrefixed = { + /** Organization slug, if an explicit `org/` prefix was provided */ + org?: string; + /** The resource name (the part after the slash, or the full arg) */ + name: string; +}; + +/** + * Parse a positional argument that supports optional `org/name` syntax. + * + * Used by create commands where the user can either provide a bare name + * (and org is auto-detected) or prefix it with `org/` for explicit targeting. + * + * @param arg - Raw CLI argument (e.g., "my-app" or "acme-corp/my-app") + * @param resourceLabel - Human-readable resource label for error messages (e.g., "Project name") + * @param usageHint - Usage example shown in error (e.g., "sentry project create / ") + * @returns Parsed org (if explicit) and resource name + * @throws {ContextError} If slash is present but org or name is empty + * + * @example + * parseOrgPrefixedArg("my-app", "Project name", "sentry project create /") + * // { name: "my-app" } + * + * parseOrgPrefixedArg("acme/my-app", "Project name", "sentry project create /") + * // { org: "acme", name: "my-app" } + */ +export function parseOrgPrefixedArg( + arg: string, + resourceLabel: string, + usageHint: string +): ParsedOrgPrefixed { + if (!arg.includes("/")) { + return { name: arg }; + } + + const slashIndex = arg.indexOf("/"); + const org = arg.slice(0, slashIndex); + const name = arg.slice(slashIndex + 1); + + if (!(org && name)) { + throw new ContextError( + resourceLabel, + `${usageHint}\n\n` + + 'Both org and name are required when using "/" syntax.' + ); + } + + return { org, name }; +} + /** * Parsed issue argument types - flattened for ergonomics. * diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts new file mode 100644 index 00000000..8102b621 --- /dev/null +++ b/src/lib/resolve-team.ts @@ -0,0 +1,123 @@ +/** + * 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, CliError, 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) { + 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; + } + + 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/src/types/sentry.ts b/src/types/sentry.ts index ffea05e7..cb36645c 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -75,6 +75,25 @@ export const SentryUserSchema = z export type SentryUser = z.infer; +// Team + +/** A Sentry team within an organization */ +export const SentryTeamSchema = z + .object({ + // Core identifiers (required) + id: z.string(), + slug: z.string(), + name: z.string(), + // Optional metadata + memberCount: z.number().optional(), + isMember: z.boolean().optional(), + teamRole: z.string().nullable().optional(), + dateCreated: z.string().nullable().optional(), + }) + .passthrough(); + +export type SentryTeam = z.infer; + // Project export const SentryProjectSchema = z @@ -810,22 +829,3 @@ export const SentryRepositorySchema = z .passthrough(); export type SentryRepository = z.infer; - -// Team - -/** A team in a Sentry organization */ -export const SentryTeamSchema = z - .object({ - // Core identifiers (required) - id: z.string(), - slug: z.string(), - name: z.string(), - // Optional metadata - dateCreated: z.string().optional(), - isMember: z.boolean().optional(), - teamRole: z.string().nullable().optional(), - memberCount: z.number().optional(), - }) - .passthrough(); - -export type SentryTeam = z.infer; diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts new file mode 100644 index 00000000..d7b21340 --- /dev/null +++ b/test/commands/project/create.test.ts @@ -0,0 +1,446 @@ +/** + * 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 tryGetPrimaryDsnSpy: ReturnType; + let listOrgsSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + beforeEach(() => { + listTeamsSpy = spyOn(apiClient, "listTeams"); + createProjectSpy = spyOn(apiClient, "createProject"); + tryGetPrimaryDsnSpy = spyOn(apiClient, "tryGetPrimaryDsn"); + listOrgsSpy = spyOn(apiClient, "listOrganizations"); + resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); + + // Default mocks + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + listTeamsSpy.mockResolvedValue([sampleTeam]); + createProjectSpy.mockResolvedValue(sampleProject); + tryGetPrimaryDsnSpy.mockResolvedValue( + "https://abc@o123.ingest.us.sentry.io/999" + ); + listOrgsSpy.mockResolvedValue([ + { slug: "acme-corp", name: "Acme Corp" }, + { slug: "other-org", name: "Other Org" }, + ]); + }); + + afterEach(() => { + listTeamsSpy.mockRestore(); + createProjectSpy.mockRestore(); + tryGetPrimaryDsnSpy.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 with available teams", 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("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 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( + "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") + ); + + 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 () => { + tryGetPrimaryDsnSpy.mockResolvedValue(null); + + 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(CliError); + 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"); + }); + + 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"); + }); +});