Skip to content

Commit 94849b0

Browse files
betegoncodex
andauthored
feat(cli): add dsn list command (#1365)
Adds `sentry dsn list` for an explicit project, matching projects across organizations, projects detected in a monorepo, or all accessible projects in an organization (`org/`). Table and JSON output include the project, key name, enabled status, creation date, and public DSN. Project lists use the shared plural target resolver, list contracts, limit validation, and cursor history. Compound cursor decoding is shared with issue and alert lists. Canonical organization/project slugs are resolved through shared metadata/cache helpers before output and pagination keys are built, so cold-cache DSN detection does not expose numeric context IDs or break navigation after caches warm. The existing alert-list budget algorithm is shared with DSN lists; alert behavior stays unchanged. Organization-wide listing uses the direct API SDK endpoint. If auto-detection finds DSNs that cannot be resolved, the context error preserves the resolver diagnostic, matching issue list and project view. The global limit, per-project cursors, exhausted projects, and partial fetch failures follow existing list conventions. If displaying every fetched row would exceed the limit, the command suggests a larger limit and suppresses a cursor that would skip undisplayed keys. Related to getsentry/cli#80. This covers DSN listing; creation remains a separate change. ## Validation - The focused DSN command and error suites pass all 139 tests, including a regression confirmed to fail before the diagnostic fix. CLI typecheck and lint also pass on the current changes. - Typecheck, root/CLI lint, documentation links, and command-fragment checks pass. Root lint reports two existing MCP warnings. - Regression coverage exercises multi-organization targets, monorepo discovery, cold-to-warm cache behavior, public fields in JSON/table/footer, next/previous navigation, global limits, exhausted projects, and partial failures. The shared budget's alert consumers also pass their existing suites. - Full-suite run on `25ebe639` (`env -u SENTRY_AUTH_TOKEN TZ=UTC pnpm run test`): 10,217 CLI tests pass, 14 are skipped, and the pre-existing musl installer fixture fails on macOS. The same failure was reproduced on the unchanged base. The recursive command stops there, so it does not complete the remaining workspace test suites. --------- Co-authored-by: GPT-6 (OpenAI) <noreply@openai.com>
1 parent c7fb528 commit 94849b0

24 files changed

Lines changed: 2039 additions & 155 deletions

File tree

‎apps/cli-docs/src/content/docs/contributing.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ toolkit/
6565
│ │ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view
6666
│ │ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload
6767
│ │ │ ├── docs/ # list, query
68+
│ │ │ ├── dsn/ # list
6869
│ │ │ ├── event/ # list, send, view
6970
│ │ │ ├── feedback/ # list, resolve, spam, unresolve, view
7071
│ │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
2+
## Examples
3+
4+
```bash
5+
# List client keys for detected projects, including monorepos
6+
sentry dsn list
7+
8+
# List enabled and disabled DSNs for one project
9+
sentry dsn list my-org/my-project
10+
11+
# List DSNs across all accessible projects in an organization
12+
sentry dsn list my-org/
13+
14+
# Navigate between pages
15+
sentry dsn list my-org/ --limit 10 -c next
16+
sentry dsn list my-org/ --limit 10 -c prev
17+
18+
# Select public DSN fields as JSON
19+
sentry dsn list my-org/my-project --json --fields name,dsn,isActive
20+
```
21+
22+
Each entry shows the project, name, enabled status, creation date, and public
23+
DSN available in Sentry's **Settings → Projects → Client Keys (DSN)**. A missing
24+
creation date is shown as `—` (`null` in JSON). Internal identifiers and private
25+
keys are not included in either output format.
26+
27+
Use `org/` for the whole organization or `org/project` for a specific project.
28+
A bare name lists matching projects across accessible organizations; when no project matches but an
29+
organization does, it lists that organization's DSNs. Add the trailing slash
30+
to select an organization even when a project has the same name.
31+
32+
Omit the target to use the usual project config or DSN auto-detection,
33+
including multiple projects in a monorepo.
34+
`--limit` caps the total number of DSNs on the page, including organization-wide
35+
results. Both organization and project listings support `-c next` and `-c prev`.
36+
When there are more projects than the limit can display, increase `--limit`
37+
as suggested before continuing; no next cursor is offered that would skip keys.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,14 @@ Search and query current Sentry documentation
541541

542542
→ Full flags and examples: `references/docs.md`
543543

544+
### Dsn
545+
546+
Find Sentry DSNs
547+
548+
- `sentry dsn list <org/project>` — List DSNs
549+
550+
→ Full flags and examples: `references/dsn.md`
551+
544552
### Platform
545553

546554
List valid Sentry platform identifiers
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
name: sentry-cli-dsn
3+
version: 0.47.0-dev.0
4+
description: Find Sentry DSNs
5+
requires:
6+
bins: ["sentry"]
7+
auth: true
8+
---
9+
10+
# Dsn Commands
11+
12+
Find Sentry DSNs
13+
14+
### `sentry dsn list <org/project>`
15+
16+
List DSNs
17+
18+
**Flags:**
19+
- `-n, --limit <value> - Maximum number of DSNs to list - (default: "25")`
20+
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
21+
- `-c, --cursor <value> - Navigate pages: "next", "prev", "first" (or raw cursor string)`
22+
23+
**Examples:**
24+
25+
```bash
26+
# List client keys for detected projects, including monorepos
27+
sentry dsn list
28+
29+
# List enabled and disabled DSNs for one project
30+
sentry dsn list my-org/my-project
31+
32+
# List DSNs across all accessible projects in an organization
33+
sentry dsn list my-org/
34+
35+
# Navigate between pages
36+
sentry dsn list my-org/ --limit 10 -c next
37+
sentry dsn list my-org/ --limit 10 -c prev
38+
39+
# Select public DSN fields as JSON
40+
sentry dsn list my-org/my-project --json --fields name,dsn,isActive
41+
```
42+
43+
All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.

‎packages/cli/src/app.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { dashboardRoute } from "./commands/dashboard/index.js";
2020
import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js";
2121
import { debugFilesRoute } from "./commands/debug-files/index.js";
2222
import { docsRoute } from "./commands/docs/index.js";
23+
import { dsnRoute } from "./commands/dsn/index.js";
24+
import { listCommand as dsnListCommand } from "./commands/dsn/list.js";
2325
import { eventRoute } from "./commands/event/index.js";
2426
import { listCommand as eventListCommand } from "./commands/event/list.js";
2527
import { exploreCommand } from "./commands/explore.js";
@@ -92,6 +94,7 @@ import { buildRouteMap } from "./lib/route-map.js";
9294
const PLURAL_TO_SINGULAR: Record<string, string> = {
9395
"agent-conversations": "agent-conversation",
9496
dashboards: "dashboard",
97+
dsns: "dsn",
9598
events: "event",
9699
issues: "issue",
97100
orgs: "org",
@@ -123,6 +126,7 @@ export const routes = buildRouteMap({
123126
"debug-files": debugFilesRoute,
124127
dashboard: dashboardRoute,
125128
docs: docsRoute,
129+
dsn: dsnRoute,
126130
org: orgRoute,
127131
platform: platformRoute,
128132
project: projectRoute,
@@ -158,6 +162,7 @@ export const routes = buildRouteMap({
158162
"bash-hook": bashHookCommand,
159163
"agent-conversations": conversationListCommand,
160164
dashboards: dashboardListCommand,
165+
dsns: dsnListCommand,
161166
issues: issueListCommand,
162167
orgs: orgListCommand,
163168
platforms: platformListCommand,
@@ -180,6 +185,7 @@ export const routes = buildRouteMap({
180185
"sentry is a command-line interface for interacting with Sentry. " +
181186
"It provides commands for authentication, viewing issues, and making API calls.",
182187
hideRoute: {
188+
dsns: true,
183189
"agent-conversations": true,
184190
dashboards: true,
185191
events: true,

‎packages/cli/src/commands/alert/issues/list.ts‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { openInBrowser } from "../../../lib/browser.js";
2323
import {
2424
advancePaginationState,
2525
buildMultiTargetContextKey,
26-
decodeCompoundCursor,
26+
decodeTargetCursors,
2727
encodeCompoundCursor,
2828
hasPreviousPage,
2929
resolveCursor,
@@ -267,26 +267,15 @@ async function handleResolvedTargets(
267267
});
268268

269269
const sortedTargetKeys = targets.map((t) => `${t.org}/${t.project}`).sort();
270-
const startCursors = new Map<string, string>();
271-
const exhaustedTargets = new Set<string>();
272270
const { cursor: rawCursor, direction } = resolveCursor(
273271
flags.cursor,
274272
PAGINATION_KEY,
275273
contextKey
276274
);
277-
if (rawCursor) {
278-
const decoded = decodeCompoundCursor(rawCursor);
279-
for (let i = 0; i < decoded.length && i < sortedTargetKeys.length; i++) {
280-
const cursor = decoded[i];
281-
// biome-ignore lint/style/noNonNullAssertion: i is within bounds
282-
const key = sortedTargetKeys[i]!;
283-
if (cursor) {
284-
startCursors.set(key, cursor);
285-
} else {
286-
exhaustedTargets.add(key);
287-
}
288-
}
289-
}
275+
const { startCursors, exhausted: exhaustedTargets } = decodeTargetCursors(
276+
rawCursor,
277+
sortedTargetKeys
278+
);
290279

291280
const activeTargets =
292281
exhaustedTargets.size > 0

‎packages/cli/src/commands/alert/list-utils.ts‎

Lines changed: 19 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,24 @@
22

33
import { ApiError, ValidationError } from "../../lib/errors.js";
44
import { LIST_MAX_LIMIT } from "../../lib/list-command.js";
5-
import { distributeFetchBudget, type FetchResult } from "../../lib/org-list.js";
5+
import {
6+
type FetchGroupsOptions,
7+
type FetchResult,
8+
fetchGroupsWithBudget,
9+
type ListFetchPage,
10+
} from "../../lib/org-list.js";
611

7-
export type AlertRuleFetchPage<TRule> = {
12+
/** Alert rules with the cursor metadata used by shared list fetching. */
13+
export type AlertRuleFetchPage<TRule> = ListFetchPage & {
14+
/** Rules returned for this group. */
815
rules: TRule[];
9-
hasMore?: boolean;
10-
nextCursor?: string;
1116
};
1217

13-
type FetchPageOptions = { limit: number; startCursor?: string };
14-
type BudgetOptions<TGroup, TRule, TPage extends AlertRuleFetchPage<TRule>> = {
15-
limit: number;
16-
startCursors?: Map<string, string>;
17-
getGroupKey: (group: TGroup) => string;
18-
fetchGroup: (
19-
group: TGroup,
20-
options: FetchPageOptions
21-
) => Promise<FetchResult<TPage>>;
22-
onProgress: (fetched: number) => void;
23-
};
18+
type BudgetOptions<
19+
TGroup,
20+
TRule,
21+
TPage extends AlertRuleFetchPage<TRule>,
22+
> = Omit<FetchGroupsOptions<TGroup, TRule, TPage>, "getItems">;
2423

2524
export function assertAlertListLimit(limit: number): void {
2625
if (limit < 1) {
@@ -70,80 +69,17 @@ export function buildAlertListFailureErrors<TKey extends string, TFailure>(
7069
});
7170
}
7271

73-
function countFetched<TRule, TPage extends AlertRuleFetchPage<TRule>>(
74-
results: FetchResult<TPage>[]
75-
): number {
76-
return results.reduce(
77-
(total, result) => total + (result.success ? result.data.rules.length : 0),
78-
0
79-
);
80-
}
81-
82-
function hasMore<TRule, TPage extends AlertRuleFetchPage<TRule>>(
83-
results: FetchResult<TPage>[]
84-
): boolean {
85-
return results.some((result) => result.success && result.data.hasMore);
86-
}
87-
88-
export async function fetchAlertRulesWithBudget<
72+
/** Preserve the alert page shape while sharing budget and cursor handling. */
73+
export function fetchAlertRulesWithBudget<
8974
TGroup,
9075
TRule,
9176
TPage extends AlertRuleFetchPage<TRule>,
9277
>(
9378
groups: TGroup[],
9479
options: BudgetOptions<TGroup, TRule, TPage>
9580
): Promise<{ results: FetchResult<TPage>[]; hasMore: boolean }> {
96-
const { limit, startCursors, getGroupKey, fetchGroup, onProgress } = options;
97-
const quotas = distributeFetchBudget(limit, groups.length, {
98-
minimumPerGroup: true,
99-
});
100-
const phase1 = await Promise.all(
101-
groups.map((group, index) =>
102-
fetchGroup(group, {
103-
limit: quotas[index] ?? 1,
104-
startCursor: startCursors?.get(getGroupKey(group)),
105-
})
106-
)
107-
);
108-
109-
let totalFetched = countFetched(phase1);
110-
onProgress(totalFetched);
111-
112-
const surplus = limit - totalFetched;
113-
if (surplus <= 0) {
114-
return { results: phase1, hasMore: hasMore(phase1) };
115-
}
116-
117-
const expandable = phase1.flatMap((result, index) => {
118-
const group = groups[index];
119-
if (!(group && result.success && result.data.nextCursor)) {
120-
return [];
121-
}
122-
return [{ group, index, cursor: result.data.nextCursor }];
81+
return fetchGroupsWithBudget<TGroup, TRule, TPage>(groups, {
82+
...options,
83+
getItems: (page) => page.rules,
12384
});
124-
const extraQuotas = distributeFetchBudget(surplus, expandable.length);
125-
const requests = expandable.flatMap((request, index) => {
126-
const extraLimit = extraQuotas[index] ?? 0;
127-
return extraLimit > 0 ? [{ ...request, limit: extraLimit }] : [];
128-
});
129-
130-
const phase2 = await Promise.all(
131-
requests.map(({ group, limit: requestLimit, cursor }) =>
132-
fetchGroup(group, { limit: requestLimit, startCursor: cursor })
133-
)
134-
);
135-
for (let index = 0; index < requests.length; index++) {
136-
const request = requests[index];
137-
const p1 = request ? phase1[request.index] : undefined;
138-
const p2 = phase2[index];
139-
if (p1?.success && p2?.success) {
140-
p1.data.rules.push(...p2.data.rules);
141-
p1.data.hasMore = p2.data.hasMore;
142-
p1.data.nextCursor = p2.data.nextCursor;
143-
}
144-
}
145-
146-
totalFetched = countFetched(phase1);
147-
onProgress(totalFetched);
148-
return { results: phase1, hasMore: hasMore(phase1) };
14985
}

‎packages/cli/src/commands/alert/metrics/list.ts‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { openInBrowser } from "../../../lib/browser.js";
2222
import {
2323
advancePaginationState,
2424
buildMultiOrgContextKey,
25-
decodeCompoundCursor,
25+
decodeTargetCursors,
2626
encodeCompoundCursor,
2727
hasPreviousPage,
2828
resolveCursor,
@@ -284,26 +284,15 @@ async function handleResolvedOrgs(
284284
const contextKey = buildMultiOrgContextKey(uniqueOrgs, flags.query);
285285
const sortedOrgKeys = [...uniqueOrgs].sort();
286286

287-
const startCursors = new Map<string, string>();
288-
const exhaustedOrgs = new Set<string>();
289287
const { cursor: rawCursor, direction } = resolveCursor(
290288
flags.cursor,
291289
PAGINATION_KEY,
292290
contextKey
293291
);
294-
if (rawCursor) {
295-
const decoded = decodeCompoundCursor(rawCursor);
296-
for (let i = 0; i < decoded.length && i < sortedOrgKeys.length; i++) {
297-
const cursor = decoded[i];
298-
// biome-ignore lint/style/noNonNullAssertion: i is within bounds
299-
const key = sortedOrgKeys[i]!;
300-
if (cursor) {
301-
startCursors.set(key, cursor);
302-
} else {
303-
exhaustedOrgs.add(key);
304-
}
305-
}
306-
}
292+
const { startCursors, exhausted: exhaustedOrgs } = decodeTargetCursors(
293+
rawCursor,
294+
sortedOrgKeys
295+
);
307296

308297
const activeOrgs =
309298
exhaustedOrgs.size > 0
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** Project client key commands. */
2+
3+
import { buildRouteMap } from "../../lib/route-map.js";
4+
import { listCommand } from "./list.js";
5+
6+
export const dsnRoute = buildRouteMap({
7+
routes: { list: listCommand },
8+
docs: {
9+
brief: "Find Sentry DSNs",
10+
fullDescription:
11+
"List the client keys (DSNs) for a Sentry project or organization.",
12+
hideRoute: {},
13+
},
14+
});

0 commit comments

Comments
 (0)