From 82d8a14c73623cac9676689f01d3d177abde8882 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 3 Feb 2026 20:24:21 +0000 Subject: [PATCH] Add `api` subcommand for arbitrary api calls not covered by existing subcommands (#879) Reviewed-on: https://gitea.com/gitea/tea/pulls/879 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- Makefile | 2 +- cmd/api.go | 274 ++++++++++++++++++++++++++++++++++++++++ cmd/cmd.go | 1 + cmd/login/helper.go | 43 ++++--- docs/CLI.md | 22 ++++ main.go | 16 ++- modules/api/client.go | 105 +++++++++++++++ modules/auth/oauth.go | 61 +-------- modules/config/login.go | 149 +++++++++++++--------- 9 files changed, 540 insertions(+), 133 deletions(-) create mode 100644 cmd/api.go create mode 100644 modules/api/client.go diff --git a/Makefile b/Makefile index 735b329..ae0038b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH) GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") # Tool packages with pinned versions -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.7.0 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 ifneq ($(DRONE_TAG),) diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 0000000..d50c925 --- /dev/null +++ b/cmd/api.go @@ -0,0 +1,274 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// CmdApi represents the api command +var CmdApi = cli.Command{ + Name: "api", + Usage: "Make an authenticated API request", + Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. + +The endpoint argument is the path to the API endpoint, which will be prefixed +with /api/v1/ if it doesn't start with /api/ or http(s)://. + +Placeholders like {owner} and {repo} in the endpoint will be replaced with +values from the current repository context. + +Use -f for string fields and -F for typed fields (numbers, booleans, null). +With -F, prefix value with @ to read from file (@- for stdin).`, + ArgsUsage: "", + Action: runApi, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"X"}, + Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)", + Value: "GET", + }, + &cli.StringSliceFlag{ + Name: "field", + Aliases: []string{"f"}, + Usage: "Add a string field to the request body (key=value)", + }, + &cli.StringSliceFlag{ + Name: "Field", + Aliases: []string{"F"}, + Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)", + }, + &cli.StringSliceFlag{ + Name: "header", + Aliases: []string{"H"}, + Usage: "Add a custom header (key:value)", + }, + &cli.BoolFlag{ + Name: "include", + Aliases: []string{"i"}, + Usage: "Include HTTP status and response headers in output (written to stderr)", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Write response body to file instead of stdout (use '-' for stdout)", + }, + }, flags.LoginRepoFlags...), +} + +func runApi(_ stdctx.Context, cmd *cli.Command) error { + ctx := context.InitCommand(cmd) + + // Get the endpoint argument + if cmd.NArg() < 1 { + return fmt.Errorf("endpoint argument required") + } + endpoint := cmd.Args().First() + + // Expand placeholders in endpoint + endpoint = expandPlaceholders(endpoint, ctx) + + // Parse headers + headers := make(map[string]string) + for _, h := range cmd.StringSlice("header") { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid header format: %q (expected key:value)", h) + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build request body from fields + var body io.Reader + stringFields := cmd.StringSlice("field") + typedFields := cmd.StringSlice("Field") + + if len(stringFields) > 0 || len(typedFields) > 0 { + bodyMap := make(map[string]any) + + // Process string fields (-f) + for _, f := range stringFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + bodyMap[parts[0]] = parts[1] + } + + // Process typed fields (-F) + for _, f := range typedFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + value := parts[1] + + parsedValue, err := parseTypedValue(value) + if err != nil { + return fmt.Errorf("failed to parse field %q: %w", key, err) + } + bodyMap[key] = parsedValue + } + + bodyBytes, err := json.Marshal(bodyMap) + if err != nil { + return fmt.Errorf("failed to encode request body: %w", err) + } + body = strings.NewReader(string(bodyBytes)) + } + + // Create API client and make request + client := api.NewClient(ctx.Login) + method := strings.ToUpper(cmd.String("method")) + + resp, err := client.Do(method, endpoint, body, headers) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Print headers to stderr if requested (so redirects/pipes work correctly) + if cmd.Bool("include") { + fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status) + for key, values := range resp.Header { + for _, value := range values { + fmt.Fprintf(os.Stderr, "%s: %s\n", key, value) + } + } + fmt.Fprintln(os.Stderr) + } + + // Determine output destination + outputPath := cmd.String("output") + forceStdout := outputPath == "-" + outputToStdout := outputPath == "" || forceStdout + + // Check for binary output to terminal (skip warning if user explicitly forced stdout) + if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) { + fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o ' to save to a file,") + fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.") + return nil + } + + var output io.Writer = os.Stdout + if !outputToStdout { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + output = file + } + + // Copy response body to output + _, err = io.Copy(output, resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Add newline for better terminal display + if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) { + fmt.Println() + } + + return nil +} + +// parseTypedValue parses a value for -F flag, handling: +// - @filename: read content from file +// - @-: read content from stdin +// - true/false: boolean +// - null: nil +// - numbers: int or float +// - otherwise: string +func parseTypedValue(value string) (any, error) { + // Handle file references + if strings.HasPrefix(value, "@") { + filename := value[1:] + var content []byte + var err error + + if filename == "-" { + content, err = io.ReadAll(os.Stdin) + } else { + content, err = os.ReadFile(filename) + } + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", value, err) + } + return strings.TrimSuffix(string(content), "\n"), nil + } + + // Handle null + if value == "null" { + return nil, nil + } + + // Handle booleans + if value == "true" { + return true, nil + } + if value == "false" { + return false, nil + } + + // Handle integers + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i, nil + } + + // Handle floats + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f, nil + } + + // Default to string + return value, nil +} + +// isTextContentType returns true if the content type indicates text data +func isTextContentType(contentType string) bool { + if contentType == "" { + return true // assume text if unknown + } + contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset + + return strings.HasPrefix(contentType, "text/") || + strings.Contains(contentType, "json") || + strings.Contains(contentType, "xml") || + strings.Contains(contentType, "javascript") || + strings.Contains(contentType, "yaml") || + strings.Contains(contentType, "toml") +} + +// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint +func expandPlaceholders(endpoint string, ctx *context.TeaContext) string { + endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner) + endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo) + + // Get current branch if available + if ctx.LocalRepo != nil { + if branch, err := ctx.LocalRepo.Head(); err == nil { + branchName := branch.Name().Short() + endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName) + } + } + + return endpoint +} diff --git a/cmd/cmd.go b/cmd/cmd.go index ac0a141..2b9e852 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -59,6 +59,7 @@ func App() *cli.Command { &CmdAdmin, + &CmdApi, &CmdGenerateManPage, }, EnableShellCompletion: true, diff --git a/cmd/login/helper.go b/cmd/login/helper.go index 14dd60d..ab788f3 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -11,9 +11,7 @@ import ( "net/url" "os" "strings" - "time" - "code.gitea.io/tea/modules/auth" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/task" "github.com/urfave/cli/v3" @@ -59,6 +57,13 @@ var CmdLoginHelper = cli.Command{ { Name: "get", Description: "Get token to auth", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "login", + Aliases: []string{"l"}, + Usage: "Use a specific login", + }, + }, Action: func(_ context.Context, cmd *cli.Command) error { wants := map[string]string{} s := bufio.NewScanner(os.Stdin) @@ -93,10 +98,21 @@ var CmdLoginHelper = cli.Command{ wants["protocol"] = "http" } - userConfig := config.GetLoginByHost(wants["host"]) - if userConfig == nil { - log.Fatal("host not exists") - } else if len(userConfig.Token) == 0 { + // Use --login flag if provided, otherwise fall back to host lookup + var userConfig *config.Login + if loginName := cmd.String("login"); loginName != "" { + userConfig = config.GetLoginByName(loginName) + if userConfig == nil { + log.Fatalf("Login '%s' not found", loginName) + } + } else { + userConfig = config.GetLoginByHost(wants["host"]) + if userConfig == nil { + log.Fatalf("No login found for host '%s'", wants["host"]) + } + } + + if len(userConfig.Token) == 0 { log.Fatal("User no set") } @@ -105,18 +121,9 @@ var CmdLoginHelper = cli.Command{ return err } - if userConfig.TokenExpiry > 0 && time.Now().Unix() > userConfig.TokenExpiry { - // Token is expired, refresh it - err = auth.RefreshAccessToken(userConfig) - if err != nil { - return err - } - - // Once token is refreshed, get the latest from the updated config - refreshedConfig := config.GetLoginByHost(wants["host"]) - if refreshedConfig != nil { - userConfig = refreshedConfig - } + // Refresh token if expired or near expiry (updates userConfig in place) + if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil { + return err } _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token) diff --git a/docs/CLI.md b/docs/CLI.md index 44bf1d6..59467dc 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1712,3 +1712,25 @@ List Users **--remote, -R**="": Discover Gitea login from remote. Optional **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +## api + +Make an authenticated API request + +**--Field, -F**="": Add a typed field to the request body (key=value, @file, or @- for stdin) + +**--field, -f**="": Add a string field to the request body (key=value) + +**--header, -H**="": Add a custom header (key:value) + +**--include, -i**: Include HTTP status and response headers in output (written to stderr) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--method, -X**="": HTTP method (GET, POST, PUT, PATCH, DELETE) (default: "GET") + +**--output, -o**="": Write response body to file instead of stdout (use '-' for stdout) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional diff --git a/main.go b/main.go index cc38c38..393c617 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ import ( func main() { app := cmd.App() app.Flags = append(app.Flags, debug.CliFlag()) - err := app.Run(context.Background(), os.Args) + err := app.Run(context.Background(), preprocessArgs(os.Args)) if err != nil { // app.Run already exits for errors implementing ErrorCoder, // so we only handle generic errors with code 1 here. @@ -24,3 +24,17 @@ func main() { os.Exit(1) } } + +// preprocessArgs normalizes command-line arguments. +// Converts "-o-" to "-o -" for the api command's output flag. +func preprocessArgs(args []string) []string { + result := make([]string, 0, len(args)+1) + for _, arg := range args { + if arg == "-o-" { + result = append(result, "-o", "-") + } else { + result = append(result, arg) + } + } + return result +} diff --git a/modules/api/client.go b/modules/api/client.go new file mode 100644 index 0000000..4c00546 --- /dev/null +++ b/modules/api/client.go @@ -0,0 +1,105 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + + "code.gitea.io/tea/modules/config" +) + +// Client provides direct HTTP access to Gitea API +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// NewClient creates a new API client from a Login config +func NewClient(login *config.Login) *Client { + // Refresh OAuth token if expired or near expiry + if err := login.RefreshOAuthTokenIfNeeded(); err != nil { + log.Printf("Warning: failed to refresh OAuth token: %v", err) + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure}, + }, + } + + return &Client{ + baseURL: strings.TrimSuffix(login.URL, "/"), + token: login.Token, + httpClient: httpClient, + } +} + +// Do executes an HTTP request with authentication headers +func (c *Client) Do(method, endpoint string, body io.Reader, headers map[string]string) (*http.Response, error) { + // Build the full URL + reqURL, err := c.buildURL(endpoint) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication header + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + // Set default content type for requests with body + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + // Apply custom headers (can override defaults) + for key, value := range headers { + req.Header.Set(key, value) + } + + return c.httpClient.Do(req) +} + +// buildURL constructs the full URL from an endpoint +func (c *Client) buildURL(endpoint string) (string, error) { + // If endpoint is already a full URL, validate it matches the login's host + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + endpointURL, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid URL: %w", err) + } + baseURL, err := url.Parse(c.baseURL) + if err != nil { + return "", fmt.Errorf("invalid base URL: %w", err) + } + if endpointURL.Host != baseURL.Host { + return "", fmt.Errorf("URL host %q does not match login host %q (token would be sent to wrong server)", endpointURL.Host, baseURL.Host) + } + return endpoint, nil + } + + // Ensure endpoint starts with / + if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + + // Auto-prefix /api/v1/ if not present + if !strings.HasPrefix(endpoint, "/api/") { + endpoint = "/api/v1" + endpoint + } + + return c.baseURL + endpoint, nil +} diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 33d92ec..8584261 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -27,9 +27,6 @@ import ( // Constants for OAuth2 PKCE flow const ( - // default client ID included in most Gitea instances - defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" - // default scopes to request defaultScopes = "admin,user,issue,misc,notification,organization,package,repository" @@ -65,7 +62,7 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error { Name: name, URL: giteaURL, Insecure: insecure, - ClientID: defaultClientID, + ClientID: config.DefaultClientID, RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort), Port: redirectPort, } @@ -82,7 +79,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { // Set defaults if needed if opts.ClientID == "" { - opts.ClientID = defaultClientID + opts.ClientID = config.DefaultClientID } // If the redirect URL is specified, parse it to extract port if needed @@ -414,55 +411,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure return nil } -// RefreshAccessToken manually renews an expired access token using the refresh token +// RefreshAccessToken manually renews an access token using the refresh token. +// This is used by the "tea login oauth-refresh" command for explicit token refresh. +// For automatic threshold-based refresh, use login.Client() which handles it internally. func RefreshAccessToken(login *config.Login) error { - if login.RefreshToken == "" { - return fmt.Errorf("no refresh token available") - } - - // Check if token actually needs refreshing - if login.TokenExpiry > 0 && time.Now().Unix() < login.TokenExpiry { - // Token is still valid, no need to refresh - return nil - } - - // Create an expired Token object - expiredToken := &oauth2.Token{ - AccessToken: login.Token, - RefreshToken: login.RefreshToken, - // Set expiry in the past to force refresh - Expiry: time.Unix(login.TokenExpiry, 0), - } - - // Set up the OAuth2 config - ctx := context.Background() - ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(login.Insecure)) - - // Configure the OAuth2 endpoints - oauth2Config := &oauth2.Config{ - ClientID: defaultClientID, - Endpoint: oauth2.Endpoint{ - TokenURL: fmt.Sprintf("%s/login/oauth/access_token", login.URL), - }, - } - - // Refresh the token - newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() - if err != nil { - return fmt.Errorf("failed to refresh token: %s", err) - } - - // Update login with new token information - login.Token = newToken.AccessToken - - if newToken.RefreshToken != "" { - login.RefreshToken = newToken.RefreshToken - } - - if !newToken.Expiry.IsZero() { - login.TokenExpiry = newToken.Expiry.Unix() - } - - // Save updated login to config - return config.UpdateLogin(login) + return login.RefreshOAuthToken() } diff --git a/modules/config/login.go b/modules/config/login.go index 0e7f79f..db8522f 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -24,6 +24,13 @@ import ( "golang.org/x/oauth2" ) +// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens. +// This is used by config.Login.Client() for automatic token refresh. +const TokenRefreshThreshold = 5 * time.Minute + +// DefaultClientID is the default OAuth2 client ID included in most Gitea instances +const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" + // Login represents a login to a gitea server, you even could add multiple logins for one gitea server type Login struct { Name string `yaml:"name"` @@ -129,21 +136,31 @@ func GetLoginByToken(token string) *Login { // GetLoginByHost finds a login by it's server URL func GetLoginByHost(host string) *Login { + logins := GetLoginsByHost(host) + if len(logins) > 0 { + return logins[0] + } + return nil +} + +// GetLoginsByHost returns all logins matching a host +func GetLoginsByHost(host string) []*Login { err := loadConfig() if err != nil { log.Fatal(err) } - for _, l := range config.Logins { - loginURL, err := url.Parse(l.URL) + var matches []*Login + for i := range config.Logins { + loginURL, err := url.Parse(config.Logins[i].URL) if err != nil { log.Fatal(err) } if loginURL.Host == host { - return &l + matches = append(matches, &config.Logins[i]) } } - return nil + return matches } // DeleteLogin delete a login by name from config @@ -208,63 +225,79 @@ func UpdateLogin(login *Login) error { return saveConfig() } +// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry. +// Returns nil without doing anything if no refresh is needed. +func (l *Login) RefreshOAuthTokenIfNeeded() error { + if l.RefreshToken == "" || l.TokenExpiry == 0 { + return nil + } + expiryTime := time.Unix(l.TokenExpiry, 0) + if time.Now().Add(TokenRefreshThreshold).After(expiryTime) { + return l.RefreshOAuthToken() + } + return nil +} + +// RefreshOAuthToken refreshes the OAuth access token using the refresh token. +// It updates the login with new token information and saves it to config. +func (l *Login) RefreshOAuthToken() error { + if l.RefreshToken == "" { + return fmt.Errorf("no refresh token available") + } + + // Create a Token object with current values + currentToken := &oauth2.Token{ + AccessToken: l.Token, + RefreshToken: l.RefreshToken, + Expiry: time.Unix(l.TokenExpiry, 0), + } + + // Set up the OAuth2 config + ctx := context.Background() + + // Create HTTP client, respecting the login's TLS settings + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure}, + }, + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + // Configure the OAuth2 endpoints + oauth2Config := &oauth2.Config{ + ClientID: DefaultClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), + }, + } + + // Refresh the token + newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token() + if err != nil { + return fmt.Errorf("failed to refresh token: %w", err) + } + + // Update login with new token information + l.Token = newToken.AccessToken + + if newToken.RefreshToken != "" { + l.RefreshToken = newToken.RefreshToken + } + + if !newToken.Expiry.IsZero() { + l.TokenExpiry = newToken.Expiry.Unix() + } + + // Save updated login to config + return UpdateLogin(l) +} + // Client returns a client to operate Gitea API. You may provide additional modifiers // for the client like gitea.SetBasicAuth() for customization func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { - // Check if token needs refreshing (if we have a refresh token and expiry time) - if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry { - // Since we can't directly call auth.RefreshAccessToken due to import cycles, - // we'll implement the token refresh logic here. - // Create an expired Token object - expiredToken := &oauth2.Token{ - AccessToken: l.Token, - RefreshToken: l.RefreshToken, - // Set expiry in the past to force refresh - Expiry: time.Unix(l.TokenExpiry, 0), - } - - // Set up the OAuth2 config - ctx := context.Background() - - // Create HTTP client with proper insecure settings - httpClient := &http.Client{} - if l.Insecure { - httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - } - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - - // Configure the OAuth2 endpoints - oauth2Config := &oauth2.Config{ - ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go - Endpoint: oauth2.Endpoint{ - TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), - }, - } - - // Refresh the token - newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() - if err != nil { - log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) - } - // Update login with new token information - l.Token = newToken.AccessToken - - if newToken.RefreshToken != "" { - l.RefreshToken = newToken.RefreshToken - } - - if !newToken.Expiry.IsZero() { - l.TokenExpiry = newToken.Expiry.Unix() - } - - // Save updated login to config - if err := UpdateLogin(l); err != nil { - log.Fatalf("Failed to save refreshed token: %s\n", err) - } + // Refresh OAuth token if expired or near expiry + if err := l.RefreshOAuthTokenIfNeeded(); err != nil { + log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) } httpClient := &http.Client{}