Skip to content

Commit 0ff95c2

Browse files
feat(cli): Pick the stored account from the target org or server (#1528)
Stacked on #1526. Covers the automatic-selection half of #1396: with several stored accounts, commands use the account that fits what they target, so users don't have to `sentry auth switch` back and forth. **How the account is chosen** (per invocation, most specific first) 1. **Organization.** If the target org isn't in the active account's cached org list, but another stored account has used that org, that account is pinned for the rest of the invocation. This hooks into `resolveOrgRegion` / `resolveEffectiveOrg`, so it covers the ~55 org-scoped call sites and DSN `oNNNN` ids. 2. **Server.** An explicit `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry URL for another account's server selects the stored account on that server. Previously this was a `HostScopeError`. 3. **Active account** otherwise. **Guardrails** - The stored active account never changes. The selection lives in memory, keyed by the invocation's env object, so library-mode calls are isolated from each other. - One invocation never mixes identities: the first pin wins. - An account is only chosen for hosts its token is already trusted for, using the same SaaS-equivalence rule as `token-host.ts`. - If the active account has no cached org list (for example right after `auth switch`), it is kept. An account that simply hasn't used the org yet can't be overridden by one that has. - An explicit server limits candidates to accounts on that server. The persisted `defaults.url` counts as soft: when the pinned account is elsewhere, it yields to that account's host. - `SENTRY_FORCE_ENV_TOKEN` is never overridden. **Storage** Schema v19 adds `org_regions.account_id`. Writes record the account whose credentials fetched the row, following in-process refresh-token rotation. The migration backfills existing rows by matching credential identity. The org→account mapping is warm right away for new logins, because login already warms the org cache. Memoized credential state (token, fingerprint, row, stored-creds flag) is now keyed by selection, and user info reads and writes go to the account in effect. Account management (`switch`, `logout`, `list`) still works on the stored active account. **Not covered** - An org that no stored account has used yet still goes to the active account. There is no retry-on-403 across accounts. - When several non-active accounts know the same org, the most recently used one wins, with no prompt. <!-- junior-request-attribution:start --> via **byk**. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:slack%3AC010884QHLG%3A1792178653.828039 --> -- [View Junior Session](https://junior-prod.sentry.dev/conversations/slack%3AC010884QHLG%3A1792178653.828039) [[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC010884QHLG%3A1792178653.828039/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Burak Yigit Kaya <burak.kaya@sentry.io>
1 parent 8d3affe commit 0ff95c2

27 files changed

Lines changed: 1930 additions & 132 deletions

‎apps/cli-docs/src/content/docs/agent-guidance.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value
325325
## Common Mistakes
326326

327327
- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix.
328-
- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account; use `sentry auth switch` to change which stored account is used.
328+
- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account. With several stored accounts, commands pick the one that knows the target organization or server automatically; use `sentry auth switch` only to change the default.
329329
- **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically.
330330
- **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `<org>/<project>` if the CLI says it can't detect the target or detects the wrong one.
331331
- **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project.

‎apps/cli-docs/src/fragments/commands/auth.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ account, or a SaaS and a self-hosted login. When you're already logged in,
6767
`sentry auth login` asks whether to add another account and makes it active;
6868
logging in again as the same user on the same server replaces that account's
6969
credentials instead of adding a duplicate. `--force` skips the prompt and
70-
replaces the active account's login instead.
70+
replaces the stored login for that server instead (or adds one when that
71+
server has none); accounts on other servers are never replaced.
7172

7273
```bash
7374
# Add (or log in again to) a specific account without prompting
@@ -92,6 +93,30 @@ Select an account by email, username, user ID, or account ID (from
9293
only candidate. If the same email is stored on several servers, add `--url`:
9394
`sentry auth switch jane@example.com --url https://sentry.example.com`.
9495

96+
Most of the time you don't need to switch: commands pick the stored account
97+
that fits what they target, without changing the active account:
98+
99+
- **Organization** — when the target organization isn't in the active
100+
account's organization list (cached at login and by `sentry org list`) but
101+
another stored account has used it, that account is used. This includes
102+
`oNNNN` IDs from DSNs.
103+
- **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry
104+
URL for another account's server (for example a self-hosted issue link) uses
105+
the account stored for that server.
106+
107+
```bash
108+
# Active account is personal; this uses the work account that knows acme-corp
109+
sentry issue list acme-corp/
110+
111+
# Uses the self-hosted account without switching
112+
sentry issue view https://sentry.example.com/organizations/acme/issues/123/
113+
```
114+
115+
Otherwise the active account is used. One command never mixes accounts; when
116+
several other accounts know the organization, the most recently used wins.
117+
This works even when the active account is logged out: the target can still
118+
pick a stored account that can authenticate.
119+
95120
Switching also points the default URL (`sentry cli defaults url`) at the
96121
account's server. `SENTRY_HOST`/`SENTRY_URL` and `SENTRY_AUTH_TOKEN` (with
97122
`SENTRY_FORCE_ENV_TOKEN=1`) still take precedence over the active account.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value
337337
### Common Mistakes
338338

