Skip to content

Commit 72292f9

Browse files
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)
1 parent 5cf9324 commit 72292f9

4 files changed

Lines changed: 357 additions & 0 deletions

File tree

src/commands/project/delete.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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 (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+
* - Confirmation prompt with strict `confirmed !== true` check (Symbol(clack:cancel) gotcha)
17+
* - Refuses to run in non-interactive mode without --yes flag
18+
*/
19+
20+
import { isatty } from "node:tty";
21+
import type { SentryContext } from "../../context.js";
22+
import { deleteProject, getProject } from "../../lib/api-client.js";
23+
import {
24+
ProjectSpecificationType,
25+
parseOrgProjectArg,
26+
} from "../../lib/arg-parsing.js";
27+
import { buildCommand } from "../../lib/command.js";
28+
import { CliError, ContextError } from "../../lib/errors.js";
29+
import { logger } from "../../lib/logger.js";
30+
import { resolveProjectBySlug } from "../../lib/resolve-target.js";
31+
32+
const log = logger.withTag("project.delete");
33+
34+
/** Usage hint for error messages */
35+
const USAGE_HINT = "sentry project delete <org>/<project>";
36+
37+
type DeleteFlags = {
38+
readonly yes: boolean;
39+
readonly json: boolean;
40+
readonly fields?: string[];
41+
};
42+
43+
export const deleteCommand = buildCommand({
44+
docs: {
45+
brief: "Delete a project",
46+
fullDescription:
47+
"Permanently delete a Sentry project. This action cannot be undone.\n\n" +
48+
"Requires explicit target — auto-detection is disabled for safety.\n\n" +
49+
"Examples:\n" +
50+
" sentry project delete acme-corp/my-app\n" +
51+
" sentry project delete my-app\n" +
52+
" sentry project delete acme-corp/my-app --yes",
53+
},
54+
output: "json",
55+
parameters: {
56+
positional: {
57+
kind: "tuple",
58+
parameters: [
59+
{
60+
placeholder: "org/project",
61+
brief: "<org>/<project> or <project> (search across orgs)",
62+
parse: String,
63+
},
64+
],
65+
},
66+
flags: {
67+
yes: {
68+
kind: "boolean",
69+
brief: "Skip confirmation prompt",
70+
default: false,
71+
},
72+
},
73+
aliases: { y: "yes" },
74+
},
75+
async func(this: SentryContext, flags: DeleteFlags, target: string) {
76+
const { stdout } = this;
77+
const parsed = parseOrgProjectArg(target);
78+
79+
let orgSlug: string;
80+
let projectSlug: string;
81+
82+
switch (parsed.type) {
83+
case ProjectSpecificationType.Explicit:
84+
orgSlug = parsed.org;
85+
projectSlug = parsed.project;
86+
break;
87+
88+
case ProjectSpecificationType.ProjectSearch: {
89+
const resolved = await resolveProjectBySlug(
90+
parsed.projectSlug,
91+
USAGE_HINT,
92+
`sentry project delete <org>/${parsed.projectSlug}`
93+
);
94+
orgSlug = resolved.org;
95+
projectSlug = resolved.project;
96+
break;
97+
}
98+
99+
case ProjectSpecificationType.OrgAll:
100+
throw new ContextError(
101+
"Specific project",
102+
`${USAGE_HINT}\n\n` +
103+
"Specify the full org/project target, not just the organization."
104+
);
105+
106+
case ProjectSpecificationType.AutoDetect:
107+
throw new ContextError("Project target", USAGE_HINT, [
108+
"Auto-detection is disabled for delete — specify the target explicitly",
109+
]);
110+
111+
default: {
112+
const _exhaustive: never = parsed;
113+
throw new ContextError("Project", String(_exhaustive));
114+
}
115+
}
116+
117+
// Verify project exists before prompting — also used to display the project name
118+
const project = await getProject(orgSlug, projectSlug);
119+
120+
// Confirmation gate
121+
if (!flags.yes) {
122+
if (!isatty(0)) {
123+
throw new CliError(
124+
`Refusing to delete '${orgSlug}/${project.slug}' in non-interactive mode. Use --yes to confirm.`
125+
);
126+
}
127+
128+
const confirmed = await log.prompt(
129+
`Delete project '${project.name}' (${orgSlug}/${project.slug})? This cannot be undone.`,
130+
{ type: "confirm", initial: false }
131+
);
132+
133+
// consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value.
134+
// Strictly check for `true` to avoid deleting on cancel.
135+
if (confirmed !== true) {
136+
stdout.write("Cancelled.\n");
137+
return;
138+
}
139+
}
140+
141+
await deleteProject(orgSlug, project.slug);
142+
143+
if (flags.json) {
144+
stdout.write(
145+
`${JSON.stringify({ deleted: true, org: orgSlug, project: project.slug })}\n`
146+
);
147+
} else {
148+
stdout.write(
149+
`Deleted project '${project.name}' (${orgSlug}/${project.slug}).\n`
150+
);
151+
}
152+
},
153+
});

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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
addAnOrganizationMemberToATeam,
1414
createANewProject,
1515
createANewTeam,
16+
deleteAProject,
1617
listAnOrganization_sIssues,
1718
listAnOrganization_sProjects,
1819
listAnOrganization_sRepositories,
@@ -719,6 +720,30 @@ export async function createProject(
719720
return data as unknown as SentryProject;
720721
}
721722

723+
/**
724+
* Delete a project from an organization.
725+
*
726+
* Sends a DELETE request to the Sentry API. Returns 204 No Content on success.
727+
*
728+
* @param orgSlug - The organization slug
729+
* @param projectSlug - The project slug to delete
730+
* @throws {ApiError} 403 if the user lacks permission, 404 if the project doesn't exist
731+
*/
732+
export async function deleteProject(
733+
orgSlug: string,
734+
projectSlug: string
735+
): Promise<void> {
736+
const config = await getOrgSdkConfig(orgSlug);
737+
const result = await deleteAProject({
738+
...config,
739+
path: {
740+
organization_id_or_slug: orgSlug,
741+
project_id_or_slug: projectSlug,
742+
},
743+
});
744+
unwrapResult(result, "Failed to delete project");
745+
}
746+
722747
/**
723748
* Create a new team in an organization and add the current user as a member.
724749
*
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Project Delete Command Tests
3+
*
4+
* Tests for the project delete command in src/commands/project/delete.ts.
5+
* Uses spyOn to mock api-client and resolve-target to test
6+
* the func() body without real HTTP calls or database access.
7+
*/
8+
9+
import {
10+
afterEach,
11+
beforeEach,
12+
describe,
13+
expect,
14+
mock,
15+
spyOn,
16+
test,
17+
} from "bun:test";
18+
import { deleteCommand } from "../../../src/commands/project/delete.js";
19+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
20+
import * as apiClient from "../../../src/lib/api-client.js";
21+
import { ApiError, ContextError } from "../../../src/lib/errors.js";
22+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
23+
import * as resolveTarget from "../../../src/lib/resolve-target.js";
24+
import type { SentryProject } from "../../../src/types/index.js";
25+
26+
const sampleProject: SentryProject = {
27+
id: "999",
28+
slug: "my-app",
29+
name: "My App",
30+
platform: "python",
31+
dateCreated: "2026-02-12T10:00:00Z",
32+
};
33+
34+
function createMockContext() {
35+
const stdoutWrite = mock(() => true);
36+
const stderrWrite = mock(() => true);
37+
return {
38+
context: {
39+
stdout: { write: stdoutWrite },
40+
stderr: { write: stderrWrite },
41+
cwd: "/tmp",
42+
setContext: mock(() => {
43+
// no-op for test
44+
}),
45+
},
46+
stdoutWrite,
47+
stderrWrite,
48+
};
49+
}
50+
51+
describe("project delete", () => {
52+
let getProjectSpy: ReturnType<typeof spyOn>;
53+
let deleteProjectSpy: ReturnType<typeof spyOn>;
54+
let resolveProjectBySlugSpy: ReturnType<typeof spyOn>;
55+
56+
beforeEach(() => {
57+
getProjectSpy = spyOn(apiClient, "getProject");
58+
deleteProjectSpy = spyOn(apiClient, "deleteProject");
59+
resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug");
60+
61+
// Default mocks
62+
getProjectSpy.mockResolvedValue(sampleProject);
63+
deleteProjectSpy.mockResolvedValue(undefined);
64+
resolveProjectBySlugSpy.mockResolvedValue({
65+
org: "acme-corp",
66+
project: "my-app",
67+
});
68+
});
69+
70+
afterEach(() => {
71+
getProjectSpy.mockRestore();
72+
deleteProjectSpy.mockRestore();
73+
resolveProjectBySlugSpy.mockRestore();
74+
});
75+
76+
test("deletes project with explicit org/project and --yes", async () => {
77+
const { context, stdoutWrite } = createMockContext();
78+
const func = await deleteCommand.loader();
79+
await func.call(context, { yes: true, json: false }, "acme-corp/my-app");
80+
81+
expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app");
82+
expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app");
83+
84+
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
85+
expect(output).toContain("Deleted project 'My App'");
86+
expect(output).toContain("acme-corp/my-app");
87+
});
88+
89+
test("deletes project with bare slug and --yes", async () => {
90+
const { context } = createMockContext();
91+
const func = await deleteCommand.loader();
92+
await func.call(context, { yes: true, json: false }, "my-app");
93+
94+
expect(resolveProjectBySlugSpy).toHaveBeenCalledWith(
95+
"my-app",
96+
"sentry project delete <org>/<project>",
97+
"sentry project delete <org>/my-app"
98+
);
99+
expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app");
100+
});
101+
102+
test("errors when only org is provided (org-all)", async () => {
103+
const { context } = createMockContext();
104+
const func = await deleteCommand.loader();
105+
106+
await expect(
107+
func.call(context, { yes: true, json: false }, "acme-corp/")
108+
).rejects.toThrow(ContextError);
109+
110+
expect(deleteProjectSpy).not.toHaveBeenCalled();
111+
});
112+
113+
test("errors in non-interactive mode without --yes", async () => {
114+
const { context } = createMockContext();
115+
const func = await deleteCommand.loader();
116+
117+
// isatty(0) returns false in test environments (non-TTY)
118+
await expect(
119+
func.call(context, { yes: false, json: false }, "acme-corp/my-app")
120+
).rejects.toThrow("non-interactive mode");
121+
122+
expect(deleteProjectSpy).not.toHaveBeenCalled();
123+
});
124+
125+
test("outputs JSON when --json flag is set", async () => {
126+
const { context, stdoutWrite } = createMockContext();
127+
const func = await deleteCommand.loader();
128+
await func.call(context, { yes: true, json: true }, "acme-corp/my-app");
129+
130+
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
131+
const parsed = JSON.parse(output.trim());
132+
expect(parsed).toEqual({
133+
deleted: true,
134+
org: "acme-corp",
135+
project: "my-app",
136+
});
137+
});
138+
139+
test("propagates 404 from getProject", async () => {
140+
getProjectSpy.mockRejectedValue(
141+
new ApiError("Not found", 404, "Project not found")
142+
);
143+
144+
const { context } = createMockContext();
145+
const func = await deleteCommand.loader();
146+
147+
await expect(
148+
func.call(context, { yes: true, json: false }, "acme-corp/my-app")
149+
).rejects.toThrow(ApiError);
150+
151+
expect(deleteProjectSpy).not.toHaveBeenCalled();
152+
});
153+
154+
test("propagates 403 from deleteProject", async () => {
155+
deleteProjectSpy.mockRejectedValue(
156+
new ApiError("Forbidden", 403, "You do not have permission")
157+
);
158+
159+
const { context } = createMockContext();
160+
const func = await deleteCommand.loader();
161+
162+
await expect(
163+
func.call(context, { yes: true, json: false }, "acme-corp/my-app")
164+
).rejects.toThrow(ApiError);
165+
});
166+
167+
test("verifies project exists before attempting delete", async () => {
168+
const { context } = createMockContext();
169+
const func = await deleteCommand.loader();
170+
await func.call(context, { yes: true, json: false }, "acme-corp/my-app");
171+
172+
// getProject must be called before deleteProject
173+
const getProjectOrder = getProjectSpy.mock.invocationCallOrder[0];
174+
const deleteProjectOrder = deleteProjectSpy.mock.invocationCallOrder[0];
175+
expect(getProjectOrder).toBeLessThan(deleteProjectOrder ?? 0);
176+
});
177+
});

0 commit comments

Comments
 (0)