From a9227fa581488e87a51e1fca66a5bc443bf96f18 Mon Sep 17 00:00:00 2001 From: Lior Balmas Date: Sun, 10 May 2026 00:22:29 +0300 Subject: [PATCH] Add custom auth store directory --- README.md | 12 +++++- store/tokens.go | 24 ++++++++--- store/tokens_test.go | 95 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a631851..0d7dd3e 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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: diff --git a/store/tokens.go b/store/tokens.go index 535f764..bc29306 100644 --- a/store/tokens.go +++ b/store/tokens.go @@ -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"` @@ -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("", "") } @@ -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), @@ -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) @@ -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, @@ -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) diff --git a/store/tokens_test.go b/store/tokens_test.go index 4ed671e..5651afc 100644 --- a/store/tokens_test.go +++ b/store/tokens_test.go @@ -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) @@ -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) +}