Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
xurl
/xurl
.xurl_test
.DS_Store# Added by goreleaser init:
dist/
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
.PHONY: build
build:
go build -o xurl
go build -o xurl ./cmd/xurl

.PHONY: install
install:
go install
go install ./cmd/xurl

.PHONY: clean
clean:
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,23 @@ Installs to `~/.local/bin`. If it's not in your PATH, the script will tell you w

### Go
```bash
go install github.com/xdevplatform/xurl@latest
go install github.com/xdevplatform/xurl/cmd/xurl@latest
```

### Use as a Go library

Import by module path in other Go projects:

```go
import "github.com/xdevplatform/xurl"
```

If your consuming project uses a local replace for this repo and requires the short path, `import "xurl"` also works with:

```go
require xurl v0.0.0

replace xurl => ../xurl
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README suggests import "xurl" will work with require xurl v0.0.0 + replace xurl => ../xurl, but this repo’s packages import each other via the full module path (github.com/xdevplatform/xurl/...). Using the short module path will break those internal imports unless they’re rewritten too. Consider removing the import "xurl" guidance or instead documenting replace github.com/xdevplatform/xurl => ../xurl (while keeping import "github.com/xdevplatform/xurl").

Suggested change
If your consuming project uses a local replace for this repo and requires the short path, `import "xurl"` also works with:
```go
require xurl v0.0.0
replace xurl => ../xurl
If your consuming project uses a local checkout of this repo, you can use a `replace` directive in your `go.mod` while still importing by the full module path:
```go
require github.com/xdevplatform/xurl v0.0.0
replace github.com/xdevplatform/xurl => ../xurl

Copilot uses AI. Check for mistakes.
```


Expand Down
11 changes: 6 additions & 5 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import (
"time"

"bufio"
"mime/multipart"
"os"
"path/filepath"
"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
xurlErrors "github.com/xdevplatform/xurl/errors"
"github.com/xdevplatform/xurl/version"
"mime/multipart"
"os"
"path/filepath"
)
Comment on lines 15 to 21
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import block isn’t gofmt-compliant: stdlib imports (bufio, mime/multipart, os, path/filepath, etc.) are interleaved with module imports and not separated into groups. Please run gofmt (or reorder into stdlib / third-party / local groups) to keep consistent formatting and avoid noisy diffs.

Copilot uses AI. Check for mistakes.

// RequestOptions contains common options for API requests
Expand Down Expand Up @@ -346,7 +346,8 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username
}

