Skip to content

Commit 99be95c

Browse files
feat(commands): add shared helpers and buildDeleteCommand for mutation commands (#639)
## Summary - Add `src/lib/mutate-command.ts` with shared infrastructure for create/delete commands, paralleling `list-command.ts` for list commands - Refactor existing create/delete commands to use the new shared helpers - Add `--yes`/`--force`/`--dry-run` flags to `dashboard widget delete` via `buildDeleteCommand` ## What's in `mutate-command.ts` **Level A — Shared flag constants:** `DRY_RUN_FLAG`, `YES_FLAG`, `FORCE_FLAG`, plus spreadable bundles (`DESTRUCTIVE_FLAGS`/`DESTRUCTIVE_ALIASES`, `DRY_RUN_ALIASES`) **Level B — Shared utilities:** `isConfirmationBypassed()`, `guardNonInteractive()`, `confirmByTyping()`, `requireExplicitTarget()` **Level C — `buildDeleteCommand()` wrapper:** Drop-in replacement for `buildCommand` that auto-injects `--yes`/`--force`/`--dry-run` flags + aliases and runs a non-interactive safety guard as a pre-hook (analogous to `applyFreshFlag()` in `buildListCommand`). Options to skip specific injections (`noForceFlag`, `noDryRunFlag`, `noNonInteractiveGuard`). ## Refactored commands | Command | Change | |---------|--------| | `project delete` | Uses `buildDeleteCommand`, `confirmByTyping`, `requireExplicitTarget` (no behavior change) | | `project create` | Uses `DRY_RUN_FLAG`/`DRY_RUN_ALIASES` (no behavior change) | | `dashboard widget delete` | Uses `buildDeleteCommand` with `noNonInteractiveGuard` (reversible op), gains `--dry-run` preview support | --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent dbeae30 commit 99be95c

File tree

12 files changed

+817
-173
lines changed

12 files changed

+817
-173
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

docs/src/content/docs/commands/dashboard.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ Delete a widget from a dashboard
126126
|--------|-------------|
127127
| `-i, --index <index>` | Widget index (0-based) |
128128
| `-t, --title <title>` | Widget title to match |
129+
| `-y, --yes` | Skip confirmation prompt |
130+
| `-f, --force` | Force the operation without confirmation |
131+
| `-n, --dry-run` | Show what would happen without making changes |
129132

130133
All commands support `--json` for machine-readable output and `--fields` to select specific JSON fields.
131134

docs/src/content/docs/commands/project.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Create a new project
2323
| Option | Description |
2424
|--------|-------------|
2525
| `-t, --team <team>` | Team to create the project under |
26-
| `-n, --dry-run` | Validate inputs and show what would be created without creating it |
26+
| `-n, --dry-run` | Show what would happen without making changes |
2727

2828
### `sentry project delete <org/project>`
2929

@@ -40,8 +40,8 @@ Delete a project
4040
| Option | Description |
4141
|--------|-------------|
4242
| `-y, --yes` | Skip confirmation prompt |
43-
| `-f, --force` | Force deletion without confirmation |
44-
| `-n, --dry-run` | Validate and show what would be deleted without deleting |
43+
| `-f, --force` | Force the operation without confirmation |
44+
| `-n, --dry-run` | Show what would happen without making changes |
4545

4646
### `sentry project list <org/project>`
4747

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: 124 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
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. Non-interactive guard is disabled (`noNonInteractiveGuard`) because
8+
* widget deletion is reversible (re-add the widget). `--yes`/`--force` are
9+
* accepted but have no effect today (no confirmation prompt); `--dry-run`
10+
* shows which widget would be removed without modifying the dashboard.
511
*/
612

713
import type { SentryContext } from "../../../context.js";
814
import { getDashboard, updateDashboard } from "../../../lib/api-client.js";
915
import { parseOrgProjectArg } from "../../../lib/arg-parsing.js";
10-
import { buildCommand, numberParser } from "../../../lib/command.js";
16+
import { numberParser } from "../../../lib/command.js";
1117
import { ValidationError } from "../../../lib/errors.js";
1218
import { formatWidgetDeleted } from "../../../lib/formatters/human.js";
1319
import { CommandOutput } from "../../../lib/formatters/output.js";
20+
import { buildDeleteCommand } from "../../../lib/mutate-command.js";
1421
import { buildDashboardUrl } from "../../../lib/sentry-urls.js";
1522
import {
1623
type DashboardDetail,
@@ -27,6 +34,9 @@ import {
2734
type DeleteFlags = {
2835
readonly index?: number;
2936
readonly title?: string;
37+
readonly yes: boolean;
38+
readonly force: boolean;
39+
readonly "dry-run": boolean;
3040
readonly json: boolean;
3141
readonly fields?: string[];
3242
};
@@ -35,97 +45,134 @@ type DeleteResult = {
3545
dashboard: DashboardDetail;
3646
widgetTitle: string;
3747
url: string;
48+
dryRun?: boolean;
3849
};
3950

40-
export const deleteCommand = buildCommand({
41-
docs: {
42-
brief: "Delete a widget from a dashboard",
43-
fullDescription:
44-
"Remove a widget from an existing Sentry dashboard.\n\n" +
45-
"The dashboard can be specified by numeric ID or title.\n" +
46-
"Identify the widget by --index (0-based) or --title.\n\n" +
47-
"Examples:\n" +
48-
" sentry dashboard widget delete 12345 --index 0\n" +
49-
" sentry dashboard widget delete 'My Dashboard' --title 'Error Rate'",
50-
},
51-
output: {
52-
human: formatWidgetDeleted,
53-
},
54-
parameters: {
55-
positional: {
56-
kind: "array",
57-
parameter: {
58-
placeholder: "org/project/dashboard",
59-
brief: "[<org/project>] <dashboard-id-or-title>",
60-
parse: String,
51+
export const deleteCommand = buildDeleteCommand(
52+
{
53+
docs: {
54+
brief: "Delete a widget from a dashboard",
55+
fullDescription:
56+
"Remove a widget from an existing Sentry dashboard.\n\n" +
57+
"The dashboard can be specified by numeric ID or title.\n" +
58+
"Identify the widget by --index (0-based) or --title.\n\n" +
59+
"Examples:\n" +
60+
" sentry dashboard widget delete 12345 --index 0\n" +
61+
" sentry dashboard widget delete 'My Dashboard' --title 'Error Rate'\n" +
62+
" sentry dashboard widget delete 12345 --index 0 --dry-run",
63+
},
64+
output: {
65+
human: formatWidgetDeleted,
66+
jsonTransform: (result: DeleteResult) => {
67+
if (result.dryRun) {
68+
return {
69+
dryRun: true,
70+
widgetTitle: result.widgetTitle,
71+
widgetCount: result.dashboard.widgets?.length ?? 0,
72+
url: result.url,
73+
};
74+
}
75+
return {
76+
deleted: true,
77+
widgetTitle: result.widgetTitle,
78+
widgetCount: result.dashboard.widgets?.length ?? 0,
79+
url: result.url,
80+
};
6181
},
6282
},
63-
flags: {
64-
index: {
65-
kind: "parsed",
66-
parse: numberParser,
67-
brief: "Widget index (0-based)",
68-
optional: true,
83+
parameters: {
84+
positional: {
85+
kind: "array",
86+
parameter: {
87+
placeholder: "org/project/dashboard",
88+
brief: "[<org/project>] <dashboard-id-or-title>",
89+
parse: String,
90+
},
6991
},
70-
title: {
71-
kind: "parsed",
72-
parse: String,
73-
brief: "Widget title to match",
74-
optional: true,
92+
flags: {
93+
index: {
94+
kind: "parsed",
95+
parse: numberParser,
96+
brief: "Widget index (0-based)",
97+
optional: true,
98+
},
99+
title: {
100+
kind: "parsed",
101+
parse: String,
102+
brief: "Widget title to match",
103+
optional: true,
104+
},
75105
},
106+
aliases: { i: "index", t: "title" },
76107
},
77-
aliases: { i: "index", t: "title" },
78-
},
79-
async *func(this: SentryContext, flags: DeleteFlags, ...args: string[]) {
80-
const { cwd } = this;
108+
async *func(this: SentryContext, flags: DeleteFlags, ...args: string[]) {
109+
const { cwd } = this;
81110

82-
if (flags.index === undefined && !flags.title) {
83-
throw new ValidationError(
84-
"Specify --index or --title to identify the widget to delete.",
85-
"index"
111+
if (flags.index === undefined && !flags.title) {
112+
throw new ValidationError(
113+
"Specify --index or --title to identify the widget to delete.",
114+
"index"
115+
);
116+
}
117+
118+
const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args);
119+
const parsed = parseOrgProjectArg(targetArg);
120+
const orgSlug = await resolveOrgFromTarget(
121+
parsed,
122+
cwd,
123+
"sentry dashboard widget delete <org>/ <id> (--index <n> | --title <name>)"
86124
);
87-
}
125+
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);
88126

89-
const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args);
90-
const parsed = parseOrgProjectArg(targetArg);
91-
const orgSlug = await resolveOrgFromTarget(
92-
parsed,
93-
cwd,
94-
"sentry dashboard widget delete <org>/ <id> (--index <n> | --title <name>)"
95-
);
96-
const dashboardId = await resolveDashboardId(orgSlug, dashboardRef);
127+
// GET current dashboard → find widget
128+
const current = await getDashboard(orgSlug, dashboardId).catch(
129+
(error: unknown) =>
130+
enrichDashboardError(error, {
131+
orgSlug,
132+
dashboardId,
133+
operation: "view",
134+
})
135+
);
136+
const widgets = current.widgets ?? [];
97137

98-
// GET current dashboard → find widget → splice → PUT
99-
const current = await getDashboard(orgSlug, dashboardId).catch(
100-
(error: unknown) =>
101-
enrichDashboardError(error, { orgSlug, dashboardId, operation: "view" })
102-
);
103-
const widgets = current.widgets ?? [];
138+
const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title);
139+
const widgetTitle = widgets[widgetIndex]?.title;
140+
const url = buildDashboardUrl(orgSlug, dashboardId);
104141

105-
const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title);
142+
// Dry-run mode: show what would be removed without removing it
143+
if (flags["dry-run"]) {
144+
yield new CommandOutput({
145+
dashboard: current,
146+
widgetTitle,
147+
url,
148+
dryRun: true,
149+
} as DeleteResult);
150+
return { hint: `Dashboard: ${url}` };
151+
}
106152

107-
const widgetTitle = widgets[widgetIndex]?.title;
108-
const updateBody = prepareDashboardForUpdate(current);
109-
updateBody.widgets.splice(widgetIndex, 1);
153+
// Splice the widget and PUT the updated dashboard
154+
const updateBody = prepareDashboardForUpdate(current);
155+
updateBody.widgets.splice(widgetIndex, 1);
110156

111-
const updated = await updateDashboard(
112-
orgSlug,
113-
dashboardId,
114-
updateBody
115-
).catch((error: unknown) =>
116-
enrichDashboardError(error, {
157+
const updated = await updateDashboard(
117158
orgSlug,
118159
dashboardId,
119-
operation: "update",
120-
})
121-
);
122-
const url = buildDashboardUrl(orgSlug, dashboardId);
160+
updateBody
161+
).catch((error: unknown) =>
162+
enrichDashboardError(error, {
163+
orgSlug,
164+
dashboardId,
165+
operation: "update",
166+
})
167+
);
123168

124-
yield new CommandOutput({
125-
dashboard: updated,
126-
widgetTitle,
127-
url,
128-
} as DeleteResult);
129-
return { hint: `Dashboard: ${url}` };
169+
yield new CommandOutput({
170+
dashboard: updated,
171+
widgetTitle,
172+
url,
173+
} as DeleteResult);
174+
return { hint: `Dashboard: ${url}` };
175+
},
130176
},
131-
});
177+
{ noNonInteractiveGuard: true }
178+
);

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,

0 commit comments

Comments
 (0)