Skip to content

Commit 56d099c

Browse files
committed
feat(commands): add shared helpers and buildDeleteCommand for mutation commands
Add src/lib/mutate-command.ts with shared infrastructure for create/delete commands, paralleling list-command.ts for list commands: - Level A: Shared flag constants (DRY_RUN_FLAG, YES_FLAG, FORCE_FLAG, DESTRUCTIVE_FLAGS/ALIASES, DRY_RUN_ALIASES) - Level B: Shared utilities (isConfirmationBypassed, guardNonInteractive, confirmByTyping, requireExplicitTarget) - Level C: buildDeleteCommand wrapper — auto-injects --yes/--force/--dry-run flags and runs a non-interactive safety guard as a pre-hook Refactor existing commands to use the new shared infrastructure: - project delete: uses buildDeleteCommand, confirmByTyping, requireExplicitTarget - project create: uses DRY_RUN_FLAG and DRY_RUN_ALIASES - dashboard widget delete: uses buildDeleteCommand, gains --dry-run support
1 parent e6ae353 commit 56d099c

File tree

10 files changed

+737
-100
lines changed

10 files changed

+737
-100
lines changed

AGENTS.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,50 @@ All non-trivial human output must use the markdown rendering pipeline:
288288

289289
Reference: `formatters/trace.ts` (`formatAncestorChain`), `formatters/human.ts` (`plainSafeMuted`)
290290

291+
### Create & Delete Command Standards
292+
293+
Mutation (create/delete) commands use shared infrastructure from `src/lib/mutate-command.ts`,
294+
paralleling `list-command.ts` for list commands.
295+
296+
**Delete commands** MUST use `buildDeleteCommand()` instead of `buildCommand()`. It:
297+
1. Auto-injects `--yes`, `--force`, `--dry-run` flags with `-y`, `-f`, `-n` aliases
298+
2. Runs a non-interactive safety guard before `func()` — refuses to proceed if
299+
stdin is not a TTY and `--yes`/`--force` was not passed (dry-run bypasses)
300+
3. Options to skip specific injections (`noForceFlag`, `noDryRunFlag`, `noNonInteractiveGuard`)
301+
302+
```typescript
303+
import { buildDeleteCommand, confirmByTyping, isConfirmationBypassed, requireExplicitTarget } from "../../lib/mutate-command.js";
304+
305+
export const deleteCommand = buildDeleteCommand({
306+
// Same args as buildCommand — flags/aliases auto-injected
307+
async *func(this: SentryContext, flags, target) {
308+
requireExplicitTarget(parsed, "Entity", "sentry entity delete <target>");
309+
if (flags["dry-run"]) { yield preview; return; }
310+
if (!isConfirmationBypassed(flags)) {
311+
if (!await confirmByTyping(expected, promptMessage)) return;
312+
}
313+
await doDelete();
314+
},
315+
});
316+
```
317+
318+
**Create commands** import `DRY_RUN_FLAG` and `DRY_RUN_ALIASES` for consistent dry-run support:
319+
320+
```typescript
321+
import { DRY_RUN_FLAG, DRY_RUN_ALIASES } from "../../lib/mutate-command.js";
322+
323+
// In parameters:
324+
flags: { "dry-run": DRY_RUN_FLAG, team: { ... } },
325+
aliases: { ...DRY_RUN_ALIASES, t: "team" },
326+
```
327+
328+
**Key utilities** in `mutate-command.ts`:
329+
- `isConfirmationBypassed(flags)` — true if `--yes` or `--force` is set
330+
- `guardNonInteractive(flags)` — throws in non-interactive mode without `--yes`
331+
- `confirmByTyping(expected, message)` — type-out confirmation prompt
332+
- `requireExplicitTarget(parsed, entityType, usage)` — blocks auto-detect for safety
333+
- `DESTRUCTIVE_FLAGS` / `DESTRUCTIVE_ALIASES` — spreadable bundles for manual use
334+
291335
### List Command Pagination
292336

293337
All list commands with API pagination MUST use the shared cursor-stack

plugins/sentry-cli/skills/sentry-cli/references/dashboards.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ Delete a widget from a dashboard
152152
**Flags:**
153153
- `-i, --index <value> - Widget index (0-based)`
154154
- `-t, --title <value> - Widget title to match`
155+
- `-y, --yes - Skip confirmation prompt`
156+
- `-f, --force - Force the operation without confirmation`
157+
- `-n, --dry-run - Show what would happen without making changes`
155158

156159
**Examples:**
157160

plugins/sentry-cli/skills/sentry-cli/references/projects.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Create a new project
1717

1818
**Flags:**
1919
- `-t, --team <value> - Team to create the project under`
20-
- `-n, --dry-run - Validate inputs and show what would be created without creating it`
20+
- `-n, --dry-run - Show what would happen without making changes`
2121

