Skip to content

Commit 011a0dc

Browse files
MathurAditya724BYKMathurAditya724
authored
feat: add project delete command (#397)
## Summary Add `sentry project delete` subcommand for permanently deleting Sentry projects via the API. ### Changes - **`src/commands/project/delete.ts`** — New command with safety measures: - Requires explicit `<org>/<project>` or `<project>` target (no auto-detect) - Interactive confirmation prompt (strict `confirmed !== true` check for Symbol(clack:cancel) gotcha) - `--yes`/`-y` flag to skip confirmation for CI/agent usage - `--dry-run`/`-n` flag to validate inputs and show what would be deleted without deleting - Refuses to run in non-interactive mode without `--yes` - Verifies project exists via `getProject()` before prompting - 403 errors throw `ApiError` with actionable message (preserves HTTP status for upstream handlers) - **`src/commands/project/index.ts`** — Registered `delete` in project route map - **`src/lib/api-client.ts`** — Added `deleteProject()` using `@sentry/api` SDK's `deleteAProject` - **`src/lib/oauth.ts`** — Added `project:admin` to OAuth scopes (required for project deletion; existing users must re-run `sentry auth login`) - **`test/commands/project/delete.test.ts`** — 10 unit tests covering happy path, error cases, dry-run, JSON output, and safety checks ### Usage ``` sentry project delete acme-corp/my-app sentry project delete my-app sentry project delete acme-corp/my-app --yes sentry project delete acme-corp/my-app --json --yes sentry project delete acme-corp/my-app --dry-run ``` ### Notes - Existing tokens do not include `project:admin` — users need to re-authenticate via `sentry auth login` after upgrading - Local caches (DSN cache, project cache, defaults) are not cleared after deletion — stale entries may persist until TTL expiry (follow-up) Co-authored-by: Burak Yigit Kaya <byk@sentry.io> Co-authored-by: MathurAditya724 <AditMathur16@gmail.com>
1 parent d8c0589 commit 011a0dc

File tree

13 files changed

+976
-53
lines changed

13 files changed

+976
-53
lines changed

AGENTS.md

Lines changed: 20 additions & 50 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,17 @@ Create a new project
180180
- `--json - Output as JSON`
181181
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
182182

183+
#### `sentry project delete <org/project>`
184+
185+
Delete a project
186+
187+
**Flags:**
188+
- `-y, --yes - Skip confirmation prompt`
189+
- `-f, --force - Force deletion without confirmation`
190+
- `-n, --dry-run - Validate and show what would be deleted without deleting`
191+
- `--json - Output as JSON`
192+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
193+
183194
#### `sentry project list <org/project>`
184195

185196
List projects

src/commands/project/delete.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* sentry project delete
3+
*
4+
* Permanently delete a Sentry project.
5+
*
6+
* ## Flow
7+
*
8+
* 1. Parse target arg → extract org/project (e.g., "acme/my-app" or "my-app")
9+
* 2. Verify the project exists via `getProject` (also displays its name)
10+
* 3. Prompt for confirmation by typing `org/project` (unless --yes is passed)
11+
* 4. Call `deleteProject` API
12+
* 5. Display result
13+
*
14+
* Safety measures:
15+
* - No auto-detect mode: requires explicit target to prevent accidental deletion
16+
* - Type-out confirmation: user must type the full `org/project` slug
17+
* - Strict cancellation check (Symbol(clack:cancel) gotcha)
18+
* - Refuses to run in non-interactive mode without --yes flag
19+
*/
20+
21+
import { isatty } from "node:tty";
22+
import type { SentryContext } from "../../context.js";
23+
import {
24+
deleteProject,
25+
getOrganization,
26+
getProject,
27+
} from "../../lib/api-client.js";
28+
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
29+
import { buildCommand } from "../../lib/command.js";
30+
import { getCachedOrgRole } from "../../lib/db/regions.js";
31+
import { ApiError, CliError, ContextError } from "../../lib/errors.js";
32+
import {
33+
formatProjectDeleted,
34+
type ProjectDeleteResult,
35+
} from "../../lib/formatters/human.js";
36+
import { CommandOutput } from "../../lib/formatters/output.js";
37+
import { logger } from "../../lib/logger.js";
38+
import { resolveOrgProjectTarget } from "../../lib/resolve-target.js";
39+
import { buildProjectUrl } from "../../lib/sentry-urls.js";
40+
41+
const log = logger.withTag("project.delete");
42+
43+
/** Command name used in error messages and resolution hints */
44+
const COMMAND_NAME = "project delete";
45+
46+
/**
47+
* Prompt for confirmation before deleting a project.
48+
*
49+
* Uses a type-out confirmation where the user must type the full
50+
* `org/project` slug — similar to GitHub's deletion confirmation.
51+
*
52+
* Throws in non-interactive mode without --yes. Returns true if
53+
* the typed input matches, false otherwise.
54+
*
55+
* @param orgSlug - Organization slug for display and matching
56+
* @param project - Project with slug and name for display and matching
57+
* @returns true if confirmed, false if cancelled or mismatched
58+
*/
59+
async function confirmDeletion(
60+
orgSlug: string,
61+
project: { slug: string; name: string }
62+
): Promise<boolean> {
63+
const expected = `${orgSlug}/${project.slug}`;
64+
65+
if (!isatty(0)) {
66+
throw new CliError(
67+
`Refusing to delete '${expected}' in non-interactive mode. ` +
68+
"Use --yes or --force to confirm."
69+
);
70+
}
71+
72+
const response = await log.prompt(
73+
`Type '${expected}' to permanently delete project '${project.name}':`,
74+
{ type: "text", placeholder: expected }
75+
);
76+
77+
// consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value.
78+
// Check type to avoid treating cancel as a valid response.
79+
if (typeof response !== "string") {
80+
return false;
81+
}
82+
83+
return response.trim() === expected;
84+
}
85+
86+
/**
87+
* Build an actionable 403 error by checking the user's org role.
88+
*
89+
* - member/billing → tell them they need a higher role
90+
* - manager/owner/admin → suggest checking token scope
91+
* - unknown/fetch failure → generic message covering both cases
92+
*
93+
* Never suggests `sentry auth login` — re-authenticating via OAuth won't
94+
* change permissions. The issue is either an insufficient org role or
95+
* a custom auth token missing the `project:admin` scope.
96+
*/
97+
async function buildPermissionError(
98+
orgSlug: string,
99+
projectSlug: string
100+
): Promise<ApiError> {
101+
const label = `'${orgSlug}/${projectSlug}'`;
102+
const rolesWithAccess = "Manager, Owner, or Admin";
103+
104+
// Try the org cache first (populated by listOrganizations), then fall back
105+
// to a fresh API call. The cache avoids an extra HTTP round-trip when the
106+
// org listing has already been fetched during this session.
107+
let orgRole = await getCachedOrgRole(orgSlug);
108+
if (!orgRole) {
109+
try {
110+
const org = await getOrganization(orgSlug);
111+
orgRole = (org as Record<string, unknown>).orgRole as string | undefined;
112+
} catch {
113+
// Fall through to generic message
114+
}
115+
}
116+
117+
if (orgRole && ["member", "billing"].includes(orgRole)) {
118+
return new ApiError(
119+
`Permission denied: cannot delete ${label}.\n\n` +
120+
`Your organization role is '${orgRole}'. ` +
121+
`Project deletion requires a ${rolesWithAccess} role.\n` +
122+
" Contact an org admin to change your role or delete the project for you.",
123+
403
124+
);
125+
}
126+
127+
if (orgRole && ["manager", "owner", "admin"].includes(orgRole)) {
128+
return new ApiError(
129+
`Permission denied: cannot delete ${label}.\n\n` +
130+
`Your org role ('${orgRole}') should have permission. ` +
131+
"If using a custom auth token, ensure it includes the 'project:admin' scope.",
132+
403
133+
);
134+
}
135+
136+
return new ApiError(
137+
`Permission denied: cannot delete ${label}.\n\n` +
138+
`This requires a ${rolesWithAccess} role, or a token with the 'project:admin' scope.\n` +
139+
` Check your role: sentry org view ${orgSlug}`,
140+
403
141+
);
142+
}
143+
144+
/** Build a result object for both dry-run and actual deletion */
145+
function buildResult(
146+
orgSlug: string,
147+
project: { slug: string; name: string },
148+
dryRun?: boolean
149+
): ProjectDeleteResult {
150+
return {
151+
orgSlug,
152+
projectSlug: project.slug,
153+
projectName: project.name,
154+
url: buildProjectUrl(orgSlug, project.slug),
155+
dryRun,
156+
};
157+
}
158+
159+
type DeleteFlags = {
160+
readonly yes: boolean;
161+
readonly force: boolean;
162+
readonly "dry-run": boolean;
163+
readonly json: boolean;
164+
readonly fields?: string[];
165+
};
166+
167+
export const deleteCommand = buildCommand({
168+
docs: {
169+
brief: "Delete a project",
170+
fullDescription:
171+
"Permanently delete a Sentry project. This action cannot be undone.\n\n" +
172+
"Requires explicit target — auto-detection is disabled for safety.\n\n" +
173+
"Examples:\n" +
174+
" sentry project delete acme-corp/my-app\n" +
175+
" sentry project delete my-app\n" +
176+
" sentry project delete acme-corp/my-app --yes\n" +
177+
" sentry project delete acme-corp/my-app --force\n" +
178+
" sentry project delete acme-corp/my-app --dry-run",
179+
},
180+
output: {
181+
human: formatProjectDeleted,
182+
jsonTransform: (result: ProjectDeleteResult) => {
183+
if (result.dryRun) {
184+
return {
185+
dryRun: true,
186+
org: result.orgSlug,
187+
project: result.projectSlug,
188+
name: result.projectName,
189+
url: result.url,
190+
};
191+
}
192+
return {
193+
deleted: true,
194+
org: result.orgSlug,
195+
project: result.projectSlug,
196+
};
197+
},
198+
},
199+
parameters: {
200+
positional: {
201+
kind: "tuple",
202+
parameters: [
203+
{
204+
placeholder: "org/project",
205+
brief: "<org>/<project> or <project> (search across orgs)",
206+
parse: String,
207+
},
208+
],
209+
},
210+
flags: {
211+
yes: {
212+
kind: "boolean",
213+
brief: "Skip confirmation prompt",
214+
default: false,
215+
},
216+
force: {
217+
kind: "boolean",
218+
brief: "Force deletion without confirmation",
219+
default: false,
220+
},
221+
"dry-run": {
222+
kind: "boolean",
223+
brief: "Validate and show what would be deleted without deleting",
224+
default: false,
225+
},
226+
},
227+
aliases: { y: "yes", f: "force", n: "dry-run" },
228+
},
229+
async *func(this: SentryContext, flags: DeleteFlags, target: string) {
230+
const { cwd } = this;
231+
232+
// Block auto-detect for safety — destructive commands require explicit targets
233+
const parsed = parseOrgProjectArg(target);
234+
if (parsed.type === "auto-detect") {
235+
throw new ContextError(
236+
"Project target",
237+
`sentry ${COMMAND_NAME} <org>/<project>`,
238+
[
239+
"Auto-detection is disabled for delete — specify the target explicitly",
240+
]
241+
);
242+
}
243+
244+
const { org: orgSlug, project: projectSlug } =
245+
await resolveOrgProjectTarget(parsed, cwd, COMMAND_NAME);
246+
247+
// Verify project exists before prompting — also used to display the project name
248+
const project = await getProject(orgSlug, projectSlug);
249+
250+
// Dry-run mode: show what would be deleted without deleting it
251+
if (flags["dry-run"]) {
252+
yield new CommandOutput(buildResult(orgSlug, project, true));
253+
return;
254+
}
255+
256+
// Confirmation gate
257+
if (!(flags.yes || flags.force)) {
258+
const confirmed = await confirmDeletion(orgSlug, project);
259+
if (!confirmed) {
260+
log.info("Cancelled.");
261+
return;
262+
}
263+
}
264+
265+
try {
266+
await deleteProject(orgSlug, project.slug);
267+
} catch (error) {
268+
if (error instanceof ApiError && error.status === 403) {
269+
throw await buildPermissionError(orgSlug, project.slug);
270+
}
271+
throw error;
272+
}
273+
274+
yield new CommandOutput(buildResult(orgSlug, project));
275+
},
276+
});

src/commands/project/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { buildRouteMap } from "@stricli/core";
22
import { createCommand } from "./create.js";
3+
import { deleteCommand } from "./delete.js";
34
import { listCommand } from "./list.js";
45
import { viewCommand } from "./view.js";
56

67
export const projectRoute = buildRouteMap({
78
routes: {
89
create: createCommand,
10+
delete: deleteCommand,
911
list: listCommand,
1012
view: viewCommand,
1113
},

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export {
6565
} from "./api/organizations.js";
6666
export {
6767
createProject,
68+
deleteProject,
6869
findProjectByDsnKey,
6970
findProjectsByPattern,
7071
findProjectsBySlug,

src/lib/api/organizations.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export async function listOrganizations(): Promise<SentryOrganization[]> {
8484
id: org.id,
8585
slug: org.slug,
8686
name: org.name,
87+
...(org.orgRole ? { orgRole: org.orgRole } : {}),
8788
}));
8889
}
8990

@@ -119,6 +120,7 @@ export async function listOrganizationsUncached(): Promise<
119120
regionUrl: baseUrl,
120121
orgId: org.id,
121122
orgName: org.name,
123+
orgRole: (org as Record<string, unknown>).orgRole as string | undefined,
122124
}))
123125
);
124126
return orgs;
@@ -146,6 +148,7 @@ export async function listOrganizationsUncached(): Promise<
146148
regionUrl: r.regionUrl,
147149
orgId: r.org.id,
148150
orgName: r.org.name,
151+
orgRole: (r.org as Record<string, unknown>).orgRole as string | undefined,
149152
}));
150153
await setOrgRegions(regionEntries);
151154

