Skip to content

Commit 8e8973d

Browse files
betegonclaude
andauthored
fix(init): prompt for team selection when user belongs to multiple teams (#621)
## Summary `sentry init` failed for most new users — people who just signed up on sentry.io and ran the wizard. The root causes: 1. The CLI's OAuth token lacked `team:write` scope, so even org Owners couldn't create teams 2. Team resolution only happened deep in the workflow (after minutes of AI analysis), not upfront 3. The 0-teams case failed with a cryptic 403 error Now all team scenarios are resolved early in `resolvePreSpinnerOptions()`, right after org selection and before the spinner starts. Also added `team:write` to the OAuth scopes. ## All team scenarios handled | Scenario | Behavior | |---|---| | **0 teams** | Auto-creates a team called "default". On failure (e.g. missing permissions), shows friendly error with link to create a team in the UI | | **1 team** | Auto-selects it silently | | **Multiple teams, user is member of 1** | Auto-selects the member team | | **Multiple teams, user is member of several** | Shows interactive prompt to pick one | | **Multiple teams, user is member of several + `--yes`** | Auto-selects the first member team | | **Multiple teams, user is member of none** (new user) | Shows interactive prompt with all org teams | | **Multiple teams, user is member of none + `--yes`** | Auto-selects the first org team | | **`--team` flag provided** | Uses it directly, skips all resolution | | **API error fetching teams** | Silently falls through to existing `resolveOrCreateTeam` as fallback | ## Test plan - [ ] `sentry login` then `sentry init` on org with 0 teams → auto-creates "default" team, proceeds - [ ] `sentry init` on org with 1 team → no prompt, auto-selects - [ ] `sentry init` on org with multiple teams → shows team selection prompt - [ ] `sentry init --team <slug>` → skips prompt - [ ] `sentry init --yes` on org with multiple teams → auto-selects first member team - [ ] Cancel team prompt (Ctrl+C) → clean exit - [ ] Existing users need `sentry login` again to get the new `team:write` scope 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 42c2ca8 commit 8e8973d

File tree

3 files changed

+102
-0
lines changed

3 files changed

+102
-0
lines changed

src/lib/init/wizard-runner.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@ import {
1818
} from "@clack/prompts";
1919
import { MastraClient } from "@mastra/client-js";
2020
import { captureException, getTraceData } from "@sentry/node-core/light";
21+
import type { SentryTeam } from "../../types/index.js";
22+
import { createTeam, listTeams } from "../api-client.js";
2123
import { formatBanner } from "../banner.js";
2224
import { CLI_VERSION } from "../constants.js";
2325
import { getAuthToken } from "../db/auth.js";
2426
import { terminalLink } from "../formatters/colors.js";
27+
import { getSentryBaseUrl } from "../sentry-urls.js";
2528
import { slugify } from "../utils.js";
2629
import {
2730
abortIfCancelled,
@@ -274,6 +277,13 @@ async function preamble(
274277
return true;
275278
}
276279

280+
/**
281+
* Derive a team slug for auto-creation when the org has no teams.
282+
*/
283+
function deriveTeamSlug(): string {
284+
return "default";
285+
}
286+
277287
/**
278288
* Resolve org and detect an existing Sentry project before the spinner starts.
279289
*
@@ -401,6 +411,65 @@ async function resolvePreSpinnerOptions(
401411
}
402412
}
403413

414+
// Resolve team upfront so failures surface before the AI workflow starts.
415+
if (!opts.team && opts.org) {
416+
try {
417+
const teams = await listTeams(opts.org);
418+
419+
if (teams.length === 0) {
420+
// New org with no teams — auto-create one
421+
const teamSlug = deriveTeamSlug();
422+
try {
423+
const created = await createTeam(opts.org, teamSlug);
424+
opts = { ...opts, team: created.slug };
425+
} catch (err) {
426+
captureException(err, {
427+
extra: { orgSlug: opts.org, teamSlug, context: "auto-create team" },
428+
});
429+
const teamsUrl = `${getSentryBaseUrl()}/settings/${opts.org}/teams/`;
430+
log.error(
431+
"No teams in your organization.\n" +
432+
`Create one at ${terminalLink(teamsUrl)} and run sentry init again.`
433+
);
434+
cancel("Setup failed.");
435+
process.exitCode = 1;
436+
return null;
437+
}
438+
} else if (teams.length === 1) {
439+
opts = { ...opts, team: (teams[0] as SentryTeam).slug };
440+
} else {
441+
// Multiple teams — prefer teams the user belongs to
442+
const memberTeams = teams.filter((t) => t.isMember === true);
443+
const candidates = memberTeams.length > 0 ? memberTeams : teams;
444+
445+
if (candidates.length === 1) {
446+
opts = { ...opts, team: (candidates[0] as SentryTeam).slug };
447+
} else if (yes) {
448+
opts = { ...opts, team: (candidates[0] as SentryTeam).slug };
449+
} else {
450+
const selected = await select({
451+
message: "Which team should own this project?",
452+
options: candidates.map((t) => ({
453+
value: t.slug,
454+
label: t.slug,
455+
hint: t.name !== t.slug ? t.name : undefined,
456+
})),
457+
});
458+
if (isCancel(selected)) {
459+
cancel("Setup cancelled.");
460+
process.exitCode = 0;
461+
return null;
462+
}
463+
opts = { ...opts, team: selected };
464+
}
465+
}
466+
} catch (err) {
467+
captureException(err, {
468+
extra: { orgSlug: opts.org, context: "early team resolution" },
469+
});
470+
}
471+
}
472+
404473
return opts;
405474
}
406475

src/lib/oauth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const SCOPES = [
5959
"event:write",
6060
"member:read",
6161
"team:read",
62+
"team:write",
6263
].join(" ");
6364

6465
type DeviceFlowCallbacks = {

test/lib/init/wizard-runner.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ import {
2020
import * as clack from "@clack/prompts";
2121
import { MastraClient } from "@mastra/client-js";
2222
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
23+
import * as apiClient from "../../../src/lib/api-client.js";
24+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
2325
import * as banner from "../../../src/lib/banner.js";
2426
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
2527
import * as auth from "../../../src/lib/db/auth.js";
2628
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
29+
import * as userDb from "../../../src/lib/db/user.js";
30+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
2731
import * as fmt from "../../../src/lib/init/formatters.js";
2832
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
2933
import * as git from "../../../src/lib/init/git.js";
@@ -36,6 +40,8 @@ import type {
3640
WorkflowRunResult,
3741
} from "../../../src/lib/init/types.js";
3842
import { runWizard } from "../../../src/lib/init/wizard-runner.js";
43+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
44+
import * as sentryUrls from "../../../src/lib/sentry-urls.js";
3945

4046
// ── Helpers ─────────────────────────────────────────────────────────────────
4147

@@ -63,6 +69,7 @@ let logInfoSpy: ReturnType<typeof spyOn>;
6369
let logWarnSpy: ReturnType<typeof spyOn>;
6470
let logErrorSpy: ReturnType<typeof spyOn>;
6571
let cancelSpy: ReturnType<typeof spyOn>;
72+
let selectSpy: ReturnType<typeof spyOn>;
6673
let spinnerSpy: ReturnType<typeof spyOn>;
6774

6875
// git
@@ -78,6 +85,10 @@ let formatErrorSpy: ReturnType<typeof spyOn>;
7885
let handleLocalOpSpy: ReturnType<typeof spyOn>;
7986
let precomputeDirListingSpy: ReturnType<typeof spyOn>;
8087
let handleInteractiveSpy: ReturnType<typeof spyOn>;
88+
let listTeamsSpy: ReturnType<typeof spyOn>;
89+
let createTeamSpy: ReturnType<typeof spyOn>;
90+
let getUserInfoSpy: ReturnType<typeof spyOn>;
91+
let getSentryBaseUrlSpy: ReturnType<typeof spyOn>;
8192

8293
// MastraClient
8394
let getWorkflowSpy: ReturnType<typeof spyOn>;
@@ -153,6 +164,7 @@ beforeEach(() => {
153164
logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop);
154165
logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop);
155166
cancelSpy = spyOn(clack, "cancel").mockImplementation(noop);
167+
selectSpy = spyOn(clack, "select").mockResolvedValue("test-team");
156168
spinnerSpy = spyOn(clack, "spinner").mockReturnValue(spinnerMock as any);
157169

158170
// Reset spinner mock call counts
@@ -183,6 +195,21 @@ beforeEach(() => {
183195
handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({
184196
action: "continue",
185197
});
198+
listTeamsSpy = spyOn(apiClient, "listTeams").mockResolvedValue([]);
199+
createTeamSpy = spyOn(apiClient, "createTeam").mockResolvedValue({
200+
id: "1",
201+
slug: "test-team",
202+
name: "test-team",
203+
isMember: true,
204+
});
205+
getUserInfoSpy = spyOn(userDb, "getUserInfo").mockReturnValue({
206+
userId: "1",
207+
username: "testuser",
208+
name: "Test User",
209+
});
210+
getSentryBaseUrlSpy = spyOn(sentryUrls, "getSentryBaseUrl").mockReturnValue(
211+
"https://sentry.io"
212+
);
186213

187214
// stderr spy (suppress banner output)
188215
stderrSpy = spyOn(process.stderr, "write").mockImplementation(
@@ -201,6 +228,7 @@ afterEach(() => {
201228
logWarnSpy.mockRestore();
202229
logErrorSpy.mockRestore();
203230
cancelSpy.mockRestore();
231+
selectSpy.mockRestore();
204232
spinnerSpy.mockRestore();
205233

206234
checkGitStatusSpy.mockRestore();
@@ -213,6 +241,10 @@ afterEach(() => {
213241
handleLocalOpSpy.mockRestore();
214242
precomputeDirListingSpy.mockRestore();
215243
handleInteractiveSpy.mockRestore();
244+
listTeamsSpy.mockRestore();
245+
createTeamSpy.mockRestore();
246+
getUserInfoSpy.mockRestore();
247+
getSentryBaseUrlSpy.mockRestore();
216248

217249
stderrSpy.mockRestore();
218250
getWorkflowSpy.mockRestore();

0 commit comments

Comments
 (0)