From b1d324afc8e5f82e5ced4b7ad61704f5af643c88 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Sun, 25 Jan 2026 16:29:21 -0500 Subject: [PATCH] feat: add ATLASSIAN_* env var fallbacks for shared credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for shared ATLASSIAN_* environment variables that work across both cfl (Confluence) and jtk (Jira) CLIs. Precedence order (first match wins): - URL: CFL_URL → ATLASSIAN_URL → config - Email: CFL_EMAIL → ATLASSIAN_EMAIL → config - Token: CFL_API_TOKEN → ATLASSIAN_API_TOKEN → config This allows users to set credentials once for both tools: export ATLASSIAN_URL=https://mycompany.atlassian.net export ATLASSIAN_EMAIL=user@example.com export ATLASSIAN_API_TOKEN=token While still allowing per-tool overrides when needed. Closes #99 --- CLAUDE.md | 13 +++++ README.md | 32 +++++++++--- internal/config/config.go | 15 ++++-- internal/config/config_test.go | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67e5ce2..241e36a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,19 @@ type pageAPI interface { ### Integration Tests After significant code changes, run through the manual integration test suite in [integration-tests.md](integration-tests.md). These tests verify real-world behavior against a live Confluence instance and catch edge cases that unit tests miss. +## Environment Variables + +Variables are checked in precedence order (first match wins): + +| Setting | Precedence | +|---------|------------| +| URL | `CFL_URL` → `ATLASSIAN_URL` → config | +| Email | `CFL_EMAIL` → `ATLASSIAN_EMAIL` → config | +| API Token | `CFL_API_TOKEN` → `ATLASSIAN_API_TOKEN` → config | +| Default Space | `CFL_DEFAULT_SPACE` → config | + +Use `ATLASSIAN_*` for shared credentials across cfl and jtk. Use `CFL_*` to override per-tool. + ## Undocumented Constants | Constant | Value | Location | diff --git a/README.md b/README.md index ea99e97..8df59e5 100644 --- a/README.md +++ b/README.md @@ -651,14 +651,30 @@ output_format: table ### Environment Variables -Environment variables override config file values: - -| Variable | Description | -|----------|-------------| -| `CFL_URL` | Confluence instance URL | -| `CFL_EMAIL` | Your Atlassian email | -| `CFL_API_TOKEN` | Your API token | -| `CFL_DEFAULT_SPACE` | Default space key | +Environment variables override config file values. Variables are checked in order of precedence (first match wins): + +| Setting | Precedence (highest to lowest) | +|---------|-------------------------------| +| URL | `CFL_URL` → `ATLASSIAN_URL` → config file | +| Email | `CFL_EMAIL` → `ATLASSIAN_EMAIL` → config file | +| API Token | `CFL_API_TOKEN` → `ATLASSIAN_API_TOKEN` → config file | +| Default Space | `CFL_DEFAULT_SPACE` → config file | + +**Shared credentials:** If you use both `cfl` and `jtk` (Jira CLI), set `ATLASSIAN_*` variables once: + +```bash +export ATLASSIAN_URL=https://mycompany.atlassian.net +export ATLASSIAN_EMAIL=user@example.com +export ATLASSIAN_API_TOKEN=your-api-token +``` + +**Per-tool override:** Use `CFL_*` to override for Confluence specifically: + +```bash +export ATLASSIAN_EMAIL=user@example.com +export ATLASSIAN_API_TOKEN=your-api-token +export CFL_URL=https://confluence.internal.corp.com # Different URL for Confluence +``` --- diff --git a/internal/config/config.go b/internal/config/config.go index 36ac8bd..6d6d55e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,14 +50,15 @@ func (c *Config) NormalizeURL() { // LoadFromEnv loads configuration from environment variables. // Environment variables override existing values only if set and non-empty. +// Precedence: CFL_* → ATLASSIAN_* → existing config value func (c *Config) LoadFromEnv() { - if url := os.Getenv("CFL_URL"); url != "" { + if url := getEnvWithFallback("CFL_URL", "ATLASSIAN_URL"); url != "" { c.URL = url } - if email := os.Getenv("CFL_EMAIL"); email != "" { + if email := getEnvWithFallback("CFL_EMAIL", "ATLASSIAN_EMAIL"); email != "" { c.Email = email } - if token := os.Getenv("CFL_API_TOKEN"); token != "" { + if token := getEnvWithFallback("CFL_API_TOKEN", "ATLASSIAN_API_TOKEN"); token != "" { c.APIToken = token } if space := os.Getenv("CFL_DEFAULT_SPACE"); space != "" { @@ -65,6 +66,14 @@ func (c *Config) LoadFromEnv() { } } +// getEnvWithFallback returns the value of the primary env var, or the fallback if primary is empty. +func getEnvWithFallback(primary, fallback string) string { + if v := os.Getenv(primary); v != "" { + return v + } + return os.Getenv(fallback) +} + // DefaultConfigPath returns the default configuration file path. func DefaultConfigPath() string { // Try XDG config directory first diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5ac5b3a..a9e64ab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -208,3 +208,94 @@ func TestLoad_FileNotFound(t *testing.T) { _, err := Load("/nonexistent/path/config.yml") require.Error(t, err) } + +func TestConfig_LoadFromEnv_AtlassianFallback(t *testing.T) { + // Clear all relevant env vars + clearEnvVars := func() { + os.Unsetenv("CFL_URL") + os.Unsetenv("CFL_EMAIL") + os.Unsetenv("CFL_API_TOKEN") + os.Unsetenv("ATLASSIAN_URL") + os.Unsetenv("ATLASSIAN_EMAIL") + os.Unsetenv("ATLASSIAN_API_TOKEN") + } + + t.Run("ATLASSIAN_* used when CFL_* not set", func(t *testing.T) { + clearEnvVars() + defer clearEnvVars() + + os.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net") + os.Setenv("ATLASSIAN_EMAIL", "shared@example.com") + os.Setenv("ATLASSIAN_API_TOKEN", "shared-token") + + cfg := &Config{} + cfg.LoadFromEnv() + + assert.Equal(t, "https://shared.atlassian.net", cfg.URL) + assert.Equal(t, "shared@example.com", cfg.Email) + assert.Equal(t, "shared-token", cfg.APIToken) + }) + + t.Run("CFL_* takes precedence over ATLASSIAN_*", func(t *testing.T) { + clearEnvVars() + defer clearEnvVars() + + os.Setenv("CFL_URL", "https://cfl.atlassian.net") + os.Setenv("CFL_EMAIL", "cfl@example.com") + os.Setenv("CFL_API_TOKEN", "cfl-token") + os.Setenv("ATLASSIAN_URL", "https://shared.atlassian.net") + os.Setenv("ATLASSIAN_EMAIL", "shared@example.com") + os.Setenv("ATLASSIAN_API_TOKEN", "shared-token") + + cfg := &Config{} + cfg.LoadFromEnv() + + assert.Equal(t, "https://cfl.atlassian.net", cfg.URL) + assert.Equal(t, "cfl@example.com", cfg.Email) + assert.Equal(t, "cfl-token", cfg.APIToken) + }) + + t.Run("mixed CFL_* and ATLASSIAN_*", func(t *testing.T) { + clearEnvVars() + defer clearEnvVars() + + // Only URL is CFL-specific, rest use shared + os.Setenv("CFL_URL", "https://cfl.atlassian.net") + os.Setenv("ATLASSIAN_EMAIL", "shared@example.com") + os.Setenv("ATLASSIAN_API_TOKEN", "shared-token") + + cfg := &Config{} + cfg.LoadFromEnv() + + assert.Equal(t, "https://cfl.atlassian.net", cfg.URL) + assert.Equal(t, "shared@example.com", cfg.Email) + assert.Equal(t, "shared-token", cfg.APIToken) + }) +} + +func TestGetEnvWithFallback(t *testing.T) { + os.Unsetenv("TEST_PRIMARY") + os.Unsetenv("TEST_FALLBACK") + defer func() { + os.Unsetenv("TEST_PRIMARY") + os.Unsetenv("TEST_FALLBACK") + }() + + t.Run("returns primary when set", func(t *testing.T) { + os.Setenv("TEST_PRIMARY", "primary-value") + os.Setenv("TEST_FALLBACK", "fallback-value") + assert.Equal(t, "primary-value", getEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) + }) + + t.Run("returns fallback when primary empty", func(t *testing.T) { + os.Unsetenv("TEST_PRIMARY") + os.Setenv("TEST_FALLBACK", "fallback-value") + assert.Equal(t, "fallback-value", getEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) + }) + + t.Run("returns empty when both empty", func(t *testing.T) { + os.Unsetenv("TEST_PRIMARY") + os.Unsetenv("TEST_FALLBACK") + assert.Equal(t, "", getEnvWithFallback("TEST_PRIMARY", "TEST_FALLBACK")) + }) +}