src/lib/api/projects.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import {
88
createANewProject,
9+
deleteAProject,
910
listAnOrganization_sProjects,
1011
listAProject_sClientKeys,
1112
retrieveAProject,
@@ -152,6 +153,30 @@ export async function createProject(
152153
return data as unknown as SentryProject;
153154
}
154155

156+
/**
157+
* Delete a project from an organization.
158+
*
159+
* Sends a DELETE request to the Sentry API. Returns 204 No Content on success.
160+
*
161+
* @param orgSlug - The organization slug
162+
* @param projectSlug - The project slug to delete
163+
* @throws {ApiError} 403 if the user lacks permission, 404 if the project doesn't exist
164+
*/
165+
export async function deleteProject(
166+
orgSlug: string,
167+
projectSlug: string
168+
): Promise<void> {
169+
const config = await getOrgSdkConfig(orgSlug);
170+
const result = await deleteAProject({
171+
...config,
172+
path: {
173+
organization_id_or_slug: orgSlug,
174+
project_id_or_slug: projectSlug,
175+
},
176+
});
177+
unwrapResult(result, "Failed to delete project");
178+
}
179+
155180
/** Result of searching for projects by slug across all organizations. */
156181
export type ProjectSearchResult = {
157182
/** Matching projects with their org context */

0 commit comments

Comments
 (0)