// If no auth type is specified, try to use the first OAuth2 token
token := c.auth.TokenStore.GetFirstOAuth2Token()
// Use ForApp variants so the active app name (set via --app) is respected.
token := c.auth.TokenStore.GetFirstOAuth2TokenForApp(c.auth.AppName())
if token != nil {
accessToken, err := c.auth.GetOAuth2Header(username)
if err == nil {
Expand All @@ -355,7 +356,7 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username
}

// If no OAuth2 token is available, try to use the first OAuth1 token
token = c.auth.TokenStore.GetOAuth1Tokens()
token = c.auth.TokenStore.GetOAuth1TokensForApp(c.auth.AppName())
if token != nil {
authHeader, err := c.auth.GetOAuth1Header(method, url, nil)
if err == nil {
Expand Down
79 changes: 79 additions & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
Expand Down Expand Up @@ -356,3 +357,81 @@ func TestStreamRequest(t *testing.T) {
assert.True(t, xurlErrors.IsAPIError(err), "Expected API error")
})
}

// futureExpiry returns a unix timestamp 1 hour in the future.
func futureExpiry() uint64 {
return uint64(time.Now().Add(time.Hour).Unix())
}

// TC 5.3: ApiClient with multi-app Auth; app-b only has Bearer → BuildRequest uses app-b's Bearer
func TestTC5_3_ApiClientUsesAppBBearerNotDefaultOAuth2(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl_api_multiapp_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

tempFile := filepath.Join(tempDir, ".xurl")
ts := &store.TokenStore{
Apps: make(map[string]*store.App),
DefaultApp: "app-a",
FilePath: tempFile,
}

// app-a: has OAuth2 (default/active for auto-selection cascade)
ts.Apps["app-a"] = &store.App{
ClientID: "id-a",
ClientSecret: "secret-a",
DefaultUser: "alice-a",
OAuth2Tokens: map[string]store.Token{
"alice-a": {
Type: store.OAuth2TokenType,
OAuth2: &store.OAuth2Token{
AccessToken: "oauth2-token-alice-a",
RefreshToken: "refresh-alice-a",
ExpirationTime: futureExpiry(),
},
},
},
BearerToken: &store.Token{
Type: store.BearerTokenType,
Bearer: "bearer-a",
},
}

// app-b: has ONLY Bearer token, no OAuth2
ts.Apps["app-b"] = &store.App{
ClientID: "id-b",
ClientSecret: "secret-b",
OAuth2Tokens: make(map[string]store.Token),
BearerToken: &store.Token{
Type: store.BearerTokenType,
Bearer: "bearer-b-only",
},
}

// Build Auth starting with app-a credentials
a := auth.NewAuth(&config.Config{
ClientID: "id-a",
ClientSecret: "secret-a",
APIBaseURL: "https://api.x.com",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
RedirectURI: "http://localhost:8080/callback",
InfoURL: "https://api.x.com/2/users/me",
}).WithTokenStore(ts)

// Switch to app-b
a.WithAppName("app-b")

cfg := &config.Config{APIBaseURL: "https://api.x.com"}
client := NewApiClient(cfg, a)

req, err := client.BuildRequest(RequestOptions{
Method: "GET",
Endpoint: "/2/users/me",
})
require.NoError(t, err)

authHeader := req.Header.Get("Authorization")
assert.Equal(t, "Bearer bearer-b-only", authHeader,
"Authorization header must use app-b's Bearer token, not app-a's OAuth2")
}
2 changes: 1 addition & 1 deletion api/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ package api
import (
"encoding/json"
"fmt"
"github.com/xdevplatform/xurl/utils"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
Comment thread
drQedwards marked this conversation as resolved.
Outdated
"github.com/xdevplatform/xurl/utils"
)

const (
Expand Down
29 changes: 15 additions & 14 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,20 @@ func (a *Auth) WithAppName(appName string) *Auth {
a.appName = appName
app := a.TokenStore.ResolveApp(appName)
if app != nil {
if a.clientID == "" {
a.clientID = app.ClientID
}
if a.clientSecret == "" {
a.clientSecret = app.ClientSecret
}
a.clientID = app.ClientID
a.clientSecret = app.ClientSecret
}
return a
}

// AppName returns the current app name override (may be empty).
func (a *Auth) AppName() string {
return a.appName
}

// GetOAuth1Header gets the OAuth1 header for a request
func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[string]string) (string, error) {
token := a.TokenStore.GetOAuth1Tokens()
token := a.TokenStore.GetOAuth1TokensForApp(a.appName)
if token == nil || token.OAuth1 == nil {
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("OAuth1 token not found"))
}
Expand Down Expand Up @@ -146,9 +147,9 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) {
var token *store.Token

if username != "" {
token = a.TokenStore.GetOAuth2Token(username)
token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username)
} else {
token = a.TokenStore.GetFirstOAuth2Token()
token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName)
}

if token == nil {
Expand Down Expand Up @@ -253,7 +254,7 @@ func (a *Auth) OAuth2Flow(username string) (string, error) {

expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix())

err = a.TokenStore.SaveOAuth2Token(usernameStr, token.AccessToken, token.RefreshToken, expirationTime)
err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, token.AccessToken, token.RefreshToken, expirationTime)
if err != nil {
return "", xurlErrors.NewAuthError("TokenStorageError", err)
}
Expand All @@ -266,9 +267,9 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) {
var token *store.Token

if username != "" {
token = a.TokenStore.GetOAuth2Token(username)
token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username)
} else {
token = a.TokenStore.GetFirstOAuth2Token()
token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName)
}

if token == nil || token.OAuth2 == nil {
Expand Down Expand Up @@ -310,7 +311,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) {

expirationTime := uint64(time.Now().Add(time.Duration(newToken.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix())

err = a.TokenStore.SaveOAuth2Token(usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime)
err = a.TokenStore.SaveOAuth2TokenForApp(a.appName, usernameStr, newToken.AccessToken, newToken.RefreshToken, expirationTime)
if err != nil {
return "", xurlErrors.NewAuthError("RefreshTokenError", err)
}
Expand All @@ -320,7 +321,7 @@ func (a *Auth) RefreshOAuth2Token(username string) (string, error) {

// GetBearerTokenHeader gets the bearer token from the token store
func (a *Auth) GetBearerTokenHeader() (string, error) {
token := a.TokenStore.GetBearerToken()
token := a.TokenStore.GetBearerTokenForApp(a.appName)
if token == nil {
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("bearer token not found"))
}
Expand Down
Loading