From 72292f9798575c350b1bbc801365da17eae889f2 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Mar 2026 00:46:06 +0530 Subject: [PATCH 1/9] feat: add project delete command Add `sentry project delete` subcommand for permanently deleting Sentry projects via the API. Safety measures: - Requires explicit target (no auto-detect to prevent accidental deletion) - Confirmation prompt with --yes/-y flag to skip - Refuses to run in non-interactive mode without --yes - Verifies project exists before prompting Changes: - src/commands/project/delete.ts: New command implementation - src/commands/project/index.ts: Register delete in route map - src/lib/api-client.ts: Add deleteProject() using @sentry/api SDK - test/commands/project/delete.test.ts: Unit tests (8 tests) --- src/commands/project/delete.ts | 153 +++++++++++++++++++++++ src/commands/project/index.ts | 2 + src/lib/api-client.ts | 25 ++++ test/commands/project/delete.test.ts | 177 +++++++++++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 src/commands/project/delete.ts create mode 100644 test/commands/project/delete.test.ts diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts new file mode 100644 index 000000000..f67e27577 --- /dev/null +++ b/src/commands/project/delete.ts @@ -0,0 +1,153 @@ +/** + * 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, getProject } from "../../lib/api-client.js"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; +import { CliError, ContextError } from "../../lib/errors.js"; +import { logger } from "../../lib/logger.js"; +import { resolveProjectBySlug } from "../../lib/resolve-target.js"; + +const log = logger.withTag("project.delete"); + +/** Usage hint for error messages */ +const USAGE_HINT = "sentry project delete /"; + +type DeleteFlags = { + readonly yes: 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", + }, + output: "json", + 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, + }, + }, + aliases: { y: "yes" }, + }, + async func(this: SentryContext, flags: DeleteFlags, target: string) { + const { stdout } = this; + const parsed = parseOrgProjectArg(target); + + let orgSlug: string; + let projectSlug: string; + + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + orgSlug = parsed.org; + projectSlug = parsed.project; + break; + + case ProjectSpecificationType.ProjectSearch: { + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry project delete /${parsed.projectSlug}` + ); + orgSlug = resolved.org; + projectSlug = resolved.project; + break; + } + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "Specific project", + `${USAGE_HINT}\n\n` + + "Specify the full org/project target, not just the organization." + ); + + case ProjectSpecificationType.AutoDetect: + throw new ContextError("Project target", USAGE_HINT, [ + "Auto-detection is disabled for delete — specify the target explicitly", + ]); + + default: { + const _exhaustive: never = parsed; + throw new ContextError("Project", String(_exhaustive)); + } + } + + // Verify project exists before prompting — also used to display the project name + const project = await getProject(orgSlug, projectSlug); + + // Confirmation gate + if (!flags.yes) { + 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. + if (confirmed !== true) { + stdout.write("Cancelled.\n"); + return; + } + } + + await deleteProject(orgSlug, project.slug); + + if (flags.json) { + stdout.write( + `${JSON.stringify({ deleted: true, org: orgSlug, project: project.slug })}\n` + ); + } else { + stdout.write( + `Deleted project '${project.name}' (${orgSlug}/${project.slug}).\n` + ); + } + }, +}); diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts index 9e3443406..f18b6f5cc 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 aa438a22e..95facd89a 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -13,6 +13,7 @@ import { addAnOrganizationMemberToATeam, createANewProject, createANewTeam, + deleteAProject, listAnOrganization_sIssues, listAnOrganization_sProjects, listAnOrganization_sRepositories, @@ -719,6 +720,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"); +} + /** * Create a new team in an organization and add the current user as a member. * diff --git a/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts new file mode 100644 index 000000000..0e643fcb2 --- /dev/null +++ b/test/commands/project/delete.test.ts @@ -0,0 +1,177 @@ +/** + * 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, 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 } from "../../../src/types/index.js"; + +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); + 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 resolveProjectBySlugSpy: ReturnType; + + beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject"); + deleteProjectSpy = spyOn(apiClient, "deleteProject"); + resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug"); + + // Default mocks + getProjectSpy.mockResolvedValue(sampleProject); + deleteProjectSpy.mockResolvedValue(undefined); + resolveProjectBySlugSpy.mockResolvedValue({ + org: "acme-corp", + project: "my-app", + }); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + deleteProjectSpy.mockRestore(); + resolveProjectBySlugSpy.mockRestore(); + }); + + test("deletes project with explicit org/project and --yes", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, { yes: true, json: false }, "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("deletes project with bare slug and --yes", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, { yes: true, json: false }, "my-app"); + + expect(resolveProjectBySlugSpy).toHaveBeenCalledWith( + "my-app", + "sentry project delete /", + "sentry project delete /my-app" + ); + expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + }); + + test("errors when only org is provided (org-all)", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + await expect( + func.call(context, { yes: true, json: false }, "acme-corp/") + ).rejects.toThrow(ContextError); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + 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, { yes: false, json: 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, { yes: 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).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, { yes: true, json: false }, "acme-corp/my-app") + ).rejects.toThrow(ApiError); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("propagates 403 from deleteProject", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + await expect( + func.call(context, { yes: true, json: false }, "acme-corp/my-app") + ).rejects.toThrow(ApiError); + }); + + test("verifies project exists before attempting delete", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, { yes: true, json: false }, "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); + }); +}); From dcc0f2de6075cb2c149fd978fe54765f9951f975 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 19:17:01 +0000 Subject: [PATCH 2/9] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c8b3d2155..226afa2b2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -167,6 +167,15 @@ 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` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + #### `sentry project list ` List projects From 6d26185d139e8529c6acbc8449b4603b4f3effab Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Mar 2026 00:59:58 +0530 Subject: [PATCH 3/9] fix: add project:admin scope and improve 403 error handling - Add 'project:admin' to OAuth SCOPES so new tokens include the permission required for project deletion - Catch 403 in project delete command and show actionable message pointing users to re-authenticate or check their org role - Existing tokens require re-login: sentry auth login --- src/commands/project/delete.ts | 16 ++++++++++++++-- src/lib/oauth.ts | 1 + test/commands/project/delete.test.ts | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts index f67e27577..78a305fdd 100644 --- a/src/commands/project/delete.ts +++ b/src/commands/project/delete.ts @@ -25,7 +25,7 @@ import { parseOrgProjectArg, } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { CliError, ContextError } from "../../lib/errors.js"; +import { ApiError, CliError, ContextError } from "../../lib/errors.js"; import { logger } from "../../lib/logger.js"; import { resolveProjectBySlug } from "../../lib/resolve-target.js"; @@ -138,7 +138,19 @@ export const deleteCommand = buildCommand({ } } - await deleteProject(orgSlug, project.slug); + try { + await deleteProject(orgSlug, project.slug); + } catch (error) { + if (error instanceof ApiError && error.status === 403) { + throw new CliError( + `Permission denied: You don't have permission to delete '${orgSlug}/${project.slug}'.\n\n` + + "Project deletion requires the 'project:admin' scope.\n" + + ` - Check your role: sentry org view ${orgSlug}\n` + + " - Re-authenticate: sentry auth login" + ); + } + throw error; + } if (flags.json) { stdout.write( diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 988fae206..28bc7c0f1 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -51,6 +51,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 index 0e643fcb2..fca4bdf91 100644 --- a/test/commands/project/delete.test.ts +++ b/test/commands/project/delete.test.ts @@ -151,7 +151,7 @@ describe("project delete", () => { expect(deleteProjectSpy).not.toHaveBeenCalled(); }); - test("propagates 403 from deleteProject", async () => { + test("shows actionable message on 403 from deleteProject", async () => { deleteProjectSpy.mockRejectedValue( new ApiError("Forbidden", 403, "You do not have permission") ); @@ -161,7 +161,7 @@ describe("project delete", () => { await expect( func.call(context, { yes: true, json: false }, "acme-corp/my-app") - ).rejects.toThrow(ApiError); + ).rejects.toThrow("project:admin"); }); test("verifies project exists before attempting delete", async () => { From 1023888b5475608b8ddb536cae60133d6b1351bf Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Mar 2026 01:03:35 +0530 Subject: [PATCH 4/9] fix: simplify 403 error message to only suggest re-authentication --- src/commands/project/delete.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts index 78a305fdd..46b6ee85f 100644 --- a/src/commands/project/delete.ts +++ b/src/commands/project/delete.ts @@ -145,8 +145,7 @@ export const deleteCommand = buildCommand({ throw new CliError( `Permission denied: You don't have permission to delete '${orgSlug}/${project.slug}'.\n\n` + "Project deletion requires the 'project:admin' scope.\n" + - ` - Check your role: sentry org view ${orgSlug}\n` + - " - Re-authenticate: sentry auth login" + " Re-authenticate: sentry auth login" ); } throw error; From e6b5a2ba2f6272888ae56dbf7a036d41cd6cf574 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Mar 2026 01:11:39 +0530 Subject: [PATCH 5/9] fix: preserve ApiError on 403 and add --dry-run support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 403 error now throws ApiError (preserving status/detail/endpoint) instead of CliError, so upstream handlers can still match on status - Add --dry-run / -n flag consistent with project create and other mutating commands — validates inputs and shows what would be deleted - Extract resolveDeleteTarget() to reduce func() complexity - Add 2 new dry-run tests (human + JSON output) --- src/commands/project/delete.ts | 118 +++++++++++++++++---------- test/commands/project/delete.test.ts | 75 ++++++++++++++--- 2 files changed, 140 insertions(+), 53 deletions(-) diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts index 46b6ee85f..50a24e3dc 100644 --- a/src/commands/project/delete.ts +++ b/src/commands/project/delete.ts @@ -28,6 +28,7 @@ import { buildCommand } from "../../lib/command.js"; import { ApiError, CliError, ContextError } from "../../lib/errors.js"; import { logger } from "../../lib/logger.js"; import { resolveProjectBySlug } from "../../lib/resolve-target.js"; +import { buildProjectUrl } from "../../lib/sentry-urls.js"; const log = logger.withTag("project.delete"); @@ -36,10 +37,55 @@ const USAGE_HINT = "sentry project delete /"; type DeleteFlags = { readonly yes: boolean; + readonly "dry-run": boolean; readonly json: boolean; readonly fields?: string[]; }; +/** + * Resolve the target argument into an org/project pair. + * + * Only explicit (`org/project`) and project-search (`project`) modes are + * supported — auto-detect and org-all are rejected for safety. + * + * @param target - Raw positional argument from the CLI + * @returns Resolved org and project slugs + */ +function resolveDeleteTarget( + target: string +): Promise<{ org: string; project: string }> { + const parsed = parseOrgProjectArg(target); + + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + return Promise.resolve({ org: parsed.org, project: parsed.project }); + + case ProjectSpecificationType.ProjectSearch: + return resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry project delete /${parsed.projectSlug}` + ); + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "Specific project", + `${USAGE_HINT}\n\n` + + "Specify the full org/project target, not just the organization." + ); + + case ProjectSpecificationType.AutoDetect: + throw new ContextError("Project target", USAGE_HINT, [ + "Auto-detection is disabled for delete — specify the target explicitly", + ]); + + default: { + const _exhaustive: never = parsed; + throw new ContextError("Project", String(_exhaustive)); + } + } +} + export const deleteCommand = buildCommand({ docs: { brief: "Delete a project", @@ -49,7 +95,8 @@ export const deleteCommand = buildCommand({ "Examples:\n" + " sentry project delete acme-corp/my-app\n" + " sentry project delete my-app\n" + - " sentry project delete acme-corp/my-app --yes", + " sentry project delete acme-corp/my-app --yes\n" + + " sentry project delete acme-corp/my-app --dry-run", }, output: "json", parameters: { @@ -69,54 +116,38 @@ export const deleteCommand = buildCommand({ 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" }, + aliases: { y: "yes", n: "dry-run" }, }, async func(this: SentryContext, flags: DeleteFlags, target: string) { const { stdout } = this; - const parsed = parseOrgProjectArg(target); - - let orgSlug: string; - let projectSlug: string; - - switch (parsed.type) { - case ProjectSpecificationType.Explicit: - orgSlug = parsed.org; - projectSlug = parsed.project; - break; - - case ProjectSpecificationType.ProjectSearch: { - const resolved = await resolveProjectBySlug( - parsed.projectSlug, - USAGE_HINT, - `sentry project delete /${parsed.projectSlug}` - ); - orgSlug = resolved.org; - projectSlug = resolved.project; - break; - } + const { org: orgSlug, project: projectSlug } = + await resolveDeleteTarget(target); - case ProjectSpecificationType.OrgAll: - throw new ContextError( - "Specific project", - `${USAGE_HINT}\n\n` + - "Specify the full org/project target, not just the organization." - ); - - case ProjectSpecificationType.AutoDetect: - throw new ContextError("Project target", USAGE_HINT, [ - "Auto-detection is disabled for delete — specify the target explicitly", - ]); + // Verify project exists before prompting — also used to display the project name + const project = await getProject(orgSlug, projectSlug); - default: { - const _exhaustive: never = parsed; - throw new ContextError("Project", String(_exhaustive)); + // Dry-run mode: show what would be deleted without deleting it + if (flags["dry-run"]) { + if (flags.json) { + stdout.write( + `${JSON.stringify({ dryRun: true, org: orgSlug, project: project.slug, name: project.name, url: buildProjectUrl(orgSlug, project.slug) })}\n` + ); + } else { + stdout.write( + `Would delete project '${project.name}' (${orgSlug}/${project.slug}).\n` + + ` URL: ${buildProjectUrl(orgSlug, project.slug)}\n` + ); } + return; } - // Verify project exists before prompting — also used to display the project name - const project = await getProject(orgSlug, projectSlug); - // Confirmation gate if (!flags.yes) { if (!isatty(0)) { @@ -142,10 +173,13 @@ export const deleteCommand = buildCommand({ await deleteProject(orgSlug, project.slug); } catch (error) { if (error instanceof ApiError && error.status === 403) { - throw new CliError( + throw new ApiError( `Permission denied: You don't have permission to delete '${orgSlug}/${project.slug}'.\n\n` + "Project deletion requires the 'project:admin' scope.\n" + - " Re-authenticate: sentry auth login" + " Re-authenticate: sentry auth login", + 403, + error.detail, + error.endpoint ); } throw error; diff --git a/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts index fca4bdf91..b9d91c696 100644 --- a/test/commands/project/delete.test.ts +++ b/test/commands/project/delete.test.ts @@ -31,6 +31,9 @@ const sampleProject: SentryProject = { 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); @@ -76,7 +79,7 @@ describe("project delete", () => { test("deletes project with explicit org/project and --yes", async () => { const { context, stdoutWrite } = createMockContext(); const func = await deleteCommand.loader(); - await func.call(context, { yes: true, json: false }, "acme-corp/my-app"); + await func.call(context, defaultFlags, "acme-corp/my-app"); expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); @@ -89,7 +92,7 @@ describe("project delete", () => { test("deletes project with bare slug and --yes", async () => { const { context } = createMockContext(); const func = await deleteCommand.loader(); - await func.call(context, { yes: true, json: false }, "my-app"); + await func.call(context, defaultFlags, "my-app"); expect(resolveProjectBySlugSpy).toHaveBeenCalledWith( "my-app", @@ -104,7 +107,7 @@ describe("project delete", () => { const func = await deleteCommand.loader(); await expect( - func.call(context, { yes: true, json: false }, "acme-corp/") + func.call(context, defaultFlags, "acme-corp/") ).rejects.toThrow(ContextError); expect(deleteProjectSpy).not.toHaveBeenCalled(); @@ -116,7 +119,7 @@ describe("project delete", () => { // isatty(0) returns false in test environments (non-TTY) await expect( - func.call(context, { yes: false, json: false }, "acme-corp/my-app") + func.call(context, { ...defaultFlags, yes: false }, "acme-corp/my-app") ).rejects.toThrow("non-interactive mode"); expect(deleteProjectSpy).not.toHaveBeenCalled(); @@ -125,7 +128,11 @@ describe("project delete", () => { test("outputs JSON when --json flag is set", async () => { const { context, stdoutWrite } = createMockContext(); const func = await deleteCommand.loader(); - await func.call(context, { yes: true, json: true }, "acme-corp/my-app"); + 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()); @@ -145,13 +152,13 @@ describe("project delete", () => { const func = await deleteCommand.loader(); await expect( - func.call(context, { yes: true, json: false }, "acme-corp/my-app") + func.call(context, defaultFlags, "acme-corp/my-app") ).rejects.toThrow(ApiError); expect(deleteProjectSpy).not.toHaveBeenCalled(); }); - test("shows actionable message on 403 from deleteProject", async () => { + test("shows actionable ApiError on 403 from deleteProject", async () => { deleteProjectSpy.mockRejectedValue( new ApiError("Forbidden", 403, "You do not have permission") ); @@ -159,19 +166,65 @@ describe("project delete", () => { const { context } = createMockContext(); const func = await deleteCommand.loader(); - await expect( - func.call(context, { yes: true, json: false }, "acme-corp/my-app") - ).rejects.toThrow("project:admin"); + 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("project:admin"); + expect(apiErr.message).toContain("sentry auth login"); + } }); test("verifies project exists before attempting delete", async () => { const { context } = createMockContext(); const func = await deleteCommand.loader(); - await func.call(context, { yes: true, json: false }, "acme-corp/my-app"); + 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(); + }); }); From 910b59f587ec4b4d21af9d8366d42b25a6393fbd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 11 Mar 2026 19:42:14 +0000 Subject: [PATCH 6/9] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 226afa2b2..1025d0ddd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -173,6 +173,7 @@ 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)` From b0fed2e115cfe7c7cb2871fe0bce02b72328d5cc Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Mar 2026 08:04:06 +0530 Subject: [PATCH 7/9] chore: trigger CI From 95a87b9a19d1d6a1940760e44835acacbe7de16d Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 12 Mar 2026 08:13:25 +0530 Subject: [PATCH 8/9] refactor: reuse resolveOrgProjectTarget instead of custom resolveDeleteTarget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the custom resolveDeleteTarget() with the shared resolveOrgProjectTarget() from resolve-target.ts. Only the auto-detect guard remains delete-specific — all other resolution modes (explicit, project-search, org-all) are handled by the shared infrastructure. This also gains resolveEffectiveOrg() region routing for the explicit case, which the custom function was missing. --- src/commands/project/delete.ts | 149 ++++++++++++++------------- test/commands/project/delete.test.ts | 47 +++++---- 2 files changed, 104 insertions(+), 92 deletions(-) diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts index 50a24e3dc..a15a6df27 100644 --- a/src/commands/project/delete.ts +++ b/src/commands/project/delete.ts @@ -20,72 +20,81 @@ import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; import { deleteProject, getProject } from "../../lib/api-client.js"; -import { - ProjectSpecificationType, - parseOrgProjectArg, -} 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 { logger } from "../../lib/logger.js"; -import { resolveProjectBySlug } from "../../lib/resolve-target.js"; +import { resolveOrgProjectTarget } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; const log = logger.withTag("project.delete"); -/** Usage hint for error messages */ -const USAGE_HINT = "sentry project delete /"; - -type DeleteFlags = { - readonly yes: boolean; - readonly "dry-run": boolean; - readonly json: boolean; - readonly fields?: string[]; -}; +/** Command name used in error messages and resolution hints */ +const COMMAND_NAME = "project delete"; /** - * Resolve the target argument into an org/project pair. + * Prompt for confirmation before deleting a project. * - * Only explicit (`org/project`) and project-search (`project`) modes are - * supported — auto-detect and org-all are rejected for safety. + * Throws in non-interactive mode without --yes. Returns true if confirmed, + * false if the user cancels. * - * @param target - Raw positional argument from the CLI - * @returns Resolved org and project slugs + * @param orgSlug - Organization slug for display + * @param project - Project with slug and name for display + * @returns true if confirmed, false if cancelled */ -function resolveDeleteTarget( - target: string -): Promise<{ org: string; project: string }> { - const parsed = parseOrgProjectArg(target); - - switch (parsed.type) { - case ProjectSpecificationType.Explicit: - return Promise.resolve({ org: parsed.org, project: parsed.project }); - - case ProjectSpecificationType.ProjectSearch: - return resolveProjectBySlug( - parsed.projectSlug, - USAGE_HINT, - `sentry project delete /${parsed.projectSlug}` - ); +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.` + ); + } - case ProjectSpecificationType.OrgAll: - throw new ContextError( - "Specific project", - `${USAGE_HINT}\n\n` + - "Specify the full org/project target, not just the organization." - ); + const confirmed = await log.prompt( + `Delete project '${project.name}' (${orgSlug}/${project.slug})? This cannot be undone.`, + { type: "confirm", initial: false } + ); - case ProjectSpecificationType.AutoDetect: - throw new ContextError("Project target", USAGE_HINT, [ - "Auto-detection is disabled for delete — specify the target explicitly", - ]); + // consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. + // Strictly check for `true` to avoid deleting on cancel. + return confirmed === true; +} - default: { - const _exhaustive: never = parsed; - throw new ContextError("Project", String(_exhaustive)); - } +/** + * Write dry-run output describing what would be deleted. + * + * @param stdout - Output stream + * @param orgSlug - Organization slug + * @param project - Project details + * @param json - Whether to output JSON + */ +function writeDryRunOutput( + stdout: { write: (s: string) => unknown }, + orgSlug: string, + project: { slug: string; name: string }, + json: boolean +): void { + if (json) { + stdout.write( + `${JSON.stringify({ dryRun: true, org: orgSlug, project: project.slug, name: project.name, url: buildProjectUrl(orgSlug, project.slug) })}\n` + ); + } else { + stdout.write( + `Would delete project '${project.name}' (${orgSlug}/${project.slug}).\n` + + ` URL: ${buildProjectUrl(orgSlug, project.slug)}\n` + ); } } +type DeleteFlags = { + readonly yes: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + export const deleteCommand = buildCommand({ docs: { brief: "Delete a project", @@ -126,44 +135,36 @@ export const deleteCommand = buildCommand({ aliases: { y: "yes", n: "dry-run" }, }, async func(this: SentryContext, flags: DeleteFlags, target: string) { - const { stdout } = this; + 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 resolveDeleteTarget(target); + 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"]) { - if (flags.json) { - stdout.write( - `${JSON.stringify({ dryRun: true, org: orgSlug, project: project.slug, name: project.name, url: buildProjectUrl(orgSlug, project.slug) })}\n` - ); - } else { - stdout.write( - `Would delete project '${project.name}' (${orgSlug}/${project.slug}).\n` + - ` URL: ${buildProjectUrl(orgSlug, project.slug)}\n` - ); - } + writeDryRunOutput(stdout, orgSlug, project, flags.json); return; } // Confirmation gate if (!flags.yes) { - 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. - if (confirmed !== true) { + const confirmed = await confirmDeletion(orgSlug, project); + if (!confirmed) { stdout.write("Cancelled.\n"); return; } diff --git a/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts index b9d91c696..2851e349b 100644 --- a/test/commands/project/delete.test.ts +++ b/test/commands/project/delete.test.ts @@ -18,7 +18,7 @@ import { 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, ContextError } from "../../../src/lib/errors.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"; @@ -54,17 +54,20 @@ function createMockContext() { describe("project delete", () => { let getProjectSpy: ReturnType; let deleteProjectSpy: ReturnType; - let resolveProjectBySlugSpy: ReturnType; + let resolveOrgProjectTargetSpy: ReturnType; beforeEach(() => { getProjectSpy = spyOn(apiClient, "getProject"); deleteProjectSpy = spyOn(apiClient, "deleteProject"); - resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug"); + resolveOrgProjectTargetSpy = spyOn( + resolveTarget, + "resolveOrgProjectTarget" + ); // Default mocks getProjectSpy.mockResolvedValue(sampleProject); deleteProjectSpy.mockResolvedValue(undefined); - resolveProjectBySlugSpy.mockResolvedValue({ + resolveOrgProjectTargetSpy.mockResolvedValue({ org: "acme-corp", project: "my-app", }); @@ -73,7 +76,7 @@ describe("project delete", () => { afterEach(() => { getProjectSpy.mockRestore(); deleteProjectSpy.mockRestore(); - resolveProjectBySlugSpy.mockRestore(); + resolveOrgProjectTargetSpy.mockRestore(); }); test("deletes project with explicit org/project and --yes", async () => { @@ -89,28 +92,36 @@ describe("project delete", () => { expect(output).toContain("acme-corp/my-app"); }); - test("deletes project with bare slug and --yes", async () => { + test("delegates to resolveOrgProjectTarget for resolution", async () => { const { context } = createMockContext(); const func = await deleteCommand.loader(); - await func.call(context, defaultFlags, "my-app"); + await func.call(context, defaultFlags, "acme-corp/my-app"); - expect(resolveProjectBySlugSpy).toHaveBeenCalledWith( - "my-app", - "sentry project delete /", - "sentry project delete /my-app" + expect(resolveOrgProjectTargetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "explicit", + org: "acme-corp", + project: "my-app", + }), + "/tmp", + "project delete" ); - expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); }); - test("errors when only org is provided (org-all)", async () => { + test("resolves bare slug via resolveOrgProjectTarget", async () => { const { context } = createMockContext(); const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "my-app"); - await expect( - func.call(context, defaultFlags, "acme-corp/") - ).rejects.toThrow(ContextError); - - expect(deleteProjectSpy).not.toHaveBeenCalled(); + 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 () => { From 658fcd5bbaf7f444440eb12e0683ddc776908c6f Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 13 Mar 2026 19:10:10 +0100 Subject: [PATCH 9/9] refactor(project): align delete command with OutputConfig pattern Convert project delete from manual stdout.write/JSON.stringify to the return-based OutputConfig pattern used by project create and other commands. Adds formatProjectDeleted formatter in human.ts, jsonTransform to preserve JSON contract, and role-aware 403 errors. Co-Authored-By: Claude Opus 4.6 --- src/commands/project/delete.ts | 127 +++++++++++++++++++-------- src/lib/formatters/human.ts | 38 ++++++++ test/commands/project/delete.test.ts | 67 +++++++++++++- 3 files changed, 192 insertions(+), 40 deletions(-) diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts index a15a6df27..e164045c5 100644 --- a/src/commands/project/delete.ts +++ b/src/commands/project/delete.ts @@ -19,10 +19,18 @@ import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; -import { deleteProject, getProject } from "../../lib/api-client.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"; @@ -63,29 +71,69 @@ async function confirmDeletion( } /** - * Write dry-run output describing what would be deleted. + * Build an actionable 403 error by checking the user's org role. * - * @param stdout - Output stream - * @param orgSlug - Organization slug - * @param project - Project details - * @param json - Whether to output JSON + * - 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 */ -function writeDryRunOutput( - stdout: { write: (s: string) => unknown }, +async function buildPermissionError( orgSlug: string, - project: { slug: string; name: string }, - json: boolean -): void { - if (json) { - stdout.write( - `${JSON.stringify({ dryRun: true, org: orgSlug, project: project.slug, name: project.name, url: buildProjectUrl(orgSlug, project.slug) })}\n` + 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 ); - } else { - stdout.write( - `Would delete project '${project.name}' (${orgSlug}/${project.slug}).\n` + - ` URL: ${buildProjectUrl(orgSlug, project.slug)}\n` + } + + 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 = { @@ -107,7 +155,26 @@ export const deleteCommand = buildCommand({ " sentry project delete acme-corp/my-app --yes\n" + " sentry project delete acme-corp/my-app --dry-run", }, - output: "json", + 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", @@ -157,8 +224,7 @@ export const deleteCommand = buildCommand({ // Dry-run mode: show what would be deleted without deleting it if (flags["dry-run"]) { - writeDryRunOutput(stdout, orgSlug, project, flags.json); - return; + return { data: buildResult(orgSlug, project, true) }; } // Confirmation gate @@ -174,26 +240,11 @@ export const deleteCommand = buildCommand({ await deleteProject(orgSlug, project.slug); } catch (error) { if (error instanceof ApiError && error.status === 403) { - throw new ApiError( - `Permission denied: You don't have permission to delete '${orgSlug}/${project.slug}'.\n\n` + - "Project deletion requires the 'project:admin' scope.\n" + - " Re-authenticate: sentry auth login", - 403, - error.detail, - error.endpoint - ); + throw await buildPermissionError(orgSlug, project.slug); } throw error; } - if (flags.json) { - stdout.write( - `${JSON.stringify({ deleted: true, org: orgSlug, project: project.slug })}\n` - ); - } else { - stdout.write( - `Deleted project '${project.name}' (${orgSlug}/${project.slug}).\n` - ); - } + return { data: buildResult(orgSlug, project) }; }, }); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 765bd6e86..3fe03ff4f 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/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts index 2851e349b..fa7f213b4 100644 --- a/test/commands/project/delete.test.ts +++ b/test/commands/project/delete.test.ts @@ -54,11 +54,13 @@ function createMockContext() { 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" @@ -67,6 +69,11 @@ describe("project delete", () => { // 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", @@ -76,6 +83,7 @@ describe("project delete", () => { afterEach(() => { getProjectSpy.mockRestore(); deleteProjectSpy.mockRestore(); + getOrganizationSpy.mockRestore(); resolveOrgProjectTargetSpy.mockRestore(); }); @@ -169,10 +177,64 @@ describe("project delete", () => { expect(deleteProjectSpy).not.toHaveBeenCalled(); }); - test("shows actionable ApiError on 403 from deleteProject", async () => { + 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(); @@ -184,8 +246,9 @@ describe("project delete", () => { expect(error).toBeInstanceOf(ApiError); const apiErr = error as ApiError; expect(apiErr.status).toBe(403); - expect(apiErr.message).toContain("project:admin"); + expect(apiErr.message).toContain("Manager, Owner, or Team Admin"); expect(apiErr.message).toContain("sentry auth login"); + expect(apiErr.message).toContain("sentry org view"); } });