Skip to content
Merged
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
255 changes: 162 additions & 93 deletions CLAUDE.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,16 @@ Tiger CLI is a Go-based command-line interface for managing Tiger resources. The
CLI commands (auth, service, db, config, mcp, version, upgrade). Each command
lives in its own file, named to match the command in snake_case
(`tiger service create` → `service_create.go`). `root.go` holds the root
command, global flags, and configuration initialization.
- **Configuration**: `internal/config/config.go` - Centralized config with Viper integration
command, global flags, and `wrapCommands`, which gives every command the same
per-invocation lifecycle: load config + API client once into a `common.App`,
initialize logging, apply color settings, check for a newer release, and track
analytics.
- **App**: `internal/common/app.go` - per-invocation config and API client, built
once by `wrapCommands` (or per request by the MCP analytics middleware) and read
by commands, MCP tool handlers, and completion functions
- **Configuration**: `internal/config/config.go` - `Config` struct plus load/write
helpers. `config.Load(flags)` resolves values through a per-call viper
instance (flag > env > file > default); there is no global config state
- **Logging**: `internal/logging/logging.go` - Structured logging with zap
- **API Client**: `internal/api/` - Generated OpenAPI client
- **MCP Server**: `internal/mcp/` - Model Context Protocol server
Expand Down
4 changes: 2 additions & 2 deletions internal/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ var ignore = []string{
type Analytics struct {
config *config.Config
projectID string
client *api.ClientWithResponses
client api.ClientWithResponsesInterface
}

// New initializes a new [Analytics] instance. The [config.Config] parameters
// is required, but the others are optional. Analytics won't be sent if the
// [api.ClientWithResponses] is nil.
func New(cfg *config.Config, client *api.ClientWithResponses, projectID string) *Analytics {
func New(cfg *config.Config, client api.ClientWithResponsesInterface, projectID string) *Analytics {
return &Analytics{
config: cfg,
projectID: projectID,
Expand Down
2 changes: 1 addition & 1 deletion internal/api/client_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func NewTigerClientWithToken(cfg *config.Config, token *oauth2.Token, persist fu
func NewTigerClientForCredentials(cfg *config.Config, creds *config.Credentials) (*ClientWithResponses, error) {
if creds.OAuth != nil {
persist := func(t *oauth2.Token) error {
return config.StoreOAuthCredentials(t, creds.ProjectID)
return cfg.StoreOAuthCredentials(t, creds.ProjectID)
}
return NewTigerClientWithToken(cfg, creds.OAuth, persist)
}
Expand Down
10 changes: 6 additions & 4 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ package cmd

import (
"github.com/spf13/cobra"

"github.com/timescale/tiger-cli/internal/common"
)

func buildAuthCmd() *cobra.Command {
func buildAuthCmd(app *common.App) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication and credentials",
Long: `Manage authentication and credentials for Tiger Cloud platform.`,
}

cmd.AddCommand(buildLoginCmd())
cmd.AddCommand(buildLogoutCmd())
cmd.AddCommand(buildStatusCmd())
cmd.AddCommand(buildLoginCmd(app))
cmd.AddCommand(buildLogoutCmd(app))
cmd.AddCommand(buildStatusCmd(app))

return cmd
}
19 changes: 12 additions & 7 deletions internal/cmd/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ type credentials struct {
secretKey string
}

func buildLoginCmd() *cobra.Command {
func buildLoginCmd(app *common.App) *cobra.Command {
var flags credentials

cmd := &cobra.Command{
Expand Down Expand Up @@ -91,11 +91,9 @@ Examples:
RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
cfg := app.GetConfig()

var err error
creds := credentials{
publicKey: flagOrEnvVar(flags.publicKey, "TIGER_PUBLIC_KEY"),
secretKey: flagOrEnvVar(flags.secretKey, "TIGER_SECRET_KEY"),
Expand All @@ -114,9 +112,13 @@ Examples:
if err != nil {
return err
}
if err := config.StoreOAuthCredentials(token, projectID); err != nil {
if err := cfg.StoreOAuthCredentials(token, projectID); err != nil {
return fmt.Errorf("failed to store credentials: %w", err)
}
// Hand the freshly authenticated client to the App so later
// readers — analytics in particular — use the new credentials
// instead of the pre-login state.
app.SetClient(client, projectID)
// Identify the user for analytics.
common.IdentifyOAuthUser(cmd.Context(), cfg, client, projectID)
finishLogin(cmd, projectID)
Expand All @@ -142,9 +144,12 @@ Examples:
if err != nil {
return fmt.Errorf("API key validation failed: %w", err)
}
if err := config.StoreCredentials(apiKey, authInfo.ApiKey.Project.Id); err != nil {
if err := cfg.StoreCredentials(apiKey, authInfo.ApiKey.Project.Id); err != nil {
return fmt.Errorf("failed to store credentials: %w", err)
}
// See the OAuth branch above: keep the App's client in sync with the
// credentials we just stored.
app.SetClient(client, authInfo.ApiKey.Project.Id)
finishLogin(cmd, authInfo.ApiKey.Project.Id)
return nil
},
Expand Down
58 changes: 32 additions & 26 deletions internal/cmd/auth_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestAuthLogin_KeyFlags(t *testing.T) {
expectedAPIKey := "test-public-key:test-secret-key"
expectedProjectID := "test-project-id" // Comes from mock validation function

creds, err := config.GetStoredCredentials()
creds, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Credentials not stored in keyring or file: %v", err)
}
Expand Down Expand Up @@ -78,7 +78,7 @@ func TestAuthLogin_KeyEnvironmentVariables(t *testing.T) {
// Verify credentials were stored
expectedAPIKey := "env-public-key:env-secret-key"
expectedProjectID := "test-project-id" // Auto-detected from mock
creds, err := config.GetStoredCredentials()
creds, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to get stored credentials: %v", err)
}
Expand Down Expand Up @@ -113,17 +113,17 @@ func TestAuthLogin_KeyringFallback(t *testing.T) {
credentialsFile := filepath.Join(tmpDir, "credentials")

// If keyring worked, manually create file scenario by clearing all credentials and adding to file
config.RemoveCredentials()
testConfig(t).RemoveCredentials()

// Store to file manually to simulate fallback
expectedAPIKey := "fallback-public:fallback-secret"
expectedProjectID := "test-project-id"
if err := config.StoreCredentialsToFile(expectedAPIKey, expectedProjectID); err != nil {
if err := testConfig(t).StoreCredentialsToFile(expectedAPIKey, expectedProjectID); err != nil {
t.Fatalf("Failed to store credentials to file: %v", err)
}

// Verify file storage works
creds, err := config.GetStoredCredentials()
creds, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to get credentials from file fallback: %v", err)
}
Expand Down Expand Up @@ -172,19 +172,19 @@ func TestAuthLogin_EnvironmentVariable_FileOnly(t *testing.T) {
}

// Clear all credentials to ensure we're testing file-only retrieval
config.RemoveCredentials()
testConfig(t).RemoveCredentials()

// Verify credentials were stored in file (since we'll manually write to file only)
expectedAPIKey := "env-file-public:env-file-secret"
expectedProjectID := "test-project-id"

// Store to file manually to simulate fallback scenario
if err := config.StoreCredentialsToFile(expectedAPIKey, expectedProjectID); err != nil {
if err := testConfig(t).StoreCredentialsToFile(expectedAPIKey, expectedProjectID); err != nil {
t.Fatalf("Failed to store credentials to file: %v", err)
}

// Verify getCredentials works with file-only storage
creds, err := config.GetStoredCredentials()
creds, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to get credentials from file: %v", err)
}
Expand All @@ -205,29 +205,31 @@ func TestAuthLogin_APIKeyValidationFailure(t *testing.T) {
}
defer os.RemoveAll(tmpDir)

// Point the command under test (and testConfig) at the test directory
t.Setenv("TIGER_CONFIG_DIR", tmpDir)

// Use a unique service name for this test
config.SetTestServiceName(t)

originalValidator := validateAPIKey

// Mock the validator to return an error
validateAPIKey = func(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) {
validateAPIKey = func(ctx context.Context, cfg *config.Config, client api.ClientWithResponsesInterface) (*api.AuthInfo, error) {
return nil, errors.New("invalid API key: authentication failed")
}

defer func() {
validateAPIKey = originalValidator
}()

// Initialize viper with test directory BEFORE calling RemoveCredentials()
// This ensures RemoveCredentials() operates on the test directory, not the user's real directory
// Write an empty config file in the test directory
if _, err := config.UseTestConfig(tmpDir, map[string]any{}); err != nil {
t.Fatalf("Failed to use test config: %v", err)
}

// Clean up credentials
config.RemoveCredentials()
defer config.RemoveCredentials()
testConfig(t).RemoveCredentials()
defer testConfig(t).RemoveCredentials()

// Execute login command with public and secret key flags - should fail validation
output, err := executeAuthCommand(t.Context(), "auth", "login", "--public-key", "invalid-public", "--secret-key", "invalid-secret")
Expand All @@ -246,7 +248,7 @@ func TestAuthLogin_APIKeyValidationFailure(t *testing.T) {
}

// Verify that no credentials were stored
if _, err := config.GetStoredCredentials(); err == nil {
if _, err := testConfig(t).GetStoredCredentials(); err == nil {
t.Error("Credentials should not be stored when validation fails")
}
}
Expand All @@ -259,13 +261,16 @@ func TestAuthLogin_APIKeyValidationSuccess(t *testing.T) {
}
defer os.RemoveAll(tmpDir)

// Point the command under test (and testConfig) at the test directory
t.Setenv("TIGER_CONFIG_DIR", tmpDir)

// Use a unique service name for this test
config.SetTestServiceName(t)

originalValidator := validateAPIKey

// Mock the validator to return success
validateAPIKey = func(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) {
validateAPIKey = func(ctx context.Context, cfg *config.Config, client api.ClientWithResponsesInterface) (*api.AuthInfo, error) {
authInfo := &api.AuthInfo{}
json.Unmarshal([]byte(`{"type":"apiKey","apiKey":{"public_key":"test-access-key","project":{"id":"test-project-valid"}}}`), authInfo)
return authInfo, nil // Success
Expand All @@ -275,15 +280,14 @@ func TestAuthLogin_APIKeyValidationSuccess(t *testing.T) {
validateAPIKey = originalValidator
}()

// Initialize viper with test directory BEFORE calling RemoveCredentials()
// This ensures RemoveCredentials() operates on the test directory, not the user's real directory
// Write an empty config file in the test directory
if _, err := config.UseTestConfig(tmpDir, map[string]any{}); err != nil {
t.Fatalf("Failed to use test config: %v", err)
}

// Clean up credentials
config.RemoveCredentials()
defer config.RemoveCredentials()
testConfig(t).RemoveCredentials()
defer testConfig(t).RemoveCredentials()

// Execute login command with public and secret key flags - should succeed
output, err := executeAuthCommand(t.Context(), "auth", "login", "--public-key", "valid-public", "--secret-key", "valid-secret")
Expand All @@ -299,7 +303,7 @@ func TestAuthLogin_APIKeyValidationSuccess(t *testing.T) {
// Verify that credentials were stored
expectedAPIKey := "valid-public:valid-secret"
expectedProjectID := "test-project-valid"
creds, err := config.GetStoredCredentials()
creds, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Credentials not stored in keyring or file: %v", err)
}
Expand Down Expand Up @@ -337,7 +341,7 @@ func TestAuthLogin_OAuth_SingleProject(t *testing.T) {
t.Errorf("Output doesn't match expected pattern.\nPattern: %s\nActual output: '%s'", expectedPattern, output)
}

stored, err := config.GetStoredCredentials()
stored, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to get stored credentials: %v", err)
}
Expand Down Expand Up @@ -396,7 +400,7 @@ func TestAuthLogin_OAuth_MultipleProjects(t *testing.T) {
t.Errorf("Output doesn't match expected pattern.\nPattern: %s\nActual output: '%s'", expectedPattern, output)
}

stored, err := config.GetStoredCredentials()
stored, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to get stored credentials: %v", err)
}
Expand Down Expand Up @@ -436,12 +440,14 @@ func TestOAuthRefresh_PersistsExpiry(t *testing.T) {
RefreshToken: "mock-refresh-token-67890",
Expiry: time.Now().Add(-time.Hour),
}
if err := config.StoreOAuthCredentials(expired, "project-789"); err != nil {
// The config file above points api_url/gateway_url at the mock server, and
// carries the test config dir so the refreshed token is persisted there.
cfg := testConfig(t)
if err := cfg.StoreOAuthCredentials(expired, "project-789"); err != nil {
t.Fatalf("Failed to store oauth credentials: %v", err)
}

cfg := &config.Config{APIURL: mockServer.URL, GatewayURL: mockServer.URL}
stored, err := config.GetStoredCredentials()
stored, err := cfg.GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to load stored credentials: %v", err)
}
Expand All @@ -458,7 +464,7 @@ func TestOAuthRefresh_PersistsExpiry(t *testing.T) {
t.Fatalf("Request failed: %v", err)
}

reloaded, err := config.GetStoredCredentials()
reloaded, err := testConfig(t).GetStoredCredentials()
if err != nil {
t.Fatalf("Failed to reload credentials: %v", err)
}
Expand Down
25 changes: 16 additions & 9 deletions internal/cmd/auth_logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import (
"github.com/spf13/cobra"

"github.com/timescale/tiger-cli/internal/api"
"github.com/timescale/tiger-cli/internal/common"
"github.com/timescale/tiger-cli/internal/config"
)

func buildLogoutCmd() *cobra.Command {
func buildLogoutCmd(app *common.App) *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
Expand All @@ -21,9 +22,11 @@ func buildLogoutCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true

revokeOAuthSession(cmd)
cfg := app.GetConfig()

if err := config.RemoveCredentials(); err != nil {
revokeOAuthSession(cmd, app, cfg)

if err := cfg.RemoveCredentials(); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}

Expand All @@ -36,19 +39,23 @@ func buildLogoutCmd() *cobra.Command {
// revokeOAuthSession asks the server to revoke the refresh token for an OAuth
// session. Failures are intentionally non-fatal — local credential removal
// must always succeed even if the server is unreachable or returns 501.
func revokeOAuthSession(cmd *cobra.Command) {
stored, err := config.GetStoredCredentials()
//
// It also replaces the App's client with one that has no persist callback. The
// new client will still renew an expired access token (which is required
// because /auth/logout and the analytics endpoint are authenticated), but it
// won't persist the token back to storage, ensuring that we don't
// unintentionally restore the credentials after deleting them (the analytics
// event deferred by wrapCommands reuses the App's client after the deletion).
func revokeOAuthSession(cmd *cobra.Command, app *common.App, cfg *config.Config) {
stored, err := cfg.GetStoredCredentials()
if err != nil || stored.OAuth == nil {
return
}
cfg, err := config.Load()
if err != nil {
return
}
client, err := api.NewTigerClientWithToken(cfg, stored.OAuth, nil)
if err != nil {
return
}
app.SetClient(client, stored.ProjectID)

ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
Expand Down
Loading
Loading