11use std:: collections:: BTreeMap ;
2+ use std:: error:: Error as StdError ;
23use std:: fs;
34use std:: io:: { IsTerminal , Write } ;
45use std:: path:: { Path , PathBuf } ;
@@ -67,6 +68,48 @@ pub struct AvailableOrg {
6768 pub api_url : Option < String > ,
6869}
6970
71+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
72+ enum RecoverableAuthErrorKind {
73+ OauthProfileSelection ,
74+ OauthClientId ,
75+ OauthRefreshToken ,
76+ StoredCredential ,
77+ }
78+
79+ #[ derive( Debug ) ]
80+ struct RecoverableAuthError {
81+ kind : RecoverableAuthErrorKind ,
82+ message : String ,
83+ }
84+
85+ impl std:: fmt:: Display for RecoverableAuthError {
86+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
87+ f. write_str ( & self . message )
88+ }
89+ }
90+
91+ impl StdError for RecoverableAuthError { }
92+
93+ fn recoverable_auth_error ( kind : RecoverableAuthErrorKind , message : String ) -> anyhow:: Error {
94+ anyhow:: Error :: new ( RecoverableAuthError { kind, message } )
95+ }
96+
97+ pub fn is_missing_credential_error ( err : & anyhow:: Error ) -> bool {
98+ err. chain ( ) . any ( |source| {
99+ source
100+ . downcast_ref :: < RecoverableAuthError > ( )
101+ . is_some_and ( |err| {
102+ matches ! (
103+ err. kind,
104+ RecoverableAuthErrorKind :: OauthProfileSelection
105+ | RecoverableAuthErrorKind :: OauthClientId
106+ | RecoverableAuthErrorKind :: OauthRefreshToken
107+ | RecoverableAuthErrorKind :: StoredCredential
108+ )
109+ } )
110+ } )
111+ }
112+
70113pub fn list_profiles ( ) -> Result < Vec < ProfileInfo > > {
71114 let store = load_auth_store ( ) ?;
72115 Ok ( store
@@ -494,15 +537,23 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result<ResolvedAuth> {
494537 . or_else ( || {
495538 ( store. profiles . len ( ) == 1 ) . then ( || store. profiles . keys ( ) . next ( ) . unwrap ( ) . as_str ( ) )
496539 } )
497- . ok_or_else ( || anyhow:: anyhow!( "oauth profile requested but none selected" ) ) ?
540+ . ok_or_else ( || {
541+ recoverable_auth_error (
542+ RecoverableAuthErrorKind :: OauthProfileSelection ,
543+ "oauth profile requested but none selected" . to_string ( ) ,
544+ )
545+ } ) ?
498546 . to_string ( ) ;
499547 let profile = store
500548 . profiles
501549 . get ( profile_name. as_str ( ) )
502550 . ok_or_else ( || anyhow:: anyhow!( "profile '{profile_name}' not found" ) ) ?;
503551 let client_id = profile. oauth_client_id . as_deref ( ) . ok_or_else ( || {
504- anyhow:: anyhow!(
505- "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`"
552+ recoverable_auth_error (
553+ RecoverableAuthErrorKind :: OauthClientId ,
554+ format ! (
555+ "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`"
556+ ) ,
506557 )
507558 } ) ?;
508559 let cached_expires_at = profile. oauth_access_expires_at ;
@@ -519,8 +570,11 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result<ResolvedAuth> {
519570 }
520571
521572 let refresh_token = load_profile_oauth_refresh_token ( & profile_name) ?. ok_or_else ( || {
522- anyhow:: anyhow!(
523- "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`"
573+ recoverable_auth_error (
574+ RecoverableAuthErrorKind :: OauthRefreshToken ,
575+ format ! (
576+ "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`"
577+ ) ,
524578 )
525579 } ) ?;
526580 let refreshed = refresh_oauth_access_token ( & api_url, & refresh_token, client_id) . await ?;
@@ -640,8 +694,11 @@ where
640694 None
641695 } else {
642696 Some ( load_secret ( profile_name) ?. ok_or_else ( || {
643- anyhow:: anyhow!(
644- "no keychain credential found for profile '{profile_name}'; re-run `bt auth login --profile {profile_name}`"
697+ recoverable_auth_error (
698+ RecoverableAuthErrorKind :: StoredCredential ,
699+ format ! (
700+ "no keychain credential found for profile '{profile_name}'; re-run `bt auth login --profile {profile_name}`"
701+ ) ,
645702 )
646703 } ) ?)
647704 } ;
@@ -831,6 +888,7 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {
831888 Ok ( ( ) )
832889}
833890
891+ #[ allow( dead_code) ]
834892pub async fn login_interactive ( base : & mut BaseArgs ) -> Result < String > {
835893 let methods = [ "OAuth (browser)" , "API key" ] ;
836894 let selected = ui:: fuzzy_select ( "Select login method" , & methods, 0 ) ?;
@@ -842,6 +900,11 @@ pub async fn login_interactive(base: &mut BaseArgs) -> Result<String> {
842900 }
843901}
844902
903+ pub async fn login_setup_oauth ( base : & mut BaseArgs ) -> Result < String > {
904+ login_interactive_oauth ( base) . await
905+ }
906+
907+ #[ allow( dead_code) ]
845908async fn login_interactive_api_key ( base : & mut BaseArgs ) -> Result < String > {
846909 let api_key = prompt_api_key ( ) ?;
847910
@@ -2642,14 +2705,16 @@ mod tests {
26422705 fn make_base ( ) -> BaseArgs {
26432706 BaseArgs {
26442707 json : false ,
2708+ verbose : false ,
26452709 quiet : false ,
26462710 no_color : false ,
2711+ no_input : false ,
26472712 profile : None ,
26482713 project : None ,
26492714 org_name : None ,
26502715 api_key : None ,
2716+ api_key_source : None ,
26512717 prefer_profile : false ,
2652- no_input : false ,
26532718 api_url : None ,
26542719 app_url : None ,
26552720 env_file : None ,
@@ -2710,6 +2775,34 @@ mod tests {
27102775 assert_err_contains ( result, "invalid api_url" ) ;
27112776 }
27122777
2778+ #[ test]
2779+ fn missing_credential_error_helper_detects_typed_auth_errors ( ) {
2780+ let err = recoverable_auth_error (
2781+ RecoverableAuthErrorKind :: OauthRefreshToken ,
2782+ "oauth refresh token missing" . to_string ( ) ,
2783+ ) ;
2784+
2785+ assert ! ( is_missing_credential_error( & err) ) ;
2786+ }
2787+
2788+ #[ test]
2789+ fn missing_credential_error_helper_ignores_unrelated_errors ( ) {
2790+ let err = anyhow:: anyhow!( "some unrelated error" ) ;
2791+
2792+ assert ! ( !is_missing_credential_error( & err) ) ;
2793+ }
2794+
2795+ #[ test]
2796+ fn missing_credential_error_helper_detects_errors_through_context ( ) {
2797+ let err = recoverable_auth_error (
2798+ RecoverableAuthErrorKind :: StoredCredential ,
2799+ "missing stored credential" . to_string ( ) ,
2800+ )
2801+ . context ( "while resolving auth" ) ;
2802+
2803+ assert ! ( is_missing_credential_error( & err) ) ;
2804+ }
2805+
27132806 fn restore_env_var ( key : & str , previous : Option < OsString > ) {
27142807 match previous {
27152808 Some ( value) => env:: set_var ( key, value) ,
0 commit comments