Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A command-line tool for interacting with the X (formerly Twitter) API, supportin
- OAuth 1.0a authentication
- Multiple OAuth 2.0 account support per app
- Default app and default user selection (interactive Bubble Tea picker or single command)
- Persistent token storage in YAML (`~/.xurl`), auto-migrates from legacy JSON
- Persistent token storage in YAML (`~/.xurl` or `$XURL_STORE_DIR/.xurl`), auto-migrates from legacy JSON
- HTTP request customization (headers, methods, body)
- Per-request app override with `--app`

Expand Down Expand Up @@ -324,7 +324,15 @@ xurl '/2/media/upload?command=STATUS&media_id=MEDIA_ID'

## Token Storage

Tokens and app credentials are stored in `~/.xurl` in YAML format. Each registered app has its own isolated set of tokens. Example:
Tokens and app credentials are stored in `~/.xurl` in YAML format. Set `XURL_STORE_DIR` to use a different folder, for example with a mounted container volume:

```bash
docker run -v "$PWD/xurl-data:/xurl-data" -e XURL_STORE_DIR=/xurl-data ...
```

With that setting, xurl stores tokens at `/xurl-data/.xurl` and imports `.twurlrc` from `/xurl-data/.twurlrc`.

Each registered app has its own isolated set of tokens. Example:

```yaml
apps:
Expand Down
24 changes: 18 additions & 6 deletions store/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type App struct {

// ─── On-disk YAML structure ─────────────────────────────────────────

// storeFile is the serialised YAML layout of ~/.xurl
// storeFile is the serialized YAML layout of the .xurl auth file.
type storeFile struct {
Apps map[string]*App `yaml:"apps"`
DefaultApp string `yaml:"default_app"`
Expand Down Expand Up @@ -98,7 +98,15 @@ func resolveHomeDir() string {
return homeDir
}

// Creates a new TokenStore, loading from ~/.xurl (auto-migrating legacy JSON).
func resolveStoreDir() string {
if storeDir := os.Getenv("XURL_STORE_DIR"); storeDir != "" {
return storeDir
}

return resolveHomeDir()
}

// Creates a new TokenStore, loading from .xurl (auto-migrating legacy JSON).
func NewTokenStore() *TokenStore {
return NewTokenStoreWithCredentials("", "")
}
Expand All @@ -107,8 +115,8 @@ func NewTokenStore() *TokenStore {
// client credentials into any app that was migrated without them (i.e. legacy
// JSON migration where CLIENT_ID / CLIENT_SECRET came from env vars).
func NewTokenStoreWithCredentials(clientID, clientSecret string) *TokenStore {
homeDir := resolveHomeDir()
filePath := filepath.Join(homeDir, ".xurl")
storeDir := resolveStoreDir()
filePath := filepath.Join(storeDir, ".xurl")

store := &TokenStore{
Apps: make(map[string]*App),
Expand Down Expand Up @@ -144,7 +152,7 @@ func NewTokenStoreWithCredentials(clientID, clientSecret string) *TokenStore {
// Import from .twurlrc if we have no apps or the default app is missing OAuth1/Bearer
app := store.activeApp()
if app == nil || app.OAuth1Token == nil || app.BearerToken == nil {
twurlPath := filepath.Join(homeDir, ".twurlrc")
twurlPath := filepath.Join(storeDir, ".twurlrc")
if _, err := os.Stat(twurlPath); err == nil {
if err := store.importFromTwurlrc(twurlPath); err != nil {
fmt.Println("Error importing from .twurlrc:", err)
Expand Down Expand Up @@ -616,7 +624,7 @@ func (s *TokenStore) HasBearerToken() bool {

// ─── Persistence ────────────────────────────────────────────────────

// Saves the token store to ~/.xurl in YAML format.
// Saves the token store to .xurl in YAML format.
func (s *TokenStore) saveToFile() error {
sf := storeFile{
Apps: s.Apps,
Expand All @@ -627,6 +635,10 @@ func (s *TokenStore) saveToFile() error {
return errors.NewJSONError(err)
}

if err := os.MkdirAll(filepath.Dir(s.FilePath), 0700); err != nil {
return errors.NewIOError(err)
}

err = os.WriteFile(s.FilePath, data, 0600)
if err != nil {
return errors.NewIOError(err)
Expand Down
95 changes: 95 additions & 0 deletions store/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,60 @@ func TestNewTokenStore(t *testing.T) {
assert.NotEmpty(t, store.FilePath, "Expected non-empty FilePath")
}

func TestNewTokenStoreUsesHomeByDefault(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl-store-home-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

t.Setenv("HOME", tempDir)
t.Setenv("XURL_STORE_DIR", "")

store := NewTokenStore()

assert.Equal(t, filepath.Join(tempDir, ".xurl"), store.FilePath)
}

func TestNewTokenStoreUsesCustomStoreDir(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl-store-dir-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

storeDir := filepath.Join(tempDir, "mounted")
t.Setenv("XURL_STORE_DIR", storeDir)

store := NewTokenStore()

assert.Equal(t, filepath.Join(storeDir, ".xurl"), store.FilePath)

err = store.SaveBearerToken("test-bearer-token")
require.NoError(t, err)

_, err = os.Stat(filepath.Join(storeDir, ".xurl"))
assert.NoError(t, err)
}

func TestSaveCreatesCustomStoreDir(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl-store-mkdir-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

storeDir := filepath.Join(tempDir, "missing", "nested")
store := &TokenStore{
Apps: make(map[string]*App),
DefaultApp: "default",
FilePath: filepath.Join(storeDir, ".xurl"),
}
store.Apps["default"] = &App{
OAuth2Tokens: make(map[string]Token),
}

err = store.SaveBearerToken("test-bearer-token")
require.NoError(t, err)

_, err = os.Stat(filepath.Join(storeDir, ".xurl"))
assert.NoError(t, err)
}

func TestTokenOperations(t *testing.T) {
store, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
Expand Down Expand Up @@ -666,3 +720,44 @@ configuration:
assert.Error(t, err, "Expected error when importing from malformed .twurlrc")
})
}

func TestTwurlrcUsesCustomStoreDir(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl-twurl-store-dir-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

homeDir := filepath.Join(tempDir, "home")
storeDir := filepath.Join(tempDir, "store")
require.NoError(t, os.MkdirAll(homeDir, 0700))
require.NoError(t, os.MkdirAll(storeDir, 0700))

t.Setenv("HOME", homeDir)
t.Setenv("XURL_STORE_DIR", storeDir)

twurlContent := `profiles:
testuser:
test_consumer_key:
username: testuser
consumer_key: test_consumer_key
consumer_secret: test_consumer_secret
token: test_access_token
secret: test_token_secret
configuration:
default_profile:
- testuser
- test_consumer_key`

err = os.WriteFile(filepath.Join(storeDir, ".twurlrc"), []byte(twurlContent), 0600)
require.NoError(t, err)

store := NewTokenStore()

assert.Equal(t, filepath.Join(storeDir, ".xurl"), store.FilePath)

oauth1Token := store.GetOAuth1Tokens()
require.NotNil(t, oauth1Token)
assert.Equal(t, "test_access_token", oauth1Token.OAuth1.AccessToken)

_, err = os.Stat(filepath.Join(storeDir, ".xurl"))
assert.NoError(t, err)
}