@@ -2,9 +2,10 @@ use std::fmt::Write;
22
33use anyhow:: { Context , Result , bail} ;
44use owo_colors:: OwoColorize ;
5+ use url:: Url ;
56
67use uv_auth:: {
7- AuthBackend , Credentials , PyxTokenStore , Service , TextCredentialStore , Username ,
8+ AuthBackend , Credentials , LookupError , PyxTokenStore , Service , TextCredentialStore , Username ,
89 is_default_pyx_domain,
910} ;
1011use uv_client:: BaseClientBuilder ;
@@ -16,7 +17,7 @@ use crate::{commands::ExitStatus, printer::Printer};
1617
1718/// Logout from a service.
1819///
19- /// If no username is provided, defaults to `__token__` .
20+ /// If no username is provided, uv tries the default token entry first .
2021pub ( crate ) async fn logout (
2122 service : Service ,
2223 username : Option < String > ,
@@ -48,40 +49,85 @@ pub(crate) async fn logout(
4849 "Cannot specify a username both via the URL and CLI; found `--username {cli}` and `{url}`"
4950 ) ;
5051 }
51- ( Some ( cli) , None ) => cli,
52- ( None , Some ( url) ) => url. to_string ( ) ,
53- ( None , None ) => "__token__" . to_string ( ) ,
52+ ( Some ( cli) , None ) => Some ( cli) ,
53+ ( None , Some ( url) ) => Some ( url. to_string ( ) ) ,
54+ ( None , None ) => None ,
5455 } ;
55- if username. is_empty ( ) {
56+ if username. as_ref ( ) . is_some_and ( String :: is_empty ) {
5657 bail ! ( "Username cannot be empty" ) ;
5758 }
5859
59- let display_url = if username == "__token__" {
60- url. without_credentials ( ) . to_string ( )
61- } else {
62- format ! ( "{username}@{}" , url. without_credentials( ) )
63- } ;
60+ let url_without_credentials = url. without_credentials ( ) ;
6461
6562 // TODO(zanieb): Consider exhaustively logging out from all backends
66- match backend {
63+ let display_url = match backend {
6764 AuthBackend :: System ( provider) => {
68- provider
69- . remove ( & url, & username)
70- . await
71- . with_context ( || format ! ( "Unable to remove credentials for {display_url}" ) ) ?;
65+ if let Some ( username) = username. as_deref ( ) {
66+ let display_url = format_display_url ( & url_without_credentials, Some ( username) ) ;
67+ provider
68+ . remove ( & url, username)
69+ . await
70+ . with_context ( || format ! ( "Unable to remove credentials for {display_url}" ) ) ?;
71+ display_url
72+ } else if provider. fetch ( & url, Some ( "__token__" ) ) . await . is_some ( ) {
73+ provider. remove ( & url, "__token__" ) . await . with_context ( || {
74+ format ! ( "Unable to remove credentials for {url_without_credentials}" )
75+ } ) ?;
76+ url_without_credentials. to_string ( )
77+ } else {
78+ bail ! ( "{}" , missing_username_hint( & url_without_credentials) ) ;
79+ }
7280 }
7381 AuthBackend :: TextStore ( mut store, _lock) => {
74- if store
75- . remove ( & service, Username :: from ( Some ( username. clone ( ) ) ) )
76- . is_none ( )
82+ let display_url = if let Some ( username) = username {
83+ let display_url = format_display_url ( & url_without_credentials, Some ( & username) ) ;
84+ if store
85+ . remove ( & service, Username :: from ( Some ( username) ) )
86+ . is_none ( )
87+ {
88+ bail ! ( "No matching entry found for {display_url}" ) ;
89+ }
90+ display_url
91+ } else if store
92+ . remove ( & service, Username :: from ( Some ( "__token__" . to_string ( ) ) ) )
93+ . is_some ( )
7794 {
78- bail ! ( "No matching entry found for {display_url}" ) ;
79- }
95+ url_without_credentials. to_string ( )
96+ } else {
97+ let lookup = store. get_credential_entry ( & url, None ) . map ( |entry| {
98+ entry. map ( |( matched_service, credentials) | {
99+ (
100+ matched_service. into_owned ( ) ,
101+ credentials. username ( ) . map ( ToString :: to_string) ,
102+ )
103+ } )
104+ } ) ;
105+ match lookup {
106+ Ok ( Some ( ( matched_service, matched_username) ) ) => {
107+ let display_url = format_display_url (
108+ & url_without_credentials,
109+ matched_username. as_deref ( ) ,
110+ ) ;
111+ if store
112+ . remove ( & matched_service, Username :: from ( matched_username) )
113+ . is_none ( )
114+ {
115+ bail ! ( "No matching entry found for {display_url}" ) ;
116+ }
117+ display_url
118+ }
119+ Ok ( None ) => bail ! ( "{}" , missing_username_hint( & url_without_credentials) ) ,
120+ Err ( LookupError :: AmbiguousUsername ( ..) ) => {
121+ bail ! ( "{}" , ambiguous_username_hint( & url_without_credentials) )
122+ }
123+ }
124+ } ;
80125 store
81126 . write ( TextCredentialStore :: default_file ( ) ?, _lock)
82127 . with_context ( || "Failed to persist changes to credentials after removal" ) ?;
128+ display_url
83129 }
84- }
130+ } ;
85131
86132 writeln ! (
87133 printer. stderr( ) ,
@@ -92,6 +138,28 @@ pub(crate) async fn logout(
92138 Ok ( ExitStatus :: Success )
93139}
94140
141+ /// Format a URL for display, including the username if it's present (and not the default token
142+ /// entry).
143+ fn format_display_url ( url : & Url , username : Option < & str > ) -> String {
144+ if let Some ( username) = username. filter ( |username| * username != "__token__" ) {
145+ format ! ( "{username}@{url}" )
146+ } else {
147+ url. to_string ( )
148+ }
149+ }
150+
151+ fn missing_username_hint ( display_url : & Url ) -> String {
152+ format ! (
153+ "No matching entry found for {display_url}. If the credentials were stored with a username, pass `--username` to `uv auth logout`."
154+ )
155+ }
156+
157+ fn ambiguous_username_hint ( display_url : & Url ) -> String {
158+ format ! (
159+ "Multiple credentials found for {display_url}. Pass `--username` to `uv auth logout` to select which credentials to remove."
160+ )
161+ }
162+
95163/// Log out via the [`PyxTokenStore`], invalidating the existing tokens.
96164async fn pyx_logout (
97165 store : & PyxTokenStore ,
0 commit comments