diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4814dc9a..f3be4568 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -174,6 +174,16 @@ Create a new project - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` +#### `sentry project delete ` + +Delete a project + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-n, --dry-run - Validate inputs and show what would be deleted without deleting it` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + #### `sentry project list ` List projects diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts new file mode 100644 index 00000000..e164045c --- /dev/null +++ b/src/commands/project/delete.ts @@ -0,0 +1,250 @@ +/** + * sentry project delete + * + * Permanently delete a Sentry project. + * + * ## Flow + * + * 1. Parse target arg → extract org/project (e.g., "acme/my-app" or "my-app") + * 2. Verify the project exists via `getProject` (also displays its name) + * 3. Prompt for confirmation (unless --yes is passed) + * 4. Call `deleteProject` API + * 5. Display result + * + * Safety measures: + * - No auto-detect mode: requires explicit target to prevent accidental deletion + * - Confirmation prompt with strict `confirmed !== true` check (Symbol(clack:cancel) gotcha) + * - Refuses to run in non-interactive mode without --yes flag + */ + +import { isatty } from "node:tty"; +import type { SentryContext } from "../../context.js"; +import { + deleteProject, + getOrganization, + getProject, +} 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 { + formatProjectDeleted, + type ProjectDeleteResult, +} from "../../lib/formatters/human.js"; +import { logger } from "../../lib/logger.js"; +import { resolveOrgProjectTarget } from "../../lib/resolve-target.js"; +import { buildProjectUrl } from "../../lib/sentry-urls.js"; + +const log = logger.withTag("project.delete"); + +/** Command name used in error messages and resolution hints */ +const COMMAND_NAME = "project delete"; + +/** + * Prompt for confirmation before deleting a project. + * + * Throws in non-interactive mode without --yes. Returns true if confirmed, + * false if the user cancels. + * + * @param orgSlug - Organization slug for display + * @param project - Project with slug and name for display + * @returns true if confirmed, false if cancelled + */ +async function confirmDeletion( + orgSlug: string, + project: { slug: string; name: string } +): Promise { + if (!isatty(0)) { + throw new CliError( + `Refusing to delete '${orgSlug}/${project.slug}' in non-interactive mode. Use --yes to confirm.` + ); + } + + const confirmed = await log.prompt( + `Delete project '${project.name}' (${orgSlug}/${project.slug})? This cannot be undone.`, + { type: "confirm", initial: false } + ); + + // consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. + // Strictly check for `true` to avoid deleting on cancel. + return confirmed === true; +} + +/** + * Build an actionable 403 error by checking the user's org role. + * + * - member/billing → tell them they need a higher role + * - manager/owner/admin → suggest re-authenticating (likely token scope) + * - unknown/fetch failure → generic message covering both cases + */ +async function buildPermissionError( + orgSlug: string, + projectSlug: string +): Promise { + const label = `'${orgSlug}/${projectSlug}'`; + const rolesWithAccess = "Manager, Owner, or Team Admin"; + + let orgRole: string | undefined; + try { + const org = await getOrganization(orgSlug); + orgRole = (org as Record).orgRole as string | undefined; + } catch { + // Best-effort — fall through to generic message + } + + if (orgRole && ["member", "billing"].includes(orgRole)) { + return new ApiError( + `Permission denied: You don't have permission to delete ${label}.\n\n` + + `Your organization role is '${orgRole}'. ` + + `Project deletion requires ${rolesWithAccess} role.\n` + + " Ask an org admin to change your role or delete the project for you.", + 403 + ); + } + + if (orgRole && ["manager", "owner", "admin"].includes(orgRole)) { + return new ApiError( + `Permission denied: You don't have permission to delete ${label}.\n\n` + + `Your org role ('${orgRole}') should allow this. ` + + "Your auth token may be missing the 'project:admin' scope.\n" + + " Re-authenticate: sentry auth login", + 403 + ); + } + + return new ApiError( + `Permission denied: You don't have permission to delete ${label}.\n\n` + + `This requires ${rolesWithAccess} role, or a token with the 'project:admin' scope.\n` + + ` Check your role: sentry org view ${orgSlug}\n` + + " Re-authenticate: sentry auth login", + 403 + ); +} + +/** Build a result object for both dry-run and actual deletion */ +function buildResult( + orgSlug: string, + project: { slug: string; name: string }, + dryRun?: boolean +): ProjectDeleteResult { + return { + orgSlug, + projectSlug: project.slug, + projectName: project.name, + url: buildProjectUrl(orgSlug, project.slug), + dryRun, + }; +} + +type DeleteFlags = { + readonly yes: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +export const deleteCommand = buildCommand({ + docs: { + brief: "Delete a project", + fullDescription: + "Permanently delete a Sentry project. This action cannot be undone.\n\n" + + "Requires explicit target — auto-detection is disabled for safety.\n\n" + + "Examples:\n" + + " sentry project delete acme-corp/my-app\n" + + " sentry project delete my-app\n" + + " sentry project delete acme-corp/my-app --yes\n" + + " sentry project delete acme-corp/my-app --dry-run", + }, + output: { + json: true, + human: formatProjectDeleted, + jsonTransform: (result: ProjectDeleteResult) => { + if (result.dryRun) { + return { + dryRun: true, + org: result.orgSlug, + project: result.projectSlug, + name: result.projectName, + url: result.url, + }; + } + return { + deleted: true, + org: result.orgSlug, + project: result.projectSlug, + }; + }, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "/ or (search across orgs)", + parse: String, + }, + ], + }, + flags: { + yes: { + kind: "boolean", + brief: "Skip confirmation prompt", + default: false, + }, + "dry-run": { + kind: "boolean", + brief: + "Validate inputs and show what would be deleted without deleting it", + default: false, + }, + }, + aliases: { y: "yes", n: "dry-run" }, + }, + async func(this: SentryContext, flags: DeleteFlags, target: string) { + const { stdout, cwd } = this; + + // Block auto-detect for safety — destructive commands require explicit targets + const parsed = parseOrgProjectArg(target); + if (parsed.type === "auto-detect") { + throw new ContextError( + "Project target", + `sentry ${COMMAND_NAME} /`, + [ + "Auto-detection is disabled for delete — specify the target explicitly", + ] + ); + } + + const { org: orgSlug, project: projectSlug } = + await resolveOrgProjectTarget(parsed, cwd, COMMAND_NAME); + + // Verify project exists before prompting — also used to display the project name + const project = await getProject(orgSlug, projectSlug); + + // Dry-run mode: show what would be deleted without deleting it + if (flags["dry-run"]) { + return { data: buildResult(orgSlug, project, true) }; + } + + // Confirmation gate + if (!flags.yes) { + const confirmed = await confirmDeletion(orgSlug, project); + if (!confirmed) { + stdout.write("Cancelled.\n"); + return; + } + } + + try { + await deleteProject(orgSlug, project.slug); + } catch (error) { + if (error instanceof ApiError && error.status === 403) { + throw await buildPermissionError(orgSlug, project.slug); + } + throw error; + } + + return { data: buildResult(orgSlug, project) }; + }, +}); diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts index 9e344340..f18b6f5c 100644 --- a/src/commands/project/index.ts +++ b/src/commands/project/index.ts @@ -1,11 +1,13 @@ import { buildRouteMap } from "@stricli/core"; import { createCommand } from "./create.js"; +import { deleteCommand } from "./delete.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; export const projectRoute = buildRouteMap({ routes: { create: createCommand, + delete: deleteCommand, list: listCommand, view: viewCommand, }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index a23504f4..83dc3b1a 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -59,6 +59,7 @@ export { } from "./api/organizations.js"; export { createProject, + deleteProject, findProjectByDsnKey, findProjectsByPattern, findProjectsBySlug, diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 48adc522..b5a97309 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -6,6 +6,7 @@ import { createANewProject, + deleteAProject, listAnOrganization_sProjects, listAProject_sClientKeys, retrieveAProject, @@ -152,6 +153,30 @@ export async function createProject( return data as unknown as SentryProject; } +/** + * Delete a project from an organization. + * + * Sends a DELETE request to the Sentry API. Returns 204 No Content on success. + * + * @param orgSlug - The organization slug + * @param projectSlug - The project slug to delete + * @throws {ApiError} 403 if the user lacks permission, 404 if the project doesn't exist + */ +export async function deleteProject( + orgSlug: string, + projectSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await deleteAProject({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + }, + }); + unwrapResult(result, "Failed to delete project"); +} + /** Result of searching for projects by slug across all organizations. */ export type ProjectSearchResult = { /** Matching projects with their org context */ diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 765bd6e8..3fe03ff4 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1928,6 +1928,44 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { return renderMarkdown(lines.join("\n")); } +// Project Deletion Formatting + +/** Result of a project deletion (or dry-run). */ +export type ProjectDeleteResult = { + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; + /** Human-readable project name */ + projectName: string; + /** Sentry web URL for the project */ + url: string; + /** When true, nothing was actually deleted — output uses tentative wording */ + dryRun?: boolean; +}; + +/** + * Format a project deletion result as rendered markdown. + * + * @param result - Deletion context + * @returns Rendered terminal string + */ +export function formatProjectDeleted(result: ProjectDeleteResult): string { + const nameEsc = escapeMarkdownInline(result.projectName); + const qualifiedSlug = `${result.orgSlug}/${result.projectSlug}`; + + if (result.dryRun) { + return renderMarkdown( + `Would delete project '${nameEsc}' (${safeCodeSpan(qualifiedSlug)}).\n\n` + + `URL: ${result.url}` + ); + } + + return renderMarkdown( + `Deleted project '${nameEsc}' (${safeCodeSpan(qualifiedSlug)}).` + ); +} + // CLI Fix Formatting /** Structured fix result (imported from the command module) */ diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 829254bb..b3d570aa 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -52,6 +52,7 @@ function getClientId(): string { const SCOPES = [ "project:read", "project:write", + "project:admin", "org:read", "event:read", "event:write", diff --git a/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts new file mode 100644 index 00000000..fa7f213b --- /dev/null +++ b/test/commands/project/delete.test.ts @@ -0,0 +1,304 @@ +/** + * Project Delete Command Tests + * + * Tests for the project delete command in src/commands/project/delete.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 { deleteCommand } from "../../../src/commands/project/delete.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ApiError } 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 } from "../../../src/types/index.js"; + +const sampleProject: SentryProject = { + id: "999", + slug: "my-app", + name: "My App", + platform: "python", + dateCreated: "2026-02-12T10:00:00Z", +}; + +/** Default flags for non-dry-run, non-JSON, confirmed deletion */ +const defaultFlags = { yes: true, "dry-run": false, json: false }; + +function createMockContext() { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd: "/tmp", + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("project delete", () => { + let getProjectSpy: ReturnType; + let deleteProjectSpy: ReturnType; + let getOrganizationSpy: ReturnType; + let resolveOrgProjectTargetSpy: ReturnType; + + beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject"); + deleteProjectSpy = spyOn(apiClient, "deleteProject"); + getOrganizationSpy = spyOn(apiClient, "getOrganization"); + resolveOrgProjectTargetSpy = spyOn( + resolveTarget, + "resolveOrgProjectTarget" + ); + + // Default mocks + getProjectSpy.mockResolvedValue(sampleProject); + deleteProjectSpy.mockResolvedValue(undefined); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + }); + resolveOrgProjectTargetSpy.mockResolvedValue({ + org: "acme-corp", + project: "my-app", + }); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + deleteProjectSpy.mockRestore(); + getOrganizationSpy.mockRestore(); + resolveOrgProjectTargetSpy.mockRestore(); + }); + + test("deletes project with explicit org/project and --yes", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "acme-corp/my-app"); + + expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Deleted project 'My App'"); + expect(output).toContain("acme-corp/my-app"); + }); + + test("delegates to resolveOrgProjectTarget for resolution", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "acme-corp/my-app"); + + expect(resolveOrgProjectTargetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "explicit", + org: "acme-corp", + project: "my-app", + }), + "/tmp", + "project delete" + ); + }); + + test("resolves bare slug via resolveOrgProjectTarget", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "my-app"); + + expect(resolveOrgProjectTargetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "project-search", + projectSlug: "my-app", + }), + "/tmp", + "project delete" + ); + expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + }); + + test("errors in non-interactive mode without --yes", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + // isatty(0) returns false in test environments (non-TTY) + await expect( + func.call(context, { ...defaultFlags, yes: false }, "acme-corp/my-app") + ).rejects.toThrow("non-interactive mode"); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("outputs JSON when --json flag is set", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { ...defaultFlags, json: true }, + "acme-corp/my-app" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output.trim()); + expect(parsed).toEqual({ + deleted: true, + org: "acme-corp", + project: "my-app", + }); + }); + + test("propagates 404 from getProject", async () => { + getProjectSpy.mockRejectedValue( + new ApiError("Not found", 404, "Project not found") + ); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + await expect( + func.call(context, defaultFlags, "acme-corp/my-app") + ).rejects.toThrow(ApiError); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("403 with member role suggests asking an admin", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + orgRole: "member", + }); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + try { + await func.call(context, defaultFlags, "acme-corp/my-app"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.message).toContain("role is 'member'"); + expect(apiErr.message).toContain("Ask an org admin"); + expect(apiErr.message).not.toContain("sentry auth login"); + } + }); + + test("403 with owner role suggests re-authenticating", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + orgRole: "owner", + }); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + try { + await func.call(context, defaultFlags, "acme-corp/my-app"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.message).toContain("('owner') should allow this"); + expect(apiErr.message).toContain("sentry auth login"); + } + }); + + test("403 with role fetch failure shows fallback message", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + getOrganizationSpy.mockRejectedValue(new Error("network error")); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + try { + await func.call(context, defaultFlags, "acme-corp/my-app"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.message).toContain("Manager, Owner, or Team Admin"); + expect(apiErr.message).toContain("sentry auth login"); + expect(apiErr.message).toContain("sentry org view"); + } + }); + + test("verifies project exists before attempting delete", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "acme-corp/my-app"); + + // getProject must be called before deleteProject + const getProjectOrder = getProjectSpy.mock.invocationCallOrder[0]; + const deleteProjectOrder = deleteProjectSpy.mock.invocationCallOrder[0]; + expect(getProjectOrder).toBeLessThan(deleteProjectOrder ?? 0); + }); + + // Dry-run tests + + test("dry-run shows what would be deleted without calling deleteProject", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true }, + "acme-corp/my-app" + ); + + expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + expect(deleteProjectSpy).not.toHaveBeenCalled(); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Would delete project 'My App'"); + expect(output).toContain("acme-corp/my-app"); + }); + + test("dry-run outputs JSON when --json is also set", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true, json: true }, + "acme-corp/my-app" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.org).toBe("acme-corp"); + expect(parsed.project).toBe("my-app"); + expect(parsed.name).toBe("My App"); + expect(parsed.url).toContain("acme-corp"); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); +});