Skip to content

Commit e6b5a2b

Browse files
fix: preserve ApiError on 403 and add --dry-run support
- 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)
1 parent 1023888 commit e6b5a2b

2 files changed

Lines changed: 140 additions & 53 deletions

File tree

src/commands/project/delete.ts

Lines changed: 76 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { buildCommand } from "../../lib/command.js";
2828
import { ApiError, CliError, ContextError } from "../../lib/errors.js";
2929
import { logger } from "../../lib/logger.js";
3030
import { resolveProjectBySlug } from "../../lib/resolve-target.js";
31+
import { buildProjectUrl } from "../../lib/sentry-urls.js";
3132

3233
const log = logger.withTag("project.delete");
3334

@@ -36,10 +37,55 @@ const USAGE_HINT = "sentry project delete <org>/<project>";
3637

3738
type DeleteFlags = {
3839
readonly yes: boolean;
40+
readonly "dry-run": boolean;
3941
readonly json: boolean;
4042
readonly fields?: string[];
4143
};
4244

45+
/**
46+
* Resolve the target argument into an org/project pair.
47+
*
48+
* Only explicit (`org/project`) and project-search (`project`) modes are
49+
* supported — auto-detect and org-all are rejected for safety.
50+
*
51+
* @param target - Raw positional argument from the CLI
52+
* @returns Resolved org and project slugs
53+
*/
54+
function resolveDeleteTarget(
55+
target: string
56+
): Promise<{ org: string; project: string }> {
57+
const parsed = parseOrgProjectArg(target);
58+
59+
switch (parsed.type) {
60+
case ProjectSpecificationType.Explicit:
61+
return Promise.resolve({ org: parsed.org, project: parsed.project });
62+
63+
case ProjectSpecificationType.ProjectSearch:
64+
return resolveProjectBySlug(
65+
parsed.projectSlug,
66+
USAGE_HINT,
67+
`sentry project delete <org>/${parsed.projectSlug}`
68+
);
69+
70+
case ProjectSpecificationType.OrgAll:
71+
throw new ContextError(
72+
"Specific project",
73+
`${USAGE_HINT}\n\n` +
74+
"Specify the full org/project target, not just the organization."
75+
);
76+
77+
case ProjectSpecificationType.AutoDetect:
78+
throw new ContextError("Project target", USAGE_HINT, [
79+
"Auto-detection is disabled for delete — specify the target explicitly",
80+
]);
81+
82+
default: {
83+
const _exhaustive: never = parsed;
84+
throw new ContextError("Project", String(_exhaustive));
85+
}
86+
}
87+
}
88+
4389
export const deleteCommand = buildCommand({
4490
docs: {
4591
brief: "Delete a project",
@@ -49,7 +95,8 @@ export const deleteCommand = buildCommand({
4995
"Examples:\n" +
5096
" sentry project delete acme-corp/my-app\n" +
5197
" sentry project delete my-app\n" +
52-
" sentry project delete acme-corp/my-app --yes",
98+
" sentry project delete acme-corp/my-app --yes\n" +
99+
" sentry project delete acme-corp/my-app --dry-run",
53100
},
54101
output: "json",
55102
parameters: {
@@ -69,54 +116,38 @@ export const deleteCommand = buildCommand({
69116
brief: "Skip confirmation prompt",
70117
default: false,
71118
},
119+
"dry-run": {
120+
kind: "boolean",
121+
brief:
122+
"Validate inputs and show what would be deleted without deleting it",
123+
default: false,
124+
},
72125
},
73-
aliases: { y: "yes" },
126+
aliases: { y: "yes", n: "dry-run" },
74127
},
75128
async func(this: SentryContext, flags: DeleteFlags, target: string) {
76129
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-
}
130+
const { org: orgSlug, project: projectSlug } =
131+
await resolveDeleteTarget(target);
98132

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-
]);
133+
// Verify project exists before prompting — also used to display the project name
134+
const project = await getProject(orgSlug, projectSlug);
110135

111-
default: {
112-
const _exhaustive: never = parsed;
113-
throw new ContextError("Project", String(_exhaustive));
136+
// Dry-run mode: show what would be deleted without deleting it
137+
if (flags["dry-run"]) {
138+
if (flags.json) {
139+
stdout.write(
140+
`${JSON.stringify({ dryRun: true, org: orgSlug, project: project.slug, name: project.name, url: buildProjectUrl(orgSlug, project.slug) })}\n`
141+
);
142+
} else {
143+
stdout.write(
144+
`Would delete project '${project.name}' (${orgSlug}/${project.slug}).\n` +
145+
` URL: ${buildProjectUrl(orgSlug, project.slug)}\n`
146+
);
114147
}
148+
return;
115149
}
116150

117-
// Verify project exists before prompting — also used to display the project name
118-
const project = await getProject(orgSlug, projectSlug);
119-
120151
// Confirmation gate
121152
if (!flags.yes) {
122153
if (!isatty(0)) {
@@ -142,10 +173,13 @@ export const deleteCommand = buildCommand({
142173
await deleteProject(orgSlug, project.slug);
143174
} catch (error) {
144175
if (error instanceof ApiError && error.status === 403) {
145-
throw new CliError(
176+
throw new ApiError(
146177
`Permission denied: You don't have permission to delete '${orgSlug}/${project.slug}'.\n\n` +
147178
"Project deletion requires the 'project:admin' scope.\n" +
148-
" Re-authenticate: sentry auth login"
179+
" Re-authenticate: sentry auth login",
180+
403,
181+
error.detail,
182+
error.endpoint
149183
);
150184
}
151185
throw error;

test/commands/project/delete.test.ts

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const sampleProject: SentryProject = {
3131
dateCreated: "2026-02-12T10:00:00Z",
3232
};
3333

34+
/** Default flags for non-dry-run, non-JSON, confirmed deletion */
35+
const defaultFlags = { yes: true, "dry-run": false, json: false };
36+
3437
function createMockContext() {
3538
const stdoutWrite = mock(() => true);
3639
const stderrWrite = mock(() => true);
@@ -76,7 +79,7 @@ describe("project delete", () => {
7679
test("deletes project with explicit org/project and --yes", async () => {
7780
const { context, stdoutWrite } = createMockContext();
7881
const func = await deleteCommand.loader();
79-
await func.call(context, { yes: true, json: false }, "acme-corp/my-app");
82+
await func.call(context, defaultFlags, "acme-corp/my-app");
8083

8184
expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app");
8285
expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app");
@@ -89,7 +92,7 @@ describe("project delete", () => {
8992
test("deletes project with bare slug and --yes", async () => {
9093
const { context } = createMockContext();
9194
const func = await deleteCommand.loader();
92-
await func.call(context, { yes: true, json: false }, "my-app");
95+
await func.call(context, defaultFlags, "my-app");
9396

9497
expect(resolveProjectBySlugSpy).toHaveBeenCalledWith(
9598
"my-app",
@@ -104,7 +107,7 @@ describe("project delete", () => {
104107
const func = await deleteCommand.loader();
105108

106109
await expect(
107-
func.call(context, { yes: true, json: false }, "acme-corp/")
110+
func.call(context, defaultFlags, "acme-corp/")
108111
).rejects.toThrow(ContextError);
109112

110113
expect(deleteProjectSpy).not.toHaveBeenCalled();
@@ -116,7 +119,7 @@ describe("project delete", () => {
116119

117120
// isatty(0) returns false in test environments (non-TTY)
118121
await expect(
119-
func.call(context, { yes: false, json: false }, "acme-corp/my-app")
122+
func.call(context, { ...defaultFlags, yes: false }, "acme-corp/my-app")
120123
).rejects.toThrow("non-interactive mode");
121124

122125
expect(deleteProjectSpy).not.toHaveBeenCalled();
@@ -125,7 +128,11 @@ describe("project delete", () => {
125128
test("outputs JSON when --json flag is set", async () => {
126129
const { context, stdoutWrite } = createMockContext();
127130
const func = await deleteCommand.loader();
128-
await func.call(context, { yes: true, json: true }, "acme-corp/my-app");
131+
await func.call(
132+
context,
133+
{ ...defaultFlags, json: true },
134+
"acme-corp/my-app"
135+
);
129136

130137
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
131138
const parsed = JSON.parse(output.trim());
@@ -145,33 +152,79 @@ describe("project delete", () => {
145152
const func = await deleteCommand.loader();
146153

147154
await expect(
148-
func.call(context, { yes: true, json: false }, "acme-corp/my-app")
155+
func.call(context, defaultFlags, "acme-corp/my-app")
149156
).rejects.toThrow(ApiError);
150157

151158
expect(deleteProjectSpy).not.toHaveBeenCalled();
152159
});
153160

154-
test("shows actionable message on 403 from deleteProject", async () => {
161+
test("shows actionable ApiError on 403 from deleteProject", async () => {
155162
deleteProjectSpy.mockRejectedValue(
156163
new ApiError("Forbidden", 403, "You do not have permission")
157164
);
158165

159166
const { context } = createMockContext();
160167
const func = await deleteCommand.loader();
161168

162-
await expect(
163-
func.call(context, { yes: true, json: false }, "acme-corp/my-app")
164-
).rejects.toThrow("project:admin");
169+
try {
170+
await func.call(context, defaultFlags, "acme-corp/my-app");
171+
expect.unreachable("should have thrown");
172+
} catch (error) {
173+
expect(error).toBeInstanceOf(ApiError);
174+
const apiErr = error as ApiError;
175+
expect(apiErr.status).toBe(403);
176+
expect(apiErr.message).toContain("project:admin");
177+
expect(apiErr.message).toContain("sentry auth login");
178+
}
165179
});
166180

167181
test("verifies project exists before attempting delete", async () => {
168182
const { context } = createMockContext();
169183
const func = await deleteCommand.loader();
170-
await func.call(context, { yes: true, json: false }, "acme-corp/my-app");
184+
await func.call(context, defaultFlags, "acme-corp/my-app");
171185

172186
// getProject must be called before deleteProject
173187
const getProjectOrder = getProjectSpy.mock.invocationCallOrder[0];
174188
const deleteProjectOrder = deleteProjectSpy.mock.invocationCallOrder[0];
175189
expect(getProjectOrder).toBeLessThan(deleteProjectOrder ?? 0);
176190
});
191+
192+
// Dry-run tests
193+
194+
test("dry-run shows what would be deleted without calling deleteProject", async () => {
195+
const { context, stdoutWrite } = createMockContext();
196+
const func = await deleteCommand.loader();
197+
await func.call(
198+
context,
199+
{ ...defaultFlags, "dry-run": true },
200+
"acme-corp/my-app"
201+
);
202+
203+
expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app");
204+
expect(deleteProjectSpy).not.toHaveBeenCalled();
205+
206+
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
207+
expect(output).toContain("Would delete project 'My App'");
208+
expect(output).toContain("acme-corp/my-app");
209+
});
210+
211+
test("dry-run outputs JSON when --json is also set", async () => {
212+
const { context, stdoutWrite } = createMockContext();
213+
const func = await deleteCommand.loader();
214+
await func.call(
215+
context,
216+
{ ...defaultFlags, "dry-run": true, json: true },
217+
"acme-corp/my-app"
218+
);
219+
220+
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
221+
const parsed = JSON.parse(output.trim());
222+
expect(parsed.dryRun).toBe(true);
223+
expect(parsed.org).toBe("acme-corp");
224+
expect(parsed.project).toBe("my-app");
225+
expect(parsed.name).toBe("My App");
226+
expect(parsed.url).toContain("acme-corp");
227+
228+
expect(deleteProjectSpy).not.toHaveBeenCalled();
229+
});
177230
});

0 commit comments

Comments
 (0)