@@ -75,38 +75,55 @@ command to run.`,
7575 return fmt .Errorf ("app not initialized" )
7676 }
7777
78+ // The server check goes first: the request may refresh the stored
79+ // credential, or forget a dead one, and the report must describe
80+ // what is stored afterwards.
81+ var verdict * checkVerdict
82+ if check && (os .Getenv ("BASECAMP_TOKEN" ) != "" || app .Auth .IsAuthenticated ()) {
83+ v , err := checkWithServer (cmd .Context (), app )
84+ if err != nil {
85+ return err
86+ }
87+ verdict = v
88+ }
7889 report , err := authStatusReport (app )
7990 if err != nil {
8091 return err
8192 }
82- if check && report .data ["authenticated" ] == true {
83- if err := report .checkWithServer (cmd .Context (), app ); err != nil {
84- return err
85- }
93+ if verdict != nil {
94+ report .record (app , verdict )
8695 }
8796
8897 if ! humanOutput (app ) {
8998 opts := []output.ResponseOption {output .WithSummary (report .summary )}
9099 if report .hint != "" {
91- opts = append (opts , output .WithBreadcrumbs (output.Breadcrumb {
92- Action : "login" , Cmd : app .Auth .LoginCommand (), Description : "Log in to Basecamp" ,
93- }))
100+ opts = append (opts , output .WithNotice (report .hint ))
94101 }
95102 return app .OK (report .data , opts ... )
96103 }
97104
105+ // A direct terminal sink: every line carries config and stored
106+ // values, so each is reduced to one terminal-safe line first, as
107+ // the envelope renderer would.
98108 w := cmd .OutOrStdout ()
99- r := output .NewRendererWithTheme (w , false , tui .ResolveTheme (tui .DetectDark ()))
109+ r := output .NewRendererWithTheme (w , app . Flags . Styled , tui .ResolveTheme (tui .DetectDark ()))
100110 headline := r .Summary
101111 if report .data ["authenticated" ] == true {
102112 headline = r .Success
103113 }
104- fmt .Fprintln (w , headline .Render (report .summary ))
114+ fmt .Fprintln (w , headline .Render (richtext . SanitizeSingleLine ( report .summary ) ))
105115 for _ , line := range report .details {
106- fmt .Fprintln (w , r .Muted .Render (" " + line ))
116+ fmt .Fprintln (w , r .Muted .Render (" " + richtext . SanitizeSingleLine ( line ) ))
107117 }
108118 if report .hint != "" {
109- fmt .Fprintln (w , r .Data .Render (" " + report .hint ))
119+ fmt .Fprintln (w , r .Data .Render (" " + richtext .SanitizeSingleLine (report .hint )))
120+ }
121+ if app .Flags .Stats && ! app .Flags .NoStats && app .Collector != nil {
122+ stats := app .Collector .Summary ()
123+ if parts := stats .FormatParts (); len (parts ) > 0 {
124+ fmt .Fprintln (w )
125+ fmt .Fprintln (w , r .Muted .Render (strings .Join (parts , " · " )))
126+ }
110127 }
111128 return nil
112129 },
@@ -117,29 +134,51 @@ command to run.`,
117134 return cmd
118135}
119136
120- // checkWithServer makes the one authenticated request --check promises and
121- // records the verdict. An auth-class failure — the server refused the token,
122- // or the credential could not be refreshed — is the "rejected" answer, not
123- // an error: that is what the caller asked. Anything else (the server could
124- // not be reached, a fault) is returned as itself, since no verdict was had.
125- func (s * authStatus ) checkWithServer (ctx context.Context , app * appctx.App ) error {
137+ // checkVerdict is the server's answer to --check: whether it accepted the
138+ // token the CLI would send right now.
139+ type checkVerdict struct {
140+ valid bool
141+ }
142+
143+ // checkWithServer makes the one authenticated request --check promises. An
144+ // auth-class failure — the server refused the token, or the credential could
145+ // not be refreshed — is the "rejected" verdict, not an error: that is what
146+ // the caller asked. Anything else (the server could not be reached, a fault)
147+ // is returned as itself, since no verdict was had.
148+ func checkWithServer (ctx context.Context , app * appctx.App ) (* checkVerdict , error ) {
126149 endpoint , err := app .Auth .AuthorizationEndpoint (ctx )
127150 if err != nil {
128- return err
151+ return nil , err
129152 }
130153 _ , err = app .SDK .Authorization ().GetInfo (ctx , & basecamp.GetInfoOptions {Endpoint : endpoint , FilterProduct : "bc3" })
131154 switch {
132155 case err == nil :
133- s .data ["valid" ] = true
134- s .details = append (s .details , "Token: valid (checked just now)" )
156+ return & checkVerdict {valid : true }, nil
135157 case output .AsError (err ).Code == output .CodeAuth :
136- s .data ["valid" ] = false
137- s .details = append (s .details , "Token: rejected by the server" )
138- s .hint = app .Auth .LoginHint ()
158+ return & checkVerdict {valid : false }, nil
139159 default :
140- return convertSDKError (err )
160+ return nil , convertSDKError (err )
161+ }
162+ }
163+
164+ // envTokenRejectedHint is the remedy when the server refuses BASECAMP_TOKEN:
165+ // every request sends the environment token ahead of any stored login, so
166+ // logging in would change nothing.
167+ const envTokenRejectedHint = "BASECAMP_TOKEN is set and the server rejected it; unset it, or export a token the server accepts"
168+
169+ // record adds the server's verdict to the report.
170+ func (s * authStatus ) record (app * appctx.App , v * checkVerdict ) {
171+ s .data ["valid" ] = v .valid
172+ if v .valid {
173+ s .details = append (s .details , "Token: valid (checked just now)" )
174+ return
175+ }
176+ s .details = append (s .details , "Token: rejected by the server" )
177+ if os .Getenv ("BASECAMP_TOKEN" ) != "" {
178+ s .hint = envTokenRejectedHint
179+ } else {
180+ s .hint = app .Auth .LoginHint ()
141181 }
142- return nil
143182}
144183
145184// humanOutput reports whether the command's output is read by a person: the
@@ -188,6 +227,7 @@ func authStatusReport(app *appctx.App) (*authStatus, error) {
188227 report .data ["authenticated" ] = true
189228 report .data ["source" ] = "BASECAMP_TOKEN"
190229 report .data ["storage" ] = "env"
230+ report .data ["refreshable" ] = false
191231 report .summary = "Logged in to " + baseURL + " via BASECAMP_TOKEN"
192232 report .details = []string {strings .Join (append (where , "Source: BASECAMP_TOKEN" , "Storage: env" ), " · " )}
193233 return report , nil
@@ -258,16 +298,22 @@ func authStatusReport(app *appctx.App) (*authStatus, error) {
258298 if creds .ExpiresAt > 0 {
259299 expiresAt := time .Unix (creds .ExpiresAt , 0 )
260300 expiresIn := time .Until (expiresAt )
301+ // A token with nothing to refresh with is refused inside the refresh
302+ // window, so from the CLI's side it is already expired there.
303+ expired := expiresIn < 0 || (! refreshable && expiresIn <= auth .RefreshWindow )
261304 report .data ["expires_at" ] = expiresAt .UTC ().Format (time .RFC3339 )
262305 report .data ["expires_in" ] = expiresIn .Round (time .Second ).String ()
263- report .data ["expired" ] = expiresIn < 0
306+ report .data ["expired" ] = expired
264307 switch {
265- case expiresIn >= 0 && refreshable :
308+ case ! expired && refreshable :
266309 expiry = "expires in " + coarseDuration (expiresIn ) + ", refreshes automatically"
267- case expiresIn >= 0 :
310+ case ! expired :
268311 expiry = "expires in " + coarseDuration (expiresIn )
269312 case refreshable :
270313 expiry = "expired, will refresh on next use"
314+ case expiresIn >= 0 :
315+ expiry = "expires in " + coarseDuration (expiresIn ) + ", inside the " + coarseDuration (auth .RefreshWindow ) + " the CLI keeps clear of expiry"
316+ report .hint = app .Auth .LoginHint ()
271317 default :
272318 expiry = "expired"
273319 report .hint = app .Auth .LoginHint ()
0 commit comments