Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/agent-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value
## Common Mistakes

- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix.
- **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.
- **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.
- **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically.
- **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.
- **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.
Expand Down
25 changes: 24 additions & 1 deletion apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
`sentry auth login` asks whether to add another account and makes it active;
logging in again as the same user on the same server replaces that account's
credentials instead of adding a duplicate. `--force` skips the prompt and
replaces the active account's login instead.
replaces the stored login for that server instead (or adds one when that
server has none); accounts on other servers are never replaced.

```bash
# Add (or log in again to) a specific account without prompting
Expand All @@ -91,16 +92,38 @@
only candidate. If the same email is stored on several servers, add `--url`:
`sentry auth switch jane@example.com --url https://sentry.example.com`.

Most of the time you don't need to switch: commands pick the stored account
that fits what they target, without changing the active account:

- **Organization** — when the target organization isn't in the active
account's organization list (cached at login and by `sentry org list`) but
another stored account has used it, that account is used. This includes
`oNNNN` IDs from DSNs.
- **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry
URL for another account's server (for example a self-hosted issue link) uses
the account stored for that server.

```bash
# Active account is personal; this uses the work account that knows acme-corp
sentry issue list acme-corp/

# Uses the self-hosted account without switching
sentry issue view https://sentry.example.com/organizations/acme/issues/123/
```

Otherwise the active account is used. One command never mixes accounts; when
several other accounts know the organization, the most recently used wins.

Switching also points the default URL (`sentry cli defaults url`) at the
account's server. `SENTRY_HOST`/`SENTRY_URL` and `SENTRY_AUTH_TOKEN` (with
`SENTRY_FORCE_ENV_TOKEN=1`) still take precedence over the active account.
Default organization and project settings are shared by all accounts.

### Logout