2222
**Examples:**
2323

@@ -38,8 +38,8 @@ Delete a project
3838

3939
**Flags:**
4040
- `-y, --yes - Skip confirmation prompt`
41-
- `-f, --force - Force deletion without confirmation`
42-
- `-n, --dry-run - Validate and show what would be deleted without deleting`
41+
- `-f, --force - Force the operation without confirmation`
42+
- `-n, --dry-run - Show what would happen without making changes`
4343

4444
**Examples:**
4545

src/commands/dashboard/widget/delete.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@
22
* sentry dashboard widget delete
33
*
44
* Remove a widget from an existing dashboard.
5+
*
6+
* Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run`
7+
* flags and enforces the non-interactive guard before `func()` runs.
8+
* `--yes`/`--force` are no-ops for now (no confirmation prompt) but
9+
* available for scripting and forward-compatibility.
510
*/
611

712
import type { SentryContext } from "../../../context.js";
813
import { getDashboard, updateDashboard } from "../../../lib/api-client.js";
914
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
10-
import { buildCommand, numberParser } from "../../../lib/command.js";
15+
import { numberParser } from "../../../lib/command.js";
1116
import { ValidationError } from "../../../lib/errors.js";
1217
import { formatWidgetDeleted } from "../../../lib/formatters/human.js";
1318
import { CommandOutput } from "../../../lib/formatters/output.js";
19+
import { buildDeleteCommand } from "../../../lib/mutate-command.js";
1420
import { buildDashboardUrl } from "../../../lib/sentry-urls.js";
1521
import {
1622
type DashboardDetail,
@@ -27,6 +33,9 @@ import {
2733
type DeleteFlags = {
2834
readonly index?: number;
2935
readonly title?: string;
36+
readonly yes: boolean;
37+
readonly force: boolean;
38+
readonly "dry-run": boolean;
3039
readonly json: boolean;
3140
readonly fields?: string[];
3241
};
@@ -35,9 +44,10 @@ type DeleteResult = {
3544
dashboard: DashboardDetail;
3645
widgetTitle: string;
3746
url: string;
47+
dryRun?: boolean;
3848
};
3949

40-
export const deleteCommand = buildCommand({
50+
export const deleteCommand = buildDeleteCommand({
4151
docs: {
4252
brief: "Delete a widget from a dashboard",
4353
fullDescription:
@@ -46,10 +56,27 @@ export const deleteCommand = buildCommand({
4656
"Identify the widget by --index (0-based) or --title.\n\n" +
4757
"Examples:\n" +
4858
" sentry dashboard widget delete 12345 --index 0\n" +
49-
" sentry dashboard widget delete 'My Dashboard' --title 'Error Rate'",
59+
" sentry dashboard widget delete 'My Dashboard' --title 'Error Rate'\n" +
60+
" sentry dashboard widget delete 12345 --index 0 --dry-run",
5061
},
5162
output: {
5263
human: formatWidgetDeleted,
64+
jsonTransform: (result: DeleteResult) => {
65+
if (result.dryRun) {
66+
return {
67+
dryRun: true,
68+
widgetTitle: result.widgetTitle,
69+
widgetCount: result.dashboard.widgets?.length ?? 0,
70+
url: result.url,
71+
};
72+
}
73+
return {
74+
deleted: true,
75+
widgetTitle: result.widgetTitle,
76+
widgetCount: result.dashboard.widgets?.length ?? 0,
77+
url: result.url,
78+
};
79+
},
5380
},
5481
parameters: {
5582
positional: {
@@ -95,16 +122,33 @@ export const deleteCommand = buildCommand({
95122
);
96123
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);
97124

98-
// GET current dashboard → find widget → splice → PUT
125+
// GET current dashboard → find widget
99126
const current = await getDashboard(orgSlug, dashboardId).catch(
100127
(error: unknown) =>
101-
enrichDashboardError(error, { orgSlug, dashboardId, operation: "view" })
128+
enrichDashboardError(error, {
129+
orgSlug,
130+
dashboardId,
131+
operation: "view",
132+
})
102133
);
103134
const widgets = current.widgets ?? [];
104135

105136
const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title);
106-
107137
const widgetTitle = widgets[widgetIndex]?.title;
138+
const url = buildDashboardUrl(orgSlug, dashboardId);
139+
140+
// Dry-run mode: show what would be removed without removing it
141+
if (flags["dry-run"]) {
142+
yield new CommandOutput({
143+
dashboard: current,
144+
widgetTitle,
145+
url,
146+
dryRun: true,
147+
} as DeleteResult);
148+
return { hint: `Dashboard: ${url}` };
149+
}
150+
151+
// Splice the widget and PUT the updated dashboard
108152
const updateBody = prepareDashboardForUpdate(current);
109153
updateBody.widgets.splice(widgetIndex, 1);
110154

@@ -119,7 +163,6 @@ export const deleteCommand = buildCommand({
119163
operation: "update",
120164
})
121165
);
122-
const url = buildDashboardUrl(orgSlug, dashboardId);
123166

124167
yield new CommandOutput({
125168
dashboard: updated,

src/commands/project/create.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { CommandOutput } from "../../lib/formatters/output.js";
4040
import { buildMarkdownTable, type Column } from "../../lib/formatters/table.js";
4141
import { renderTextTable } from "../../lib/formatters/text-table.js";
4242
import { logger } from "../../lib/logger.js";
43+
import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js";
4344
import {
4445
COMMON_PLATFORMS,
4546
isValidPlatform,
@@ -312,14 +313,9 @@ export const createCommand = buildCommand({
312313
brief: "Team to create the project under",
313314
optional: true,
314315
},
315-
"dry-run": {
316-
kind: "boolean",
317-
brief:
318-
"Validate inputs and show what would be created without creating it",
319-
default: false,
320-
},
316+
"dry-run": DRY_RUN_FLAG,
321317
},
322-
aliases: { t: "team", n: "dry-run" },
318+
aliases: { ...DRY_RUN_ALIASES, t: "team" },
323319
},
324320
async *func(
325321
this: SentryContext,

src/commands/project/delete.ts

Lines changed: 24 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,33 @@
1212
* 5. Display result
1313
*
1414
* 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
15+
* - Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run`
16+
* flags and enforces the non-interactive guard before `func()` runs
17+
* - No auto-detect mode: `requireExplicitTarget` blocks accidental deletion
18+
* - Type-out confirmation via `confirmByTyping` (unless --yes/--force)
1919
*/
2020