339339
- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix.
340-
- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account; use `sentry auth switch` to change which stored account is used.
340+
- **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account. With several stored accounts, commands pick the one that knows the target organization or server automatically; use `sentry auth switch` only to change the default.
341341
- **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically.
342342
- **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `<org>/<project>` if the CLI says it can't detect the target or detects the wrong one.
343343
- **Manually discovering the project before running a command**: Don't list the orgs you belong to, then list every project in each, to match the local checkout to a project — the CLI already does exactly this resolution internally on each command. Skip the fan-out and run the command directly; correct the target afterwards only if the output shows the wrong org/project.

‎packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Authenticate with Sentry
2222
**Flags:**
2323
- `--token <value> - Authenticate using an API token instead of OAuth`
2424
- `--timeout <value> - Timeout for OAuth flow in seconds (default: 900) - (default: "900")`
25-
- `--force - Replace the active account's login without prompting, instead of adding another account`
25+
- `--force - Replace the stored login for this server without prompting, instead of adding another account`
2626
- `--url <value> - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).`
2727
- `--read-only - Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.`
2828
- `-s, --scope <value>... - Request specific OAuth scopes (repeatable, comma-separated). E.g. --scope project:read --scope org:read. Overrides the default scope set.`

‎packages/cli/src/cli.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ async function preloadProjectContext(cwd: string): Promise<void> {
5858
const url = getDefaultUrl();
5959
if (url) {
6060
env.SENTRY_URL = url;
61+
const { markPersistedDefaultUrl } = await import("./lib/constants.js");
62+
markPersistedDefaultUrl(url);
6163
}
6264
} catch {
6365
// DB not available — skip

‎packages/cli/src/commands/auth/login.ts‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { normalizeAuthToken } from "../../lib/auth-header.js";
1111
import { buildCommand, numberParser } from "../../lib/command.js";
1212
import { normalizeUrl } from "../../lib/constants.js";
1313
import {
14+
getAccountIdToReplace,
1415
getActiveAccountId,
1516
getActiveEnvVarName,
1617
hasStoredAuthCredentials,
@@ -345,7 +346,8 @@ export function applyLoginUrl(url: string | undefined): string {
345346
* command should exit early. Without `--force` the new login is added
346347
* alongside the current account and becomes active (logging in again as
347348
* the same user replaces that user's old account); with `--force` it
348-
* replaces the active account's credentials.
349+
* replaces the stored login for the same server (or adds one when that
350+
* server has none). Accounts on other servers are never touched.
349351
*
350352
* - Env-var auth only: proceeds (stored OAuth is kept separately).
351353
* - `--force`: proceeds without prompting.
@@ -474,7 +476,8 @@ export const loginCommand = buildCommand({
474476
"currently-authenticated host.\n\n" +
475477
"When already logged in, the new login is stored as another account\n" +
476478
"(see 'sentry auth list' / 'sentry auth switch'); --force instead\n" +
477-
"replaces the active account's login. Pass an email to add or log in\n" +
479+
"replaces the stored login for that server (accounts on other servers\n" +
480+
"are kept). Pass an email to add or log in\n" +
478481
"again to that account without prompting: Sentry approves the request\n" +
479482
"as whoever is signed in to the browser, so a login approved as another\n" +
480483
"user is refused and nothing is saved.\n\n" +
@@ -501,7 +504,7 @@ export const loginCommand = buildCommand({
501504
force: {
502505
kind: "boolean",
503506
brief:
504-
"Replace the active account's login without prompting, instead of adding another account",
507+
"Replace the stored login for this server without prompting, instead of adding another account",
505508
default: false,
506509
},
507510
url: {
@@ -586,16 +589,20 @@ export const loginCommand = buildCommand({
586589
// Non-fatal: cache directory may not exist
587590
}
588591

589-
// `--force` replaces the active account; otherwise the login is stored
590-
// as another account (see handleExistingAuth).
592+
// `--force` replaces the stored login for this server; otherwise the
593+
// login is stored as another account (see handleExistingAuth).
591594
const newAccount = email !== undefined || !flags.force;
592595

593596
if (token !== undefined) {
594597
// Save token first (with host scope), then validate by fetching user
595598
// regions. It is always stored as its own account so a rejected token
596-
// never touches the stored ones; with `--force` the previously active
597-
// account is replaced only once the new token checks out.
599+
// never touches the stored ones; with `--force` the stored login for
600+
// this server (never another server's) is replaced only once the new
601+
// token checks out.
598602
const previousAccountId = getActiveAccountId();
603+
const replacedAccountId = newAccount
604+
? undefined
605+
: getAccountIdToReplace(effectiveHost);
599606
const accountId = setAuthToken(token, undefined, undefined, {
600607
host: effectiveHost,
601608
newAccount: true,
@@ -645,11 +652,11 @@ export const loginCommand = buildCommand({
645652
}
646653
result.user = toLoginUser(user);
647654
}
648-
// Replace the previous account only now. After setUserInfo, so a
649-
// re-login as the same user folds that account in (keeping a known
650-
// username) instead of losing it; then this is a no-op.
651-
if (!newAccount && previousAccountId && previousAccountId !== accountId) {
652-
await removeAccount(previousAccountId);
655+
// Replace this server's previous login only now. After setUserInfo,
656+
// so a re-login as the same user folds that account in (keeping a
657+
// known username) instead of losing it; then this is a no-op.
658+
if (replacedAccountId && replacedAccountId !== accountId) {
659+
await removeAccount(replacedAccountId);
653660
}
654661

655662
// Warm the org + region cache so the first real command is fast.

‎packages/cli/src/lib/command.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
} from "@stricli/core";
3939
import type { Writer } from "../types/index.js";
4040
import { appendCacheHint } from "./cache-hint.js";
41-
import { getAuthConfig } from "./db/auth.js";
41+
import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js";
4242
import { getEnv } from "./env.js";
4343
import { AuthError, CliError, OutputError } from "./errors.js";
4444
import { warning } from "./formatters/colors.js";
@@ -785,7 +785,9 @@ export function buildCommand<
785785
// `sentry issue list help`). Without this, the auth prompt would fire
786786
// before the help-recovery path could show the command's help text.
787787
try {
788-
if (requiresAuth && !getAuthConfig()) {
788+
// A usable stored account defers the check: the command's target may
789+
// select it even when the account in effect is logged out.
790+
if (requiresAuth && !getAuthConfig() && !hasUsableStoredAccount()) {
789791
throw new AuthError("not_authenticated");
790792
}
791793

‎packages/cli/src/lib/constants.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,39 @@ export function getConfiguredSentryUrl(): string | undefined {
101101
}
102102
}
103103

104+
/** `SENTRY_URL` values the CLI applied from `defaults.url`, per invocation env. */
105+
const persistedDefaultUrls = new WeakMap<NodeJS.ProcessEnv, string>();
106+
107+
/**
108+
* Record that `env.SENTRY_URL` was set from the persisted default URL (not
109+
* by the user), so account selection may route to another stored
110+
* account's server instead of treating it as an explicit choice.
111+
*/
112+
export function markPersistedDefaultUrl(url: string): void {
113+
persistedDefaultUrls.set(getEnv(), url);
114+
}
115+
116+
/**
117+
* Forget which URL came from `defaults.url`, so the configured URL counts as
118+
* an explicit server choice from now on (e.g. a pasted SaaS URL pinning the
119+
* server to the same value as the persisted default). Tests also use it to
120+
* reset state.
121+
*/
122+
export function clearPersistedDefaultUrl(): void {
123+
persistedDefaultUrls.delete(getEnv());
124+
}
125+
126+
/** Whether the configured URL is still the one applied from `defaults.url`. */
127+
export function isPersistedDefaultUrlInEffect(): boolean {
128+
const env = getEnv();
129+
const applied = persistedDefaultUrls.get(env);
130+
return (
131+
applied !== undefined &&
132+
!env.SENTRY_HOST?.trim() &&
133+
env.SENTRY_URL?.trim() === applied
134+
);
135+
}
136+
104137
/** CLI version string, available for help output and other uses */
105138
export const CLI_VERSION =
106139
typeof SENTRY_CLI_VERSION !== "undefined" ? SENTRY_CLI_VERSION : "0.0.0-dev";
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Per-invocation account selection.
3+
*
4+
* Commands can resolve which stored account to use from what they target
5+
* (see `selectAccountForOrg` in `auth.ts`). That choice becomes the
6+
* invocation's default account: every credential read for the rest of the
7+
* invocation uses it. It is never persisted — the stored active account
8+
* (`sentry auth login` / `sentry auth switch`) is untouched, so the next
9+
* invocation starts from scratch and may pick a different account.
10+
*
11+
* Why this is keyed by the environment object instead of being a plain
12+
* module-level variable:
13+
*
14+
* - CLI mode runs one command per process. `getEnv()` always returns
15+
* `process.env`, so there is exactly one scope and this behaves like a
16+
* module-level variable.
17+
* - Library mode (`createSentrySDK()`, see `sdk-invoke.ts`) runs many
18+
* commands inside the consumer's long-lived process, and they
19+
* can overlap: each call builds its own isolated env copy and runs under
20+
* `withEnv()` (AsyncLocalStorage), so `getEnv()` returns that copy for
21+
* everything the call awaits. A module-level variable would be shared by
22+
* all of them — a call targeting org A would select account 1, and a
23+
* concurrent (or later) call targeting org B would silently reuse account
24+
* 1 instead of resolving its own. Keying by the env object gives every
25+
* call its own scope with no explicit setup or teardown.
26+
* - A `WeakMap` lets a finished call's scope be garbage-collected together
27+
* with its env copy, so long-running library consumers do not accumulate
28+
* one entry per call.
29+
*/
30+
31+
import { getEnv } from "../env.js";
32+
33+
type AccountScope = {
34+
/** Default account for the rest of this invocation, chosen from its target. */
35+
accountId?: string;
36+
};
37+
38+
/** One scope per invocation env; see the module comment for why. */
39+
let scopes = new WeakMap<NodeJS.ProcessEnv, AccountScope>();
40+
41+
/** The mutable account selection for the current invocation. */
42+
export function getAccountScope(): AccountScope {
43+
const env = getEnv();
44+
let scope = scopes.get(env);
45+
if (!scope) {
46+
scope = {};
47+
scopes.set(env, scope);
48+
}
49+
return scope;
50+
}
51+
52+
/** Forget every invocation's account selection. Tests only. */
53+
export function resetAccountScopes(): void {
54+
scopes = new WeakMap();
55+
}

0 commit comments

Comments
 (0)