```bash
# Log out of the active account (the most recently used other account becomes active)
sentry auth logout

Check warning on line 126 in apps/cli-docs/src/fragments/commands/auth.md

View check run for this annotation

@sentry/warden / warden: code-review

Auth guard prevents target-based account selection when the active account is unusable

For an auth-required command targeting an organization or pasted Sentry URL owned by another stored account, an unusable active account causes authentication to fail before the command parses the target and selects that account. The invocation then fails as unauthenticated or starts interactive auto-login instead of using the matching stored credentials.

# Log out of specific accounts (space- or comma-separated)
sentry auth logout jane@example.com
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value
### Common Mistakes

- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix.
- **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.
- **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.
- **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically.
- **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.
- **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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Authenticate with Sentry
**Flags:**
- `--token <value> - Authenticate using an API token instead of OAuth`
- `--timeout <value> - Timeout for OAuth flow in seconds (default: 900) - (default: "900")`
- `--force - Replace the active account's login without prompting, instead of adding another account`
- `--force - Replace the stored login for this server without prompting, instead of adding another account`
- `--url <value> - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).`
- `--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.`
- `-s, --scope <value>... - Request specific OAuth scopes (repeatable, comma-separated). E.g. --scope project:read --scope org:read. Overrides the default scope set.`
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ async function preloadProjectContext(cwd: string): Promise<void> {
const url = getDefaultUrl();
if (url) {
env.SENTRY_URL = url;
const { markPersistedDefaultUrl } = await import("./lib/constants.js");
markPersistedDefaultUrl(url);
}
} catch {
// DB not available — skip
Expand Down
31 changes: 19 additions & 12 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { normalizeAuthToken } from "../../lib/auth-header.js";
import { buildCommand, numberParser } from "../../lib/command.js";
import { normalizeUrl } from "../../lib/constants.js";
import {
getAccountIdToReplace,
getActiveAccountId,
getActiveEnvVarName,
hasStoredAuthCredentials,
Expand Down Expand Up @@ -345,7 +346,8 @@ export function applyLoginUrl(url: string | undefined): string {
* command should exit early. Without `--force` the new login is added
* alongside the current account and becomes active (logging in again as
* the same user replaces that user's old account); with `--force` it
* replaces the active account's credentials.
* replaces the stored login for the same server (or adds one when that
* server has none). Accounts on other servers are never touched.
*
* - Env-var auth only: proceeds (stored OAuth is kept separately).
* - `--force`: proceeds without prompting.
Expand Down Expand Up @@ -474,7 +476,8 @@ export const loginCommand = buildCommand({
"currently-authenticated host.\n\n" +
"When already logged in, the new login is stored as another account\n" +
"(see 'sentry auth list' / 'sentry auth switch'); --force instead\n" +
"replaces the active account's login. Pass an email to add or log in\n" +
"replaces the stored login for that server (accounts on other servers\n" +
"are kept). Pass an email to add or log in\n" +
"again to that account without prompting: Sentry approves the request\n" +
"as whoever is signed in to the browser, so a login approved as another\n" +
"user is refused and nothing is saved.\n\n" +
Expand All @@ -501,7 +504,7 @@ export const loginCommand = buildCommand({
force: {
kind: "boolean",
brief:
"Replace the active account's login without prompting, instead of adding another account",
"Replace the stored login for this server without prompting, instead of adding another account",
default: false,
},
url: {
Expand Down Expand Up @@ -586,16 +589,20 @@ export const loginCommand = buildCommand({
// Non-fatal: cache directory may not exist
}

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

if (token !== undefined) {
// Save token first (with host scope), then validate by fetching user
// regions. It is always stored as its own account so a rejected token
// never touches the stored ones; with `--force` the previously active
// account is replaced only once the new token checks out.
// never touches the stored ones; with `--force` the stored login for
// this server (never another server's) is replaced only once the new
// token checks out.
const previousAccountId = getActiveAccountId();
const replacedAccountId = newAccount
? undefined
: getAccountIdToReplace(effectiveHost);
const accountId = setAuthToken(token, undefined, undefined, {
host: effectiveHost,
newAccount: true,
Expand Down Expand Up @@ -645,11 +652,11 @@ export const loginCommand = buildCommand({
}
result.user = toLoginUser(user);
}
// Replace the previous account only now. After setUserInfo, so a
// re-login as the same user folds that account in (keeping a known
// username) instead of losing it; then this is a no-op.
if (!newAccount && previousAccountId && previousAccountId !== accountId) {
await removeAccount(previousAccountId);
// Replace this server's previous login only now. After setUserInfo,
// so a re-login as the same user folds that account in (keeping a
// known username) instead of losing it; then this is a no-op.
if (replacedAccountId && replacedAccountId !== accountId) {
await removeAccount(replacedAccountId);
}

// Warm the org + region cache so the first real command is fast.
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,34 @@ export function getConfiguredSentryUrl(): string | undefined {
}
}

/** `SENTRY_URL` values the CLI applied from `defaults.url`, per invocation env. */
const persistedDefaultUrls = new WeakMap<NodeJS.ProcessEnv, string>();

/**
* Record that `env.SENTRY_URL` was set from the persisted default URL (not
* by the user), so account selection may route to another stored
* account's server instead of treating it as an explicit choice.
*/
export function markPersistedDefaultUrl(url: string): void {
persistedDefaultUrls.set(getEnv(), url);
}

/** Forget which URL came from `defaults.url`. Tests only. */
export function clearPersistedDefaultUrl(): void {
persistedDefaultUrls.delete(getEnv());
}

/** Whether the configured URL is still the one applied from `defaults.url`. */
export function isPersistedDefaultUrlInEffect(): boolean {
const env = getEnv();
const applied = persistedDefaultUrls.get(env);
return (
applied !== undefined &&
!env.SENTRY_HOST?.trim() &&
env.SENTRY_URL?.trim() === applied
);
}

/** CLI version string, available for help output and other uses */
export const CLI_VERSION =
typeof SENTRY_CLI_VERSION !== "undefined" ? SENTRY_CLI_VERSION : "0.0.0-dev";
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/lib/db/account-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Per-invocation account selection.
*
* Commands can resolve which stored account to use from what they target
* (see `selectAccountForOrg` in `auth.ts`). That choice becomes the
* invocation's default account: every credential read for the rest of the
* invocation uses it. It is never persisted — the stored active account
* (`sentry auth login` / `sentry auth switch`) is untouched, so the next
* invocation starts from scratch and may pick a different account.
*
* Why this is keyed by the environment object instead of being a plain
* module-level variable:
*
* - CLI mode runs one command per process. `getEnv()` always returns
* `process.env`, so there is exactly one scope and this behaves like a
* module-level variable.
* - Library mode (`createSentrySDK()`, see `sdk-invoke.ts`) runs many
* commands inside the consumer's long-lived process, and they
* can overlap: each call builds its own isolated env copy and runs under
* `withEnv()` (AsyncLocalStorage), so `getEnv()` returns that copy for
* everything the call awaits. A module-level variable would be shared by
* all of them — a call targeting org A would select account 1, and a
* concurrent (or later) call targeting org B would silently reuse account
* 1 instead of resolving its own. Keying by the env object gives every
* call its own scope with no explicit setup or teardown.
* - A `WeakMap` lets a finished call's scope be garbage-collected together
* with its env copy, so long-running library consumers do not accumulate
* one entry per call.
*/

import { getEnv } from "../env.js";

type AccountScope = {
/** Default account for the rest of this invocation, chosen from its target. */
accountId?: string;
};

/** One scope per invocation env; see the module comment for why. */
let scopes = new WeakMap<NodeJS.ProcessEnv, AccountScope>();
Comment thread
sentry-junior[bot] marked this conversation as resolved.

/** The mutable account selection for the current invocation. */
export function getAccountScope(): AccountScope {
const env = getEnv();
let scope = scopes.get(env);
if (!scope) {
scope = {};
scopes.set(env, scope);
}
return scope;
}

/** Forget every invocation's account selection. Tests only. */
export function resetAccountScopes(): void {
scopes = new WeakMap();
}
Loading
Loading