@@ -42,6 +42,10 @@ import {
4242 normalizeOrigin ,
4343 normalizeUserInputToOrigin ,
4444} from "../../lib/sentry-urls.js" ;
45+ import {
46+ loadSentryCliRc ,
47+ type SentryCliRcConfig ,
48+ } from "../../lib/sentryclirc.js" ;
4549import {
4650 isLoginTrustAnchorFor ,
4751 registerLoginTrustAnchor ,
@@ -115,10 +119,15 @@ export function parseLoginUrl(raw: string): string {
115119 * a trusted source (`--url` flag or boot-time env snapshot), so "no
116120 * matching anchor" is the load-bearing signal that the host arrived via
117121 * an untrusted channel.
122+ *
123+ * @param rcSource - Path of the `.sentryclirc` file that provided the URL,
124+ * if that's where the host came from. Used to produce a more actionable
125+ * error message pointing at the specific file.
118126 */
119127function refuseLoginToUntrustedHost (
120128 flags : LoginFlags ,
121- effectiveHost : string
129+ effectiveHost : string ,
130+ rcSource ?: string
122131) : void {
123132 if (
124133 flags . url ||
@@ -127,11 +136,78 @@ function refuseLoginToUntrustedHost(
127136 ) {
128137 return ;
129138 }
130- const tokenHint = flags . token ? " --token <token>" : "" ;
139+ const tokenFlag = flags . token ? " --token <your-token>" : "" ;
140+ const sourceClause = rcSource
141+ ? `this URL was read from .sentryclirc (${ rcSource } ) but hasn't been confirmed as trusted yet`
142+ : "--url was not provided" ;
131143 throw new HostScopeError (
132- `Refusing to log in against ${ effectiveHost } without explicit --url.\n` +
133- "Pass the host explicitly to confirm you trust it:\n" +
134- ` sentry auth login --url ${ effectiveHost } ${ tokenHint } `
144+ `Refusing to log in against ${ effectiveHost } — ${ sourceClause } .\n\n` +
145+ "To authenticate against this self-hosted instance, confirm the host explicitly:\n" +
146+ ` sentry auth login --url ${ effectiveHost } ${ tokenFlag } `
147+ ) ;
148+ }
149+
150+ /**
151+ * Resolve which `.sentryclirc` file (if any) provided the effective host, and
152+ * return its path alongside the full rc config for downstream use.
153+ */
154+ async function resolveRcContext (
155+ flagUrl : string | undefined ,
156+ cwd : string ,
157+ effectiveHost : string
158+ ) : Promise < {
159+ rcConfig : SentryCliRcConfig ;
160+ urlFromRc : string | undefined ;
161+ } > {
162+ const rcConfig = await loadSentryCliRc ( cwd ) ;
163+ const rcUrlNormalized = rcConfig . url
164+ ? normalizeOrigin ( normalizeUrl ( rcConfig . url ) )
165+ : undefined ;
166+ const urlFromRc =
167+ ! flagUrl &&
168+ ! ! rcUrlNormalized &&
169+ normalizeOrigin ( effectiveHost ) === rcUrlNormalized
170+ ? rcConfig . sources . url
171+ : undefined ;
172+ return { rcConfig, urlFromRc } ;
173+ }
174+
175+ /**
176+ * Returns a hint string when .sentryclirc contains a token the user could
177+ * pass directly via --token instead of going through the OAuth flow.
178+ * Returned as a footer hint so it appears after login completes, not before.
179+ *
180+ * Only shown when the stored token is plausibly for the current host: either
181+ * no URL is set in the rc file (global SaaS token) or the rc URL matches
182+ * effectiveHost. A mismatched URL means the token is for a different instance.
183+ */
184+ /** @internal exported for testing */
185+ export function rcTokenHint (
186+ rcConfig : SentryCliRcConfig ,
187+ effectiveHost : string
188+ ) : string | undefined {
189+ if ( ! rcConfig . token ) {
190+ return ;
191+ }
192+ const rcUrl = rcConfig . url
193+ ? normalizeOrigin ( normalizeUrl ( rcConfig . url ) )
194+ : undefined ;
195+ // Token is for a different host — don't suggest it
196+ if ( rcUrl && rcUrl !== normalizeOrigin ( effectiveHost ) ) {
197+ return ;
198+ }
199+ // No URL in rc means a bare SaaS token — don't suggest it for self-hosted
200+ if ( ! ( rcUrl || isSaaSTrustOrigin ( effectiveHost ) ) ) {
201+ return ;
202+ }
203+ // Always include --url for self-hosted instances regardless of how the host
204+ // was supplied — omitting it would point the user at SaaS instead.
205+ const urlHint = isSaaSTrustOrigin ( effectiveHost )
206+ ? ""
207+ : ` --url ${ effectiveHost } ` ;
208+ return (
209+ `Found a token in .sentryclirc (${ rcConfig . sources . token } ). ` +
210+ `To skip OAuth next time: sentry auth login --token <token>${ urlHint } `
135211 ) ;
136212}
137213
@@ -287,43 +363,46 @@ export const loginCommand = buildCommand({
287363 // requested instance. Default URL persistence is deferred until login
288364 // succeeds — see persistLoginUrlAsDefault calls below.
289365 const effectiveHost = applyLoginUrl ( flags . url ) ;
290- refuseLoginToUntrustedHost ( flags , effectiveHost ) ;
291366
292- // Check if already authenticated and handle re-authentication
367+ // Check whether the effective URL came from .sentryclirc so we can name
368+ // the source file in trust-refusal errors and show a migration tip.
369+ const { rcConfig, urlFromRc } = await resolveRcContext (
370+ flags . url ,
371+ this . cwd ,
372+ effectiveHost
373+ ) ;
374+
375+ refuseLoginToUntrustedHost ( flags , effectiveHost , urlFromRc ) ;
376+
293377 if ( isAuthenticated ( ) ) {
294378 const shouldProceed = await handleExistingAuth ( flags . force ) ;
295379 if ( ! shouldProceed ) {
296380 return ;
297381 }
298382 }
299383
300- // Clear stale cached responses from a previous session
301384 try {
302385 await clearResponseCache ( ) ;
303386 } catch {
304387 // Non-fatal: cache directory may not exist
305388 }
306389
307- // Token-based authentication
308390 if ( flags . token ) {
309391 // Save token first (with host scope), then validate by fetching user regions
310392 await setAuthToken ( flags . token , undefined , undefined , {
311393 host : effectiveHost ,
312394 } ) ;
313395
314- // Validate token by fetching user regions
315396 try {
316397 await getUserRegions ( ) ;
317398 } catch {
318- // Token is invalid - clear it and throw
319399 await clearAuth ( ) ;
320400 throw new AuthError (
321401 "invalid" ,
322402 "Invalid API token. Please check your token and try again."
323403 ) ;
324404 }
325405
326- // Login succeeded — persist default URL for subsequent invocations.
327406 persistLoginUrlAsDefault ( flags . url , effectiveHost ) ;
328407
329408 // Fetch and cache user info via /auth/ (works with all token types).
@@ -357,16 +436,13 @@ export const loginCommand = buildCommand({
357436 } ) ;
358437
359438 if ( result ) {
360- // Login succeeded — persist default URL for subsequent invocations.
361439 persistLoginUrlAsDefault ( flags . url , effectiveHost ) ;
362- // Warm the org + region cache so the first real command is fast.
363- // Fire-and-forget — login already succeeded, caching is best-effort.
364440 warmOrgCache ( ) ;
365441 yield new CommandOutput ( result ) ;
366- } else {
367- // Error already displayed by runInteractiveLogin
368- process . exitCode = 1 ;
442+ return { hint : rcTokenHint ( rcConfig , effectiveHost ) } ;
369443 }
444+ // Error already displayed by runInteractiveLogin
445+ process . exitCode = 1 ;
370446 } ,
371447} ) ;
372448
0 commit comments