21-
import { isatty } from "node:tty";
2221
import type { SentryContext } from "../../context.js";
2322
import {
2423
deleteProject,
2524
getOrganization,
2625
getProject,
2726
} from "../../lib/api-client.js";
2827
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
29-
import { buildCommand } from "../../lib/command.js";
3028
import { getCachedOrgRole } from "../../lib/db/regions.js";
31-
import { ApiError, CliError, ContextError } from "../../lib/errors.js";
29+
import { ApiError } from "../../lib/errors.js";
3230
import {
3331
formatProjectDeleted,
3432
type ProjectDeleteResult,
3533
} from "../../lib/formatters/human.js";
3634
import { CommandOutput } from "../../lib/formatters/output.js";
3735
import { logger } from "../../lib/logger.js";
36+
import {
37+
buildDeleteCommand,
38+
confirmByTyping,
39+
isConfirmationBypassed,
40+
requireExplicitTarget,
41+
} from "../../lib/mutate-command.js";
3842
import { resolveOrgProjectTarget } from "../../lib/resolve-target.js";
3943
import { buildProjectUrl } from "../../lib/sentry-urls.js";
4044

@@ -43,46 +47,6 @@ const log = logger.withTag("project.delete");
4347
/** Command name used in error messages and resolution hints */
4448
const COMMAND_NAME = "project delete";
4549

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-
8650
/**
8751
* Build an actionable 403 error by checking the user's org role.
8852
*
@@ -164,7 +128,7 @@ type DeleteFlags = {
164128
readonly fields?: string[];
165129
};
166130

167-
export const deleteCommand = buildCommand({
131+
export const deleteCommand = buildDeleteCommand({
168132
docs: {
169133
brief: "Delete a project",
170134
fullDescription:
@@ -207,39 +171,17 @@ export const deleteCommand = buildCommand({
207171
},
208172
],
209173
},
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" },
228174
},
229175
async *func(this: SentryContext, flags: DeleteFlags, target: string) {
230176
const { cwd } = this;
231177

232178
// Block auto-detect for safety — destructive commands require explicit targets
233179
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-
}
180+
requireExplicitTarget(
181+
parsed,
182+
"Project target",
183+
`sentry ${COMMAND_NAME} <org>/<project>`
184+
);
243185

244186
const resolved = await resolveOrgProjectTarget(parsed, cwd, COMMAND_NAME);
245187
const { org: orgSlug, project: projectSlug } = resolved;
@@ -255,9 +197,13 @@ export const deleteCommand = buildCommand({
255197
return;
256198
}
257199

258-
// Confirmation gate
259-
if (!(flags.yes || flags.force)) {
260-
const confirmed = await confirmDeletion(orgSlug, project);
200+
// Confirmation gate — non-interactive guard is handled by buildDeleteCommand
201+
if (!isConfirmationBypassed(flags)) {
202+
const expected = `${orgSlug}/${project.slug}`;
203+
const confirmed = await confirmByTyping(
204+
expected,
205+
`Type '${expected}' to permanently delete project '${project.name}':`
206+
);
261207
if (!confirmed) {
262208
log.info("Cancelled.");
263209
return;

0 commit comments

Comments
 (0)