From bc9111d1a743f6803924beb3970a5cd10e12cae8 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Tue, 4 Aug 2026 16:04:41 -0400 Subject: [PATCH 1/4] Improve config implementation, remove global viper instance --- CLAUDE.md | 78 +- docs/development.md | 4 +- internal/api/client_util.go | 2 +- internal/cmd/auth_login.go | 6 +- internal/cmd/auth_login_test.go | 48 +- internal/cmd/auth_logout.go | 17 +- internal/cmd/auth_logout_test.go | 8 +- internal/cmd/auth_status.go | 3 +- internal/cmd/auth_status_test.go | 2 +- internal/cmd/auth_test.go | 14 +- internal/cmd/completion_helper.go | 6 +- internal/cmd/config_reset.go | 2 +- internal/cmd/config_reset_test.go | 18 +- internal/cmd/config_set.go | 2 +- internal/cmd/config_set_test.go | 4 +- internal/cmd/config_show.go | 28 +- internal/cmd/config_test.go | 3 +- internal/cmd/config_unset.go | 2 +- internal/cmd/config_unset_test.go | 4 +- internal/cmd/db.go | 5 +- internal/cmd/db_connect.go | 39 +- internal/cmd/db_connect_test.go | 22 +- internal/cmd/db_connection_string.go | 4 +- internal/cmd/db_connection_string_test.go | 13 +- internal/cmd/db_create_role.go | 7 +- internal/cmd/db_save_password.go | 4 +- internal/cmd/db_save_password_test.go | 37 +- internal/cmd/db_schema.go | 4 +- internal/cmd/db_test.go | 9 +- internal/cmd/db_test_connection.go | 4 +- internal/cmd/flag_helper.go | 21 - internal/cmd/integration_test.go | 19 - internal/cmd/main_test.go | 16 +- internal/cmd/mcp_get.go | 5 +- internal/cmd/mcp_list.go | 5 +- internal/cmd/mcp_start.go | 9 +- internal/cmd/mcp_start_http.go | 9 +- internal/cmd/mcp_start_stdio.go | 2 +- internal/cmd/mcp_test.go | 7 - internal/cmd/password_helper.go | 11 +- internal/cmd/root.go | 29 +- internal/cmd/root_test.go | 166 +++-- internal/cmd/service.go | 17 +- internal/cmd/service_create.go | 7 +- internal/cmd/service_delete.go | 2 +- internal/cmd/service_fork.go | 7 +- internal/cmd/service_get.go | 5 +- internal/cmd/service_list.go | 14 +- internal/cmd/service_list_test.go | 8 +- internal/cmd/service_logs.go | 3 +- .../cmd/service_metrics_available_series.go | 11 +- internal/cmd/service_metrics_series.go | 5 +- internal/cmd/service_resize.go | 2 +- internal/cmd/service_start.go | 2 +- internal/cmd/service_stop.go | 2 +- internal/cmd/service_test.go | 17 +- internal/cmd/service_update_password.go | 17 +- internal/cmd/upgrade.go | 2 +- internal/cmd/upgrade_test.go | 1 - internal/cmd/version.go | 2 +- internal/common/client.go | 6 +- internal/common/client_test.go | 2 +- internal/common/config.go | 9 +- internal/common/connection.go | 21 +- internal/common/connection_test.go | 58 +- internal/common/password_storage.go | 10 +- internal/common/password_storage_test.go | 24 +- internal/common/replica.go | 5 +- internal/common/schema_fetch.go | 5 +- internal/config/config.go | 666 +++++++----------- internal/config/config_test.go | 148 ++-- internal/config/credentials.go | 41 +- internal/config/credentials_test.go | 40 +- internal/mcp/db_execute_query.go | 4 +- internal/mcp/db_schema.go | 4 +- internal/mcp/proxy.go | 2 +- internal/mcp/server.go | 16 +- internal/mcp/service_create.go | 6 +- internal/mcp/service_fork.go | 6 +- internal/mcp/service_get.go | 4 +- internal/mcp/service_list.go | 2 +- internal/mcp/service_logs.go | 2 +- internal/mcp/service_metrics_available.go | 2 +- internal/mcp/service_metrics_series.go | 2 +- internal/mcp/service_resize.go | 4 +- internal/mcp/service_start.go | 2 +- internal/mcp/service_stop.go | 2 +- internal/mcp/service_update_password.go | 4 +- internal/mcp/utils.go | 5 +- 89 files changed, 825 insertions(+), 1099 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36b33b04..e067176b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,18 +136,20 @@ go generate ./internal/api **IMPORTANT:** Follow these rules when working with configuration: -1. **Always use the Config struct** - Never read configuration values directly from the global viper instance. Always load a `Config` struct and use its fields. +1. **There is no global config** - `config.Load(flags)` builds a fresh `viper` instance per call and unmarshals it into a `Config`. Nothing reads the global viper instance, and nothing should: always take a `*config.Config` (or `*common.Config`) and use its fields. -2. **Load once, pass down** - Load the config once at the start of a command or operation, then pass it down to functions that need it. Do not reload the config if one is already available higher in the call chain. +2. **Pass the command's flag set to Load** - `config.Load(cmd.Flags())` binds the flags in `flagBindings` (`internal/config/config.go`) so precedence stays flag > env > file > default. `cmd.Flags()` includes the persistent flags inherited from parents, and flags a command doesn't define are skipped, so command-local flags (e.g. `--output`) bind only where they exist. Pass `nil` when there are no flags to apply. -3. **MCP tools reload per-call** - In MCP tool implementations, always load a fresh config at the start of each tool call. This ensures that configuration changes made by the user (via `tiger config set`) take effect immediately for the next tool call, without requiring the MCP server to be restarted. +3. **Load once, pass down** - Load the config once at the start of a command or operation, then pass it down to functions that need it. Do not reload the config if one is already available higher in the call chain. + +4. **MCP tools reload per-call** - In MCP tool implementations, always load a fresh config at the start of each tool call, using the flag set the server was started with (`s.flags`). This ensures that configuration changes made by the user (via `tiger config set`) take effect immediately for the next tool call, without requiring the MCP server to be restarted. **Example:** ```go // ✅ Good: Load config once and pass it down func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest, input ServiceListInput) (*mcp.CallToolResult, ServiceListOutput, error) { // Load fresh config at start of MCP tool call - cfg, err := s.loadConfigWithProjectID() + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceListOutput{}, err } @@ -156,18 +158,34 @@ func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest return doWork(cfg) } +// ✅ Good: A CLI command loads with its own flag set +func run(cmd *cobra.Command, args []string) error { + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + // ... +} + // ❌ Bad: Reading from viper directly func handleCommand() { projectID := viper.GetString("project_id") // Don't do this } +// ❌ Bad: Dropping the flag set, so --config-dir/--service-id are ignored +func run(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(nil) // Don't do this in a command +} + // ❌ Bad: Reloading config when already available func processData(cfg *config.Config) { - freshCfg, _ := config.Load() // Don't reload if cfg is already available + freshCfg, _ := config.Load(nil) // Don't reload if cfg is already available // Use cfg instead } ``` +Config-derived state lives on the `Config` too: credential storage is a set of +methods on it (`cfg.StoreCredentials`, `cfg.GetStoredCredentials`, +`cfg.RemoveCredentials`), keyed off `cfg.ConfigDir`, and `cfg.Set`/`Unset`/`Reset` +write the config file and then reload the struct through the same precedence. + ### CLI and MCP Synchronization When implementing or updating functionality: @@ -274,7 +292,9 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da cross-group helpers rather than commands — see "Where Helpers Go" below. - `db_connect.go` - The whole `db connect`/`psql` flow, including read replica selection: in an interactive terminal, when the service has one or more active read replicas (listed via the `/replicaSets` API), prompts to connect to the primary or one of the replicas. Skipped when stdin is not a TTY, when `--no-replica-prompt` is set, or when the service has no read replicas. Also handles password recovery when the stored password is rejected. - `upgrade.go` - Self-update command (download latest release, verify checksum, replace running binary in place) -- **Configuration**: `internal/config/config.go` - Centralized config with Viper integration +- **Configuration**: `internal/config/config.go` - `Config` struct plus load/write + helpers. Each `Load` uses its own viper instance (no global state); see + "Configuration Management" above - **Logging**: `internal/logging/logging.go` - Structured logging with zap - **API Client**: `internal/api/` - Generated OpenAPI client with mocks - **MCP Server**: `internal/mcp/` - Model Context Protocol server implementation. @@ -592,15 +612,14 @@ func buildRootCmd() *cobra.Command { Short: "Tiger CLI - Tiger Cloud Platform command-line interface", Long: `Complete CLI description...`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Bind persistent flags to viper at execution time - if err := errors.Join( - viper.BindPFlag("debug", cmd.Flags().Lookup("debug")), - // ... bind remaining flags - ); err != nil { - return fmt.Errorf("failed to bind flags: %w", err) + // Load the config for the command being run; cmd.Flags() carries + // the persistent flags inherited from parents + cfg, err := config.Load(cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) } - // Setup configuration and initialize logging + // Initialize logging // ... rest of initialization }, } @@ -676,21 +695,22 @@ func buildMyFlaggedCmd() *cobra.Command { } ``` -### Commands with Flags That Need Viper Binding +### Commands with Flags That Override Config Values -For commands that need their flags bound to viper for configuration precedence (flag > env > config > default), use the `bindFlags()` helper: +A flag that should override a config value needs no wiring in the command: pass +the command's flag set to `config.Load` and the binding table in +`internal/config/config.go` does the rest. ```go func buildMyConfigurableFlagCmd() *cobra.Command { var output string cmd := &cobra.Command{ - Use: "my-command", - Short: "Command with configurable flag", - PreRunE: bindFlags("output"), // Binds flag to viper + Use: "my-command", + Short: "Command with configurable flag", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load() // Now includes bound flag value + cfg, err := config.Load(cmd.Flags()) // ... use cfg.Output which respects: flag > env > config > default }, } @@ -700,17 +720,15 @@ func buildMyConfigurableFlagCmd() *cobra.Command { } ``` -The `bindFlags()` helper (defined in `internal/cmd/flag_helper.go`) automatically converts flag names to config keys (e.g., `"new-password"` → `"new_password"`) and supports binding multiple flags: `bindFlags("output", "new-password")`. - -**Why bind flags in PreRunE?** - -Flags must be bound to viper at **execution time**, not at **build time**, for two critical reasons: - -1. **Prevents binding conflicts**: When all commands are built at startup (the builder pattern), binding flags at build time can cause commands' flags to bind to the same viper keys, silently overwriting each other. Only the last binding wins. - -2. **Ensures correct precedence**: Viper must bind flags after the command tree is built but before `config.Load()` is called. This happens in `PreRunE` (or `PersistentPreRunE` for persistent flags), ensuring the precedence order works correctly: command-line flags > environment variables > config file > defaults. +To make a *new* flag override a config value, add it to `flagBindings`, which +maps flag names to config keys (e.g. `"password-storage"` → `"password_storage"`). +Bindings are applied per load against the flag set passed in, so a flag bound for +one command never leaks into another, and a command that doesn't define the flag +simply skips it. -**Note:** Use `PreRunE` for command-specific flags, and `PersistentPreRunE` for persistent flags on the root command that apply to all subcommands. +Flags that aren't config values (e.g. `--auto-generate`) stay as plain local +variables; a flag that only needs an env-var fallback can read it directly (see +`--new-password` and `TIGER_NEW_PASSWORD` in `service_update_password.go`). ### Parent Commands with Subcommands @@ -846,7 +864,7 @@ When adding new commands to this architecture: 1. **Create a builder function** following the `buildXXXCmd()` pattern 2. **Declare flags locally** within the builder function scope -3. **Bind flags to viper in PreRunE** if the flag needs to be configurable via config file or environment variables +3. **Add the flag to `flagBindings`** (in `internal/config/config.go`) if it should override a config value, and load with `config.Load(cmd.Flags())` 4. **Add to root command** by calling `cmd.AddCommand(buildXXXCmd())` in `buildRootCmd()` 5. **No init() function** required - everything goes through the root builder 6. **Test with `buildRootCmd()`** instead of recreating flag setup diff --git a/docs/development.md b/docs/development.md index d3c798ea..e5c2efca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -129,7 +129,9 @@ Tiger CLI is a Go-based command-line interface for managing Tiger resources. The 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 +- **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 diff --git a/internal/api/client_util.go b/internal/api/client_util.go index 63284ac0..d1173cdc 100644 --- a/internal/api/client_util.go +++ b/internal/api/client_util.go @@ -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) } diff --git a/internal/cmd/auth_login.go b/internal/cmd/auth_login.go index a854619e..79ebc336 100644 --- a/internal/cmd/auth_login.go +++ b/internal/cmd/auth_login.go @@ -91,7 +91,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } @@ -114,7 +114,7 @@ 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) } // Identify the user for analytics. @@ -142,7 +142,7 @@ 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) } finishLogin(cmd, authInfo.ApiKey.Project.Id) diff --git a/internal/cmd/auth_login_test.go b/internal/cmd/auth_login_test.go index c032ab58..13cbdeb6 100644 --- a/internal/cmd/auth_login_test.go +++ b/internal/cmd/auth_login_test.go @@ -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) } @@ -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) } @@ -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) } @@ -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) } @@ -219,15 +219,14 @@ func TestAuthLogin_APIKeyValidationFailure(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 fail validation output, err := executeAuthCommand(t.Context(), "auth", "login", "--public-key", "invalid-public", "--secret-key", "invalid-secret") @@ -246,7 +245,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") } } @@ -275,15 +274,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") @@ -299,7 +297,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) } @@ -337,7 +335,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) } @@ -396,7 +394,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) } @@ -436,12 +434,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) } @@ -458,7 +458,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) } diff --git a/internal/cmd/auth_logout.go b/internal/cmd/auth_logout.go index 0e83a3ee..a0bf4d1a 100644 --- a/internal/cmd/auth_logout.go +++ b/internal/cmd/auth_logout.go @@ -21,9 +21,14 @@ func buildLogoutCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - revokeOAuthSession(cmd) + cfg, err := config.Load(cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + revokeOAuthSession(cmd, cfg) - if err := config.RemoveCredentials(); err != nil { + if err := cfg.RemoveCredentials(); err != nil { return fmt.Errorf("failed to remove credentials: %w", err) } @@ -36,15 +41,11 @@ 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() +func revokeOAuthSession(cmd *cobra.Command, 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 diff --git a/internal/cmd/auth_logout_test.go b/internal/cmd/auth_logout_test.go index c2cd3ac2..fafec377 100644 --- a/internal/cmd/auth_logout_test.go +++ b/internal/cmd/auth_logout_test.go @@ -2,21 +2,19 @@ package cmd import ( "testing" - - "github.com/timescale/tiger-cli/internal/config" ) func TestAuthLogout_Success(t *testing.T) { setupAuthTest(t) // Store credentials first - err := config.StoreCredentials("test-api-key-logout", "test-project-logout") + err := testConfig(t).StoreCredentials("test-api-key-logout", "test-project-logout") if err != nil { t.Fatalf("Failed to store credentials: %v", err) } // Verify credentials are stored - _, err = config.GetStoredCredentials() + _, err = testConfig(t).GetStoredCredentials() if err != nil { t.Fatalf("Credentials should be stored: %v", err) } @@ -32,7 +30,7 @@ func TestAuthLogout_Success(t *testing.T) { } // Verify credentials are removed - _, err = config.GetStoredCredentials() + _, err = testConfig(t).GetStoredCredentials() if err == nil { t.Fatal("Credentials should be removed after logout") } diff --git a/internal/cmd/auth_status.go b/internal/cmd/auth_status.go index ac83bf73..5eb747a5 100644 --- a/internal/cmd/auth_status.go +++ b/internal/cmd/auth_status.go @@ -28,12 +28,11 @@ func buildStatusCmd() *cobra.Command { Long: "Displays whether you are logged in and shows your currently configured project ID.", Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { if errors.Is(err, config.ErrNotLoggedIn) { return common.ExitWithCode(common.ExitAuthenticationError, config.ErrNotLoggedIn) diff --git a/internal/cmd/auth_status_test.go b/internal/cmd/auth_status_test.go index d0830c80..7481da08 100644 --- a/internal/cmd/auth_status_test.go +++ b/internal/cmd/auth_status_test.go @@ -43,7 +43,7 @@ func TestAuthStatus_LoggedIn(t *testing.T) { } // Store credentials first - err := config.StoreCredentials("test-api-key-789", "test-project-789") + err := testConfig(t).StoreCredentials("test-api-key-789", "test-project-789") if err != nil { t.Fatalf("Failed to store credentials: %v", err) } diff --git a/internal/cmd/auth_test.go b/internal/cmd/auth_test.go index 6fd2f550..719265e6 100644 --- a/internal/cmd/auth_test.go +++ b/internal/cmd/auth_test.go @@ -31,28 +31,24 @@ func setupAuthTest(t *testing.T) string { t.Fatalf("Failed to create temp dir: %v", err) } - // Set TIGER_CONFIG_DIR environment variable so that when commands execute - // and reinitialize viper, they use the test directory + // Set TIGER_CONFIG_DIR environment variable so that commands executed by + // the test load their config from the test directory os.Setenv("TIGER_CONFIG_DIR", tmpDir) // Disable analytics for auth tests to avoid tracking test events os.Setenv("TIGER_ANALYTICS", "false") - // Reset global config and viper to ensure test isolation - // This ensures proper test isolation by resetting all viper state - // MUST be done before RemoveCredentials() so it uses the test 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 any existing test credentials - config.RemoveCredentials() + testConfig(t).RemoveCredentials() t.Cleanup(func() { // Clean up test credentials - config.RemoveCredentials() - // Reset global config and viper first - config.ResetGlobalConfig() + testConfig(t).RemoveCredentials() validateAPIKey = originalValidator // Restore original validator // Remove config file explicitly configFile := config.GetConfigFile(tmpDir) diff --git a/internal/cmd/completion_helper.go b/internal/cmd/completion_helper.go index 17269204..a20cedb3 100644 --- a/internal/cmd/completion_helper.go +++ b/internal/cmd/completion_helper.go @@ -37,7 +37,7 @@ func serviceIDCompletion(cmd *cobra.Command, args []string, toComplete string) ( func listServices(cmd *cobra.Command) ([]api.Service, error) { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { return nil, err } @@ -79,13 +79,13 @@ func mcpGetCompletion(cmd *cobra.Command, args []string, toComplete string) ([]s return nil, cobra.ShellCompDirectiveNoFileComp } - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } // Create MCP server to get capabilities - server, err := mcp.NewServer(cmd.Context(), cfg) + server, err := mcp.NewServer(cmd.Context(), cfg, cmd.Flags()) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/cmd/config_reset.go b/internal/cmd/config_reset.go index dc880f25..5b8c182b 100644 --- a/internal/cmd/config_reset.go +++ b/internal/cmd/config_reset.go @@ -19,7 +19,7 @@ func buildConfigResetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/cmd/config_reset_test.go b/internal/cmd/config_reset_test.go index 119105b5..91ca0b5f 100644 --- a/internal/cmd/config_reset_test.go +++ b/internal/cmd/config_reset_test.go @@ -1,6 +1,7 @@ package cmd import ( + "os" "strings" "testing" @@ -8,10 +9,10 @@ import ( ) func TestConfigReset(t *testing.T) { - _, _ = setupConfigTest(t) + tmpDir, _ := setupConfigTest(t) // First set some custom values - cfg, err := config.Load() + cfg, err := config.Load(nil) if err != nil { t.Fatalf("Failed to load config: %v", err) } @@ -30,7 +31,7 @@ func TestConfigReset(t *testing.T) { t.Errorf("Expected output to contain reset message, got '%s'", strings.TrimSpace(output)) } - cfg, err = config.Load() + cfg, err = config.Load(nil) if err != nil { t.Fatalf("Failed to load config: %v", err) } @@ -45,7 +46,14 @@ func TestConfigReset(t *testing.T) { if cfg.Output != config.DefaultOutput { t.Errorf("Expected default Output %s, got %s", config.DefaultOutput, cfg.Output) } - if cfg.Analytics != config.DefaultAnalytics { - t.Errorf("Expected default Analytics %t, got %t", config.DefaultAnalytics, cfg.Analytics) + + // Reset empties the config file rather than writing defaults into it, so + // env vars still apply afterwards (setupConfigTest sets TIGER_ANALYTICS). + contents, err := os.ReadFile(config.GetConfigFile(tmpDir)) + if err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + if strings.TrimSpace(string(contents)) != "{}" { + t.Errorf("Expected an empty config file, got %q", string(contents)) } } diff --git a/internal/cmd/config_set.go b/internal/cmd/config_set.go index 1f467391..01a853c9 100644 --- a/internal/cmd/config_set.go +++ b/internal/cmd/config_set.go @@ -20,7 +20,7 @@ func buildConfigSetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/cmd/config_set_test.go b/internal/cmd/config_set_test.go index 9b4917ad..c29218c6 100644 --- a/internal/cmd/config_set_test.go +++ b/internal/cmd/config_set_test.go @@ -39,7 +39,7 @@ func TestConfigSet_ValidValues(t *testing.T) { } // Verify the value was actually set - cfg, err := config.Load() + cfg, err := config.Load(nil) if err != nil { t.Fatalf("Failed to load config: %v", err) } @@ -191,7 +191,7 @@ func TestConfigSet_OutputDoesPersist(t *testing.T) { } // Also verify by loading config - cfg, err := config.Load() + cfg, err := config.Load(nil) if err != nil { t.Fatalf("Failed to load config: %v", err) } diff --git a/internal/cmd/config_show.go b/internal/cmd/config_show.go index fe0cac7d..e8b2b934 100644 --- a/internal/cmd/config_show.go +++ b/internal/cmd/config_show.go @@ -6,7 +6,6 @@ import ( "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" @@ -23,35 +22,18 @@ func buildConfigShowCmd() *cobra.Command { Long: `Display the current CLI configuration settings`, Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } - configFile, err := cfg.EnsureConfigDir() - if err != nil { - return err - } - - // a new viper, free from env and cli flags - v := viper.New() - v.SetConfigFile(configFile) - if withEnv { - config.ApplyEnvOverrides(v) - } - if !noDefaults { - config.ApplyDefaults(v) - } - if err := config.ReadInConfig(v); err != nil { - return err - } - config.MigrateVersionCheck(v) - - cfgOut, err := config.ForOutputFromViper(v) + // Values are re-read free of env and CLI flags (unless --with-env + // is given), so `config show -o json` reports the configured + // `output` value rather than the flag's. + cfgOut, err := config.LoadForOutput(cfg.ConfigDir, withEnv, noDefaults) if err != nil { return err } diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index edbaa34a..5a1af742 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -39,7 +39,6 @@ func setupConfigTest(t *testing.T) (string, func()) { // Reset global config in the config package // This is important for test isolation // We need to clear the singleton - config.ResetGlobalConfig() } t.Cleanup(cleanup) @@ -120,7 +119,7 @@ func TestConfigCommands_Integration(t *testing.T) { } // 6. Verify everything is back to defaults - cfg, err := config.Load() + cfg, err := config.Load(nil) if err != nil { t.Fatalf("Failed to load config after reset: %v", err) } diff --git a/internal/cmd/config_unset.go b/internal/cmd/config_unset.go index cb26846c..d535f910 100644 --- a/internal/cmd/config_unset.go +++ b/internal/cmd/config_unset.go @@ -20,7 +20,7 @@ func buildConfigUnsetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/cmd/config_unset_test.go b/internal/cmd/config_unset_test.go index 61d95be1..7f95084b 100644 --- a/internal/cmd/config_unset_test.go +++ b/internal/cmd/config_unset_test.go @@ -11,7 +11,7 @@ func TestConfigUnset_ValidKeys(t *testing.T) { _, _ = setupConfigTest(t) // First set some values - cfg, err := config.Load() + cfg, err := config.Load(nil) if err != nil { t.Fatalf("Failed to load config: %v", err) } @@ -41,7 +41,7 @@ func TestConfigUnset_ValidKeys(t *testing.T) { } // Verify the value was actually unset - cfg, err := config.Load() + cfg, err := config.Load(nil) if err != nil { t.Fatalf("Failed to load config: %v", err) } diff --git a/internal/cmd/db.go b/internal/cmd/db.go index c7c78d2a..6b37286c 100644 --- a/internal/cmd/db.go +++ b/internal/cmd/db.go @@ -9,6 +9,7 @@ import ( "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" ) // getServiceDetailsFunc can be overridden for testing @@ -57,9 +58,9 @@ func warnReplicaPooler(cmd *cobra.Command, target *common.ConnectionTarget, pool // buildConnectionDetailsForTarget builds connection details for a target, // warning first when a replica falls back from a requested pooler. -func buildConnectionDetailsForTarget(cmd *cobra.Command, target *common.ConnectionTarget, opts common.ConnectionDetailsOptions) (*common.ConnectionDetails, error) { +func buildConnectionDetailsForTarget(cmd *cobra.Command, cfg *config.Config, target *common.ConnectionTarget, opts common.ConnectionDetailsOptions) (*common.ConnectionDetails, error) { warnReplicaPooler(cmd, target, opts.Pooled) - return target.Details(opts) + return target.Details(cfg, opts) } // getServiceDetails is a helper that handles common service lookup logic and returns the service details diff --git a/internal/cmd/db_connect.go b/internal/cmd/db_connect.go index 27ba2331..1e849ca6 100644 --- a/internal/cmd/db_connect.go +++ b/internal/cmd/db_connect.go @@ -17,6 +17,7 @@ import ( "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) @@ -91,7 +92,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { return err } @@ -118,7 +119,7 @@ Examples: // Connects straight to a replica named by ID, or offers the interactive // replica menu for a primary. Returns nil details if the user cancels. - details, err := selectConnection(cmd.Context(), cmd, cfg.Client, cfg.ProjectID, target, opts, dbConnectNoReplicaPrompt) + details, err := selectConnection(cmd.Context(), cmd, cfg, target, opts, dbConnectNoReplicaPrompt) if err != nil { return err } @@ -128,7 +129,7 @@ Examples: // Read replicas share the primary's credentials, so password storage // and recovery always operate on the credential service. - return connectWithPasswordMenu(cmd.Context(), cmd, cfg.Client, target.CredentialService, details, psqlPath, psqlFlags) + return connectWithPasswordMenu(cmd.Context(), cmd, cfg, target.CredentialService, details, psqlPath, psqlFlags) }, } @@ -171,8 +172,7 @@ func separateServiceAndPsqlArgs(cmd ArgsLenAtDashProvider, args []string) ([]str func selectConnection( ctx context.Context, cmd *cobra.Command, - client *api.ClientWithResponses, - projectID string, + cfg *common.Config, target *common.ConnectionTarget, opts common.ConnectionDetailsOptions, noReplicaPrompt bool, @@ -183,7 +183,7 @@ func selectConnection( // Offer the replica menu only for a primary on an interactive terminal. if !target.IsReplica && !noReplicaPrompt && checkStdinIsTTY() { primary := target.ConnectionService - replicas, err := fetchReplicaSets(ctx, client, projectID, util.DerefStr(primary.ServiceId)) + replicas, err := fetchReplicaSets(ctx, cfg.Client, cfg.ProjectID, util.DerefStr(primary.ServiceId)) if err != nil { // Don't block the connection if we can't list replicas. fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not list read replicas: %v\n", err) @@ -201,7 +201,7 @@ func selectConnection( } } - details, err := buildConnectionDetailsForTarget(cmd, chosen, opts) + details, err := buildConnectionDetailsForTarget(cmd, cfg.Config, chosen, opts) if err != nil { return nil, err } @@ -359,14 +359,14 @@ func selectConnectTargetOption(out io.Writer, primary api.Service, replicas []ap func connectWithPasswordMenu( ctx context.Context, cmd *cobra.Command, - client *api.ClientWithResponses, + cfg *common.Config, service api.Service, details *common.ConnectionDetails, psqlPath string, psqlFlags []string, ) error { // Interactive mode: Get stored password (if any) - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(cfg.Config) storedPassword, err := storage.Get(service, details.Role) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve stored password: %v\n", err) @@ -376,7 +376,7 @@ func connectWithPasswordMenu( err = testConnectionWithPassword(ctx, details, storedPassword) if err == nil { // Password works, launch psql - return launchPsql(details, psqlPath, psqlFlags, service, cmd) + return launchPsql(cfg.Config, details, psqlPath, psqlFlags, service, cmd) } // Check if it's an auth error @@ -417,7 +417,7 @@ func connectWithPasswordMenu( // Test, save, and launch details.Password = password - if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, details, psqlPath, psqlFlags, service); err != nil { + if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, cfg.Config, details, psqlPath, psqlFlags, service); err != nil { if isAuthenticationError(err) { fmt.Fprintf(cmd.ErrOrStderr(), "Password incorrect. Please try again.\n\n") continue @@ -428,7 +428,7 @@ func connectWithPasswordMenu( case optionResetPassword: // Prompt and reset - password, err := promptAndResetPassword(ctx, cmd.ErrOrStderr(), client, service, details.Role) + password, err := promptAndResetPassword(ctx, cfg.Config, cmd.ErrOrStderr(), cfg.Client, service, details.Role) if err != nil { if errors.Is(err, context.Canceled) { return nil // user cancelled @@ -439,7 +439,7 @@ func connectWithPasswordMenu( fmt.Fprintf(cmd.ErrOrStderr(), "✅ Master password for '%s' user updated successfully\n", details.Role) // Launch psql (password is now in storage) details.Password = password - return launchPsql(details, psqlPath, psqlFlags, service, cmd) + return launchPsql(cfg.Config, details, psqlPath, psqlFlags, service, cmd) case optionExit: return nil @@ -588,6 +588,7 @@ func selectPasswordRecoveryOption(out io.Writer, canResetPassword bool) (passwor func testSaveAndLaunchPsqlWithPassword( ctx context.Context, cmd *cobra.Command, + cfg *config.Config, details *common.ConnectionDetails, psqlPath string, psqlFlags []string, @@ -599,7 +600,7 @@ func testSaveAndLaunchPsqlWithPassword( } // Password works! Save it - result, saveErr := common.SavePasswordWithResult(service, details.Password, details.Role) + result, saveErr := common.SavePasswordWithResult(cfg, service, details.Password, details.Role) if saveErr != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save password: %v\n", saveErr) } else if result.Success { @@ -607,18 +608,18 @@ func testSaveAndLaunchPsqlWithPassword( } // Launch psql - return launchPsql(details, psqlPath, psqlFlags, service, cmd) + return launchPsql(cfg, details, psqlPath, psqlFlags, service, cmd) } // launchPsql launches psql using the connection string and additional flags. // It retrieves the password from storage and sets PGPASSWORD environment variable. -func launchPsql(details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) error { - psqlCmd := buildPsqlCommand(details, psqlPath, additionalFlags, service, cmd) +func launchPsql(cfg *config.Config, details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) error { + psqlCmd := buildPsqlCommand(cfg, details, psqlPath, additionalFlags, service, cmd) return psqlCmd.Run() } // buildPsqlCommand creates the psql command with proper environment setup -func buildPsqlCommand(details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) *exec.Cmd { +func buildPsqlCommand(cfg *config.Config, details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) *exec.Cmd { password := details.Password // Ensure we don't include password in the connection string to make it not show up in process lists // Passwords are passed via PGPASSWORD environment variable (see below) @@ -640,7 +641,7 @@ func buildPsqlCommand(details *common.ConnectionDetails, psqlPath string, additi if password != "" { psqlCmd.Env = append(os.Environ(), "PGPASSWORD="+password) } else { - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(cfg) // Only set PGPASSWORD for keyring storage method // pgpass storage relies on psql automatically reading ~/.pgpass file if _, isKeyring := storage.(*common.KeyringStorage); isKeyring { diff --git a/internal/cmd/db_connect_test.go b/internal/cmd/db_connect_test.go index d94d5cde..56a132bd 100644 --- a/internal/cmd/db_connect_test.go +++ b/internal/cmd/db_connect_test.go @@ -12,7 +12,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/jackc/pgx/v5/pgconn" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" @@ -307,7 +306,8 @@ func TestSelectConnection_NoReplicasSkipsPrompt(t *testing.T) { cmd.SetErr(io.Discard) target := &common.ConnectionTarget{ConnectionService: primary, CredentialService: primary} - details, err := selectConnection(context.Background(), cmd, client, "proj-1", target, + cfg := &common.Config{Config: testConfig(t), Client: client, ProjectID: "proj-1"} + details, err := selectConnection(context.Background(), cmd, cfg, target, common.ConnectionDetailsOptions{Role: "tsdbadmin"}, false /*noReplicaPrompt*/) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -395,7 +395,7 @@ func TestLaunchPsqlWithConnectionString(t *testing.T) { } // This will fail because psql path doesn't exist, but we can verify the error - err := launchPsql(connectionDetails, psqlPath, []string{}, service, cmd) + err := launchPsql(testConfig(t), connectionDetails, psqlPath, []string{}, service, cmd) // Should fail with exec error since fake psql path doesn't exist if err == nil { @@ -432,7 +432,7 @@ func TestLaunchPsqlWithAdditionalFlags(t *testing.T) { } // This will fail because psql path doesn't exist, but we can verify the error - err := launchPsql(connectionDetails, psqlPath, additionalFlags, service, cmd) + err := launchPsql(testConfig(t), connectionDetails, psqlPath, additionalFlags, service, cmd) // Should fail with exec error since fake psql path doesn't exist if err == nil { @@ -451,9 +451,7 @@ func TestBuildPsqlCommand_KeyringPasswordEnvVar(t *testing.T) { config.SetTestServiceName(t) // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") // Create a test service serviceID := "test-psql-service" @@ -465,7 +463,7 @@ func TestBuildPsqlCommand_KeyringPasswordEnvVar(t *testing.T) { // Store a test password in keyring testPassword := "test-password-12345" - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) err := storage.Save(service, testPassword, "tsdbadmin") if err != nil { t.Fatalf("Failed to save test password: %v", err) @@ -487,7 +485,7 @@ func TestBuildPsqlCommand_KeyringPasswordEnvVar(t *testing.T) { testCmd := &cobra.Command{} // Call the actual production function that builds the command - psqlCmd := buildPsqlCommand(connectionDetails, psqlPath, additionalFlags, service, testCmd) + psqlCmd := buildPsqlCommand(testConfig(t), connectionDetails, psqlPath, additionalFlags, service, testCmd) if psqlCmd == nil { t.Fatal("buildPsqlCommand returned nil") @@ -510,9 +508,7 @@ func TestBuildPsqlCommand_KeyringPasswordEnvVar(t *testing.T) { func TestBuildPsqlCommand_PgpassStorage_NoEnvVar(t *testing.T) { // Set pgpass as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "pgpass") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "pgpass") // Create a test service serviceID := "test-service-id" @@ -536,7 +532,7 @@ func TestBuildPsqlCommand_PgpassStorage_NoEnvVar(t *testing.T) { testCmd := &cobra.Command{} // Call the actual production function that builds the command - psqlCmd := buildPsqlCommand(connectionDetails, psqlPath, []string{}, service, testCmd) + psqlCmd := buildPsqlCommand(testConfig(t), connectionDetails, psqlPath, []string{}, service, testCmd) if psqlCmd == nil { t.Fatal("buildPsqlCommand returned nil") diff --git a/internal/cmd/db_connection_string.go b/internal/cmd/db_connection_string.go index 79c117f1..57a59b3c 100644 --- a/internal/cmd/db_connection_string.go +++ b/internal/cmd/db_connection_string.go @@ -55,7 +55,7 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -66,7 +66,7 @@ Examples: return err } - details, err := buildConnectionDetailsForTarget(cmd, target, common.ConnectionDetailsOptions{ + details, err := buildConnectionDetailsForTarget(cmd, cfg.Config, target, common.ConnectionDetailsOptions{ Pooled: dbConnectionStringPooled, Role: dbConnectionStringRole, WithPassword: dbConnectionStringWithPassword, diff --git a/internal/cmd/db_connection_string_test.go b/internal/cmd/db_connection_string_test.go index 549ca396..4302cae9 100644 --- a/internal/cmd/db_connection_string_test.go +++ b/internal/cmd/db_connection_string_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" @@ -79,7 +78,7 @@ func TestDBConnectionString_PoolerWarning(t *testing.T) { } // Request pooled connection when pooler is not available - details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{ + details, err := common.GetConnectionDetails(testConfig(t), service, common.ConnectionDetailsOptions{ Pooled: true, Role: "tsdbadmin", }) @@ -107,9 +106,7 @@ func TestDBConnectionString_WithPassword(t *testing.T) { config.SetTestServiceName(t) // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") // Create a test service serviceID := "test-e2e-service" @@ -127,7 +124,7 @@ func TestDBConnectionString_WithPassword(t *testing.T) { // Store a test password testPassword := "test-e2e-password-789" - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) err := storage.Save(service, testPassword, "tsdbadmin") if err != nil { t.Fatalf("Failed to save test password: %v", err) @@ -135,7 +132,7 @@ func TestDBConnectionString_WithPassword(t *testing.T) { defer storage.Remove(service, "tsdbadmin") // Clean up after test // Test connection string without password (default behavior) - details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{ + details, err := common.GetConnectionDetails(testConfig(t), service, common.ConnectionDetailsOptions{ Role: "tsdbadmin", }) if err != nil { @@ -154,7 +151,7 @@ func TestDBConnectionString_WithPassword(t *testing.T) { } // Test connection string with password (simulating --with-password flag) - details2, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{ + details2, err := common.GetConnectionDetails(testConfig(t), service, common.ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: true, }) diff --git a/internal/cmd/db_create_role.go b/internal/cmd/db_create_role.go index 6e68aa2d..17886b58 100644 --- a/internal/cmd/db_create_role.go +++ b/internal/cmd/db_create_role.go @@ -84,14 +84,13 @@ PostgreSQL Configuration Parameters That May Be Set: (kills queries that exceed the specified duration, in milliseconds)`, Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { // Validate arguments if roleName == "" { return fmt.Errorf("--name is required") } - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -116,7 +115,7 @@ PostgreSQL Configuration Parameters That May Be Set: } // Build connection string - details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{ + details, err := common.GetConnectionDetails(cfg.Config, service, common.ConnectionDetailsOptions{ Pooled: false, Role: "tsdbadmin", // Use admin role to create new roles WithPassword: true, @@ -141,7 +140,7 @@ PostgreSQL Configuration Parameters That May Be Set: } // Save password to storage with the new role name - result, err := common.SavePasswordWithResult(service, rolePassword, roleName) + result, err := common.SavePasswordWithResult(cfg.Config, service, rolePassword, roleName) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message) } else if !result.Success { diff --git a/internal/cmd/db_save_password.go b/internal/cmd/db_save_password.go index 3bdb5bf4..82466880 100644 --- a/internal/cmd/db_save_password.go +++ b/internal/cmd/db_save_password.go @@ -46,7 +46,7 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -94,7 +94,7 @@ Examples: } // Save password using configured storage - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(cfg.Config) if err := storage.Save(service, passwordToSave, dbSavePasswordRole); err != nil { return fmt.Errorf("failed to save password: %w", err) } diff --git a/internal/cmd/db_save_password_test.go b/internal/cmd/db_save_password_test.go index 0629ec27..1d582135 100644 --- a/internal/cmd/db_save_password_test.go +++ b/internal/cmd/db_save_password_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" @@ -23,9 +22,7 @@ func TestDBSavePassword_ExplicitPassword(t *testing.T) { tmpDir := setupDBTest(t) // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") // Set up config _, err := config.UseTestConfig(tmpDir, map[string]any{ @@ -75,7 +72,7 @@ func TestDBSavePassword_ExplicitPassword(t *testing.T) { } // Verify password was actually saved - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) retrievedPassword, err := storage.Get(mockService, "tsdbadmin") if err != nil { t.Fatalf("Failed to retrieve saved password: %v", err) @@ -94,9 +91,7 @@ func TestDBSavePassword_ReplicaResolvesToParent(t *testing.T) { config.SetTestServiceName(t) tmpDir := setupDBTest(t) - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") const projectID = "test-project-123" port := 5432 @@ -157,7 +152,7 @@ func TestDBSavePassword_ReplicaResolvesToParent(t *testing.T) { t.Errorf("expected parent primary id in output, got: %s", output) } - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) // Stored against the parent primary, matching the connect read path. got, err := storage.Get(primary, "tsdbadmin") if err != nil { @@ -179,9 +174,7 @@ func TestDBSavePassword_EnvironmentVariable(t *testing.T) { tmpDir := setupDBTest(t) // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") // Set up config _, err := config.UseTestConfig(tmpDir, map[string]any{ @@ -231,7 +224,7 @@ func TestDBSavePassword_EnvironmentVariable(t *testing.T) { } // Verify password was actually saved - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) retrievedPassword, err := storage.Get(mockService, "tsdbadmin") if err != nil { t.Fatalf("Failed to retrieve saved password: %v", err) @@ -249,9 +242,7 @@ func TestDBSavePassword_InteractivePrompt(t *testing.T) { tmpDir := setupDBTest(t) // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") // Set up config _, err := config.UseTestConfig(tmpDir, map[string]any{ @@ -321,7 +312,7 @@ func TestDBSavePassword_InteractivePrompt(t *testing.T) { } // Verify password was actually saved - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) retrievedPassword, err := storage.Get(mockService, "tsdbadmin") if err != nil { t.Fatalf("Failed to retrieve saved password: %v", err) @@ -396,9 +387,7 @@ func TestDBSavePassword_CustomRole(t *testing.T) { tmpDir := setupDBTest(t) // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "keyring") // Set up config _, err := config.UseTestConfig(tmpDir, map[string]any{ @@ -449,7 +438,7 @@ func TestDBSavePassword_CustomRole(t *testing.T) { } // Verify password was saved for the custom role - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) retrievedPassword, err := storage.Get(mockService, customRole) if err != nil { t.Fatalf("Failed to retrieve saved password for role %s: %v", customRole, err) @@ -526,9 +515,7 @@ func TestDBSavePassword_PgpassStorage(t *testing.T) { tmpDir := setupDBTest(t) // Set pgpass as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "pgpass") - defer viper.Set("password_storage", originalStorage) + t.Setenv("TIGER_PASSWORD_STORAGE", "pgpass") // Set up config _, err := config.UseTestConfig(tmpDir, map[string]any{ @@ -575,7 +562,7 @@ func TestDBSavePassword_PgpassStorage(t *testing.T) { } // Verify password was saved in pgpass storage - storage := common.GetPasswordStorage() + storage := common.GetPasswordStorage(testConfig(t)) retrievedPassword, err := storage.Get(mockService, "tsdbadmin") if err != nil { t.Fatalf("Failed to retrieve saved password from pgpass: %v", err) diff --git a/internal/cmd/db_schema.go b/internal/cmd/db_schema.go index 8b31ecaa..4701716a 100644 --- a/internal/cmd/db_schema.go +++ b/internal/cmd/db_schema.go @@ -51,7 +51,7 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -64,7 +64,7 @@ Examples: warnReplicaPooler(cmd, target, dbSchemaPooled) - schema, err := common.FetchServiceSchema(cmd.Context(), target, dbSchemaRole, dbSchemaPooled, common.SchemaOptions{ + schema, err := common.FetchServiceSchema(cmd.Context(), cfg.Config, target, dbSchemaRole, dbSchemaPooled, common.SchemaOptions{ Schema: dbSchemaSchema, IncludeInternal: dbSchemaInternal, IncludeDefinitions: dbSchemaDefinitions, diff --git a/internal/cmd/db_test.go b/internal/cmd/db_test.go index 7e7cb37d..d3893011 100644 --- a/internal/cmd/db_test.go +++ b/internal/cmd/db_test.go @@ -38,12 +38,7 @@ func setupDBTest(t *testing.T) string { // Disable analytics for DB tests to avoid tracking test events os.Setenv("TIGER_ANALYTICS", "false") - // Reset global config and viper to ensure test isolation - config.ResetGlobalConfig() - t.Cleanup(func() { - // Reset global config and viper first - config.ResetGlobalConfig() // Clean up environment variables BEFORE cleaning up file system os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_ANALYTICS") @@ -212,7 +207,7 @@ func TestBuildConnectionDetailsForTarget_ReplicaPoolerFallback(t *testing.T) { cmd.SetErr(buf) cmd.SetOut(io.Discard) - details, err := buildConnectionDetailsForTarget(cmd, target, common.ConnectionDetailsOptions{Pooled: true, Role: "tsdbadmin"}) + details, err := buildConnectionDetailsForTarget(cmd, testConfig(t), target, common.ConnectionDetailsOptions{Pooled: true, Role: "tsdbadmin"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -242,7 +237,7 @@ func TestBuildConnectionDetailsForTarget_PrimaryRequiresPooler(t *testing.T) { cmd.SetErr(io.Discard) cmd.SetOut(io.Discard) - if _, err := buildConnectionDetailsForTarget(cmd, target, common.ConnectionDetailsOptions{Pooled: true, Role: "tsdbadmin"}); err == nil { + if _, err := buildConnectionDetailsForTarget(cmd, testConfig(t), target, common.ConnectionDetailsOptions{Pooled: true, Role: "tsdbadmin"}); err == nil { t.Fatal("expected an error when a pooler is unavailable for the primary, got nil") } } diff --git a/internal/cmd/db_test_connection.go b/internal/cmd/db_test_connection.go index 15d71387..247558cc 100644 --- a/internal/cmd/db_test_connection.go +++ b/internal/cmd/db_test_connection.go @@ -53,7 +53,7 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return common.ExitWithCode(common.ExitInvalidParameters, err) @@ -65,7 +65,7 @@ Examples: } // Build connection string for testing with password (if available) - details, err := buildConnectionDetailsForTarget(cmd, target, common.ConnectionDetailsOptions{ + details, err := buildConnectionDetailsForTarget(cmd, cfg.Config, target, common.ConnectionDetailsOptions{ Pooled: dbTestConnectionPooled, Role: dbTestConnectionRole, WithPassword: true, diff --git a/internal/cmd/flag_helper.go b/internal/cmd/flag_helper.go index 57ed602b..b8fb2dc4 100644 --- a/internal/cmd/flag_helper.go +++ b/internal/cmd/flag_helper.go @@ -1,11 +1,6 @@ package cmd import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/config" ) @@ -46,19 +41,3 @@ func (o *outputWithEnvFlag) String() string { func (o *outputWithEnvFlag) Type() string { return "string" } - -type runE func(cmd *cobra.Command, args []string) error - -var flagNameReplacer = strings.NewReplacer("-", "_") - -func bindFlags(flags ...string) runE { - return func(cmd *cobra.Command, args []string) error { - for _, flag := range flags { - key := flagNameReplacer.Replace(flag) - if err := viper.BindPFlag(key, cmd.Flags().Lookup(flag)); err != nil { - return fmt.Errorf("failed to bind %s flag: %w", flag, err) - } - } - return nil - } -} diff --git a/internal/cmd/integration_test.go b/internal/cmd/integration_test.go index 90b471ec..7d92c286 100644 --- a/internal/cmd/integration_test.go +++ b/internal/cmd/integration_test.go @@ -11,8 +11,6 @@ import ( "testing" "time" - "github.com/spf13/viper" - "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/config" @@ -34,13 +32,6 @@ func setupIntegrationTest(t *testing.T) string { // Disable analytics for integration tests to avoid tracking test events os.Setenv("TIGER_ANALYTICS", "false") - // Reset global config and viper to ensure test isolation - config.ResetGlobalConfig() - - // Re-establish viper environment configuration after reset - viper.SetEnvPrefix("TIGER") - viper.AutomaticEnv() - // Set API URL in temporary config if integration URL is provided if apiURL := os.Getenv("TIGER_API_URL_INTEGRATION"); apiURL != "" { // Use a simple command execution without the full executeIntegrationCommand wrapper @@ -56,8 +47,6 @@ func setupIntegrationTest(t *testing.T) string { } t.Cleanup(func() { - // Reset global config and viper first - config.ResetGlobalConfig() // Clean up environment variables BEFORE cleaning up file system os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_ANALYTICS") @@ -70,14 +59,6 @@ func setupIntegrationTest(t *testing.T) string { // executeIntegrationCommand executes a CLI command for integration testing func executeIntegrationCommand(ctx context.Context, args ...string) (string, error) { - // Reset both global config and viper before each command execution - // This ensures fresh config loading with proper flag precedence - config.ResetGlobalConfig() - - // Re-establish viper environment configuration after reset - viper.SetEnvPrefix("TIGER") - viper.AutomaticEnv() - // Use buildRootCmd() to get a complete root command with all flags and subcommands testRoot, err := buildRootCmd(ctx) if err != nil { diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go index 0686bf23..4c23493a 100644 --- a/internal/cmd/main_test.go +++ b/internal/cmd/main_test.go @@ -10,7 +10,6 @@ import ( func TestMain(m *testing.M) { // Clean up any global state before tests - config.ResetGlobalConfig() code := m.Run() os.Exit(code) } @@ -34,7 +33,6 @@ func setupTestCommand(t *testing.T) (string, func()) { cleanup := func() { os.RemoveAll(tmpDir) os.Unsetenv("TIGER_ANALYTICS") - config.ResetGlobalConfig() } t.Cleanup(cleanup) @@ -42,12 +40,24 @@ func setupTestCommand(t *testing.T) (string, func()) { return tmpDir, cleanup } +// testConfig loads the config for the test's config directory, which the setup +// helpers point at via TIGER_CONFIG_DIR. Use it where a test needs the config +// itself (credential storage, password storage) rather than running a command. +func testConfig(t *testing.T) *config.Config { + t.Helper() + cfg, err := config.Load(nil) + if err != nil { + t.Fatalf("Failed to load test config: %v", err) + } + return cfg +} + // mockStoredCredentials overrides the common.GetStoredCredentials seam for the // duration of the test, restoring the original automatically via t.Cleanup. func mockStoredCredentials(t *testing.T, creds *config.Credentials, err error) { t.Helper() original := common.GetStoredCredentials - common.GetStoredCredentials = func() (*config.Credentials, error) { + common.GetStoredCredentials = func(*config.Config) (*config.Credentials, error) { return creds, err } t.Cleanup(func() { common.GetStoredCredentials = original }) diff --git a/internal/cmd/mcp_get.go b/internal/cmd/mcp_get.go index a90c784d..535157eb 100644 --- a/internal/cmd/mcp_get.go +++ b/internal/cmd/mcp_get.go @@ -40,20 +40,19 @@ Examples: tiger mcp get service_create -o yaml`, Args: cobra.ExactArgs(1), ValidArgsFunction: mcpGetCompletion, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { capabilityName := args[0] cmd.SilenceUsage = true // Get config - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } // Create MCP server - server, err := mcp.NewServer(cmd.Context(), cfg) + server, err := mcp.NewServer(cmd.Context(), cfg, cmd.Flags()) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_list.go b/internal/cmd/mcp_list.go index c7a4632b..e8b0acf8 100644 --- a/internal/cmd/mcp_list.go +++ b/internal/cmd/mcp_list.go @@ -34,18 +34,17 @@ Examples: tiger mcp list -o yaml`, Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Get config - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } // Create MCP server - server, err := mcp.NewServer(cmd.Context(), cfg) + server, err := mcp.NewServer(cmd.Context(), cfg, cmd.Flags()) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_start.go b/internal/cmd/mcp_start.go index e4bfebff..8fff59cd 100644 --- a/internal/cmd/mcp_start.go +++ b/internal/cmd/mcp_start.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/spf13/pflag" "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/config" @@ -37,7 +38,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { // Default behavior when no subcommand is specified - use stdio cmd.SilenceUsage = true - return startStdioServer(cmd.Context()) + return startStdioServer(cmd.Context(), cmd.Flags()) }, } @@ -49,16 +50,16 @@ Examples: } // startStdioServer starts the MCP server with stdio transport -func startStdioServer(ctx context.Context) error { +func startStdioServer(ctx context.Context, flags *pflag.FlagSet) error { logging.Info("Starting Tiger MCP server", zap.String("transport", "stdio")) - cfg, err := config.Load() + cfg, err := config.Load(flags) if err != nil { return fmt.Errorf("failed to load config: %w", err) } // Create MCP server - server, err := mcp.NewServer(ctx, cfg) + server, err := mcp.NewServer(ctx, cfg, flags) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_start_http.go b/internal/cmd/mcp_start_http.go index 935e4107..1ecc574c 100644 --- a/internal/cmd/mcp_start_http.go +++ b/internal/cmd/mcp_start_http.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/spf13/cobra" + "github.com/spf13/pflag" "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/config" @@ -42,7 +43,7 @@ Examples: ValidArgsFunction: cobra.NoFileCompletions, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return startHTTPServer(cmd.Context(), httpHost, httpPort) + return startHTTPServer(cmd.Context(), cmd.Flags(), httpHost, httpPort) }, } @@ -54,16 +55,16 @@ Examples: } // startHTTPServer starts the MCP server with HTTP transport -func startHTTPServer(ctx context.Context, host string, port int) error { +func startHTTPServer(ctx context.Context, flags *pflag.FlagSet, host string, port int) error { logging.Info("Starting Tiger MCP server", zap.String("transport", "http")) - cfg, err := config.Load() + cfg, err := config.Load(flags) if err != nil { return fmt.Errorf("failed to load config: %w", err) } // Create MCP server - server, err := mcp.NewServer(ctx, cfg) + server, err := mcp.NewServer(ctx, cfg, flags) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_start_stdio.go b/internal/cmd/mcp_start_stdio.go index 22986e98..475d6c37 100644 --- a/internal/cmd/mcp_start_stdio.go +++ b/internal/cmd/mcp_start_stdio.go @@ -18,7 +18,7 @@ Examples: ValidArgsFunction: cobra.NoFileCompletions, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return startStdioServer(cmd.Context()) + return startStdioServer(cmd.Context(), cmd.Flags()) }, } } diff --git a/internal/cmd/mcp_test.go b/internal/cmd/mcp_test.go index 76fad441..15f2d87a 100644 --- a/internal/cmd/mcp_test.go +++ b/internal/cmd/mcp_test.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/require" - - "github.com/timescale/tiger-cli/internal/config" ) // setupMCPTest sets up a test environment for MCP command tests. @@ -31,12 +29,7 @@ func setupMCPTest(t *testing.T) (*cobra.Command, string) { // Disable analytics for tests os.Setenv("TIGER_ANALYTICS", "false") - // Reset global config and viper to ensure test isolation - config.ResetGlobalConfig() - t.Cleanup(func() { - // Reset global config and viper first - config.ResetGlobalConfig() // Clean up environment variables BEFORE cleaning up file system os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_ANALYTICS") diff --git a/internal/cmd/password_helper.go b/internal/cmd/password_helper.go index 7c0c263f..8e9e9144 100644 --- a/internal/cmd/password_helper.go +++ b/internal/cmd/password_helper.go @@ -7,6 +7,7 @@ import ( "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) @@ -14,6 +15,7 @@ import ( // It handles the API call and password storage. func updateAndSaveServicePassword( ctx context.Context, + cfg *config.Config, client api.ClientWithResponsesInterface, service api.Service, newPassword string, @@ -32,7 +34,7 @@ func updateAndSaveServicePassword( } // Save password locally - if result, err := common.SavePasswordWithResult(service, newPassword, role); err != nil { + if result, err := common.SavePasswordWithResult(cfg, service, newPassword, role); err != nil { fmt.Fprintf(statusOut, "Warning: could not save password: %v\n", err) } else if result.Success { fmt.Fprintf(statusOut, "%s\n", result.Message) @@ -43,7 +45,7 @@ func updateAndSaveServicePassword( } // resetServicePassword resets the password via API. If newPassword is empty, generates one. -func resetServicePassword(ctx context.Context, client api.ClientWithResponsesInterface, service api.Service, role string, newPassword string, statusOut io.Writer) (string, error) { +func resetServicePassword(ctx context.Context, cfg *config.Config, client api.ClientWithResponsesInterface, service api.Service, role string, newPassword string, statusOut io.Writer) (string, error) { // Generate password if not provided if newPassword == "" { var err error @@ -54,7 +56,7 @@ func resetServicePassword(ctx context.Context, client api.ClientWithResponsesInt } // Update and save password - if err := updateAndSaveServicePassword(ctx, client, service, newPassword, role, statusOut); err != nil { + if err := updateAndSaveServicePassword(ctx, cfg, client, service, newPassword, role, statusOut); err != nil { return "", err } return newPassword, nil @@ -65,6 +67,7 @@ func resetServicePassword(ctx context.Context, client api.ClientWithResponsesInt // Returns the new password on success. func promptAndResetPassword( ctx context.Context, + cfg *config.Config, out io.Writer, client api.ClientWithResponsesInterface, service api.Service, @@ -77,5 +80,5 @@ func promptAndResetPassword( return "", fmt.Errorf("error reading password: %w", err) } - return resetServicePassword(ctx, client, service, role, newPassword, out) + return resetServicePassword(ctx, cfg, client, service, role, newPassword, out) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f16cb710..ab367c7e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "errors" "fmt" "os" "strconv" @@ -10,7 +9,6 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/spf13/viper" "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/analytics" @@ -58,25 +56,10 @@ tiger auth login PersistentPreRunE: func(cmd *cobra.Command, args []string) error { cmd.SetContext(ctx) - // Bind persistent flags to viper - // Use cmd.Flags() which includes inherited persistent flags from parents - if err := errors.Join( - viper.BindPFlag("debug", cmd.Flags().Lookup("debug")), - viper.BindPFlag("service_id", cmd.Flags().Lookup("service-id")), - viper.BindPFlag("analytics", cmd.Flags().Lookup("analytics")), - viper.BindPFlag("password_storage", cmd.Flags().Lookup("password-storage")), - viper.BindPFlag("color", cmd.Flags().Lookup("color")), - ); err != nil { - return fmt.Errorf("failed to bind flags: %w", err) - } - - // Setup configuration initialization - configDirFlag := cmd.Flags().Lookup("config-dir") - if err := config.SetupViper(config.GetEffectiveConfigDir(configDirFlag)); err != nil { - return fmt.Errorf("error setting up config: %w", err) - } - - cfg, err := config.Load() + // Load the config for the command being run. cmd.Flags() includes + // the persistent flags inherited from parents, so the flags in + // config.Load's binding table take precedence over env and file. + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } @@ -121,7 +104,7 @@ tiger auth login return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } @@ -174,7 +157,7 @@ func wrapCommandsWithAnalytics(cmd *cobra.Command) { // Reload config after command to account for config changes // during command (e.g. `tiger config set analytics false` // should not result in an analytics event being sent). - cfg, err := config.Load() + cfg, err := config.Load(c.Flags()) if err != nil { return } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 1619ca2e..aeb2b5b6 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -4,24 +4,51 @@ import ( "os" "testing" - "github.com/spf13/viper" - "github.com/timescale/tiger-cli/internal/config" ) +// loadEffectiveConfig runs the given args, then returns the config as the +// executed command resolved it: cobra parses flags into the leaf command's flag +// set, which is what commands hand to config.Load. +func loadEffectiveConfig(t *testing.T, args ...string) *config.Config { + t.Helper() + + testCmd, err := buildRootCmd(t.Context()) + if err != nil { + t.Fatalf("Failed to build root command: %v", err) + } + testCmd.SetArgs(args) + if err := testCmd.Execute(); err != nil { + t.Fatalf("Command execution failed: %v", err) + } + + executed, _, err := testCmd.Find(args) + if err != nil { + t.Fatalf("Failed to find executed command: %v", err) + } + cfg, err := config.Load(executed.Flags()) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + return cfg +} + +func writeTestConfigFile(t *testing.T, dir, contents string) { + t.Helper() + if err := os.WriteFile(config.GetConfigFile(dir), []byte(contents), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } +} + func TestFlagPrecedence(t *testing.T) { tmpDir, _ := setupTestCommand(t) // Create config file with some values - configContent := `api_url: https://file.api.com/v1 + writeTestConfigFile(t, tmpDir, `api_url: https://file.api.com/v1 service_id: file-service output: table analytics: true -` - configFile := config.GetConfigFile(tmpDir) - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - t.Fatalf("Failed to write config file: %v", err) - } +`) // Set environment variables os.Setenv("TIGER_CONFIG_DIR", tmpDir) @@ -36,36 +63,35 @@ analytics: true os.Unsetenv("TIGER_ANALYTICS") }() - // Use buildRootCmd() to get a complete root command - testCmd, err := buildRootCmd(t.Context()) - if err != nil { - t.Fatalf("Failed to build root command: %v", err) - } - - // Set CLI flags (these should take precedence) - args := []string{ + // CLI flags take precedence over both env vars and the config file + cfg := loadEffectiveConfig(t, "--config-dir", tmpDir, "--service-id", "flag-service", "--analytics=false", "--debug", "version", // Need a subcommand to execute - } - - testCmd.SetArgs(args) + ) - // Execute the command to trigger PersistentPreRunE - err = testCmd.Execute() - if err != nil { - t.Fatalf("Command execution failed: %v", err) + if cfg.ServiceID != "flag-service" { + t.Errorf("Expected service_id 'flag-service', got '%s'", cfg.ServiceID) } - - // Verify Viper reflects the CLI flag values (highest precedence) - if viper.GetString("service_id") != "flag-service" { - t.Errorf("Expected Viper service_id 'flag-service', got '%s'", viper.GetString("service_id")) + if !cfg.Debug { + t.Error("Expected debug true from flag, got false") + } + if cfg.ConfigDir != tmpDir { + t.Errorf("Expected config dir '%s' from flag, got '%s'", tmpDir, cfg.ConfigDir) + } + // Env var wins where no flag was given + if cfg.Output != "json" { + t.Errorf("Expected output 'json' from env var, got '%s'", cfg.Output) + } + // Config file wins where neither a flag nor an env var was given + if cfg.APIURL != "https://file.api.com/v1" { + t.Errorf("Expected api_url from config file, got '%s'", cfg.APIURL) } } -func TestFlagBindingWithViper(t *testing.T) { +func TestFlagOverridesEnvVar(t *testing.T) { tmpDir, _ := setupTestCommand(t) // Set environment variable @@ -78,36 +104,15 @@ func TestFlagBindingWithViper(t *testing.T) { }() // Test 1: Environment variable should be used when no flag is set - testCmd1, err := buildRootCmd(t.Context()) - if err != nil { - t.Fatalf("Failed to build root command: %v", err) + cfg := loadEffectiveConfig(t, "version") + if cfg.ServiceID != "test-service-1" { + t.Errorf("Expected service_id 'test-service-1' from env var, got '%s'", cfg.ServiceID) } - testCmd1.SetArgs([]string{"version"}) // Need a subcommand - err = testCmd1.Execute() - if err != nil { - t.Fatalf("Command execution failed: %v", err) - } - - if viper.GetString("service_id") != "test-service-1" { - t.Errorf("Expected service_id 'test-service-1' from env var, got '%s'", viper.GetString("service_id")) - } - - // Reset for next test - config.ResetGlobalConfig() // Test 2: Flag should override environment variable - testCmd2, err := buildRootCmd(t.Context()) - if err != nil { - t.Fatalf("Failed to build root command: %v", err) - } - testCmd2.SetArgs([]string{"--service-id", "test-service-2", "version"}) - err = testCmd2.Execute() - if err != nil { - t.Fatalf("Command execution failed: %v", err) - } - - if viper.GetString("service_id") != "test-service-2" { - t.Errorf("Expected service_id 'test-service-2' from flag, got '%s'", viper.GetString("service_id")) + cfg = loadEffectiveConfig(t, "--service-id", "test-service-2", "version") + if cfg.ServiceID != "test-service-2" { + t.Errorf("Expected service_id 'test-service-2' from flag, got '%s'", cfg.ServiceID) } } @@ -115,37 +120,52 @@ func TestConfigFilePrecedence(t *testing.T) { tmpDir, _ := setupTestCommand(t) // Create config file - configContent := `output: json + writeTestConfigFile(t, tmpDir, `output: json analytics: false -` - configFile := config.GetConfigFile(tmpDir) - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - t.Fatalf("Failed to write config file: %v", err) - } +`) // Set environment that should be overridden by config file os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") - // Use buildRootCmd() to get a complete root command - testCmd, err := buildRootCmd(t.Context()) + // Values should come from config file since no flags were set + cfg := loadEffectiveConfig(t, "--config-dir", tmpDir, "version") + if cfg.Output != "json" { + t.Errorf("Expected output 'json' from config file, got '%s'", cfg.Output) + } + if cfg.Analytics != false { + t.Errorf("Expected analytics false from config file, got %t", cfg.Analytics) + } +} + +// Only the flags a command actually defines are bound, so a command without an +// --output flag still resolves output from the env and config file. +func TestFlagBindingIsPerCommand(t *testing.T) { + tmpDir, _ := setupTestCommand(t) + + writeTestConfigFile(t, tmpDir, "output: yaml\n") + + os.Setenv("TIGER_CONFIG_DIR", tmpDir) + defer os.Unsetenv("TIGER_CONFIG_DIR") + + rootCmd, err := buildRootCmd(t.Context()) if err != nil { t.Fatalf("Failed to build root command: %v", err) } - - // Execute with config file specified - testCmd.SetArgs([]string{"--config-dir", tmpDir, "version"}) - err = testCmd.Execute() + noOutputCmd, _, err := rootCmd.Find([]string{"config", "unset"}) if err != nil { - t.Fatalf("Command execution failed: %v", err) + t.Fatalf("Failed to find command: %v", err) + } + if noOutputCmd.Flags().Lookup("output") != nil { + t.Fatal("Expected `config unset` to have no --output flag") } - // Values should come from config file since no flags were set - if viper.GetString("output") != "json" { - t.Errorf("Expected output 'json' from config file, got '%s'", viper.GetString("output")) + cfg, err := config.Load(noOutputCmd.Flags()) + if err != nil { + t.Fatalf("Failed to load config: %v", err) } - if viper.GetBool("analytics") != false { - t.Errorf("Expected analytics false from config file, got %t", viper.GetBool("analytics")) + if cfg.Output != "yaml" { + t.Errorf("Expected output 'yaml' from config file, got '%s'", cfg.Output) } } diff --git a/internal/cmd/service.go b/internal/cmd/service.go index 93c1704a..e413c873 100644 --- a/internal/cmd/service.go +++ b/internal/cmd/service.go @@ -55,9 +55,9 @@ type OutputService struct { } // outputService formats and outputs a single service based on the specified format -func outputService(cmd *cobra.Command, service api.Service, format string, withPassword bool, strict bool) error { +func outputService(cmd *cobra.Command, cfg *config.Config, service api.Service, format string, withPassword bool, strict bool) error { // Prepare the output service with computed fields - outputSvc := prepareServiceForOutput(service, withPassword, cmd.ErrOrStderr()) + outputSvc := prepareServiceForOutput(cfg, service, withPassword, cmd.ErrOrStderr()) if strict && withPassword && outputSvc.Password == "" { return fmt.Errorf("password requested but not available for service %s", util.Deref(outputSvc.ServiceId)) } @@ -179,7 +179,7 @@ func outputServiceTable(service OutputService, output io.Writer) error { return table.Render() } -func prepareServiceForOutput(service api.Service, withPassword bool, output io.Writer) OutputService { +func prepareServiceForOutput(cfg *config.Config, service api.Service, withPassword bool, output io.Writer) OutputService { outputSvc := OutputService{ Service: service, } @@ -191,7 +191,7 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W InitialPassword: util.Deref(service.InitialPassword), } - if connectionDetails, err := common.GetConnectionDetails(service, opts); err != nil { + if connectionDetails, err := common.GetConnectionDetails(cfg, service, opts); err != nil { if output != nil { fmt.Fprintf(output, "⚠️ Warning: Failed to get connection details: %v\n", err) } @@ -201,10 +201,7 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W } // Build console URL - if cfg, err := config.Load(); err == nil { - url := fmt.Sprintf("%s/dashboard/services/%s", cfg.ConsoleURL, *service.ServiceId) - outputSvc.ConsoleURL = url - } + outputSvc.ConsoleURL = fmt.Sprintf("%s/dashboard/services/%s", cfg.ConsoleURL, *service.ServiceId) return outputSvc } @@ -212,10 +209,10 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W // handlePasswordSaving handles saving password using the configured storage // method and displaying appropriate messages. Returns true if the password was // successfully saved, or false if not. -func handlePasswordSaving(service api.Service, initialPassword string, output io.Writer) bool { +func handlePasswordSaving(cfg *config.Config, service api.Service, initialPassword string, output io.Writer) bool { // Note: We don't fail the service creation if password saving fails // The error is handled by displaying the appropriate message below - result, _ := common.SavePasswordWithResult(service, initialPassword, "tsdbadmin") + result, _ := common.SavePasswordWithResult(cfg, service, initialPassword, "tsdbadmin") if result.Method == "none" && result.Message == "No password provided" { // Don't output anything for empty password diff --git a/internal/cmd/service_create.go b/internal/cmd/service_create.go index eff8c137..f922dd9b 100644 --- a/internal/cmd/service_create.go +++ b/internal/cmd/service_create.go @@ -82,7 +82,6 @@ Allowed CPU/Memory Configurations: Note: You can specify both CPU and memory together, or specify only one (the other will be automatically configured).`, Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { // Auto-generate service name if not provided if createServiceName == "" { @@ -118,7 +117,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.SilenceUsage = true // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { return err } @@ -175,7 +174,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth // Save password immediately after service creation, before any waiting // This ensures users have access even if they interrupt the wait or it fails - passwordSaved := handlePasswordSaving(service, util.Deref(service.InitialPassword), statusOutput) + passwordSaved := handlePasswordSaving(cfg.Config, service, util.Deref(service.InitialPassword), statusOutput) // Set as default service unless --no-set-default is specified if !createNoSetDefault { @@ -211,7 +210,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth } } - if err := outputService(cmd, service, cfg.Output, createWithPassword, false); err != nil { + if err := outputService(cmd, cfg.Config, service, cfg.Output, createWithPassword, false); err != nil { fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) } diff --git a/internal/cmd/service_delete.go b/internal/cmd/service_delete.go index b7f70283..db326975 100644 --- a/internal/cmd/service_delete.go +++ b/internal/cmd/service_delete.go @@ -54,7 +54,7 @@ Examples: // Load config before the confirmation prompt so read-only mode // refuses without asking the user to type the service ID. - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { return err } diff --git a/internal/cmd/service_fork.go b/internal/cmd/service_fork.go index 58c5f6a6..cf2aa132 100644 --- a/internal/cmd/service_fork.go +++ b/internal/cmd/service_fork.go @@ -72,7 +72,6 @@ Examples: tiger service fork svc-12345 --now --wait-timeout 45m`, Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { // Validate timing flags first - exactly one must be specified timingFlagsSet := 0 @@ -101,7 +100,7 @@ Examples: } // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -196,7 +195,7 @@ Examples: fmt.Fprintf(statusOutput, "📋 New Service ID: %s\n", forkedServiceID) // Save password immediately after service fork - passwordSaved := handlePasswordSaving(forkedService, util.Deref(forkedService.InitialPassword), statusOutput) + passwordSaved := handlePasswordSaving(cfg.Config, forkedService, util.Deref(forkedService.InitialPassword), statusOutput) // Set as default service unless --no-set-default is used if !forkNoSetDefault { @@ -232,7 +231,7 @@ Examples: } } - if err := outputService(cmd, forkedService, cfg.Output, forkWithPassword, false); err != nil { + if err := outputService(cmd, cfg.Config, forkedService, cfg.Output, forkWithPassword, false); err != nil { fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) } diff --git a/internal/cmd/service_get.go b/internal/cmd/service_get.go index 39dbd193..5e77e95f 100644 --- a/internal/cmd/service_get.go +++ b/internal/cmd/service_get.go @@ -40,10 +40,9 @@ Examples: tiger service get svc-12345 --output yaml`, Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -77,7 +76,7 @@ Examples: service := *resp.JSON200 // Output service in requested format - return outputService(cmd, service, cfg.Output, withPassword, true) + return outputService(cmd, cfg.Config, service, cfg.Output, withPassword, true) }, } diff --git a/internal/cmd/service_list.go b/internal/cmd/service_list.go index 20689bca..5b533263 100644 --- a/internal/cmd/service_list.go +++ b/internal/cmd/service_list.go @@ -13,6 +13,7 @@ import ( "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) @@ -26,12 +27,11 @@ func buildServiceListCmd() *cobra.Command { Long: `List all database services in the current project.`, Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { return err } @@ -70,7 +70,7 @@ func buildServiceListCmd() *cobra.Command { } // Output services in requested format - return outputServices(cmd, services, cfg.Output) + return outputServices(cmd, cfg.Config, services, cfg.Output) }, } @@ -80,8 +80,8 @@ func buildServiceListCmd() *cobra.Command { } // outputServices formats and outputs the services list based on the specified format -func outputServices(cmd *cobra.Command, services []api.Service, format string) error { - outputServices := prepareServicesForOutput(services, cmd.ErrOrStderr()) +func outputServices(cmd *cobra.Command, cfg *config.Config, services []api.Service, format string) error { + outputServices := prepareServicesForOutput(cfg, services, cmd.ErrOrStderr()) outputWriter := cmd.OutOrStdout() switch strings.ToLower(format) { @@ -97,10 +97,10 @@ func outputServices(cmd *cobra.Command, services []api.Service, format string) e } // prepareServicesForOutput creates copies of services with sensitive fields removed -func prepareServicesForOutput(services []api.Service, output io.Writer) []OutputService { +func prepareServicesForOutput(cfg *config.Config, services []api.Service, output io.Writer) []OutputService { prepared := make([]OutputService, len(services)) for i, service := range services { - prepared[i] = prepareServiceForOutput(service, false, output) + prepared[i] = prepareServiceForOutput(cfg, service, false, output) } return prepared } diff --git a/internal/cmd/service_list_test.go b/internal/cmd/service_list_test.go index f1b8197d..feb1660c 100644 --- a/internal/cmd/service_list_test.go +++ b/internal/cmd/service_list_test.go @@ -91,7 +91,7 @@ func TestOutputServices_JSON(t *testing.T) { cmd.SetOut(buf) // Test JSON output - err := outputServices(cmd, services, "json") + err := outputServices(cmd, testConfig(t), services, "json") if err != nil { t.Fatalf("Failed to output JSON: %v", err) } @@ -119,7 +119,7 @@ func TestOutputServices_YAML(t *testing.T) { cmd.SetOut(buf) // Test YAML output - err := outputServices(cmd, services, "yaml") + err := outputServices(cmd, testConfig(t), services, "yaml") if err != nil { t.Fatalf("Failed to output YAML: %v", err) } @@ -147,7 +147,7 @@ func TestOutputServices_Table(t *testing.T) { cmd.SetOut(buf) // Test table output - err := outputServices(cmd, services, "table") + err := outputServices(cmd, testConfig(t), services, "table") if err != nil { t.Fatalf("Failed to output table: %v", err) } @@ -195,7 +195,7 @@ func TestSanitizeServicesForOutput(t *testing.T) { } // Sanitize the services - sanitized := prepareServicesForOutput(services, nil) + sanitized := prepareServicesForOutput(testConfig(t), services, nil) // Verify that we have the same number of services if len(sanitized) != len(services) { diff --git a/internal/cmd/service_logs.go b/internal/cmd/service_logs.go index 07993537..a9379286 100644 --- a/internal/cmd/service_logs.go +++ b/internal/cmd/service_logs.go @@ -53,10 +53,9 @@ Examples: tiger service logs --tail 1000`, Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, - PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err diff --git a/internal/cmd/service_metrics_available_series.go b/internal/cmd/service_metrics_available_series.go index 44ca3635..14f49042 100644 --- a/internal/cmd/service_metrics_available_series.go +++ b/internal/cmd/service_metrics_available_series.go @@ -18,13 +18,12 @@ func buildServiceMetricsAvailableSeriesCmd() *cobra.Command { var output string cmd := &cobra.Command{ - Use: "available-series [service-id]", - Short: "List available metric series", - Long: `List the names of all metric series available for a service.`, - Args: cobra.MaximumNArgs(1), - PreRunE: bindFlags("output"), + Use: "available-series [service-id]", + Short: "List available metric series", + Long: `List the names of all metric series available for a service.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err diff --git a/internal/cmd/service_metrics_series.go b/internal/cmd/service_metrics_series.go index f1213055..66771fda 100644 --- a/internal/cmd/service_metrics_series.go +++ b/internal/cmd/service_metrics_series.go @@ -55,8 +55,7 @@ Examples: tiger service metrics series --metric some_metric_name \ --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z \ --filter ordinal=0`, - Args: cobra.MaximumNArgs(1), - PreRunE: bindFlags("output"), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { fromTime, err := time.Parse(time.RFC3339, from) if err != nil { @@ -72,7 +71,7 @@ Examples: return err } - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err diff --git a/internal/cmd/service_resize.go b/internal/cmd/service_resize.go index 98463ae1..c4f834cb 100644 --- a/internal/cmd/service_resize.go +++ b/internal/cmd/service_resize.go @@ -60,7 +60,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err diff --git a/internal/cmd/service_start.go b/internal/cmd/service_start.go index e9be236e..00baa7d3 100644 --- a/internal/cmd/service_start.go +++ b/internal/cmd/service_start.go @@ -37,7 +37,7 @@ Examples: Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err diff --git a/internal/cmd/service_stop.go b/internal/cmd/service_stop.go index a05c072e..8b7c9510 100644 --- a/internal/cmd/service_stop.go +++ b/internal/cmd/service_stop.go @@ -37,7 +37,7 @@ Examples: Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err diff --git a/internal/cmd/service_test.go b/internal/cmd/service_test.go index 875de9a6..90870baf 100644 --- a/internal/cmd/service_test.go +++ b/internal/cmd/service_test.go @@ -36,12 +36,7 @@ func setupServiceTest(t *testing.T) string { // Disable analytics for service tests to avoid tracking test events os.Setenv("TIGER_ANALYTICS", "false") - // Reset global config and viper to ensure test isolation - config.ResetGlobalConfig() - t.Cleanup(func() { - // Reset global config and viper first - config.ResetGlobalConfig() // Clean up environment variables BEFORE cleaning up file system os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_ANALYTICS") @@ -193,7 +188,7 @@ func TestOutputService_JSON(t *testing.T) { cmd.SetOut(buf) // Test JSON output - err := outputService(cmd, service, "json", false, false) + err := outputService(cmd, testConfig(t), service, "json", false, false) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -262,7 +257,7 @@ func TestOutputService_YAML(t *testing.T) { cmd.SetOut(buf) // Test YAML output - err := outputService(cmd, service, "yaml", false, false) + err := outputService(cmd, testConfig(t), service, "yaml", false, false) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -362,7 +357,7 @@ func TestOutputService_Table(t *testing.T) { cmd.SetOut(buf) // Test table output - err := outputService(cmd, service, "table", false, false) + err := outputService(cmd, testConfig(t), service, "table", false, false) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -447,7 +442,7 @@ func TestOutputService_FreeTier(t *testing.T) { cmd.SetOut(buf) // Test table output - err := outputService(cmd, service, "table", false, false) + err := outputService(cmd, testConfig(t), service, "table", false, false) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -492,7 +487,7 @@ func TestPrepareServiceForOutput_WithoutPassword(t *testing.T) { cmd.SetErr(buf) // Prepare service for output without password - outputSvc := prepareServiceForOutput(service, false, cmd.ErrOrStderr()) + outputSvc := prepareServiceForOutput(testConfig(t), service, false, cmd.ErrOrStderr()) // Verify that password is removed if outputSvc.InitialPassword != nil { @@ -536,7 +531,7 @@ func TestPrepareServiceForOutput_WithPassword(t *testing.T) { cmd.SetErr(buf) // Prepare service for output with password - outputSvc := prepareServiceForOutput(service, true, cmd.ErrOrStderr()) + outputSvc := prepareServiceForOutput(testConfig(t), service, true, cmd.ErrOrStderr()) // Verify that password is preserved if outputSvc.InitialPassword != nil { diff --git a/internal/cmd/service_update_password.go b/internal/cmd/service_update_password.go index 12f18501..d0376c42 100644 --- a/internal/cmd/service_update_password.go +++ b/internal/cmd/service_update_password.go @@ -4,10 +4,10 @@ import ( "context" "fmt" "net/http" + "os" "time" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/util" @@ -54,10 +54,9 @@ Examples: tiger service update-password --auto-generate`, Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, - PreRunE: bindFlags("new-password"), RunE: func(cmd *cobra.Command, args []string) error { // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) + cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) if err != nil { cmd.SilenceUsage = true return err @@ -74,8 +73,11 @@ Examples: return err } - // Get password from flag or environment variable via viper - password := viper.GetString("new_password") + // The password comes from the flag, falling back to the env var + password := updatePasswordValue + if password == "" { + password = os.Getenv("TIGER_NEW_PASSWORD") + } if autoGenerate && password != "" { return fmt.Errorf("cannot use --auto-generate and --new-password together") } @@ -109,7 +111,7 @@ Examples: if autoGenerate { // Auto-generate password using existing function - if _, err := resetServicePassword(ctx, cfg.Client, service, "tsdbadmin", "", statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cfg.Config, cfg.Client, service, "tsdbadmin", "", statusOutput); err != nil { return err } } else if password == "" { @@ -119,6 +121,7 @@ Examples: } _, err := promptAndResetPassword( ctx, + cfg.Config, statusOutput, cfg.Client, service, @@ -128,7 +131,7 @@ Examples: return err } } else { - if _, err := resetServicePassword(ctx, cfg.Client, service, "tsdbadmin", password, statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cfg.Config, cfg.Client, service, "tsdbadmin", password, statusOutput); err != nil { return err } } diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go index 76cccce6..90e3e9b7 100644 --- a/internal/cmd/upgrade.go +++ b/internal/cmd/upgrade.go @@ -77,7 +77,7 @@ If Tiger CLI was installed via a package manager (Homebrew, apt, yum/dnf), the u } func runUpgrade(cmd *cobra.Command, requestedVersion string, force bool) error { - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/cmd/upgrade_test.go b/internal/cmd/upgrade_test.go index b2c8563d..5d08fd0c 100644 --- a/internal/cmd/upgrade_test.go +++ b/internal/cmd/upgrade_test.go @@ -43,7 +43,6 @@ func setupUpgradeTest(t *testing.T, releasesURL string) string { t.Setenv("TIGER_ANALYTICS", "false") t.Cleanup(func() { os.RemoveAll(tmpDir) - config.ResetGlobalConfig() }) return tmpDir diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 2b879c40..b7ecf8db 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -46,7 +46,7 @@ func buildVersionCmd() *cobra.Command { updateAvailable := false if checkVersion { - cfg, err := config.Load() + cfg, err := config.Load(cmd.Flags()) if err != nil { return fmt.Errorf("Error loading config: %w", err) } diff --git a/internal/common/client.go b/internal/common/client.go index 82e90146..8ce69cdf 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -15,7 +15,9 @@ var ( // GetStoredCredentials loads the stored credentials (PAT or OAuth) from the // keyring or fallback file. It's a package var so tests can override it to // inject credentials of either shape. - GetStoredCredentials = config.GetStoredCredentials + GetStoredCredentials = func(cfg *config.Config) (*config.Credentials, error) { + return cfg.GetStoredCredentials() + } // Cache of validated API Keys. Useful for avoided unnecessary calls to the // /auth/info and /analytics/identify endpoints when the API client is @@ -40,7 +42,7 @@ func NewAPIClient(ctx context.Context, cfg *config.Config) (*api.ClientWithRespo // If there were no credentials in the environment, try to load stored credentials if publicKey == "" && secretKey == "" { - stored, err := GetStoredCredentials() + stored, err := GetStoredCredentials(cfg) if err != nil { return nil, "", ExitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) } diff --git a/internal/common/client_test.go b/internal/common/client_test.go index bf8cb236..41b4dec7 100644 --- a/internal/common/client_test.go +++ b/internal/common/client_test.go @@ -43,7 +43,7 @@ func TestNewAPIClient_OAuthCredentials(t *testing.T) { // Override the credential seam with an OAuth token. original := GetStoredCredentials - GetStoredCredentials = func() (*config.Credentials, error) { + GetStoredCredentials = func(*config.Config) (*config.Credentials, error) { return &config.Credentials{ OAuth: &oauth2.Token{AccessToken: "test-access-token", Expiry: time.Now().Add(time.Hour)}, ProjectID: "proj-oauth-123", diff --git a/internal/common/config.go b/internal/common/config.go index 5c2933ca..ae851860 100644 --- a/internal/common/config.go +++ b/internal/common/config.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/spf13/pflag" + "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/config" ) @@ -19,8 +21,11 @@ type Config struct { ProjectID string `json:"-"` } -func LoadConfig(ctx context.Context) (*Config, error) { - cfg, err := config.Load() +// LoadConfig loads the config and API client. The flag set of the command being +// run is passed through to [config.Load] so that CLI flags take precedence over +// env vars and the config file; it may be nil when there are no flags to apply. +func LoadConfig(ctx context.Context, flags *pflag.FlagSet) (*Config, error) { + cfg, err := config.Load(flags) if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } diff --git a/internal/common/connection.go b/internal/common/connection.go index a5bc5cc4..ab9e58b7 100644 --- a/internal/common/connection.go +++ b/internal/common/connection.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) @@ -74,26 +75,26 @@ func (d *ConnectionDetails) RequirePooler(requested bool) error { // activates Tiger Cloud's immutable read-only connection mode. const readOnlyConnectionOption = "options=-c%20tsdb_admin.read_only_connection%3Dtrue" -func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { - return GetConnectionDetailsFor(service, service, opts) +func GetConnectionDetails(cfg *config.Config, service api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { + return GetConnectionDetailsFor(cfg, service, service, opts) } // GetConnectionDetailsFor builds connection details using connService for the // endpoint/pooler and credService for the password lookup. For a primary the // two are the same; for a read replica connService is the replica (its own // endpoint) and credService is the parent primary whose credentials it shares. -func GetConnectionDetailsFor(connService, credService api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { +func GetConnectionDetailsFor(cfg *config.Config, connService, credService api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { if connService.Endpoint == nil { return nil, fmt.Errorf("service endpoint not available") } - return buildConnectionDetails(connService.Endpoint, connService.ConnectionPooler, credService, opts) + return buildConnectionDetails(cfg, connService.Endpoint, connService.ConnectionPooler, credService, opts) } // ConnectTarget opens a pgx connection to the target (see // ConnectionTarget.Details for the pooler policy). The caller owns the returned // connection and must Close it. -func ConnectTarget(ctx context.Context, target *ConnectionTarget, opts ConnectionDetailsOptions, mode pgx.QueryExecMode) (*pgx.Conn, error) { - details, err := target.Details(opts) +func ConnectTarget(ctx context.Context, cfg *config.Config, target *ConnectionTarget, opts ConnectionDetailsOptions, mode pgx.QueryExecMode) (*pgx.Conn, error) { + details, err := target.Details(cfg, opts) if err != nil { return nil, err } @@ -115,7 +116,7 @@ func connectWithDetails(ctx context.Context, details *ConnectionDetails, mode pg // buildConnectionDetails selects the endpoint (pooler when requested and // available, otherwise direct) and assembles the connection details. The // password, if requested, is looked up against passwordService. -func buildConnectionDetails(direct *api.Endpoint, pooler *api.ConnectionPooler, passwordService api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { +func buildConnectionDetails(cfg *config.Config, direct *api.Endpoint, pooler *api.ConnectionPooler, passwordService api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { endpoint := direct isPooler := false if opts.Pooled && pooler != nil && pooler.Endpoint != nil { @@ -142,7 +143,7 @@ func buildConnectionDetails(direct *api.Endpoint, pooler *api.ConnectionPooler, if opts.WithPassword { if opts.InitialPassword != "" { details.Password = opts.InitialPassword - } else if password, err := GetPassword(passwordService, opts.Role); err == nil { + } else if password, err := GetPassword(cfg, passwordService, opts.Role); err == nil { details.Password = password } } @@ -169,8 +170,8 @@ func (d *ConnectionDetails) String() string { // GetPassword fetches the password for the specified service from the // configured password storage mechanism. It returns an error if it fails to // find the password. -func GetPassword(service api.Service, role string) (string, error) { - storage := GetPasswordStorage() +func GetPassword(cfg *config.Config, service api.Service, role string) (string, error) { + storage := GetPasswordStorage(cfg) password, err := storage.Get(service, role) if err != nil { // Provide specific error messages based on storage type diff --git a/internal/common/connection_test.go b/internal/common/connection_test.go index cd67499d..39ca07cd 100644 --- a/internal/common/connection_test.go +++ b/internal/common/connection_test.go @@ -6,8 +6,6 @@ import ( "strings" "testing" - "github.com/spf13/viper" - "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" @@ -183,7 +181,7 @@ func TestBuildConnectionString_Basic(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result, err := GetConnectionDetails(tc.service, tc.opts) + result, err := GetConnectionDetails(testConfig(""), tc.service, tc.opts) if tc.expectError { if err == nil { @@ -212,10 +210,7 @@ func TestBuildConnectionString_WithPassword_KeyringStorage(t *testing.T) { // Use a unique service name for this test to avoid conflicts config.SetTestServiceName(t) - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + cfg := testConfig("keyring") // Create a test service serviceID := "test-password-service" @@ -234,14 +229,14 @@ func TestBuildConnectionString_WithPassword_KeyringStorage(t *testing.T) { // Store a test password in keyring testPassword := "test-password-keyring-123" role := "tsdbadmin" - storage := GetPasswordStorage() + storage := GetPasswordStorage(cfg) err := storage.Save(service, testPassword, role) if err != nil { t.Fatalf("Failed to save test password: %v", err) } defer storage.Remove(service, role) // Clean up after test - details, err := GetConnectionDetails(service, ConnectionDetailsOptions{ + details, err := GetConnectionDetails(cfg, service, ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: true, }) @@ -264,10 +259,7 @@ func TestBuildConnectionString_WithPassword_KeyringStorage(t *testing.T) { } func TestBuildConnectionString_WithPassword_PgpassStorage(t *testing.T) { - // Set pgpass as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "pgpass") - defer viper.Set("password_storage", originalStorage) + cfg := testConfig("pgpass") // Create a test service with endpoint information (required for pgpass) serviceID := "test-pgpass-service" @@ -286,14 +278,14 @@ func TestBuildConnectionString_WithPassword_PgpassStorage(t *testing.T) { // Store a test password in pgpass testPassword := "test-password-pgpass-456" role := "tsdbadmin" - storage := GetPasswordStorage() + storage := GetPasswordStorage(cfg) err := storage.Save(service, testPassword, role) if err != nil { t.Fatalf("Failed to save test password: %v", err) } defer storage.Remove(service, role) // Clean up after test - details, err := GetConnectionDetails(service, ConnectionDetailsOptions{ + details, err := GetConnectionDetails(cfg, service, ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: true, }) @@ -340,9 +332,7 @@ func TestConnectionDetailsString_EncodesSpecialCharPassword(t *testing.T) { func TestBuildConnectionString_WithPassword_NoStorage(t *testing.T) { // Set no storage as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "none") - defer viper.Set("password_storage", originalStorage) + cfg := testConfig("none") // Create a test service serviceID := "test-nostorage-service" @@ -358,7 +348,7 @@ func TestBuildConnectionString_WithPassword_NoStorage(t *testing.T) { }, } - result, err := GetConnectionDetails(service, ConnectionDetailsOptions{ + result, err := GetConnectionDetails(cfg, service, ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: true, }) @@ -381,10 +371,7 @@ func TestBuildConnectionString_WithPassword_NoPasswordAvailable(t *testing.T) { // Use a unique service name for this test to avoid conflicts config.SetTestServiceName(t) - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + cfg := testConfig("keyring") // Create a test service (but don't store any password for it) serviceID := "test-nopassword-service" @@ -400,7 +387,7 @@ func TestBuildConnectionString_WithPassword_NoPasswordAvailable(t *testing.T) { }, } - result, err := GetConnectionDetails(service, ConnectionDetailsOptions{ + result, err := GetConnectionDetails(cfg, service, ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: true, }) @@ -422,9 +409,7 @@ func TestBuildConnectionString_WithPassword_NoPasswordAvailable(t *testing.T) { func TestBuildConnectionString_ReadOnly_WithPassword(t *testing.T) { config.SetTestServiceName(t) - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + cfg := testConfig("keyring") serviceID := "test-readonly-service" projectID := "test-readonly-project" @@ -441,13 +426,13 @@ func TestBuildConnectionString_ReadOnly_WithPassword(t *testing.T) { testPassword := "test-password-readonly-789" role := "tsdbadmin" - storage := GetPasswordStorage() + storage := GetPasswordStorage(cfg) if err := storage.Save(service, testPassword, role); err != nil { t.Fatalf("Failed to save test password: %v", err) } defer storage.Remove(service, role) - details, err := GetConnectionDetails(service, ConnectionDetailsOptions{ + details, err := GetConnectionDetails(cfg, service, ConnectionDetailsOptions{ Role: role, WithPassword: true, ReadOnly: true, @@ -469,10 +454,7 @@ func TestBuildConnectionString_WithPassword_InvalidServiceEndpoint(t *testing.T) // Use a unique service name for this test to avoid conflicts config.SetTestServiceName(t) - // Set keyring as the password storage method for this test - originalStorage := viper.GetString("password_storage") - viper.Set("password_storage", "keyring") - defer viper.Set("password_storage", originalStorage) + cfg := testConfig("keyring") // Create a test service without endpoint (invalid) serviceID := "test-invalid-service" @@ -483,7 +465,7 @@ func TestBuildConnectionString_WithPassword_InvalidServiceEndpoint(t *testing.T) Endpoint: nil, // Invalid - no endpoint } - _, err := GetConnectionDetails(service, ConnectionDetailsOptions{ + _, err := GetConnectionDetails(cfg, service, ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: true, }) @@ -524,7 +506,7 @@ func TestGetConnectionDetailsFor(t *testing.T) { Endpoint: &api.Endpoint{Host: &replicaHost, Port: &port}, } - details, err := GetConnectionDetailsFor(conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin"}) + details, err := GetConnectionDetailsFor(testConfig(""), conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -552,7 +534,7 @@ func TestGetConnectionDetailsFor(t *testing.T) { }, } - details, err := GetConnectionDetailsFor(conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin", Pooled: true}) + details, err := GetConnectionDetailsFor(testConfig(""), conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin", Pooled: true}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -570,7 +552,7 @@ func TestGetConnectionDetailsFor(t *testing.T) { Endpoint: &api.Endpoint{Host: &replicaHost, Port: &port}, } - details, err := GetConnectionDetailsFor(conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin", Pooled: true}) + details, err := GetConnectionDetailsFor(testConfig(""), conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin", Pooled: true}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -584,7 +566,7 @@ func TestGetConnectionDetailsFor(t *testing.T) { t.Run("error when endpoint missing", func(t *testing.T) { conn := api.Service{ServiceId: util.Ptr("rep-1")} - if _, err := GetConnectionDetailsFor(conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin"}); err == nil { + if _, err := GetConnectionDetailsFor(testConfig(""), conn, primary, ConnectionDetailsOptions{Role: "tsdbadmin"}); err == nil { t.Fatal("expected error for missing connection endpoint") } }) diff --git a/internal/common/password_storage.go b/internal/common/password_storage.go index 70be2980..832ed516 100644 --- a/internal/common/password_storage.go +++ b/internal/common/password_storage.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/config" "github.com/zalando/go-keyring" @@ -331,9 +330,8 @@ func (n *NoStorage) GetStorageResult(err error, password string) PasswordStorage } // GetPasswordStorage returns the appropriate PasswordStorage implementation based on configuration -func GetPasswordStorage() PasswordStorage { - storageMethod := viper.GetString("password_storage") - switch storageMethod { +func GetPasswordStorage(cfg *config.Config) PasswordStorage { + switch cfg.PasswordStorage { case "keyring": return &KeyringStorage{} case "pgpass": @@ -346,7 +344,7 @@ func GetPasswordStorage() PasswordStorage { } // SavePasswordWithResult handles saving a password and returns both error and result info -func SavePasswordWithResult(service api.Service, password string, role string) (PasswordStorageResult, error) { +func SavePasswordWithResult(cfg *config.Config, service api.Service, password string, role string) (PasswordStorageResult, error) { if password == "" { return PasswordStorageResult{ Success: false, @@ -362,7 +360,7 @@ func SavePasswordWithResult(service api.Service, password string, role string) ( }, fmt.Errorf("role is required") } - storage := GetPasswordStorage() + storage := GetPasswordStorage(cfg) err := storage.Save(service, password, role) result := storage.GetStorageResult(err, password) diff --git a/internal/common/password_storage_test.go b/internal/common/password_storage_test.go index 2ae0469e..3c708acb 100644 --- a/internal/common/password_storage_test.go +++ b/internal/common/password_storage_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" - "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" @@ -286,6 +285,12 @@ func TestPgpassStorage_Get_NoFile(t *testing.T) { } } +// testConfig returns a config that selects the given password storage method. +// An empty method exercises GetPasswordStorage's default. +func testConfig(passwordStorage string) *config.Config { + return &config.Config{PasswordStorage: passwordStorage} +} + func TestGetPasswordStorage(t *testing.T) { tests := []struct { name string @@ -301,18 +306,12 @@ func TestGetPasswordStorage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set up viper for this test - viper.Set("password_storage", tt.storageMethod) - - storage := GetPasswordStorage() + storage := GetPasswordStorage(testConfig(tt.storageMethod)) actualType := fmt.Sprintf("%T", storage) if actualType != tt.expectedType { t.Errorf("GetPasswordStorage() with %s = %v, want %v", tt.storageMethod, actualType, tt.expectedType) } - - // Clean up - viper.Set("password_storage", "") }) } } @@ -620,7 +619,7 @@ func TestPgpassStorage_GetStorageResult_Error(t *testing.T) { func TestSavePasswordWithResult_EmptyPassword(t *testing.T) { service := createTestService("test-service-123") - result, err := SavePasswordWithResult(service, "", "tsdbadmin") + result, err := SavePasswordWithResult(testConfig(""), service, "", "tsdbadmin") if err != nil { t.Errorf("SavePasswordWithResult() with empty password should not return error, got: %v", err) } @@ -638,11 +637,8 @@ func TestSavePasswordWithResult_EmptyPassword(t *testing.T) { func TestSavePasswordWithResult_WithPassword(t *testing.T) { service := createTestService("test-service-123") - // Set up viper to use NoStorage for predictable behavior - viper.Set("password_storage", "none") - defer viper.Set("password_storage", "") - - result, err := SavePasswordWithResult(service, "test-password", "tsdbadmin") + // Use NoStorage for predictable behavior + result, err := SavePasswordWithResult(testConfig("none"), service, "test-password", "tsdbadmin") if err != nil { t.Errorf("SavePasswordWithResult() should not return error with NoStorage, got: %v", err) } diff --git a/internal/common/replica.go b/internal/common/replica.go index 73814205..b6059294 100644 --- a/internal/common/replica.go +++ b/internal/common/replica.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) @@ -21,8 +22,8 @@ type ConnectionTarget struct { // Details builds the target's connection details. A requested-but-unavailable // pooler is a hard error for a primary but silently falls back to direct for a // replica. -func (t *ConnectionTarget) Details(opts ConnectionDetailsOptions) (*ConnectionDetails, error) { - details, err := GetConnectionDetailsFor(t.ConnectionService, t.CredentialService, opts) +func (t *ConnectionTarget) Details(cfg *config.Config, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { + details, err := GetConnectionDetailsFor(cfg, t.ConnectionService, t.CredentialService, opts) if err != nil { return nil, fmt.Errorf("failed to build connection string: %w", err) } diff --git a/internal/common/schema_fetch.go b/internal/common/schema_fetch.go index 6c6d3332..c043d3dc 100644 --- a/internal/common/schema_fetch.go +++ b/internal/common/schema_fetch.go @@ -5,6 +5,7 @@ import ( "github.com/jackc/pgx/v5" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) @@ -15,13 +16,13 @@ import ( // // The connection is forced read-only: introspection only issues SELECTs, so // this is always safe and guards against accidental writes. -func FetchServiceSchema(ctx context.Context, target *ConnectionTarget, role string, pooled bool, opts SchemaOptions) (*DatabaseSchema, error) { +func FetchServiceSchema(ctx context.Context, cfg *config.Config, target *ConnectionTarget, role string, pooled bool, opts SchemaOptions) (*DatabaseSchema, error) { if err := CheckServiceReady(target.ConnectionService); err != nil { return nil, err } // Introspection runs parameterless statements, so the simple protocol fits. - conn, err := ConnectTarget(ctx, target, ConnectionDetailsOptions{ + conn, err := ConnectTarget(ctx, cfg, target, ConnectionDetailsOptions{ Pooled: pooled, Role: role, WithPassword: true, diff --git a/internal/config/config.go b/internal/config/config.go index c1ae70c4..7141be3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,45 +16,6 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -type Config struct { - APIURL string `mapstructure:"api_url"` - Analytics bool `mapstructure:"analytics"` - Color bool `mapstructure:"color"` - ConfigDir string `mapstructure:"config_dir"` - ConsoleURL string `mapstructure:"console_url"` - Debug bool `mapstructure:"debug"` - DocsMCP bool `mapstructure:"docs_mcp"` - DocsMCPURL string `mapstructure:"docs_mcp_url"` - GatewayURL string `mapstructure:"gateway_url"` - MCPMaxRows int `mapstructure:"mcp_max_rows"` - Output string `mapstructure:"output"` - PasswordStorage string `mapstructure:"password_storage"` - ReadOnly bool `mapstructure:"read_only"` - ReleasesURL string `mapstructure:"releases_url"` - ServiceID string `mapstructure:"service_id"` - VersionCheck bool `mapstructure:"version_check"` - viper *viper.Viper `mapstructure:"-"` -} - -type ConfigOutput struct { - APIURL *string `mapstructure:"api_url" json:"api_url,omitempty"` - Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty"` - Color *bool `mapstructure:"color" json:"color,omitempty"` - ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty"` - ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty"` - Debug *bool `mapstructure:"debug" json:"debug,omitempty"` - DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"` - DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"` - GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"` - MCPMaxRows *int `mapstructure:"mcp_max_rows" json:"mcp_max_rows,omitempty"` - Output *string `mapstructure:"output" json:"output,omitempty"` - PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"` - ReadOnly *bool `mapstructure:"read_only" json:"read_only,omitempty"` - ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty"` - ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty"` - VersionCheck *bool `mapstructure:"version_check" json:"version_check,omitempty"` -} - const ( ConfigFileName = "config.yaml" DefaultAPIURL = "https://console.cloud.tigerdata.com/public/api/v1" @@ -96,92 +57,97 @@ var defaultValues = map[string]any{ "version_check": DefaultVersionCheck, } -func ValidConfigOptions() []string { - return slices.Collect(maps.Keys(defaultValues)) +// flagBindings maps CLI flag names to the config keys they override. Flags +// missing from a caller's flag set are skipped, so command-local flags (e.g. +// --output) bind only for the commands that define them. +var flagBindings = map[string]string{ + "analytics": "analytics", + "color": "color", + "debug": "debug", + "output": "output", + "password-storage": "password_storage", + "service-id": "service_id", } -func ApplyDefaults(v *viper.Viper) { - for key, value := range defaultValues { - v.SetDefault(key, value) - } +// Config holds the effective configuration for a single command invocation, +// resolved through viper's normal precedence (flag > env > file > default). +type Config struct { + APIURL string `mapstructure:"api_url"` + Analytics bool `mapstructure:"analytics"` + Color bool `mapstructure:"color"` + ConsoleURL string `mapstructure:"console_url"` + Debug bool `mapstructure:"debug"` + DocsMCP bool `mapstructure:"docs_mcp"` + DocsMCPURL string `mapstructure:"docs_mcp_url"` + GatewayURL string `mapstructure:"gateway_url"` + MCPMaxRows int `mapstructure:"mcp_max_rows"` + Output string `mapstructure:"output"` + PasswordStorage string `mapstructure:"password_storage"` + ReadOnly bool `mapstructure:"read_only"` + ReleasesURL string `mapstructure:"releases_url"` + ServiceID string `mapstructure:"service_id"` + VersionCheck bool `mapstructure:"version_check"` + + ConfigDir string `mapstructure:"-"` + flags *pflag.FlagSet `mapstructure:"-"` } -func ApplyEnvOverrides(v *viper.Viper) { - v.SetEnvPrefix("TIGER") - v.AutomaticEnv() +// ConfigOutput is the shape `tiger config show` renders. Every field is a +// pointer so unset values can be omitted when defaults are suppressed. +type ConfigOutput struct { + APIURL *string `mapstructure:"api_url" json:"api_url,omitempty"` + Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty"` + Color *bool `mapstructure:"color" json:"color,omitempty"` + ConfigDir *string `mapstructure:"-" json:"config_dir,omitempty"` + ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty"` + Debug *bool `mapstructure:"debug" json:"debug,omitempty"` + DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"` + DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"` + GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"` + MCPMaxRows *int `mapstructure:"mcp_max_rows" json:"mcp_max_rows,omitempty"` + Output *string `mapstructure:"output" json:"output,omitempty"` + PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"` + ReadOnly *bool `mapstructure:"read_only" json:"read_only,omitempty"` + ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty"` + ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty"` + VersionCheck *bool `mapstructure:"version_check" json:"version_check,omitempty"` } -func ReadInConfig(v *viper.Viper) error { - // Try to read config file if it exists - // If file doesn't exist, that's okay - we'll use defaults and env vars - if err := v.ReadInConfig(); err != nil && - !errors.As(err, &viper.ConfigFileNotFoundError{}) && - !errors.Is(err, fs.ErrNotExist) { - return err +// Load creates a new Config instance. The provided flag set is used to resolve +// the effective config directory (via the config-dir flag) and to bind the CLI +// flags in flagBindings so they override file/env values. It may be nil for +// callers that have no flags to apply. +func Load(flags *pflag.FlagSet) (*Config, error) { + cfg := &Config{ + ConfigDir: getEffectiveConfigDir(flags), + flags: flags, } - return nil -} - -// SetupViper configures the global Viper instance with defaults, env vars, and config file -func SetupViper(configDir string) error { - v := viper.GetViper() - - // Configure viper to read from config file - configFile := GetConfigFile(configDir) - v.SetConfigFile(configFile) - - // Configure viper to read from env vars - ApplyEnvOverrides(v) - - // Set defaults for all config values - ApplyDefaults(v) - - if err := ReadInConfig(v); err != nil { - return err + if err := cfg.reload(); err != nil { + return nil, err } - - MigrateVersionCheck(v) - return nil + return cfg, nil } -// MigrateVersionCheck preserves backward compatibility with configs written by -// older CLI versions, which used a `version_check_interval` duration (0 to -// disable) instead of the current `version_check` bool. If a pre-existing -// config file set the old key and not the new one, we derive the new value -// from it (0 → false, any non-zero interval → true) so a user who had disabled -// update checks doesn't have them silently re-enabled on upgrade. -// -// The derived value is applied via SetDefault, so an explicit `version_check` -// from the config file or a TIGER_VERSION_CHECK env var still takes precedence. -// It must be called after ApplyDefaults so the derived value overrides the -// generic default, and after ReadInConfig so InConfig can see the file keys. -// This is an in-memory shim only; the old key remains in the file until it is -// rewritten (e.g. via `tiger config set`/`unset`). -func MigrateVersionCheck(v *viper.Viper) { - if v.InConfig("version_check_interval") && !v.InConfig("version_check") { - v.SetDefault("version_check", v.GetDuration("version_check_interval") != 0) - } -} +// LoadForOutput loads config values for display purposes using a fresh viper +// instance, independent of CLI flags. This keeps `tiger config show -o json` +// from reporting the flag's format as the configured `output` value. +func LoadForOutput(configDir string, withEnv bool, noDefaults bool) (*ConfigOutput, error) { + v := viper.New() + v.SetConfigFile(GetConfigFile(configDir)) -func FromViper(v *viper.Viper) (*Config, error) { - cfg := &Config{ - ConfigDir: filepath.Dir(v.ConfigFileUsed()), - viper: v, + if withEnv { + applyEnvOverrides(v) } - - if err := v.Unmarshal(cfg); err != nil { - return nil, fmt.Errorf("error unmarshaling config: %w", err) + if !noDefaults { + applyDefaults(v) } - return cfg, nil -} - -func ForOutputFromViper(v *viper.Viper) (*ConfigOutput, error) { - configDir := filepath.Dir(v.ConfigFileUsed()) - cfg := &ConfigOutput{ - ConfigDir: &configDir, + if err := readInConfig(v); err != nil { + return nil, err } + migrateVersionCheck(v) + cfg := &ConfigOutput{ConfigDir: &configDir} if err := v.Unmarshal(cfg); err != nil { return nil, fmt.Errorf("error unmarshaling config for output: %w", err) } @@ -189,73 +155,33 @@ func ForOutputFromViper(v *viper.Viper) (*ConfigOutput, error) { return cfg, nil } -// Load creates a new Config instance from the current viper state -// This function should be called after SetupViper has been called to initialize viper -func Load() (*Config, error) { - v := viper.GetViper() - - // Try to read config file into viper to ensure we're unmarshaling the most - // up-to-date values into the config struct. - if err := ReadInConfig(v); err != nil { - return nil, err - } - - return FromViper(v) -} - -func ensureConfigDir(configDir string) (string, error) { - if err := os.MkdirAll(configDir, 0755); err != nil { - return "", fmt.Errorf("error creating config directory: %w (set TIGER_CONFIG_DIR or --config-dir to a writable path)", err) - } - return GetConfigFile(configDir), nil -} - -func (c *Config) EnsureConfigDir() (string, error) { - return ensureConfigDir(c.ConfigDir) -} - -// UseTestConfig writes only the specified key-value pairs to the config file and -// returns a Config instance with those values set. -// This function is intended for testing purposes only, where you need to set up -// specific config file state without writing default values for unspecified keys. -func UseTestConfig(configDir string, values map[string]any) (*Config, error) { - configFile, err := ensureConfigDir(configDir) - if err != nil { - return nil, err - } - +// reload reads the config file and resolves effective values through viper's +// normal precedence (flag > env > file > default). Called by Load for the +// initial load, and by Set/Unset/Reset after writing the config file. +func (c *Config) reload() error { v := viper.New() - v.SetConfigFile(configFile) - - // Write only the specified key-value pairs - for key, value := range values { - v.Set(key, value) - } - - if err := v.WriteConfigAs(configFile); err != nil { - return nil, fmt.Errorf("error writing config file: %w", err) - } + v.SetConfigFile(c.GetConfigFile()) + applyEnvOverrides(v) + applyDefaults(v) - viper.Reset() - if err := SetupViper(configDir); err != nil { - return nil, err + if err := bindFlags(v, c.flags); err != nil { + return fmt.Errorf("failed to bind flags: %w", err) } - // Construct and return a Config instance with the values - cfg := &Config{ - ConfigDir: configDir, + if err := readInConfig(v); err != nil { + return err } + migrateVersionCheck(v) - if err := viper.Unmarshal(cfg); err != nil { - return nil, fmt.Errorf("error unmarshaling config: %w", err) + if err := v.Unmarshal(c); err != nil { + return fmt.Errorf("error unmarshaling config: %w", err) } - - return cfg, nil + return nil } func (c *Config) Set(key, value string) error { - // Validate and update the field - validated, err := c.UpdateField(key, value) + // Validate and convert the value to the correct type for the config file + validated, err := validateValue(key, value) if err != nil { return err } @@ -275,228 +201,8 @@ func (c *Config) Set(key, value string) error { if err := v.WriteConfigAs(configFile); err != nil { return fmt.Errorf("error writing config file: %w", err) } - return nil -} - -func setBool(key, val string) (bool, error) { - b, err := strconv.ParseBool(val) - if err != nil { - return false, fmt.Errorf("invalid %s value: %s (must be true or false)", key, val) - } - return b, nil -} - -func setInt(key, val string) (int, error) { - n, err := strconv.Atoi(val) - if err != nil { - return 0, fmt.Errorf("invalid %s value: %s (must be an integer)", key, val) - } - return n, nil -} - -// UpdateField updates the field in the Config struct corresponding to the given key. -// It accepts either a string (from user input) or a typed value (string/bool from defaults). -// The function validates the value and updates both the struct field and viper state. -func (c *Config) UpdateField(key string, value any) (any, error) { - var validated any - - switch key { - case "api_url": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("api_url must be string, got %T", value) - } - c.APIURL = s - validated = s - - case "console_url": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("console_url must be string, got %T", value) - } - c.ConsoleURL = s - validated = s - - case "gateway_url": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("gateway_url must be string, got %T", value) - } - c.GatewayURL = s - validated = s - - case "docs_mcp": - switch v := value.(type) { - case bool: - c.DocsMCP = v - validated = v - case string: - b, err := setBool("docs_mcp", v) - if err != nil { - return nil, err - } - c.DocsMCP = b - validated = b - default: - return nil, fmt.Errorf("docs_mcp must be string or bool, got %T", value) - } - - case "docs_mcp_url": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("docs_mcp_url must be string, got %T", value) - } - c.DocsMCPURL = s - validated = s - - case "service_id": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("service_id must be string, got %T", value) - } - c.ServiceID = s - validated = s - - case "color": - switch v := value.(type) { - case bool: - c.Color = v - validated = v - case string: - b, err := setBool("color", v) - if err != nil { - return nil, err - } - c.Color = b - validated = b - default: - return nil, fmt.Errorf("color must be string or bool, got %T", value) - } - - case "output": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("output must be string, got %T", value) - } - if err := ValidateOutputFormat(s, false); err != nil { - return nil, err - } - c.Output = s - validated = s - - case "analytics": - switch v := value.(type) { - case bool: - c.Analytics = v - validated = v - case string: - b, err := setBool("analytics", v) - if err != nil { - return nil, err - } - c.Analytics = b - validated = b - default: - return nil, fmt.Errorf("analytics must be string or bool, got %T", value) - } - - case "password_storage": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("password_storage must be string, got %T", value) - } - if s != "keyring" && s != "pgpass" && s != "none" { - return nil, fmt.Errorf("invalid password_storage value: %s (must be keyring, pgpass, or none)", s) - } - c.PasswordStorage = s - validated = s - - case "read_only": - switch v := value.(type) { - case bool: - c.ReadOnly = v - validated = v - case string: - b, err := setBool("read_only", v) - if err != nil { - return nil, err - } - c.ReadOnly = b - validated = b - default: - return nil, fmt.Errorf("read_only must be string or bool, got %T", value) - } - - case "debug": - switch v := value.(type) { - case bool: - c.Debug = v - validated = v - case string: - b, err := setBool("debug", v) - if err != nil { - return nil, err - } - c.Debug = b - validated = b - default: - return nil, fmt.Errorf("debug must be string or bool, got %T", value) - } - - case "releases_url": - s, ok := value.(string) - if !ok { - return nil, fmt.Errorf("releases_url must be string, got %T", value) - } - c.ReleasesURL = s - validated = s - - case "version_check": - switch v := value.(type) { - case bool: - c.VersionCheck = v - validated = v - case string: - b, err := setBool("version_check", v) - if err != nil { - return nil, err - } - c.VersionCheck = b - validated = b - default: - return nil, fmt.Errorf("version_check must be string or bool, got %T", value) - } - - case "mcp_max_rows": - var n int - switch v := value.(type) { - case int: - n = v - case string: - parsed, err := setInt("mcp_max_rows", v) - if err != nil { - return nil, err - } - n = parsed - default: - return nil, fmt.Errorf("mcp_max_rows must be string or int, got %T", value) - } - if n < 1 { - return nil, fmt.Errorf("mcp_max_rows must be at least 1, got %d", n) - } - c.MCPMaxRows = n - validated = n - - default: - return nil, fmt.Errorf("unknown configuration key: %s", key) - } - if c.viper == nil { - viper.Set(key, validated) - } else { - c.viper.Set(key, validated) - } - return validated, nil + return c.reload() } func (c *Config) Unset(key string) error { @@ -525,17 +231,11 @@ func (c *Config) Unset(key string) error { return fmt.Errorf("unknown configuration key: %s", key) } - // Apply the default to the current global viper state - if def, ok := defaultValues[key]; ok { - if _, err := c.UpdateField(key, def); err != nil { - return err - } - } - if err := vNew.WriteConfigAs(configFile); err != nil { return fmt.Errorf("error writing config file: %w", err) } - return nil + + return c.reload() } func (c *Config) Reset() error { @@ -551,29 +251,25 @@ func (c *Config) Reset() error { return fmt.Errorf("error writing config file: %w", err) } - // Apply all defaults to the current global viper state - for key, value := range defaultValues { - if _, err := c.UpdateField(key, value); err != nil { - return err - } - } - - return nil + return c.reload() } -func GetConfigFile(dir string) string { - return filepath.Join(dir, ConfigFileName) +// EnsureConfigDir creates the config directory if it does not already exist +// and returns the path to the config file within it. +func (c *Config) EnsureConfigDir() (string, error) { + return ensureConfigDir(c.ConfigDir) } func (c *Config) GetConfigFile() string { return GetConfigFile(c.ConfigDir) } -// TODO: This function is currently used to get the directory that the API -// key fallback file should be stored in (see credentials.go). But ideally, those -// functions would take a Config struct and use the ConfigDir field instead. -func GetConfigDir() string { - return filepath.Dir(viper.ConfigFileUsed()) +func ValidConfigOptions() []string { + return slices.Collect(maps.Keys(defaultValues)) +} + +func GetConfigFile(dir string) string { + return filepath.Join(dir, ConfigFileName) } func GetDefaultConfigDir() string { @@ -585,9 +281,13 @@ func GetDefaultConfigDir() string { return filepath.Join(homeDir, ".config", "tiger") } -func GetEffectiveConfigDir(configDirFlag *pflag.Flag) string { - if configDirFlag.Changed { - return util.ExpandPath(configDirFlag.Value.String()) +// getEffectiveConfigDir resolves the config directory from the --config-dir +// flag, then TIGER_CONFIG_DIR, then the default location. +func getEffectiveConfigDir(flags *pflag.FlagSet) string { + if flags != nil { + if flag := flags.Lookup("config-dir"); flag != nil && flag.Changed { + return util.ExpandPath(flag.Value.String()) + } } if dir := os.Getenv("TIGER_CONFIG_DIR"); dir != "" { @@ -597,8 +297,140 @@ func GetEffectiveConfigDir(configDirFlag *pflag.Flag) string { return GetDefaultConfigDir() } -// ResetGlobalConfig clears the global viper state for testing -// This is mainly used to reset viper configuration between test runs -func ResetGlobalConfig() { - viper.Reset() +func ensureConfigDir(configDir string) (string, error) { + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", fmt.Errorf("error creating config directory: %w (set TIGER_CONFIG_DIR or --config-dir to a writable path)", err) + } + return GetConfigFile(configDir), nil +} + +func applyDefaults(v *viper.Viper) { + for key, value := range defaultValues { + v.SetDefault(key, value) + } +} + +func applyEnvOverrides(v *viper.Viper) { + v.SetEnvPrefix("TIGER") + v.AutomaticEnv() +} + +func readInConfig(v *viper.Viper) error { + // Try to read config file if it exists + // If file doesn't exist, that's okay - we'll use defaults and env vars + if err := v.ReadInConfig(); err != nil && + !errors.As(err, &viper.ConfigFileNotFoundError{}) && + !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} + +func bindFlags(v *viper.Viper, flags *pflag.FlagSet) error { + if flags == nil { + return nil + } + + var errs []error + for name, key := range flagBindings { + if flag := flags.Lookup(name); flag != nil { + errs = append(errs, v.BindPFlag(key, flag)) + } + } + return errors.Join(errs...) +} + +// migrateVersionCheck preserves backward compatibility with configs written by +// older CLI versions, which used a `version_check_interval` duration (0 to +// disable) instead of the current `version_check` bool. If a pre-existing +// config file set the old key and not the new one, we derive the new value +// from it (0 → false, any non-zero interval → true) so a user who had disabled +// update checks doesn't have them silently re-enabled on upgrade. +// +// The derived value is applied via SetDefault, so an explicit `version_check` +// from the config file or a TIGER_VERSION_CHECK env var still takes precedence. +// It must be called after applyDefaults so the derived value overrides the +// generic default, and after readInConfig so InConfig can see the file keys. +// This is an in-memory shim only; the old key remains in the file until it is +// rewritten (e.g. via `tiger config set`/`unset`). +func migrateVersionCheck(v *viper.Viper) { + if v.InConfig("version_check_interval") && !v.InConfig("version_check") { + v.SetDefault("version_check", v.GetDuration("version_check_interval") != 0) + } +} + +// validateValue validates and converts a user-provided value for the given +// config key. String values are returned as-is (after any key-specific +// validation); bool and int keys are parsed from their string form. Returns +// the converted value suitable for writing to the config file. +func validateValue(key, value string) (any, error) { + switch key { + case "api_url", "console_url", "docs_mcp_url", "gateway_url", "releases_url", "service_id": + return value, nil + case "analytics", "color", "debug", "docs_mcp", "read_only", "version_check": + return parseBool(key, value) + case "mcp_max_rows": + return parsePositiveInt(key, value) + case "output": + if err := ValidateOutputFormat(value, false); err != nil { + return nil, err + } + return value, nil + case "password_storage": + if value != "keyring" && value != "pgpass" && value != "none" { + return nil, fmt.Errorf("invalid password_storage value: %s (must be keyring, pgpass, or none)", value) + } + return value, nil + default: + return nil, fmt.Errorf("unknown configuration key: %s", key) + } +} + +func parseBool(key, value string) (bool, error) { + b, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("invalid %s value: %s (must be true or false)", key, value) + } + return b, nil +} + +func parsePositiveInt(key, value string) (int, error) { + n, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("invalid %s value: %s (must be an integer)", key, value) + } + if n < 1 { + return 0, fmt.Errorf("%s must be at least 1, got %d", key, n) + } + return n, nil +} + +// UseTestConfig writes only the specified key-value pairs to the config file in +// the given directory and returns a Config instance loaded from it. +// This function is intended for testing purposes only, where you need to set up +// specific config file state without writing default values for unspecified keys. +func UseTestConfig(configDir string, values map[string]any) (*Config, error) { + configFile, err := ensureConfigDir(configDir) + if err != nil { + return nil, err + } + + v := viper.New() + v.SetConfigFile(configFile) + + // Write only the specified key-value pairs + for key, value := range values { + v.Set(key, value) + } + + if err := v.WriteConfigAs(configFile); err != nil { + return nil, fmt.Errorf("error writing config file: %w", err) + } + + cfg := &Config{ConfigDir: configDir} + if err := cfg.reload(); err != nil { + return nil, err + } + + return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dbc5ca9c..b2500e77 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,13 +11,6 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -func TestMain(m *testing.M) { - // Reset viper state before each test run - ResetGlobalConfig() - code := m.Run() - os.Exit(code) -} - func setupTestConfig(t *testing.T) string { t.Helper() @@ -32,32 +25,21 @@ func setupTestConfig(t *testing.T) string { t.Cleanup(func() { os.RemoveAll(tmpDir) - ResetGlobalConfig() }) return tmpDir } -func setupViper(t *testing.T, tmpDir string) { - t.Helper() - - // Set up Viper configuration using the shared function - if err := SetupViper(tmpDir); err != nil { - t.Fatalf("Failed to setup Viper: %v", err) - } -} - func TestLoad_DefaultValues(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) // Set temporary config directory os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") - cfg, err := Load() + cfg, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } // Verify default values @@ -96,15 +78,13 @@ read_only: true t.Fatalf("Failed to write config file: %v", err) } - setupViper(t, tmpDir) - // Set temporary config directory os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") - cfg, err := Load() + cfg, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } // Verify loaded values @@ -166,11 +146,9 @@ func TestLoad_MigrateVersionCheck(t *testing.T) { t.Cleanup(func() { os.Unsetenv(k) }) } - setupViper(t, tmpDir) - - cfg, err := Load() + cfg, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } if cfg.VersionCheck != tt.want { t.Errorf("VersionCheck = %t, want %t", cfg.VersionCheck, tt.want) @@ -190,8 +168,6 @@ func TestLoad_FromEnvironmentVariables(t *testing.T) { os.Setenv("TIGER_ANALYTICS", "false") os.Setenv("TIGER_READ_ONLY", "true") - setupViper(t, tmpDir) - defer func() { os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_API_URL") @@ -201,9 +177,9 @@ func TestLoad_FromEnvironmentVariables(t *testing.T) { os.Unsetenv("TIGER_READ_ONLY") }() - cfg, err := Load() + cfg, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } // Verify environment values @@ -241,16 +217,14 @@ analytics: true os.Setenv("TIGER_CONFIG_DIR", tmpDir) os.Setenv("TIGER_OUTPUT", "json") - setupViper(t, tmpDir) - defer func() { os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_OUTPUT") }() - cfg, err := Load() + cfg, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } // Environment should override config file @@ -269,21 +243,20 @@ analytics: true func TestLoad_IndependentInstances(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") // First load - cfg1, err := Load() + cfg1, err := Load(nil) if err != nil { - t.Fatalf("First Load() failed: %v", err) + t.Fatalf("First Load(nil) failed: %v", err) } // Second load should return new independent instance - cfg2, err := Load() + cfg2, err := Load(nil) if err != nil { - t.Fatalf("Second Load() failed: %v", err) + t.Fatalf("Second Load(nil) failed: %v", err) } // Should be different instances but same values @@ -299,7 +272,6 @@ func TestLoad_IndependentInstances(t *testing.T) { func TestSave(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) cfg, err := UseTestConfig(tmpDir, map[string]any{ "api_url": "https://test.api.com/v1", @@ -321,12 +293,7 @@ func TestSave(t *testing.T) { os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") - ResetGlobalConfig() - - // Setup Viper again to read the saved config file - setupViper(t, tmpDir) - - loadedCfg, err := Load() + loadedCfg, err := Load(nil) if err != nil { t.Fatalf("Failed to load saved config: %v", err) } @@ -347,7 +314,6 @@ func TestSave(t *testing.T) { func TestSet(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) cfg := &Config{ APIURL: DefaultAPIURL, @@ -475,7 +441,6 @@ func TestSet(t *testing.T) { func TestUnset(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) cfg := &Config{ APIURL: "https://custom.api.com/v1", @@ -545,7 +510,6 @@ func TestUnset(t *testing.T) { func TestReset(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) cfg := &Config{ APIURL: "https://custom.api.com/v1", @@ -577,18 +541,17 @@ func TestReset(t *testing.T) { func TestLoad_WithMissingConfigFile(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) // Test Load succeeds with missing file os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") - cfg, err := Load() + cfg, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } if cfg == nil { - t.Error("Load() returned nil config") + t.Error("Load(nil) returned nil config") } // Should return defaults when config file is missing @@ -603,12 +566,12 @@ func TestLoad_WithMissingConfigFile(t *testing.T) { } // Second load should create new instance with same values - cfg2, err := Load() + cfg2, err := Load(nil) if err != nil { - t.Fatalf("Second Load() failed: %v", err) + t.Fatalf("Second Load(nil) failed: %v", err) } if cfg == cfg2 { - t.Error("Expected Load() to create new instances, got same instance") + t.Error("Expected Load(nil) to create new instances, got same instance") } if cfg.APIURL != cfg2.APIURL { t.Error("Expected same configuration values across different instances") @@ -616,7 +579,7 @@ func TestLoad_WithMissingConfigFile(t *testing.T) { } func TestLoad_ErrorHandling(t *testing.T) { - // Test SetupViper() when it fails due to invalid config file + // Test Load() when it fails due to invalid config file tmpDir := setupTestConfig(t) // Create invalid YAML config file @@ -631,9 +594,9 @@ invalid yaml content [ os.Setenv("TIGER_CONFIG_DIR", tmpDir) defer os.Unsetenv("TIGER_CONFIG_DIR") - // SetupViper should fail with invalid config file - if err := SetupViper(tmpDir); err == nil { - t.Error("Expected SetupViper() to fail with invalid config file, but it succeeded") + // Load should fail with invalid config file + if _, err := Load(nil); err == nil { + t.Error("Expected Load() to fail with invalid config file, but it succeeded") } } @@ -644,12 +607,19 @@ func TestGetEffectiveConfigDir(t *testing.T) { name string envVar string flagValue string + noFlags bool expected string }{ { name: "default behavior", expected: GetDefaultConfigDir(), }, + { + name: "no flag set", + noFlags: true, + envVar: "/env/config/path", + expected: "/env/config/path", + }, { name: "env var normal path", envVar: "/env/config/path", @@ -685,17 +655,19 @@ func TestGetEffectiveConfigDir(t *testing.T) { defer os.Unsetenv("TIGER_CONFIG_DIR") } - // Create mock flag - var flagVar string - fs := pflag.NewFlagSet("test", pflag.ContinueOnError) - fs.StringVar(&flagVar, "config-dir", "", "config directory") - if tt.flagValue != "" { - fs.Set("config-dir", tt.flagValue) + // Create mock flag set + var fs *pflag.FlagSet + if !tt.noFlags { + var flagVar string + fs = pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.StringVar(&flagVar, "config-dir", "", "config directory") + if tt.flagValue != "" { + fs.Set("config-dir", tt.flagValue) + } } - flag := fs.Lookup("config-dir") // Test the function - result := GetEffectiveConfigDir(flag) + result := getEffectiveConfigDir(fs) if result != tt.expected { t.Errorf("Expected %s, got %s", tt.expected, result) } @@ -752,52 +724,50 @@ func TestSave_CreateDirectory(t *testing.T) { } } -func TestResetGlobalConfig(t *testing.T) { +// Each Load uses its own viper instance, so there's no global state to carry a +// stale value across loads. +func TestLoad_RereadsEnvironment(t *testing.T) { tmpDir := setupTestConfig(t) - setupViper(t, tmpDir) - // Set environment variable for test os.Setenv("TIGER_CONFIG_DIR", tmpDir) - os.Setenv("TIGER_SERVICE_ID", "test-service-before-reset") + os.Setenv("TIGER_SERVICE_ID", "test-service-before") defer func() { os.Unsetenv("TIGER_CONFIG_DIR") os.Unsetenv("TIGER_SERVICE_ID") }() // Load config first - cfg1, err := Load() + cfg1, err := Load(nil) if err != nil { - t.Fatalf("Load() failed: %v", err) + t.Fatalf("Load(nil) failed: %v", err) } // Verify environment was used - if cfg1.ServiceID != "test-service-before-reset" { + if cfg1.ServiceID != "test-service-before" { t.Errorf("Expected service ID from env, got %s", cfg1.ServiceID) } - // Reset global viper state - ResetGlobalConfig() - - // Re-setup viper after reset - setupViper(t, tmpDir) - // Change env var - os.Setenv("TIGER_SERVICE_ID", "test-service-after-reset") + os.Setenv("TIGER_SERVICE_ID", "test-service-after") // Load again should pick up new env value - cfg2, err := Load() + cfg2, err := Load(nil) if err != nil { - t.Fatalf("Second Load() failed: %v", err) + t.Fatalf("Second Load(nil) failed: %v", err) } // Should be different instances if cfg1 == cfg2 { - t.Error("Expected different config instances after reset, got same instance") + t.Error("Expected different config instances, got same instance") } // Should have new env value - if cfg2.ServiceID != "test-service-after-reset" { - t.Errorf("Expected new service ID after reset, got %s", cfg2.ServiceID) + if cfg2.ServiceID != "test-service-after" { + t.Errorf("Expected new service ID, got %s", cfg2.ServiceID) + } + // The first instance is untouched by the second load + if cfg1.ServiceID != "test-service-before" { + t.Errorf("Expected first config to keep its value, got %s", cfg1.ServiceID) } } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 426b8c94..c5060550 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -64,14 +64,13 @@ func SetTestServiceName(t *testing.T) { }) } -func getCredentialsFileName() string { - configDir := GetConfigDir() - return fmt.Sprintf("%s/credentials", configDir) +func (c *Config) credentialsFileName() string { + return fmt.Sprintf("%s/credentials", c.ConfigDir) } // StoreCredentials stores a PAT credential. -func StoreCredentials(apiKey, projectID string) error { - return storeCredentials(storedCredentials{ +func (c *Config) StoreCredentials(apiKey, projectID string) error { + return c.storeCredentials(storedCredentials{ APIKey: apiKey, ProjectID: projectID, }) @@ -79,18 +78,18 @@ func StoreCredentials(apiKey, projectID string) error { // StoreOAuthCredentials stores an OAuth token (access + refresh + expiry) and // project ID. Use this for the PKCE login path; use StoreCredentials for PAT. -func StoreOAuthCredentials(token *oauth2.Token, projectID string) error { +func (c *Config) StoreOAuthCredentials(token *oauth2.Token, projectID string) error { if token == nil { return fmt.Errorf("oauth token must not be nil") } - return storeCredentials(storedCredentials{ + return c.storeCredentials(storedCredentials{ OAuth: token, ProjectID: projectID, }) } // StoreCredentialsToFile stores credentials to file (test helper) -func StoreCredentialsToFile(apiKey, projectID string) error { +func (c *Config) StoreCredentialsToFile(apiKey, projectID string) error { creds := storedCredentials{ APIKey: apiKey, ProjectID: projectID, @@ -101,10 +100,10 @@ func StoreCredentialsToFile(apiKey, projectID string) error { return fmt.Errorf("failed to marshal credentials: %w", err) } - return storeToFile(string(credentialsJSON)) + return c.storeToFile(string(credentialsJSON)) } -func storeCredentials(creds storedCredentials) error { +func (c *Config) storeCredentials(creds storedCredentials) error { credentialsJSON, err := json.Marshal(creds) if err != nil { return fmt.Errorf("failed to marshal credentials: %w", err) @@ -113,7 +112,7 @@ func storeCredentials(creds storedCredentials) error { if err := storeToKeyring(string(credentialsJSON)); err == nil { return nil } - return storeToFile(string(credentialsJSON)) + return c.storeToFile(string(credentialsJSON)) } func storeToKeyring(credentials string) error { @@ -121,8 +120,8 @@ func storeToKeyring(credentials string) error { } // storeToFile stores credentials to ~/.config/tiger/credentials with restricted permissions -func storeToFile(credentials string) error { - credentialsFile := getCredentialsFileName() +func (c *Config) storeToFile(credentials string) error { + credentialsFile := c.credentialsFileName() if err := os.MkdirAll(filepath.Dir(credentialsFile), 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -146,8 +145,8 @@ func storeToFile(credentials string) error { var ErrNotLoggedIn = errors.New("not logged in") -func GetStoredCredentials() (*Credentials, error) { - raw, err := loadCredentialsBlob() +func (c *Config) GetStoredCredentials() (*Credentials, error) { + raw, err := c.loadCredentialsBlob() if err != nil { return nil, err } @@ -171,7 +170,7 @@ func GetStoredCredentials() (*Credentials, error) { } // loadCredentialsBlob returns the raw JSON blob from keyring or file fallback. -func loadCredentialsBlob() (string, error) { +func (c *Config) loadCredentialsBlob() (string, error) { if blob, err := keyring.Get(GetServiceName(), keyringUsername); err == nil { if blob == "" { return "", ErrNotLoggedIn @@ -179,7 +178,7 @@ func loadCredentialsBlob() (string, error) { return blob, nil } - credentialsFile := getCredentialsFileName() + credentialsFile := c.credentialsFileName() data, err := os.ReadFile(credentialsFile) if err != nil { if os.IsNotExist(err) { @@ -194,10 +193,10 @@ func loadCredentialsBlob() (string, error) { } // RemoveCredentials removes stored credentials from keyring and file fallback -func RemoveCredentials() error { +func (c *Config) RemoveCredentials() error { // Remove from keyring (ignore errors as it might not exist) removeCredentialsFromKeyring() - return removeCredentialsFile() + return c.removeCredentialsFile() } // removeCredentialsFromKeyring removes credentials from keyring (test helper) @@ -206,8 +205,8 @@ func removeCredentialsFromKeyring() { } // removeCredentialsFile removes credentials file -func removeCredentialsFile() error { - credentialsFile := getCredentialsFileName() +func (c *Config) removeCredentialsFile() error { + credentialsFile := c.credentialsFileName() if err := os.Remove(credentialsFile); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove credentials file: %w", err) } diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 535ecfba..f421a2ee 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func setupCredentialTest(t *testing.T) string { +func setupCredentialTest(t *testing.T) (string, *Config) { t.Helper() // Use a unique service name for this test to avoid conflicts @@ -18,35 +18,27 @@ func setupCredentialTest(t *testing.T) string { t.Fatalf("Failed to create temp dir: %v", err) } - // Reset viper completely and set up with test directory - // This ensures proper test isolation by resetting all viper state - // MUST be done before RemoveCredentials() so it uses the test directory! - if _, err := UseTestConfig(tmpDir, map[string]any{}); err != nil { + cfg, err := UseTestConfig(tmpDir, map[string]any{}) + if err != nil { t.Fatalf("Failed to use test config: %v", err) } // Clean up any existing credentials in the test directory - RemoveCredentials() + cfg.RemoveCredentials() t.Cleanup(func() { - // Clean up credentials - RemoveCredentials() - - // Reset global config to ensure test isolation - ResetGlobalConfig() - - // Clean up file system + cfg.RemoveCredentials() os.RemoveAll(tmpDir) }) - return tmpDir + return tmpDir, cfg } func TestStoreCredentialsToFile(t *testing.T) { - tmpDir := setupCredentialTest(t) + tmpDir, cfg := setupCredentialTest(t) // Store credentials in new JSON format - if err := StoreCredentialsToFile("public:secret", "project123"); err != nil { + if err := cfg.StoreCredentialsToFile("public:secret", "project123"); err != nil { t.Fatalf("Failed to store credentials to file: %v", err) } @@ -75,7 +67,7 @@ func TestStoreCredentialsToFile(t *testing.T) { } func TestGetCredentialsFromFile(t *testing.T) { - tmpDir := setupCredentialTest(t) + tmpDir, cfg := setupCredentialTest(t) // Write credentials to file in JSON format credentialsFile := filepath.Join(tmpDir, "credentials") @@ -86,7 +78,7 @@ func TestGetCredentialsFromFile(t *testing.T) { // Get credentials - should get from file since keyring is empty // (each test uses a unique keyring service name) - creds, err := GetStoredCredentials() + creds, err := cfg.GetStoredCredentials() if err != nil { t.Fatalf("Failed to get credentials from file: %v", err) } @@ -101,10 +93,10 @@ func TestGetCredentialsFromFile(t *testing.T) { } func TestGetCredentialsFromFile_NotExists(t *testing.T) { - setupCredentialTest(t) + _, cfg := setupCredentialTest(t) // Try to get credentials when file doesn't exist - _, err := GetStoredCredentials() + _, err := cfg.GetStoredCredentials() if err == nil { t.Fatal("Expected error when credentials file doesn't exist") } @@ -115,7 +107,7 @@ func TestGetCredentialsFromFile_NotExists(t *testing.T) { } func TestRemoveCredentialsFromFile(t *testing.T) { - tmpDir := setupCredentialTest(t) + tmpDir, cfg := setupCredentialTest(t) // Write credentials to file credentialsFile := filepath.Join(tmpDir, "credentials") @@ -124,7 +116,7 @@ func TestRemoveCredentialsFromFile(t *testing.T) { } // Remove credentials file - if err := RemoveCredentials(); err != nil { + if err := cfg.RemoveCredentials(); err != nil { t.Fatalf("Failed to remove credentials file: %v", err) } @@ -135,10 +127,10 @@ func TestRemoveCredentialsFromFile(t *testing.T) { } func TestRemoveCredentialsFromFile_NotExists(t *testing.T) { - setupCredentialTest(t) + _, cfg := setupCredentialTest(t) // Try to remove credentials file when it doesn't exist (should not error) - if err := RemoveCredentials(); err != nil { + if err := cfg.RemoveCredentials(); err != nil { t.Fatalf("Should not error when removing non-existent file: %v", err) } } diff --git a/internal/mcp/db_execute_query.go b/internal/mcp/db_execute_query.go index 382922ed..3406d331 100644 --- a/internal/mcp/db_execute_query.go +++ b/internal/mcp/db_execute_query.go @@ -152,7 +152,7 @@ WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL // handleDBExecuteQuery handles the db_execute_query MCP tool func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequest, input DBExecuteQueryInput) (*mcp.CallToolResult, DBExecuteQueryOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, DBExecuteQueryOutput{}, err } @@ -197,7 +197,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ } // Connect to database - conn, err := common.ConnectTarget(queryCtx, target, common.ConnectionDetailsOptions{ + conn, err := common.ConnectTarget(queryCtx, cfg.Config, target, common.ConnectionDetailsOptions{ Pooled: input.Pooled, Role: input.Role, WithPassword: true, diff --git a/internal/mcp/db_schema.go b/internal/mcp/db_schema.go index 7606ba29..bed8715a 100644 --- a/internal/mcp/db_schema.go +++ b/internal/mcp/db_schema.go @@ -91,7 +91,7 @@ By default only user-facing schemas and objects are shown; view/routine definiti // handleDBSchema handles the db_schema MCP tool func (s *Server) handleDBSchema(ctx context.Context, req *mcp.CallToolRequest, input DBSchemaInput) (*mcp.CallToolResult, DBSchemaOutput, error) { - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, DBSchemaOutput{}, err } @@ -116,7 +116,7 @@ func (s *Server) handleDBSchema(ctx context.Context, req *mcp.CallToolRequest, i // A replica without a pooler connects directly; surface that as a warning. warning := common.ReplicaPoolerWarning(target, input.Pooled) - schema, err := common.FetchServiceSchema(ctx, target, input.Role, input.Pooled, common.SchemaOptions{ + schema, err := common.FetchServiceSchema(ctx, cfg.Config, target, input.Role, input.Pooled, common.SchemaOptions{ Schema: input.SchemaName, IncludeInternal: input.Internal, IncludeDefinitions: input.Definitions, diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index c35be9a4..09cbbccd 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -52,7 +52,7 @@ func isMethodNotFoundError(err error) bool { // the server. Does not connect if the docs MCP server is disabled in the // config or there is no URL in the config. func (s *Server) registerDocsProxy(ctx context.Context) { - cfg, err := config.Load() + cfg, err := config.Load(s.flags) if err != nil { logging.Error("Failed to load config", zap.Error(err)) return diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8ea3f574..34c7c282 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -12,6 +12,7 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/pflag" "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/analytics" @@ -46,6 +47,12 @@ const ( type Server struct { mcpServer *mcp.Server docsProxyClient *ProxyClient + + // flags is the flag set of the command that started the server. Tool + // handlers pass it to [common.LoadConfig] on every call so the flags given + // to `tiger mcp start` (e.g. --config-dir, --service-id) keep taking + // precedence over the config file, which is re-read per call. + flags *pflag.FlagSet } // addTool registers an MCP tool, skipping readOnlyGatedTools in read-only mode. @@ -74,8 +81,10 @@ func buildServerInstructions(cfg *config.Config) string { } // NewServer creates a new Tiger MCP server instance. The caller-supplied cfg -// is used only to render the read-only warning in server instructions. -func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { +// is used only to render the read-only warning in server instructions and to +// gate tool registration; flags is retained so each tool call reloads the +// config with the same flag precedence (see Server.flags). +func NewServer(ctx context.Context, cfg *config.Config, flags *pflag.FlagSet) (*Server, error) { mcpServer := mcp.NewServer(&mcp.Implementation{ Name: ServerName, Title: serverTitle, @@ -84,6 +93,7 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { server := &Server{ mcpServer: mcpServer, + flags: flags, } // Register all tools (including proxied docs tools). readOnly and @@ -165,7 +175,7 @@ func (s *Server) analyticsMiddleware(next mcp.MethodHandler) mcp.MethodHandler { start := time.Now() // Load config for analytics - cfg, err := config.Load() + cfg, err := config.Load(s.flags) if err != nil { // If we can't load config, just skip analytics and continue return next(ctx, method, req) diff --git a/internal/mcp/service_create.go b/internal/mcp/service_create.go index c8f2684d..b60dd389 100644 --- a/internal/mcp/service_create.go +++ b/internal/mcp/service_create.go @@ -101,7 +101,7 @@ WARNING: Creates billable resources.`, // handleServiceCreate handles the service_create MCP tool func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolRequest, input ServiceCreateInput) (*mcp.CallToolResult, ServiceCreateOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceCreateOutput{}, err } @@ -179,7 +179,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque // This ensures the password is stored even if the wait fails or is interrupted var passwordStorage *common.PasswordStorageResult if service.InitialPassword != nil { - result, err := common.SavePasswordWithResult(api.Service(service), *service.InitialPassword, "tsdbadmin") + result, err := common.SavePasswordWithResult(cfg.Config, api.Service(service), *service.InitialPassword, "tsdbadmin") passwordStorage = &result if err != nil { logging.Debug("MCP: Password storage failed", zap.Error(err)) @@ -210,7 +210,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque // Convert service to output format (after wait so status is accurate) output := ServiceCreateOutput{ - Service: s.convertToServiceDetail(service, input.WithPassword), + Service: s.convertToServiceDetail(cfg.Config, service, input.WithPassword), Message: message, PasswordStorage: passwordStorage, } diff --git a/internal/mcp/service_fork.go b/internal/mcp/service_fork.go index 2622c5ec..eb54b177 100644 --- a/internal/mcp/service_fork.go +++ b/internal/mcp/service_fork.go @@ -103,7 +103,7 @@ WARNING: Creates billable resources.`, // handleServiceFork handles the service_fork MCP tool func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest, input ServiceForkInput) (*mcp.CallToolResult, ServiceForkOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceForkOutput{}, err } @@ -181,7 +181,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest // This ensures the password is stored even if the wait fails or is interrupted var passwordStorage *common.PasswordStorageResult if service.InitialPassword != nil { - result, err := common.SavePasswordWithResult(api.Service(service), *service.InitialPassword, "tsdbadmin") + result, err := common.SavePasswordWithResult(cfg.Config, api.Service(service), *service.InitialPassword, "tsdbadmin") passwordStorage = &result if err != nil { logging.Debug("MCP: Password storage failed", zap.Error(err)) @@ -222,7 +222,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest // Convert service to output format (after wait so status is accurate) output := ServiceForkOutput{ - Service: s.convertToServiceDetail(service, input.WithPassword), + Service: s.convertToServiceDetail(cfg.Config, service, input.WithPassword), Message: message, PasswordStorage: passwordStorage, } diff --git a/internal/mcp/service_get.go b/internal/mcp/service_get.go index 98b1fb33..9938ccb7 100644 --- a/internal/mcp/service_get.go +++ b/internal/mcp/service_get.go @@ -57,7 +57,7 @@ func newServiceGetTool() *mcp.Tool { // handleServiceGet handles the service_get MCP tool func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, input ServiceGetInput) (*mcp.CallToolResult, ServiceGetOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceGetOutput{}, err } @@ -85,7 +85,7 @@ func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, } output := ServiceGetOutput{ - Service: s.convertToServiceDetail(*resp.JSON200, input.WithPassword), + Service: s.convertToServiceDetail(cfg.Config, *resp.JSON200, input.WithPassword), } // Check if password was requested but not available diff --git a/internal/mcp/service_list.go b/internal/mcp/service_list.go index 4af22d66..f024fd15 100644 --- a/internal/mcp/service_list.go +++ b/internal/mcp/service_list.go @@ -68,7 +68,7 @@ func newServiceListTool() *mcp.Tool { // handleServiceList handles the service_list MCP tool func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest, input ServiceListInput) (*mcp.CallToolResult, ServiceListOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceListOutput{}, err } diff --git a/internal/mcp/service_logs.go b/internal/mcp/service_logs.go index 056dad37..02ec89ec 100644 --- a/internal/mcp/service_logs.go +++ b/internal/mcp/service_logs.go @@ -77,7 +77,7 @@ Supports filtering by time (via since/until parameters) and node (for services w // handleServiceLogs handles the service_logs MCP tool func (s *Server) handleServiceLogs(ctx context.Context, req *mcp.CallToolRequest, input ServiceLogsInput) (*mcp.CallToolResult, ServiceLogsOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceLogsOutput{}, err } diff --git a/internal/mcp/service_metrics_available.go b/internal/mcp/service_metrics_available.go index ea98a97e..65b4f5a9 100644 --- a/internal/mcp/service_metrics_available.go +++ b/internal/mcp/service_metrics_available.go @@ -53,7 +53,7 @@ func newServiceMetricsAvailableTool() *mcp.Tool { // handleServiceMetricsAvailable handles the service_metrics_available MCP tool func (s *Server) handleServiceMetricsAvailable(ctx context.Context, req *mcp.CallToolRequest, input ServiceMetricsAvailableInput) (*mcp.CallToolResult, ServiceMetricsAvailableOutput, error) { - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceMetricsAvailableOutput{}, err } diff --git a/internal/mcp/service_metrics_series.go b/internal/mcp/service_metrics_series.go index bee3a11a..eab3f247 100644 --- a/internal/mcp/service_metrics_series.go +++ b/internal/mcp/service_metrics_series.go @@ -105,7 +105,7 @@ Available metrics include: CPU usage/allocation, memory usage/total, disk usage, // handleServiceMetricsSeries handles the service_metrics_series MCP tool func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallToolRequest, input ServiceMetricsSeriesInput) (*mcp.CallToolResult, any, error) { - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, nil, err } diff --git a/internal/mcp/service_resize.go b/internal/mcp/service_resize.go index dca805e9..ad13edfd 100644 --- a/internal/mcp/service_resize.go +++ b/internal/mcp/service_resize.go @@ -74,7 +74,7 @@ WARNING: Creates billable resource changes. Increasing resources will increase c // handleServiceResize handles the service_resize MCP tool func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolRequest, input ServiceResizeInput) (*mcp.CallToolResult, ServiceResizeOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceResizeOutput{}, err } @@ -142,7 +142,7 @@ func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolReque } // Return status, resources, and message (after wait so status is accurate) - detail := s.convertToServiceDetail(service, false) + detail := s.convertToServiceDetail(cfg.Config, service, false) output := ServiceResizeOutput{ Status: detail.Status, Resources: detail.Resources, diff --git a/internal/mcp/service_start.go b/internal/mcp/service_start.go index 629f2be6..ef4b3615 100644 --- a/internal/mcp/service_start.go +++ b/internal/mcp/service_start.go @@ -66,7 +66,7 @@ This operation starts a service that is currently in a stopped/paused state. The // handleServiceStart handles the service_start MCP tool func (s *Server) handleServiceStart(ctx context.Context, req *mcp.CallToolRequest, input ServiceStartInput) (*mcp.CallToolResult, ServiceStartOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceStartOutput{}, err } diff --git a/internal/mcp/service_stop.go b/internal/mcp/service_stop.go index 4ad05f03..24d1950c 100644 --- a/internal/mcp/service_stop.go +++ b/internal/mcp/service_stop.go @@ -66,7 +66,7 @@ This operation stops a service that is currently running. The service will trans // handleServiceStop handles the service_stop MCP tool func (s *Server) handleServiceStop(ctx context.Context, req *mcp.CallToolRequest, input ServiceStopInput) (*mcp.CallToolResult, ServiceStopOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceStopOutput{}, err } diff --git a/internal/mcp/service_update_password.go b/internal/mcp/service_update_password.go index 175d4053..dd100be4 100644 --- a/internal/mcp/service_update_password.go +++ b/internal/mcp/service_update_password.go @@ -64,7 +64,7 @@ func newServiceUpdatePasswordTool() *mcp.Tool { // handleServiceUpdatePassword handles the service_update_password MCP tool func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallToolRequest, input ServiceUpdatePasswordInput) (*mcp.CallToolResult, ServiceUpdatePasswordOutput, error) { // Load config and API client - cfg, err := common.LoadConfig(ctx) + cfg, err := common.LoadConfig(ctx, s.flags) if err != nil { return nil, ServiceUpdatePasswordOutput{}, err } @@ -112,7 +112,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT } // Save the new password using the service we already fetched. - result, saveErr := common.SavePasswordWithResult(service, input.Password, "tsdbadmin") + result, saveErr := common.SavePasswordWithResult(cfg.Config, service, input.Password, "tsdbadmin") passwordStorage := &result if saveErr != nil { logging.Debug("MCP: Password storage failed", zap.Error(saveErr)) diff --git a/internal/mcp/utils.go b/internal/mcp/utils.go index 8976dd7d..0115fc26 100644 --- a/internal/mcp/utils.go +++ b/internal/mcp/utils.go @@ -10,6 +10,7 @@ import ( "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/logging" "github.com/timescale/tiger-cli/internal/util" ) @@ -76,7 +77,7 @@ func (ServiceDetail) Schema() *jsonschema.Schema { } // convertToServiceDetail converts an API Service to MCP ServiceDetail -func (s *Server) convertToServiceDetail(service api.Service, withPassword bool) ServiceDetail { +func (s *Server) convertToServiceDetail(cfg *config.Config, service api.Service, withPassword bool) ServiceDetail { detail := ServiceDetail{ ServiceID: util.Deref(service.ServiceId), Name: util.Deref(service.Name), @@ -149,7 +150,7 @@ func (s *Server) convertToServiceDetail(service api.Service, withPassword bool) // Always include connection string in ServiceDetail // Password is embedded in connection string only if with_password=true - if details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{ + if details, err := common.GetConnectionDetails(cfg, service, common.ConnectionDetailsOptions{ Role: "tsdbadmin", WithPassword: withPassword, InitialPassword: util.Deref(service.InitialPassword), From 95287b43c2a20582fd4f3bb2f01db573a046bde8 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Tue, 4 Aug 2026 17:22:29 -0400 Subject: [PATCH 2/4] Use App struct pattern to improve config/client loading --- CLAUDE.md | 198 ++++++++++------- docs/development.md | 8 +- internal/analytics/analytics.go | 4 +- internal/cmd/auth.go | 10 +- internal/cmd/auth_login.go | 15 +- internal/cmd/auth_login_test.go | 10 +- internal/cmd/auth_logout.go | 8 +- internal/cmd/auth_status.go | 7 +- internal/cmd/auth_test.go | 2 +- internal/cmd/completion_helper.go | 101 +++++---- internal/cmd/config.go | 12 +- internal/cmd/config_reset.go | 9 +- internal/cmd/config_set.go | 9 +- internal/cmd/config_show.go | 8 +- internal/cmd/config_unset.go | 9 +- internal/cmd/db.go | 36 +-- internal/cmd/db_connect.go | 43 ++-- internal/cmd/db_connect_test.go | 4 +- internal/cmd/db_connection_string.go | 10 +- internal/cmd/db_connection_string_test.go | 2 +- internal/cmd/db_create.go | 6 +- internal/cmd/db_create_role.go | 12 +- internal/cmd/db_create_role_test.go | 2 +- internal/cmd/db_save_password.go | 10 +- internal/cmd/db_save_password_test.go | 14 +- internal/cmd/db_schema.go | 10 +- internal/cmd/db_schema_test.go | 2 +- internal/cmd/db_test.go | 26 +-- internal/cmd/db_test_connection.go | 10 +- internal/cmd/main_test.go | 54 ++++- internal/cmd/mcp.go | 12 +- internal/cmd/mcp_get.go | 14 +- internal/cmd/mcp_install.go | 3 +- internal/cmd/mcp_list.go | 12 +- internal/cmd/mcp_start.go | 20 +- internal/cmd/mcp_start_http.go | 16 +- internal/cmd/mcp_start_stdio.go | 6 +- internal/cmd/root.go | 205 +++++++++--------- internal/cmd/root_test.go | 42 ++++ internal/cmd/service.go | 26 +-- internal/cmd/service_create.go | 19 +- internal/cmd/service_delete.go | 20 +- internal/cmd/service_fork.go | 23 +- internal/cmd/service_get.go | 13 +- internal/cmd/service_list.go | 9 +- internal/cmd/service_logs.go | 19 +- internal/cmd/service_metrics.go | 8 +- .../cmd/service_metrics_available_series.go | 8 +- internal/cmd/service_metrics_series.go | 8 +- internal/cmd/service_resize.go | 17 +- internal/cmd/service_start.go | 19 +- internal/cmd/service_stop.go | 19 +- internal/cmd/service_update_password.go | 21 +- internal/cmd/upgrade.go | 13 +- internal/cmd/version.go | 7 +- internal/common/app.go | 158 ++++++++++++++ internal/common/client.go | 4 +- internal/common/config.go | 43 ---- internal/common/logs.go | 40 ++-- internal/common/wait.go | 2 +- internal/mcp/db_execute_query.go | 9 +- internal/mcp/db_schema.go | 8 +- internal/mcp/proxy.go | 6 +- internal/mcp/server.go | 37 ++-- internal/mcp/service_create.go | 17 +- internal/mcp/service_fork.go | 17 +- internal/mcp/service_get.go | 9 +- internal/mcp/service_list.go | 7 +- internal/mcp/service_logs.go | 15 +- internal/mcp/service_metrics_available.go | 6 +- internal/mcp/service_metrics_series.go | 6 +- internal/mcp/service_resize.go | 15 +- internal/mcp/service_start.go | 13 +- internal/mcp/service_stop.go | 13 +- internal/mcp/service_update_password.go | 13 +- 75 files changed, 974 insertions(+), 694 deletions(-) create mode 100644 internal/common/app.go delete mode 100644 internal/common/config.go diff --git a/CLAUDE.md b/CLAUDE.md index e067176b..c5020bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,31 +136,31 @@ go generate ./internal/api **IMPORTANT:** Follow these rules when working with configuration: -1. **There is no global config** - `config.Load(flags)` builds a fresh `viper` instance per call and unmarshals it into a `Config`. Nothing reads the global viper instance, and nothing should: always take a `*config.Config` (or `*common.Config`) and use its fields. +1. **There is no global config** - `config.Load(flags)` builds a fresh `viper` instance per call and unmarshals it into a `Config`. Nothing reads the global viper instance, and nothing should: always take a `*config.Config` and use its fields. -2. **Pass the command's flag set to Load** - `config.Load(cmd.Flags())` binds the flags in `flagBindings` (`internal/config/config.go`) so precedence stays flag > env > file > default. `cmd.Flags()` includes the persistent flags inherited from parents, and flags a command doesn't define are skipped, so command-local flags (e.g. `--output`) bind only where they exist. Pass `nil` when there are no flags to apply. +2. **Read the config from the App, don't load it yourself** - `common.App` (`internal/common/app.go`) holds the config and the API client built from it. `wrapCommands` loads it once per CLI invocation (and the MCP analytics middleware once per request); command bodies and helpers then read it with `app.GetAll()`, `app.GetConfig()`, or `app.GetClient()`. Call `config.Load` directly only where there is no App: `config.LoadForOutput` for `tiger config show`, and tests. -3. **Load once, pass down** - Load the config once at the start of a command or operation, then pass it down to functions that need it. Do not reload the config if one is already available higher in the call chain. +3. **The App owns flag precedence** - `app.SetFlags(cmd.Flags())` runs before `app.Load`, and `config.Load` binds the flags in `flagBindings` (`internal/config/config.go`) so precedence stays flag > env > file > default. `cmd.Flags()` includes the persistent flags inherited from parents, and flags a command doesn't define are skipped, so command-local flags (e.g. `--output`) bind only where they exist. -4. **MCP tools reload per-call** - In MCP tool implementations, always load a fresh config at the start of each tool call, using the flag set the server was started with (`s.flags`). This ensures that configuration changes made by the user (via `tiger config set`) take effect immediately for the next tool call, without requiring the MCP server to be restarted. +4. **Pass what you read down the call chain** - Pass the `*config.Config` (plus client and project ID where needed) to the functions that need them. Don't reload, and don't pass the App into `internal/common` helpers — they take the values they use, so they stay usable from both CLI and MCP. + +5. **MCP reloads per request** - The analytics middleware in `internal/mcp/server.go` calls `s.app.Load(ctx)` on every request, so configuration changes (via `tiger config set`) and logins/logouts take effect on the next tool call without restarting the server. Tool handlers then read that state via `s.app.GetAll()`/`GetClient()` — they must not load it again. **Example:** ```go -// ✅ Good: Load config once and pass it down +// ✅ Good: an MCP handler reads what the middleware loaded for this request func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest, input ServiceListInput) (*mcp.CallToolResult, ServiceListOutput, error) { - // Load fresh config at start of MCP tool call - cfg, err := common.LoadConfig(ctx, s.flags) + client, projectID, err := s.app.GetClient() if err != nil { return nil, ServiceListOutput{}, err } - // Use cfg.ProjectID, cfg.ServiceID, etc. - return doWork(cfg) + return doWork(ctx, client, projectID) } -// ✅ Good: A CLI command loads with its own flag set +// ✅ Good: a CLI command reads what wrapCommands loaded for this invocation func run(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() // ... } @@ -169,7 +169,8 @@ func handleCommand() { projectID := viper.GetString("project_id") // Don't do this } -// ❌ Bad: Dropping the flag set, so --config-dir/--service-id are ignored +// ❌ Bad: Loading again when the App already holds it — this also drops flag +// precedence, so --config-dir/--service-id are ignored func run(cmd *cobra.Command, args []string) error { cfg, err := config.Load(nil) // Don't do this in a command } @@ -184,7 +185,10 @@ func processData(cfg *config.Config) { Config-derived state lives on the `Config` too: credential storage is a set of methods on it (`cfg.StoreCredentials`, `cfg.GetStoredCredentials`, `cfg.RemoveCredentials`), keyed off `cfg.ConfigDir`, and `cfg.Set`/`Unset`/`Reset` -write the config file and then reload the struct through the same precedence. +write the config file and then reload the struct **in place**. Reloading in place +is what lets a command change the config mid-run and have the App's readers see +it — that's how `tiger config set analytics false` suppresses its own analytics +event, and how `version_check false` suppresses the update notice. ### CLI and MCP Synchronization @@ -217,7 +221,7 @@ Tiger CLI tracks usage analytics to help improve the product. Analytics are auto #### Automatic Tracking via Middleware -**CLI Commands** - All commands are automatically wrapped with analytics middleware in `wrapCommandsWithAnalytics()` in `internal/cmd/root.go` +**CLI Commands** - All commands are automatically wrapped with the per-invocation lifecycle in `wrapCommands()` in `internal/cmd/root.go`, which tracks analytics as one of its steps This middleware: - Automatically tracks all CLI commands with event name like `"Run tiger service create"` @@ -270,7 +274,7 @@ The middleware automatically excludes sensitive fields using a centralized ignor 2. **For positional arguments:** Currently, all positional arguments are tracked automatically. If a command is added that accepts sensitive data as a positional argument (not as a flag), you must either: - Refactor to use a flag instead - - Add filtering logic in `wrapCommandsWithAnalytics()` in `internal/cmd/root.go` to sanitize or omit the args from tracking + - Add filtering logic in `wrapCommands()` in `internal/cmd/root.go` to sanitize or omit the args from tracking **Common sensitive fields to watch for:** - Credentials: API keys, tokens, passwords, secret keys @@ -304,6 +308,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da `errors.go`), the remote docs proxy (`proxy.go`), and capability listing (`capabilities.go`). - **Common Package**: `internal/common/` - Shared business logic used by both CLI and MCP + - `App` (`app.go`) - per-invocation config + API client, shared by CLI commands and MCP handlers - Password storage utilities (keyring, pgpass, validation) - Wait operations and polling logic (WaitForService) - Connection detail helpers (GetConnectionDetails, GetReplicaConnectionDetails for read replicas) @@ -339,19 +344,19 @@ intentionally-undocumented env var — `TIGER_EXPERIMENTAL` (default `false`). **This is env-var only** — deliberately not a config-file key, not a flag, and not surfaced by `tiger config show`. It mirrors ghost's `GHOST_EXPERIMENTAL` -pattern: `strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL"))` is read once at -build time in `buildRootCmd` (CLI) and once at `NewServer` (MCP), and -threaded through to the subtree/tool registration sites as a plain bool. +pattern: `strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL"))` is read once in +`buildRootCmd` and stored on the `App` as `app.Experimental`, which both the CLI +and the MCP server read at registration time. -- CLI: `buildServiceCmd(experimental bool)` guards `cmd.AddCommand(buildServiceMetricsCmd())` with `if experimental { … }`. When the env var is unset, the `metrics` subtree isn't added to the command tree at all — the command literally does not exist (no help entry, no tab completion, `unknown command` error like any typo). -- MCP: `registerServiceTools(readOnly, experimental bool)` guards the metrics tool `addTool` calls the same way, so the tools aren't advertised to MCP clients when the env var is off. Restart the MCP server after toggling. +- CLI: `buildServiceCmd` guards `cmd.AddCommand(buildServiceMetricsCmd(app))` with `if app.Experimental { … }`. When the env var is unset, the `metrics` subtree isn't added to the command tree at all — the command literally does not exist (no help entry, no tab completion, `unknown command` error like any typo). +- MCP: `NewServer` passes `app.Experimental` to `registerServiceTools(readOnly, experimental bool)`, which guards the metrics tool `addTool` calls the same way, so the tools aren't advertised to MCP clients when the env var is off. Restart the MCP server after toggling. **Do not mention `TIGER_EXPERIMENTAL` in user-facing docs, command help, spec files, or error messages.** When a feature graduates, remove the `x-preview: -true` marker upstream, delete the `if experimental { … }` wrapper (both -CLI and MCP), and drop the `experimental bool` parameter from the affected -builder. The call sites already use the normal `cfg.Client` (v1) — no client -wiring needs to change. +true` marker upstream, delete the `if app.Experimental { … }` / `if experimental +{ … }` wrappers (both CLI and MCP), drop the `experimental bool` parameter from +`registerServiceTools`, and remove the `Experimental` field from `common.App`. The +call sites already use the normal v1 client — no client wiring needs to change. ### MCP Server Architecture @@ -362,6 +367,16 @@ The Tiger MCP server provides AI assistants with programmatic access to Tiger re 1. **Direct Tiger Tools** - Native tools for Tiger service management and database operations, one file per tool 2. **Proxied Documentation Tools** (`proxy.go`) - Tools forwarded from a remote docs MCP server (see `proxy.go` for implementation) +**Server State:** + +`NewServer(ctx, app)` takes the already-loaded `*common.App` and keeps it on the +`Server`. Read-only mode, the experimental gate, and the docs-proxy settings are +read once here at startup (a client must restart the server to pick those up), +while the analytics middleware calls `s.app.Load(ctx)` on every request so tool +handlers see current config and credentials. Handlers therefore never load +anything themselves — they read `s.app.GetAll()`, `s.app.GetClient()`, or +`s.app.GetConfig()`. + **One File Per MCP Tool:** Every tool gets its own file in `internal/mcp/`, named to match the tool: @@ -379,7 +394,7 @@ helpers and API-to-output conversion live in `utils.go`. **Read-Only Mode Gate:** -Write/destructive MCP tool handlers and CLI command `RunE` functions must call `common.CheckReadOnly(cfg.Config)` (defined in `internal/common/errors.go`) immediately after `common.LoadConfig(ctx)`. When `cfg.ReadOnly` is `true`, the call returns `common.ErrReadOnly` and the API client is never invoked. The gated CLI commands today are `service create`, `service fork`, `service start`, `service stop`, `service resize`, `service update-password`, and `service delete`. +Write/destructive MCP tool handlers and CLI command `RunE` functions must call `common.CheckReadOnly(cfg)` (defined in `internal/common/errors.go`) immediately after reading the config from the App. When `cfg.ReadOnly` is `true`, the call returns `common.ErrReadOnly` and the API client is never invoked. The gated CLI commands today are `service create`, `service fork`, `service start`, `service stop`, `service resize`, `service update-password`, and `service delete`. **Tool Definition Pattern:** @@ -547,9 +562,37 @@ Tiger CLI uses a pure functional builder pattern with **zero global command stat ### Architecture Overview -`buildRootCmd()` builds the whole tree: it adds a `build*Cmd()` per top-level -command, and each group command adds its own subcommand builders. To see the -current tree, read `buildRootCmd()` in `root.go` and follow the builders down. +`buildRootCmd(ctx)` builds the whole tree: it creates the `*common.App` that +carries per-invocation state, adds a `build*Cmd(app)` per top-level command, and +each group command adds its own subcommand builders. To see the current tree, +read `buildRootCmd()` in `root.go` and follow the builders down. + +Every builder takes the `*common.App` and passes it to its children, so any +command body can reach the config and API client without loading them itself. The +App is what makes "load once per invocation" possible while keeping commands +free of global state. + +### Per-Invocation Lifecycle + +There are no `PersistentPreRunE`/`PersistentPostRunE` hooks. `wrapCommands()` +wraps the `RunE` of every command in the tree with the shared lifecycle, in this +order: + +1. `app.SetFlags(cmd.Flags())` then `app.Load(ctx)` — the single config + API + client load for the invocation +2. `logging.Init(cfg.Debug)`, with `logging.Sync()` deferred +3. `color.NoColor` from `cfg.Color` +4. `versionCheck(...)` — starts a background release check, deferring the print + so it lands after the command's own output +5. analytics — deferred, so it records the command's outcome and re-reads the App + (see "Configuration Management") + +Commands cobra adds after `wrapCommands` runs — `help`, `completion`, and the +`__complete` command behind tab completion — are deliberately **not** wrapped, so +they never touch the config file, the system keyring, or the network. Group +commands (`tiger service`) have no `RunE` and are skipped for the same reason. +Completion functions that do need the config or client wrap themselves with +`withAppLoad` (`completion_helper.go`). ### One File Per Command @@ -601,7 +644,10 @@ lives in `main_test.go`; per-group test helpers live in the group's test file. The root command builder creates the complete CLI structure: ```go -func buildRootCmd() *cobra.Command { +func buildRootCmd(ctx context.Context) (*cobra.Command, error) { + // Per-invocation state, threaded through every builder + app := &common.App{Experimental: experimental} + // Declare ALL flag variables locally within this function var configDir string var debug bool @@ -611,30 +657,25 @@ func buildRootCmd() *cobra.Command { Use: "tiger", Short: "Tiger CLI - Tiger Cloud Platform command-line interface", Long: `Complete CLI description...`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Load the config for the command being run; cmd.Flags() carries - // the persistent flags inherited from parents - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Initialize logging - // ... rest of initialization - }, } + // Cobra copies this onto the command it executes + cmd.SetContext(ctx) + // Set up persistent flags cmd.PersistentFlags().StringVar(&configDir, "config-dir", config.GetDefaultConfigDir(), "config directory") cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") // ... add remaining persistent flags // Add all subcommands (complete tree building) - cmd.AddCommand(buildVersionCmd()) - cmd.AddCommand(buildConfigCmd()) + cmd.AddCommand(buildVersionCmd(app)) + cmd.AddCommand(buildConfigCmd(app)) // ... add remaining subcommands - return cmd + // Wrap every RunE in the tree with the shared lifecycle + wrapCommands(cmd, app, &skipUpdateCheck) + + return cmd, nil } ``` @@ -645,25 +686,29 @@ See `internal/cmd/root.go` for the complete implementation. For commands without flags: ```go -func buildVersionCmd() *cobra.Command { +func buildVersionCmd(app *common.App) *cobra.Command { return &cobra.Command{ Use: "version", Short: "Show version information", Long: `Display version, build time, and git commit information.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { fmt.Printf("Tiger CLI %s\n", Version) // ... version output + return nil }, } } ``` +Use `RunE`, not `Run`: only `RunE` commands are wrapped by `wrapCommands`, so a +`Run` command would silently skip the config load, analytics, and version check. + ### Commands with Local Flags For commands that need their own flags: ```go -func buildMyFlaggedCmd() *cobra.Command { +func buildMyFlaggedCmd(app *common.App) *cobra.Command { // Declare flag variables locally (NEVER globally!) var myFlag string var enableFeature bool @@ -697,12 +742,12 @@ func buildMyFlaggedCmd() *cobra.Command { ### Commands with Flags That Override Config Values -A flag that should override a config value needs no wiring in the command: pass -the command's flag set to `config.Load` and the binding table in -`internal/config/config.go` does the rest. +A flag that should override a config value needs no wiring in the command: the +lifecycle wrapper already hands the command's flag set to `config.Load`, and the +binding table in `internal/config/config.go` does the rest. ```go -func buildMyConfigurableFlagCmd() *cobra.Command { +func buildMyConfigurableFlagCmd(app *common.App) *cobra.Command { var output string cmd := &cobra.Command{ @@ -710,7 +755,7 @@ func buildMyConfigurableFlagCmd() *cobra.Command { Short: "Command with configurable flag", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) + cfg := app.GetConfig() // ... use cfg.Output which respects: flag > env > config > default }, } @@ -735,7 +780,7 @@ variables; a flag that only needs an env-var fallback can read it directly (see For commands that contain subcommands, build the complete tree: ```go -func buildParentCmd() *cobra.Command { +func buildParentCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "parent", Short: "Parent command with subcommands", @@ -756,17 +801,14 @@ func buildParentCmd() *cobra.Command { The main application uses a single builder call: ```go -func Execute() { +func Execute(ctx context.Context) error { // Build complete command tree fresh each time - rootCmd := buildRootCmd() - - err := rootCmd.Execute() + rootCmd, err := buildRootCmd(ctx) if err != nil { - if exitErr, ok := err.(interface{ ExitCode() int }); ok { - os.Exit(exitErr.ExitCode()) - } - os.Exit(1) + return err } + + return rootCmd.Execute() } ``` @@ -790,22 +832,25 @@ func init() { Tests use the full root command builder: ```go -func executeCommand(args ...string) (string, error) { - // Build complete CLI fresh for each test - rootCmd := buildRootCmd() +func executeCommand(ctx context.Context, args ...string) (string, error) { + // Build complete CLI fresh for each test, including its App + rootCmd, err := buildRootCmd(ctx) + if err != nil { + return "", err + } buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) rootCmd.SetArgs(args) - err := rootCmd.Execute() + err = rootCmd.Execute() return buf.String(), err } func TestMyCommand(t *testing.T) { // Each test gets completely fresh CLI instance - output, err := executeCommand("my-command", "--flag", "value") + output, err := executeCommand(t.Context(), "my-command", "--flag", "value") if err != nil { t.Fatalf("Command failed: %v", err) @@ -822,19 +867,22 @@ func TestMyCommand(t *testing.T) { For tests that need to verify flag values: ```go -func executeAndReturnRoot(args ...string) (*cobra.Command, string, error) { - rootCmd := buildRootCmd() +func executeAndReturnRoot(ctx context.Context, args ...string) (*cobra.Command, string, error) { + rootCmd, err := buildRootCmd(ctx) + if err != nil { + return nil, "", err + } buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetArgs(args) - err := rootCmd.Execute() + err = rootCmd.Execute() return rootCmd, buf.String(), err } func TestFlagValues(t *testing.T) { - rootCmd, output, err := executeAndReturnRoot("service", "create", "--name", "test") + rootCmd, output, err := executeAndReturnRoot(t.Context(), "service", "create", "--name", "test") // Navigate to specific command serviceCmd, _, _ := rootCmd.Find([]string{"service"}) @@ -862,12 +910,14 @@ func TestFlagValues(t *testing.T) { When adding new commands to this architecture: -1. **Create a builder function** following the `buildXXXCmd()` pattern +1. **Create a builder function** following the `buildXXXCmd(app *common.App)` pattern 2. **Declare flags locally** within the builder function scope -3. **Add the flag to `flagBindings`** (in `internal/config/config.go`) if it should override a config value, and load with `config.Load(cmd.Flags())` -4. **Add to root command** by calling `cmd.AddCommand(buildXXXCmd())` in `buildRootCmd()` -5. **No init() function** required - everything goes through the root builder -6. **Test with `buildRootCmd()`** instead of recreating flag setup +3. **Use `RunE`** (not `Run`) so the command gets the shared lifecycle from `wrapCommands` +4. **Read config and client from the App** (`app.GetAll()`/`GetConfig()`/`GetClient()`) rather than loading them +5. **Add the flag to `flagBindings`** (in `internal/config/config.go`) if it should override a config value +6. **Add to root command** by calling `cmd.AddCommand(buildXXXCmd(app))` in `buildRootCmd()` +7. **No init() function** required - everything goes through the root builder +8. **Test with `buildRootCmd(ctx)`** instead of recreating flag setup This architecture ensures Tiger CLI remains maintainable and testable as it grows. diff --git a/docs/development.md b/docs/development.md index e5c2efca..21bf439a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -128,7 +128,13 @@ 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. + 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 diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index aed39cf6..6540aaac 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -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, diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index e90bb494..2bd0b4d3 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -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 } diff --git a/internal/cmd/auth_login.go b/internal/cmd/auth_login.go index 79ebc336..b105cdbd 100644 --- a/internal/cmd/auth_login.go +++ b/internal/cmd/auth_login.go @@ -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{ @@ -91,11 +91,9 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) - 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"), @@ -117,6 +115,10 @@ Examples: 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) @@ -145,6 +147,9 @@ Examples: 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 }, diff --git a/internal/cmd/auth_login_test.go b/internal/cmd/auth_login_test.go index 13cbdeb6..5bb4fcbd 100644 --- a/internal/cmd/auth_login_test.go +++ b/internal/cmd/auth_login_test.go @@ -205,13 +205,16 @@ 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") } @@ -258,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 diff --git a/internal/cmd/auth_logout.go b/internal/cmd/auth_logout.go index a0bf4d1a..aea76c09 100644 --- a/internal/cmd/auth_logout.go +++ b/internal/cmd/auth_logout.go @@ -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", @@ -21,10 +22,7 @@ func buildLogoutCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() revokeOAuthSession(cmd, cfg) diff --git a/internal/cmd/auth_status.go b/internal/cmd/auth_status.go index 5eb747a5..3041212e 100644 --- a/internal/cmd/auth_status.go +++ b/internal/cmd/auth_status.go @@ -19,7 +19,7 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -func buildStatusCmd() *cobra.Command { +func buildStatusCmd(app *common.App) *cobra.Command { var output string cmd := &cobra.Command{ @@ -31,8 +31,7 @@ func buildStatusCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, _, err := app.GetAll() if err != nil { if errors.Is(err, config.ErrNotLoggedIn) { return common.ExitWithCode(common.ExitAuthenticationError, config.ErrNotLoggedIn) @@ -44,7 +43,7 @@ func buildStatusCmd() *cobra.Command { ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.GetAuthInfoWithResponse(ctx) + resp, err := client.GetAuthInfoWithResponse(ctx) if err != nil { return fmt.Errorf("failed to get auth information: %w", err) } diff --git a/internal/cmd/auth_test.go b/internal/cmd/auth_test.go index 719265e6..85e9233b 100644 --- a/internal/cmd/auth_test.go +++ b/internal/cmd/auth_test.go @@ -19,7 +19,7 @@ func setupAuthTest(t *testing.T) string { // Mock the API key validation for testing originalValidator := validateAPIKey - 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-id"}}}`), authInfo) return authInfo, nil diff --git a/internal/cmd/completion_helper.go b/internal/cmd/completion_helper.go index a20cedb3..444bfaa8 100644 --- a/internal/cmd/completion_helper.go +++ b/internal/cmd/completion_helper.go @@ -15,29 +15,47 @@ import ( "github.com/timescale/tiger-cli/internal/mcp" ) -func serviceIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Service ID is always first positional argument - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp +// withAppLoad wraps a completion function, loading the config and API client +// before invoking it. The App is only loaded automatically for wrapped commands +// (see wrapCommands), not for the __complete command that drives live tab +// completion — so completions that don't need the config or client (static +// lists, subcommand and flag names) stay clear of the config file, the system +// keyring, and the network. Completion functions that do need them must be +// wrapped with this helper. +func withAppLoad(app *common.App, fn cobra.CompletionFunc) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + app.SetFlags(cmd.Flags()) + if _, _, _, err := app.Load(cmd.Context()); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return fn(cmd, args, toComplete) } +} - services, err := listServices(cmd) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } +func serviceIDCompletion(app *common.App) cobra.CompletionFunc { + return withAppLoad(app, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Service ID is always first positional argument + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } - results := make([]string, 0, len(services)) - for _, service := range services { - if service.ServiceId != nil && strings.HasPrefix(*service.ServiceId, toComplete) { - results = append(results, cobra.CompletionWithDesc(*service.ServiceId, *service.Name)) + services, err := listServices(cmd, app) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp } - } - return results, cobra.ShellCompDirectiveNoFileComp + + results := make([]string, 0, len(services)) + for _, service := range services { + if service.ServiceId != nil && strings.HasPrefix(*service.ServiceId, toComplete) { + results = append(results, cobra.CompletionWithDesc(*service.ServiceId, *service.Name)) + } + } + return results, cobra.ShellCompDirectiveNoFileComp + }) } -func listServices(cmd *cobra.Command) ([]api.Service, error) { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) +func listServices(cmd *cobra.Command, app *common.App) ([]api.Service, error) { + client, projectID, err := app.GetClient() if err != nil { return nil, err } @@ -46,7 +64,7 @@ func listServices(cmd *cobra.Command) ([]api.Service, error) { ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServicesWithResponse(ctx, cfg.ProjectID) + resp, err := client.GetServicesWithResponse(ctx, projectID) if err != nil { return nil, fmt.Errorf("failed to list services: %w", err) } @@ -73,35 +91,32 @@ func configOptionCompletion(cmd *cobra.Command, args []string, toComplete string } // mcpGetCompletion provides custom completions for the get command -func mcpGetCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Capability name is always first positional argument - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } +func mcpGetCompletion(app *common.App) cobra.CompletionFunc { + return withAppLoad(app, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Capability name is always first positional argument + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } - // Create MCP server to get capabilities - server, err := mcp.NewServer(cmd.Context(), cfg, cmd.Flags()) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - defer server.Close() + // Create MCP server to get capabilities + server, err := mcp.NewServer(cmd.Context(), app) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer server.Close() - capabilities, err := server.ListCapabilities(cmd.Context()) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } + capabilities, err := server.ListCapabilities(cmd.Context()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } - // Close the MCP server when finished - if err := server.Close(); err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } + // Close the MCP server when finished + if err := server.Close(); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } - return filterCompletionsByPrefix(capabilities.Names(), toComplete), cobra.ShellCompDirectiveNoFileComp + return filterCompletionsByPrefix(capabilities.Names(), toComplete), cobra.ShellCompDirectiveNoFileComp + }) } // filterCompletionsByPrefix filters a slice of strings to only include items diff --git a/internal/cmd/config.go b/internal/cmd/config.go index d4a88d0f..83bd31a4 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -2,19 +2,21 @@ package cmd import ( "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" ) -func buildConfigCmd() *cobra.Command { +func buildConfigCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage CLI configuration", Long: `Manage CLI configuration settings stored in ~/.config/tiger/config.yaml`, } - cmd.AddCommand(buildConfigShowCmd()) - cmd.AddCommand(buildConfigSetCmd()) - cmd.AddCommand(buildConfigUnsetCmd()) - cmd.AddCommand(buildConfigResetCmd()) + cmd.AddCommand(buildConfigShowCmd(app)) + cmd.AddCommand(buildConfigSetCmd(app)) + cmd.AddCommand(buildConfigUnsetCmd(app)) + cmd.AddCommand(buildConfigResetCmd(app)) return cmd } diff --git a/internal/cmd/config_reset.go b/internal/cmd/config_reset.go index 5b8c182b..7185058c 100644 --- a/internal/cmd/config_reset.go +++ b/internal/cmd/config_reset.go @@ -5,11 +5,11 @@ import ( "github.com/spf13/cobra" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" ) -func buildConfigResetCmd() *cobra.Command { +func buildConfigResetCmd(app *common.App) *cobra.Command { return &cobra.Command{ Use: "reset", Short: "Reset to defaults", @@ -19,10 +19,7 @@ func buildConfigResetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() if err := cfg.Reset(); err != nil { return fmt.Errorf("failed to reset config: %w", err) diff --git a/internal/cmd/config_set.go b/internal/cmd/config_set.go index 01a853c9..51939e0a 100644 --- a/internal/cmd/config_set.go +++ b/internal/cmd/config_set.go @@ -6,11 +6,11 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" ) -func buildConfigSetCmd() *cobra.Command { +func buildConfigSetCmd(app *common.App) *cobra.Command { return &cobra.Command{ Use: "set ", Short: "Set configuration value", @@ -20,10 +20,7 @@ func buildConfigSetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() key, value := args[0], args[1] if err := cfg.Set(key, value); err != nil { diff --git a/internal/cmd/config_show.go b/internal/cmd/config_show.go index e8b2b934..c4188af8 100644 --- a/internal/cmd/config_show.go +++ b/internal/cmd/config_show.go @@ -7,11 +7,12 @@ import ( "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/config" "github.com/timescale/tiger-cli/internal/util" ) -func buildConfigShowCmd() *cobra.Command { +func buildConfigShowCmd(app *common.App) *cobra.Command { var output string var noDefaults bool var withEnv bool @@ -25,10 +26,7 @@ func buildConfigShowCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() // Values are re-read free of env and CLI flags (unless --with-env // is given), so `config show -o json` reports the configured diff --git a/internal/cmd/config_unset.go b/internal/cmd/config_unset.go index d535f910..5bc88e27 100644 --- a/internal/cmd/config_unset.go +++ b/internal/cmd/config_unset.go @@ -6,11 +6,11 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" ) -func buildConfigUnsetCmd() *cobra.Command { +func buildConfigUnsetCmd(app *common.App) *cobra.Command { return &cobra.Command{ Use: "unset ", Short: "Remove configuration value", @@ -20,10 +20,7 @@ func buildConfigUnsetCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() key := args[0] if err := cfg.Unset(key); err != nil { diff --git a/internal/cmd/db.go b/internal/cmd/db.go index 6b37286c..c700c136 100644 --- a/internal/cmd/db.go +++ b/internal/cmd/db.go @@ -15,19 +15,19 @@ import ( // getServiceDetailsFunc can be overridden for testing var getServiceDetailsFunc = getServiceDetails -func buildDbCmd() *cobra.Command { +func buildDbCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "db", Short: "Database operations and management", Long: `Database-specific operations including connection management, testing, and configuration.`, } - cmd.AddCommand(buildDbConnectionStringCmd()) - cmd.AddCommand(buildDbConnectCmd()) - cmd.AddCommand(buildDbTestConnectionCmd()) - cmd.AddCommand(buildDbSavePasswordCmd()) - cmd.AddCommand(buildDbCreateCmd()) - cmd.AddCommand(buildDbSchemaCmd()) + cmd.AddCommand(buildDbConnectionStringCmd(app)) + cmd.AddCommand(buildDbConnectCmd(app)) + cmd.AddCommand(buildDbTestConnectionCmd(app)) + cmd.AddCommand(buildDbSavePasswordCmd(app)) + cmd.AddCommand(buildDbCreateCmd(app)) + cmd.AddCommand(buildDbSchemaCmd(app)) return cmd } @@ -35,8 +35,13 @@ func buildDbCmd() *cobra.Command { // lookupConnectionTarget looks up the target named by args, which may be a // primary service ID or a read replica set ID. This lets a replica ID work // anywhere a service ID does across the db connection commands. -func lookupConnectionTarget(cmd *cobra.Command, cfg *common.Config, args []string) (*common.ConnectionTarget, error) { - service, err := getServiceDetailsFunc(cmd, cfg, args) +func lookupConnectionTarget(cmd *cobra.Command, app *common.App, args []string) (*common.ConnectionTarget, error) { + service, err := getServiceDetailsFunc(cmd, app, args) + if err != nil { + return nil, err + } + + client, projectID, err := app.GetClient() if err != nil { return nil, err } @@ -45,7 +50,7 @@ func lookupConnectionTarget(cmd *cobra.Command, cfg *common.Config, args []strin // replica comes back linked to its parent, whose credentials it shares. ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - return common.ResolveConnectionTarget(ctx, cfg.Client, cfg.ProjectID, service) + return common.ResolveConnectionTarget(ctx, client, projectID, service) } // warnReplicaPooler prints the replica pooler-fallback warning to stderr, if @@ -64,9 +69,14 @@ func buildConnectionDetailsForTarget(cmd *cobra.Command, cfg *config.Config, tar } // getServiceDetails is a helper that handles common service lookup logic and returns the service details -func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { +func getServiceDetails(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return api.Service{}, err + } + // Determine service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return api.Service{}, err } @@ -76,7 +86,7 @@ func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (a ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - service, err := common.GetService(ctx, cfg.Client, cfg.ProjectID, serviceID) + service, err := common.GetService(ctx, client, projectID, serviceID) if err != nil { return api.Service{}, err } diff --git a/internal/cmd/db_connect.go b/internal/cmd/db_connect.go index 1e849ca6..ef78485c 100644 --- a/internal/cmd/db_connect.go +++ b/internal/cmd/db_connect.go @@ -21,7 +21,7 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -func buildDbConnectCmd() *cobra.Command { +func buildDbConnectCmd(app *common.App) *cobra.Command { var dbConnectPooled bool var dbConnectRole string var dbConnectReadOnly bool @@ -88,11 +88,11 @@ Examples: tiger db connect svc-12345 -- --single-transaction --quiet tiger db psql svc-12345 -- -c "SELECT version();" --no-psqlrc`, Args: cobra.ArbitraryArgs, - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, _, _, err := app.GetAll() if err != nil { return err } @@ -100,7 +100,7 @@ Examples: // Separate service ID from additional psql flags serviceArgs, psqlFlags := separateServiceAndPsqlArgs(cmd, args) - target, err := lookupConnectionTarget(cmd, cfg, serviceArgs) + target, err := lookupConnectionTarget(cmd, app, serviceArgs) if err != nil { return err } @@ -119,7 +119,7 @@ Examples: // Connects straight to a replica named by ID, or offers the interactive // replica menu for a primary. Returns nil details if the user cancels. - details, err := selectConnection(cmd.Context(), cmd, cfg, target, opts, dbConnectNoReplicaPrompt) + details, err := selectConnection(cmd.Context(), cmd, app, target, opts, dbConnectNoReplicaPrompt) if err != nil { return err } @@ -129,7 +129,7 @@ Examples: // Read replicas share the primary's credentials, so password storage // and recovery always operate on the credential service. - return connectWithPasswordMenu(cmd.Context(), cmd, cfg, target.CredentialService, details, psqlPath, psqlFlags) + return connectWithPasswordMenu(cmd.Context(), cmd, app, target.CredentialService, details, psqlPath, psqlFlags) }, } @@ -172,18 +172,23 @@ func separateServiceAndPsqlArgs(cmd ArgsLenAtDashProvider, args []string) ([]str func selectConnection( ctx context.Context, cmd *cobra.Command, - cfg *common.Config, + app *common.App, target *common.ConnectionTarget, opts common.ConnectionDetailsOptions, noReplicaPrompt bool, ) (*common.ConnectionDetails, error) { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return nil, err + } + // chosen is what we connect to; the menu below may replace it with a replica. chosen := target // Offer the replica menu only for a primary on an interactive terminal. if !target.IsReplica && !noReplicaPrompt && checkStdinIsTTY() { primary := target.ConnectionService - replicas, err := fetchReplicaSets(ctx, cfg.Client, cfg.ProjectID, util.DerefStr(primary.ServiceId)) + replicas, err := fetchReplicaSets(ctx, client, projectID, util.DerefStr(primary.ServiceId)) if err != nil { // Don't block the connection if we can't list replicas. fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not list read replicas: %v\n", err) @@ -201,10 +206,11 @@ func selectConnection( } } - details, err := buildConnectionDetailsForTarget(cmd, cfg.Config, chosen, opts) + details, err := buildConnectionDetailsForTarget(cmd, cfg, chosen, opts) if err != nil { return nil, err } + if chosen.IsReplica { fmt.Fprintf(cmd.ErrOrStderr(), "Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name)) } @@ -223,7 +229,7 @@ func connectableReplicas(replicas []api.ReadReplicaSet) []api.ReadReplicaSet { } // fetchReplicaSets retrieves the read replica sets for a service. -func fetchReplicaSets(ctx context.Context, client *api.ClientWithResponses, projectID, serviceID string) ([]api.ReadReplicaSet, error) { +func fetchReplicaSets(ctx context.Context, client api.ClientWithResponsesInterface, projectID, serviceID string) ([]api.ReadReplicaSet, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -359,14 +365,19 @@ func selectConnectTargetOption(out io.Writer, primary api.Service, replicas []ap func connectWithPasswordMenu( ctx context.Context, cmd *cobra.Command, - cfg *common.Config, + app *common.App, service api.Service, details *common.ConnectionDetails, psqlPath string, psqlFlags []string, ) error { + cfg, client, _, err := app.GetAll() + if err != nil { + return err + } + // Interactive mode: Get stored password (if any) - storage := common.GetPasswordStorage(cfg.Config) + storage := common.GetPasswordStorage(cfg) storedPassword, err := storage.Get(service, details.Role) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve stored password: %v\n", err) @@ -376,7 +387,7 @@ func connectWithPasswordMenu( err = testConnectionWithPassword(ctx, details, storedPassword) if err == nil { // Password works, launch psql - return launchPsql(cfg.Config, details, psqlPath, psqlFlags, service, cmd) + return launchPsql(cfg, details, psqlPath, psqlFlags, service, cmd) } // Check if it's an auth error @@ -417,7 +428,7 @@ func connectWithPasswordMenu( // Test, save, and launch details.Password = password - if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, cfg.Config, details, psqlPath, psqlFlags, service); err != nil { + if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, cfg, details, psqlPath, psqlFlags, service); err != nil { if isAuthenticationError(err) { fmt.Fprintf(cmd.ErrOrStderr(), "Password incorrect. Please try again.\n\n") continue @@ -428,7 +439,7 @@ func connectWithPasswordMenu( case optionResetPassword: // Prompt and reset - password, err := promptAndResetPassword(ctx, cfg.Config, cmd.ErrOrStderr(), cfg.Client, service, details.Role) + password, err := promptAndResetPassword(ctx, cfg, cmd.ErrOrStderr(), client, service, details.Role) if err != nil { if errors.Is(err, context.Canceled) { return nil // user cancelled @@ -439,7 +450,7 @@ func connectWithPasswordMenu( fmt.Fprintf(cmd.ErrOrStderr(), "✅ Master password for '%s' user updated successfully\n", details.Role) // Launch psql (password is now in storage) details.Password = password - return launchPsql(cfg.Config, details, psqlPath, psqlFlags, service, cmd) + return launchPsql(cfg, details, psqlPath, psqlFlags, service, cmd) case optionExit: return nil diff --git a/internal/cmd/db_connect_test.go b/internal/cmd/db_connect_test.go index 56a132bd..c60471ef 100644 --- a/internal/cmd/db_connect_test.go +++ b/internal/cmd/db_connect_test.go @@ -306,8 +306,8 @@ func TestSelectConnection_NoReplicasSkipsPrompt(t *testing.T) { cmd.SetErr(io.Discard) target := &common.ConnectionTarget{ConnectionService: primary, CredentialService: primary} - cfg := &common.Config{Config: testConfig(t), Client: client, ProjectID: "proj-1"} - details, err := selectConnection(context.Background(), cmd, cfg, target, + app := newTestApp(t, client, "proj-1") + details, err := selectConnection(context.Background(), cmd, app, target, common.ConnectionDetailsOptions{Role: "tsdbadmin"}, false /*noReplicaPrompt*/) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/cmd/db_connection_string.go b/internal/cmd/db_connection_string.go index 57a59b3c..8f87c11c 100644 --- a/internal/cmd/db_connection_string.go +++ b/internal/cmd/db_connection_string.go @@ -8,7 +8,7 @@ import ( "github.com/timescale/tiger-cli/internal/common" ) -func buildDbConnectionStringCmd() *cobra.Command { +func buildDbConnectionStringCmd(app *common.App) *cobra.Command { var dbConnectionStringPooled bool var dbConnectionStringRole string var dbConnectionStringWithPassword bool @@ -53,20 +53,20 @@ Examples: # Get connection string with password included (less secure) tiger db connection-string svc-12345 --with-password`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, _, _, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - target, err := lookupConnectionTarget(cmd, cfg, args) + target, err := lookupConnectionTarget(cmd, app, args) if err != nil { return err } - details, err := buildConnectionDetailsForTarget(cmd, cfg.Config, target, common.ConnectionDetailsOptions{ + details, err := buildConnectionDetailsForTarget(cmd, cfg, target, common.ConnectionDetailsOptions{ Pooled: dbConnectionStringPooled, Role: dbConnectionStringRole, WithPassword: dbConnectionStringWithPassword, diff --git a/internal/cmd/db_connection_string_test.go b/internal/cmd/db_connection_string_test.go index 4302cae9..cc958460 100644 --- a/internal/cmd/db_connection_string_test.go +++ b/internal/cmd/db_connection_string_test.go @@ -206,7 +206,7 @@ func TestDBConnectionString_ReadOnlyConfig(t *testing.T) { mockTestPAT(t) originalGetServiceDetails := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { host := "test-host.com" port := 5432 return api.Service{ diff --git a/internal/cmd/db_create.go b/internal/cmd/db_create.go index 3f8f18ba..2938e1db 100644 --- a/internal/cmd/db_create.go +++ b/internal/cmd/db_create.go @@ -2,16 +2,18 @@ package cmd import ( "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" ) -func buildDbCreateCmd() *cobra.Command { +func buildDbCreateCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create database resources", Long: `Create database resources such as roles, databases, and extensions.`, } - cmd.AddCommand(buildDbCreateRoleCmd()) + cmd.AddCommand(buildDbCreateRoleCmd(app)) return cmd } diff --git a/internal/cmd/db_create_role.go b/internal/cmd/db_create_role.go index 17886b58..0bc945e7 100644 --- a/internal/cmd/db_create_role.go +++ b/internal/cmd/db_create_role.go @@ -14,7 +14,7 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -func buildDbCreateRoleCmd() *cobra.Command { +func buildDbCreateRoleCmd(app *common.App) *cobra.Command { var roleName string var readOnly bool var fromRoles []string @@ -83,21 +83,21 @@ PostgreSQL Configuration Parameters That May Be Set: - statement_timeout: Set when --statement-timeout flag is provided (kills queries that exceed the specified duration, in milliseconds)`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { // Validate arguments if roleName == "" { return fmt.Errorf("--name is required") } - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, _, _, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } // Get service details - service, err := getServiceDetailsFunc(cmd, cfg, args) + service, err := getServiceDetailsFunc(cmd, app, args) if err != nil { return err } @@ -115,7 +115,7 @@ PostgreSQL Configuration Parameters That May Be Set: } // Build connection string - details, err := common.GetConnectionDetails(cfg.Config, service, common.ConnectionDetailsOptions{ + details, err := common.GetConnectionDetails(cfg, service, common.ConnectionDetailsOptions{ Pooled: false, Role: "tsdbadmin", // Use admin role to create new roles WithPassword: true, @@ -140,7 +140,7 @@ PostgreSQL Configuration Parameters That May Be Set: } // Save password to storage with the new role name - result, err := common.SavePasswordWithResult(cfg.Config, service, rolePassword, roleName) + result, err := common.SavePasswordWithResult(cfg, service, rolePassword, roleName) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message) } else if !result.Success { diff --git a/internal/cmd/db_create_role_test.go b/internal/cmd/db_create_role_test.go index 0d1ab6aa..2dbba687 100644 --- a/internal/cmd/db_create_role_test.go +++ b/internal/cmd/db_create_role_test.go @@ -32,7 +32,7 @@ func TestDBCreateRole_ReadReplicaRejected(t *testing.T) { ForkedFrom: &api.ForkSpec{IsStandby: util.Ptr(true), ServiceId: util.Ptr("svcprimary")}, } orig := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return standby, nil } defer func() { getServiceDetailsFunc = orig }() diff --git a/internal/cmd/db_save_password.go b/internal/cmd/db_save_password.go index 82466880..fd8740fe 100644 --- a/internal/cmd/db_save_password.go +++ b/internal/cmd/db_save_password.go @@ -9,7 +9,7 @@ import ( "github.com/timescale/tiger-cli/internal/common" ) -func buildDbSavePasswordCmd() *cobra.Command { +func buildDbSavePasswordCmd(app *common.App) *cobra.Command { var dbSavePasswordRole string var dbSavePasswordValue string @@ -44,9 +44,9 @@ Examples: # Save to specific storage location tiger db save-password svc-12345 --password=your-password --password-storage pgpass`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, _, _, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err @@ -55,7 +55,7 @@ Examples: // Resolve the target so a read replica id stores the password against // its parent primary: replicas share the primary's credentials, and // connect/test-connection look the password up against the primary. - target, err := lookupConnectionTarget(cmd, cfg, args) + target, err := lookupConnectionTarget(cmd, app, args) if err != nil { return err } @@ -94,7 +94,7 @@ Examples: } // Save password using configured storage - storage := common.GetPasswordStorage(cfg.Config) + storage := common.GetPasswordStorage(cfg) if err := storage.Save(service, passwordToSave, dbSavePasswordRole); err != nil { return fmt.Errorf("failed to save password: %w", err) } diff --git a/internal/cmd/db_save_password_test.go b/internal/cmd/db_save_password_test.go index 1d582135..7cdc944e 100644 --- a/internal/cmd/db_save_password_test.go +++ b/internal/cmd/db_save_password_test.go @@ -50,7 +50,7 @@ func TestDBSavePassword_ExplicitPassword(t *testing.T) { originalGetServiceDetails := getServiceDetailsFunc mockTestPAT(t) - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -138,7 +138,7 @@ func TestDBSavePassword_ReplicaResolvesToParent(t *testing.T) { mockTestPAT(t) originalGetServiceDetails := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return replica, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -202,7 +202,7 @@ func TestDBSavePassword_EnvironmentVariable(t *testing.T) { originalGetServiceDetails := getServiceDetailsFunc mockTestPAT(t) - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -270,7 +270,7 @@ func TestDBSavePassword_InteractivePrompt(t *testing.T) { originalGetServiceDetails := getServiceDetailsFunc mockTestPAT(t) - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -347,7 +347,7 @@ func TestDBSavePassword_InteractivePromptEmpty(t *testing.T) { originalGetServiceDetails := getServiceDetailsFunc mockTestPAT(t) - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -415,7 +415,7 @@ func TestDBSavePassword_CustomRole(t *testing.T) { originalGetServiceDetails := getServiceDetailsFunc mockTestPAT(t) - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -543,7 +543,7 @@ func TestDBSavePassword_PgpassStorage(t *testing.T) { originalGetServiceDetails := getServiceDetailsFunc mockTestPAT(t) - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() diff --git a/internal/cmd/db_schema.go b/internal/cmd/db_schema.go index 4701716a..41834795 100644 --- a/internal/cmd/db_schema.go +++ b/internal/cmd/db_schema.go @@ -8,7 +8,7 @@ import ( "github.com/timescale/tiger-cli/internal/common" ) -func buildDbSchemaCmd() *cobra.Command { +func buildDbSchemaCmd(app *common.App) *cobra.Command { var dbSchemaSchema string var dbSchemaInternal bool var dbSchemaDefinitions bool @@ -49,22 +49,22 @@ Examples: # Include catalog, TimescaleDB internals, and extension-owned objects tiger db schema svc-12345 --internal`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, _, _, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - target, err := lookupConnectionTarget(cmd, cfg, args) + target, err := lookupConnectionTarget(cmd, app, args) if err != nil { return err } warnReplicaPooler(cmd, target, dbSchemaPooled) - schema, err := common.FetchServiceSchema(cmd.Context(), cfg.Config, target, dbSchemaRole, dbSchemaPooled, common.SchemaOptions{ + schema, err := common.FetchServiceSchema(cmd.Context(), cfg, target, dbSchemaRole, dbSchemaPooled, common.SchemaOptions{ Schema: dbSchemaSchema, IncludeInternal: dbSchemaInternal, IncludeDefinitions: dbSchemaDefinitions, diff --git a/internal/cmd/db_schema_test.go b/internal/cmd/db_schema_test.go index af3ff415..48adf404 100644 --- a/internal/cmd/db_schema_test.go +++ b/internal/cmd/db_schema_test.go @@ -60,7 +60,7 @@ func TestDBSchema_NoAuth(t *testing.T) { func withMockService(t *testing.T, service api.Service) { t.Helper() original := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return service, nil } t.Cleanup(func() { getServiceDetailsFunc = original }) diff --git a/internal/cmd/db_test.go b/internal/cmd/db_test.go index d3893011..af7b3533 100644 --- a/internal/cmd/db_test.go +++ b/internal/cmd/db_test.go @@ -65,9 +65,9 @@ func executeDBCommand(ctx context.Context, args ...string) (string, error) { return buf.String(), err } -// serviceClientConfig builds a Config whose client serves the getService -// endpoint from the given services keyed by ID (404 when absent). -func serviceClientConfig(t *testing.T, services map[string]api.Service) *common.Config { +// serviceClientApp builds an App whose client serves the getService endpoint +// from the given services keyed by ID (404 when absent). +func serviceClientApp(t *testing.T, services map[string]api.Service) *common.App { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -87,7 +87,7 @@ func serviceClientConfig(t *testing.T, services map[string]api.Service) *common. if err != nil { t.Fatalf("failed to build client: %v", err) } - return &common.Config{Config: &config.Config{}, ProjectID: "proj1", Client: client} + return newTestApp(t, client, "proj1") } func primarySvc() api.Service { @@ -121,16 +121,16 @@ func standbySvc() api.Service { // target (connect == credential, no parent fetch, so no client needed). func TestLookupConnectionTarget_Primary(t *testing.T) { orig := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return primarySvc(), nil } defer func() { getServiceDetailsFunc = orig }() - cfg := &common.Config{Config: &config.Config{}, ProjectID: "proj1"} + app := newTestApp(t, nil, "proj1") cmd := &cobra.Command{} cmd.SetContext(context.Background()) - target, err := lookupConnectionTarget(cmd, cfg, []string{"svcprimary"}) + target, err := lookupConnectionTarget(cmd, app, []string{"svcprimary"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -146,16 +146,16 @@ func TestLookupConnectionTarget_Primary(t *testing.T) { // but resolves credentials against the parent (fetched via the client). func TestLookupConnectionTarget_Replica(t *testing.T) { orig := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return standbySvc(), nil } defer func() { getServiceDetailsFunc = orig }() - cfg := serviceClientConfig(t, map[string]api.Service{"svcprimary": primarySvc()}) + app := serviceClientApp(t, map[string]api.Service{"svcprimary": primarySvc()}) cmd := &cobra.Command{} cmd.SetContext(context.Background()) - target, err := lookupConnectionTarget(cmd, cfg, []string{"rep1234567"}) + target, err := lookupConnectionTarget(cmd, app, []string{"rep1234567"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -173,16 +173,16 @@ func TestLookupConnectionTarget_Replica(t *testing.T) { // TestLookupConnectionTarget_LookupError: a service-lookup failure is surfaced. func TestLookupConnectionTarget_LookupError(t *testing.T) { orig := getServiceDetailsFunc - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { + getServiceDetailsFunc = func(cmd *cobra.Command, app *common.App, args []string) (api.Service, error) { return api.Service{}, fmt.Errorf("lookup failed") } defer func() { getServiceDetailsFunc = orig }() - cfg := &common.Config{Config: &config.Config{}, ProjectID: "proj1"} + app := newTestApp(t, nil, "proj1") cmd := &cobra.Command{} cmd.SetContext(context.Background()) - if _, err := lookupConnectionTarget(cmd, cfg, []string{"x"}); err == nil { + if _, err := lookupConnectionTarget(cmd, app, []string{"x"}); err == nil { t.Fatal("expected an error, got nil") } } diff --git a/internal/cmd/db_test_connection.go b/internal/cmd/db_test_connection.go index 247558cc..ae1c8d29 100644 --- a/internal/cmd/db_test_connection.go +++ b/internal/cmd/db_test_connection.go @@ -13,7 +13,7 @@ import ( "github.com/timescale/tiger-cli/internal/common" ) -func buildDbTestConnectionCmd() *cobra.Command { +func buildDbTestConnectionCmd(app *common.App) *cobra.Command { var dbTestConnectionTimeout time.Duration var dbTestConnectionPooled bool var dbTestConnectionRole string @@ -51,21 +51,21 @@ Examples: # Test connection with no timeout (wait indefinitely) tiger db test-connection svc-12345 --timeout 0`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, _, _, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return common.ExitWithCode(common.ExitInvalidParameters, err) } - target, err := lookupConnectionTarget(cmd, cfg, args) + target, err := lookupConnectionTarget(cmd, app, args) if err != nil { return common.ExitWithCode(common.ExitInvalidParameters, err) } // Build connection string for testing with password (if available) - details, err := buildConnectionDetailsForTarget(cmd, cfg.Config, target, common.ConnectionDetailsOptions{ + details, err := buildConnectionDetailsForTarget(cmd, cfg, target, common.ConnectionDetailsOptions{ Pooled: dbTestConnectionPooled, Role: dbTestConnectionRole, WithPassword: true, diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go index 4c23493a..40d87ca1 100644 --- a/internal/cmd/main_test.go +++ b/internal/cmd/main_test.go @@ -1,9 +1,13 @@ package cmd import ( + "context" "os" "testing" + "github.com/spf13/pflag" + + "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/config" ) @@ -40,18 +44,60 @@ func setupTestCommand(t *testing.T) (string, func()) { return tmpDir, cleanup } -// testConfig loads the config for the test's config directory, which the setup -// helpers point at via TIGER_CONFIG_DIR. Use it where a test needs the config -// itself (credential storage, password storage) rather than running a command. +// testConfigDir returns the config directory the test is using: the one its setup +// helper exported via TIGER_CONFIG_DIR, or an isolated empty directory when the +// test set none — so a config never leaks in from the machine running the tests. +// Tests that exercise credential storage need the former, since credentials live +// in this directory (the auth setup helpers set it). +func testConfigDir(t *testing.T) string { + t.Helper() + if dir := os.Getenv("TIGER_CONFIG_DIR"); dir != "" { + return dir + } + return t.TempDir() +} + +// testFlags returns a flag set shaped like a command's, with --config-dir pointed +// at dir, so config.Load resolves the same way it would for a real command. +func testFlags(t *testing.T, dir string) *pflag.FlagSet { + t.Helper() + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("config-dir", "", "config directory") + if err := flags.Set("config-dir", dir); err != nil { + t.Fatalf("Failed to set config-dir flag: %v", err) + } + return flags +} + +// testConfig loads the config for the test's config directory. Use it where a +// test needs the config itself (credential storage, password storage) rather than +// running a command. func testConfig(t *testing.T) *config.Config { t.Helper() - cfg, err := config.Load(nil) + cfg, err := config.Load(testFlags(t, testConfigDir(t))) if err != nil { t.Fatalf("Failed to load test config: %v", err) } return cfg } +// newTestApp returns an App loaded against the test's config directory, with API +// client creation stubbed out to return the given client. Load still runs, so +// config resolution and flag precedence go through the real code path — only the +// client is injected (see common.App.SetClientFactory). +func newTestApp(t *testing.T, client api.ClientWithResponsesInterface, projectID string) *common.App { + t.Helper() + app := &common.App{} + app.SetFlags(testFlags(t, testConfigDir(t))) + app.SetClientFactory(func(context.Context, *config.Config) (api.ClientWithResponsesInterface, string, error) { + return client, projectID, nil + }) + if _, _, _, err := app.Load(t.Context()); err != nil { + t.Fatalf("Failed to load test app: %v", err) + } + return app +} + // mockStoredCredentials overrides the common.GetStoredCredentials seam for the // duration of the test, restoring the original automatically via t.Cleanup. func mockStoredCredentials(t *testing.T, creds *config.Credentials, err error) { diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 9e840fdf..d458cf1e 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -2,10 +2,12 @@ package cmd import ( "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" ) // buildMCPCmd creates the MCP server command with subcommands -func buildMCPCmd() *cobra.Command { +func buildMCPCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "mcp", Short: "Tiger Model Context Protocol (MCP) server", @@ -28,10 +30,10 @@ Use 'tiger mcp start' to launch the MCP server.`, } // Add subcommands - cmd.AddCommand(buildMCPInstallCmd()) - cmd.AddCommand(buildMCPStartCmd()) - cmd.AddCommand(buildMCPListCmd()) - cmd.AddCommand(buildMCPGetCmd()) + cmd.AddCommand(buildMCPInstallCmd(app)) + cmd.AddCommand(buildMCPStartCmd(app)) + cmd.AddCommand(buildMCPListCmd(app)) + cmd.AddCommand(buildMCPGetCmd(app)) return cmd } diff --git a/internal/cmd/mcp_get.go b/internal/cmd/mcp_get.go index 535157eb..7c9e5483 100644 --- a/internal/cmd/mcp_get.go +++ b/internal/cmd/mcp_get.go @@ -11,13 +11,13 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/mcp" "github.com/timescale/tiger-cli/internal/util" ) // buildMCPGetCmd creates the get subcommand for displaying detailed info on a specific MCP capability -func buildMCPGetCmd() *cobra.Command { +func buildMCPGetCmd(app *common.App) *cobra.Command { var outputFormat string cmd := &cobra.Command{ @@ -39,20 +39,16 @@ Examples: # Get details as YAML tiger mcp get service_create -o yaml`, Args: cobra.ExactArgs(1), - ValidArgsFunction: mcpGetCompletion, + ValidArgsFunction: mcpGetCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { capabilityName := args[0] cmd.SilenceUsage = true - // Get config - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() // Create MCP server - server, err := mcp.NewServer(cmd.Context(), cfg, cmd.Flags()) + server, err := mcp.NewServer(cmd.Context(), app) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_install.go b/internal/cmd/mcp_install.go index 2458ac4e..3dd32149 100644 --- a/internal/cmd/mcp_install.go +++ b/internal/cmd/mcp_install.go @@ -19,13 +19,14 @@ import ( "github.com/tailscale/hujson" "go.uber.org/zap" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" "github.com/timescale/tiger-cli/internal/mcp" "github.com/timescale/tiger-cli/internal/util" ) // buildMCPInstallCmd creates the install subcommand for configuring editors -func buildMCPInstallCmd() *cobra.Command { +func buildMCPInstallCmd(app *common.App) *cobra.Command { var noBackup bool var configPath string diff --git a/internal/cmd/mcp_list.go b/internal/cmd/mcp_list.go index e8b0acf8..9b684ec4 100644 --- a/internal/cmd/mcp_list.go +++ b/internal/cmd/mcp_list.go @@ -7,13 +7,13 @@ import ( "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/mcp" "github.com/timescale/tiger-cli/internal/util" ) // buildMCPListCmd creates the list subcommand for displaying available MCP capabilities -func buildMCPListCmd() *cobra.Command { +func buildMCPListCmd(app *common.App) *cobra.Command { var outputFormat string cmd := &cobra.Command{ @@ -37,14 +37,10 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - // Get config - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cfg := app.GetConfig() // Create MCP server - server, err := mcp.NewServer(cmd.Context(), cfg, cmd.Flags()) + server, err := mcp.NewServer(cmd.Context(), app) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_start.go b/internal/cmd/mcp_start.go index 8fff59cd..8e531aff 100644 --- a/internal/cmd/mcp_start.go +++ b/internal/cmd/mcp_start.go @@ -6,16 +6,15 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/spf13/pflag" "go.uber.org/zap" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" "github.com/timescale/tiger-cli/internal/mcp" ) // buildMCPStartCmd creates the start subcommand with transport options -func buildMCPStartCmd() *cobra.Command { +func buildMCPStartCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "start", Short: "Start the Tiger MCP server", @@ -38,28 +37,23 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { // Default behavior when no subcommand is specified - use stdio cmd.SilenceUsage = true - return startStdioServer(cmd.Context(), cmd.Flags()) + return startStdioServer(cmd.Context(), app) }, } // Add transport subcommands - cmd.AddCommand(buildMCPStdioCmd()) - cmd.AddCommand(buildMCPHTTPCmd()) + cmd.AddCommand(buildMCPStdioCmd(app)) + cmd.AddCommand(buildMCPHTTPCmd(app)) return cmd } // startStdioServer starts the MCP server with stdio transport -func startStdioServer(ctx context.Context, flags *pflag.FlagSet) error { +func startStdioServer(ctx context.Context, app *common.App) error { logging.Info("Starting Tiger MCP server", zap.String("transport", "stdio")) - cfg, err := config.Load(flags) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - // Create MCP server - server, err := mcp.NewServer(ctx, cfg, flags) + server, err := mcp.NewServer(ctx, app) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_start_http.go b/internal/cmd/mcp_start_http.go index 1ecc574c..5e82097b 100644 --- a/internal/cmd/mcp_start_http.go +++ b/internal/cmd/mcp_start_http.go @@ -7,16 +7,15 @@ import ( "net/http" "github.com/spf13/cobra" - "github.com/spf13/pflag" "go.uber.org/zap" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" "github.com/timescale/tiger-cli/internal/mcp" ) // buildMCPHTTPCmd creates the http subcommand with port/host flags -func buildMCPHTTPCmd() *cobra.Command { +func buildMCPHTTPCmd(app *common.App) *cobra.Command { var httpPort int var httpHost string @@ -43,7 +42,7 @@ Examples: ValidArgsFunction: cobra.NoFileCompletions, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return startHTTPServer(cmd.Context(), cmd.Flags(), httpHost, httpPort) + return startHTTPServer(cmd.Context(), app, httpHost, httpPort) }, } @@ -55,16 +54,11 @@ Examples: } // startHTTPServer starts the MCP server with HTTP transport -func startHTTPServer(ctx context.Context, flags *pflag.FlagSet, host string, port int) error { +func startHTTPServer(ctx context.Context, app *common.App, host string, port int) error { logging.Info("Starting Tiger MCP server", zap.String("transport", "http")) - cfg, err := config.Load(flags) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - // Create MCP server - server, err := mcp.NewServer(ctx, cfg, flags) + server, err := mcp.NewServer(ctx, app) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/internal/cmd/mcp_start_stdio.go b/internal/cmd/mcp_start_stdio.go index 475d6c37..f21f02e1 100644 --- a/internal/cmd/mcp_start_stdio.go +++ b/internal/cmd/mcp_start_stdio.go @@ -2,10 +2,12 @@ package cmd import ( "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" ) // buildMCPStdioCmd creates the stdio subcommand -func buildMCPStdioCmd() *cobra.Command { +func buildMCPStdioCmd(app *common.App) *cobra.Command { return &cobra.Command{ Use: "stdio", Short: "Start MCP server with stdio transport", @@ -18,7 +20,7 @@ Examples: ValidArgsFunction: cobra.NoFileCompletions, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return startStdioServer(cmd.Context(), cmd.Flags()) + return startStdioServer(cmd.Context(), app) }, } } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ab367c7e..afe708ee 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -28,19 +28,18 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) { // CLAUDE.md's "Experimental Feature Gating" section. experimental, _ := strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL")) + app := &common.App{ + Experimental: experimental, + } + var configDir string var debug bool var serviceID string - var analytics bool + var analyticsEnabled bool var passwordStorage string var skipUpdateCheck bool var colorFlag bool - // versionCheckCh receives the result of the background update check started - // in PersistentPreRunE and drained in PersistentPostRunE. nil when no check - // was launched (disabled, non-interactive, CI, or --skip-update-check). - var versionCheckCh chan *version.CheckResult - cmd := &cobra.Command{ Use: "tiger", Short: "Tiger CLI - Tiger Cloud Platform command-line interface", @@ -53,119 +52,86 @@ To get started, run: tiger auth login `, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - cmd.SetContext(ctx) - - // Load the config for the command being run. cmd.Flags() includes - // the persistent flags inherited from parents, so the flags in - // config.Load's binding table take precedence over env and file. - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - if err := logging.Init(cfg.Debug); err != nil { - return fmt.Errorf("failed to initialize logging: %w", err) - } - - logging.Debug("CLI initialized", - zap.String("config_dir", cfg.ConfigDir), - zap.String("output", cfg.Output), - zap.Bool("debug", cfg.Debug), - ) - - if !cfg.Color { - color.NoColor = true - } - - // Kick off a background check for a newer release so the network - // fetch overlaps with the command's actual work; the result is - // printed in PersistentPostRunE. Gated to interactive, non-CI - // terminals. `version --check` runs its own synchronous check, and - // `upgrade` is excluded because it performs its own check. - isVersionCheckCmd := cmd.Name() == "version" && cmd.Flag("check") != nil && cmd.Flag("check").Changed - isUpgradeCmd := cmd.Name() == "upgrade" - if cfg.VersionCheck && !skipUpdateCheck && !isVersionCheckCmd && !isUpgradeCmd && - !util.IsCI() && util.IsTerminal(cmd.ErrOrStderr()) { - versionCheckCh = make(chan *version.CheckResult, 1) - go func() { - result, err := version.CheckForUpdate(cfg) - if err != nil { - // A failed check (e.g. offline) shouldn't spam a warning - // on every command; surface it only in debug logs. - logging.Debug("background version check failed", zap.Error(err)) - versionCheckCh <- nil - return - } - versionCheckCh <- result - }() - } - - return nil - }, - PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Print the result of the background check started in - // PersistentPreRunE, if one was launched. Re-check cfg.VersionCheck - // in case the command itself toggled it off (e.g. - // `tiger config set version_check false`). - if versionCheckCh != nil && cfg.VersionCheck { - output := cmd.ErrOrStderr() - version.PrintUpdateWarning(<-versionCheckCh, cfg, &output) - } - - logging.Sync() - return nil - }, } + // Every command runs with this context — cobra copies it onto the command it + // executes — so handlers can use cmd.Context() for cancellation. + cmd.SetContext(ctx) + // Add persistent flags cmd.PersistentFlags().StringVar(&configDir, "config-dir", config.GetDefaultConfigDir(), "config directory") cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") cmd.PersistentFlags().StringVar(&serviceID, "service-id", "", "service ID") - cmd.PersistentFlags().BoolVar(&analytics, "analytics", true, "enable/disable usage analytics") + cmd.PersistentFlags().BoolVar(&analyticsEnabled, "analytics", true, "enable/disable usage analytics") cmd.PersistentFlags().StringVar(&passwordStorage, "password-storage", config.DefaultPasswordStorage, "password storage method (keyring, pgpass, none)") cmd.PersistentFlags().BoolVar(&skipUpdateCheck, "skip-update-check", false, "skip checking for updates on startup") cmd.PersistentFlags().BoolVar(&colorFlag, "color", true, "enable colored output") // Add all subcommands - cmd.AddCommand(buildVersionCmd()) - cmd.AddCommand(buildUpgradeCmd()) - cmd.AddCommand(buildConfigCmd()) - cmd.AddCommand(buildAuthCmd()) - cmd.AddCommand(buildServiceCmd(experimental)) - cmd.AddCommand(buildDbCmd()) - cmd.AddCommand(buildMCPCmd()) + cmd.AddCommand(buildVersionCmd(app)) + cmd.AddCommand(buildUpgradeCmd(app)) + cmd.AddCommand(buildConfigCmd(app)) + cmd.AddCommand(buildAuthCmd(app)) + cmd.AddCommand(buildServiceCmd(app)) + cmd.AddCommand(buildDbCmd(app)) + cmd.AddCommand(buildMCPCmd(app)) - wrapCommandsWithAnalytics(cmd) + wrapCommands(cmd, app, &skipUpdateCheck) return cmd, nil } -func wrapCommandsWithAnalytics(cmd *cobra.Command) { +// wrapCommands recursively wraps the RunE of every command in the tree rooted at +// cmd with the shared per-invocation lifecycle: loading the config and API +// client, initializing logging, configuring color output, checking for a newer +// release, and tracking analytics. +// +// Commands added to the tree after this runs (cobra's built-in help, completion, +// and __complete commands) are not wrapped and so skip the load entirely, which +// keeps `tiger --help` and tab completion away from the config file, the system +// keyring, and the network. Completion functions that do need the config or +// client load on demand via withAppLoad. Group commands (`tiger service`) have no +// RunE of their own and only print help, so they're skipped as well. +func wrapCommands(cmd *cobra.Command, app *common.App, skipUpdateCheck *bool) { // Wrap this command's RunE if it exists if cmd.RunE != nil { originalRunE := cmd.RunE cmd.RunE = func(c *cobra.Command, args []string) (runErr error) { - start := time.Now() + // Load the config and API client once for the whole invocation. + // c.Flags() carries the persistent flags inherited from parents, so + // flags take precedence over env vars and the config file. + app.SetFlags(c.Flags()) + cfg, _, _, err := app.Load(c.Context()) + if err != nil { + return err + } + + if err := logging.Init(cfg.Debug); err != nil { + return fmt.Errorf("failed to initialize logging: %w", err) + } + defer logging.Sync() + logging.Debug("CLI initialized", + zap.String("config_dir", cfg.ConfigDir), + zap.String("output", cfg.Output), + zap.Bool("debug", cfg.Debug), + ) + + if !cfg.Color { + color.NoColor = true + } + + // Check for a newer release in the background, printing the result + // after the command's own output. + defer versionCheck(c, cfg, *skipUpdateCheck)() + + // Track analytics. The config and client are re-read from the App so + // changes the command made are reflected: `tiger config set analytics + // false` sends no event, and `tiger auth login` is attributed to the + // credentials it just stored. + start := time.Now() defer func() { - // Reload config after command to account for config changes - // during command (e.g. `tiger config set analytics false` - // should not result in an analytics event being sent). - cfg, err := config.Load(c.Flags()) - if err != nil { - return - } - - // Reload credentials after command to account for credentials - // changes during command (e.g. `tiger auth login` should - // record an analytics event). - client, projectID, _ := common.NewAPIClient(cmd.Context(), cfg) + cfg, client, projectID := app.TryGetAll() a := analytics.New(cfg, client, projectID) a.Track(fmt.Sprintf("Run %s", c.CommandPath()), analytics.Property("args", args), // NOTE: Safe right now, but might need allow-list in the future if some args end up containing sensitive info @@ -181,7 +147,50 @@ func wrapCommandsWithAnalytics(cmd *cobra.Command) { // Recursively wrap all children for _, child := range cmd.Commands() { - wrapCommandsWithAnalytics(child) + wrapCommands(child, app, skipUpdateCheck) + } +} + +// versionCheck starts a background check for a newer release and returns the +// function that prints the result. Deferring the returned function lets the +// network fetch overlap with the command's own work. +// +// The check is limited to interactive, non-CI terminals. `tiger version --check` +// runs its own synchronous check and `tiger upgrade` performs its own version +// comparison, so both are excluded to avoid a duplicate notice. +func versionCheck(cmd *cobra.Command, cfg *config.Config, skipUpdateCheck bool) func() { + isVersionCheckCmd := cmd.Name() == "version" && cmd.Flag("check") != nil && cmd.Flag("check").Changed + isUpgradeCmd := cmd.Name() == "upgrade" + if !cfg.VersionCheck || skipUpdateCheck || isVersionCheckCmd || isUpgradeCmd || + util.IsCI() || !util.IsTerminal(cmd.ErrOrStderr()) { + return func() {} + } + + resultCh := make(chan *version.CheckResult, 1) + go func() { + result, err := version.CheckForUpdate(cfg) + if err != nil { + // A failed check (e.g. offline) shouldn't spam a warning on every + // command; surface it only in debug logs. + logging.Debug("background version check failed", zap.Error(err)) + resultCh <- nil + return + } + resultCh <- result + }() + + return func() { + result := <-resultCh + + // Re-check cfg.VersionCheck: the command may have turned checks off in + // place (e.g. `tiger config set version_check false`, which reloads the + // config struct rather than replacing it). + if !cfg.VersionCheck { + return + } + + output := cmd.ErrOrStderr() + version.PrintUpdateWarning(result, cfg, &output) } } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index aeb2b5b6..020b1e47 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -1,9 +1,13 @@ package cmd import ( + "context" + "io" "os" "testing" + "github.com/spf13/cobra" + "github.com/timescale/tiger-cli/internal/config" ) @@ -33,6 +37,44 @@ func loadEffectiveConfig(t *testing.T, args ...string) *config.Config { return cfg } +// The context passed to buildRootCmd must reach the command that runs, so +// handlers can rely on cmd.Context() for cancellation. It's set on the root at +// build time (cobra copies it onto the executed command) rather than in a +// PersistentPreRunE hook. +func TestContextReachesCommand(t *testing.T) { + setupTestCommand(t) + + type ctxKey struct{} + ctx := context.WithValue(t.Context(), ctxKey{}, "from-execute") + + rootCmd, err := buildRootCmd(ctx) + if err != nil { + t.Fatalf("Failed to build root command: %v", err) + } + + var got any + versionCmd, _, err := rootCmd.Find([]string{"version"}) + if err != nil { + t.Fatalf("Failed to find version command: %v", err) + } + inner := versionCmd.RunE + versionCmd.RunE = func(c *cobra.Command, args []string) error { + got = c.Context().Value(ctxKey{}) + return inner(c, args) + } + + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + rootCmd.SetArgs([]string{"version"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Command execution failed: %v", err) + } + + if got != "from-execute" { + t.Errorf("Expected the command to run with the context passed to buildRootCmd, got value %v", got) + } +} + func writeTestConfigFile(t *testing.T, dir, contents string) { t.Helper() if err := os.WriteFile(config.GetConfigFile(dir), []byte(contents), 0644); err != nil { diff --git a/internal/cmd/service.go b/internal/cmd/service.go index e413c873..f495e93c 100644 --- a/internal/cmd/service.go +++ b/internal/cmd/service.go @@ -18,7 +18,7 @@ import ( // experimental gates preview-stage subcommands (currently `metrics`); when // false, those subtrees are not added to the tree at all — matching ghost's // TIGER_EXPERIMENTAL pattern. See CLAUDE.md's "Experimental Feature Gating". -func buildServiceCmd(experimental bool) *cobra.Command { +func buildServiceCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "service", Aliases: []string{"services", "svc"}, @@ -27,20 +27,20 @@ func buildServiceCmd(experimental bool) *cobra.Command { } // Add all subcommands - cmd.AddCommand(buildServiceGetCmd()) - cmd.AddCommand(buildServiceListCmd()) - cmd.AddCommand(buildServiceCreateCmd()) - cmd.AddCommand(buildServiceDeleteCmd()) - cmd.AddCommand(buildServiceStartCmd()) - cmd.AddCommand(buildServiceStopCmd()) - cmd.AddCommand(buildServiceUpdatePasswordCmd()) - cmd.AddCommand(buildServiceForkCmd()) - cmd.AddCommand(buildServiceResizeCmd()) - cmd.AddCommand(buildServiceLogsCmd()) + cmd.AddCommand(buildServiceGetCmd(app)) + cmd.AddCommand(buildServiceListCmd(app)) + cmd.AddCommand(buildServiceCreateCmd(app)) + cmd.AddCommand(buildServiceDeleteCmd(app)) + cmd.AddCommand(buildServiceStartCmd(app)) + cmd.AddCommand(buildServiceStopCmd(app)) + cmd.AddCommand(buildServiceUpdatePasswordCmd(app)) + cmd.AddCommand(buildServiceForkCmd(app)) + cmd.AddCommand(buildServiceResizeCmd(app)) + cmd.AddCommand(buildServiceLogsCmd(app)) // Experimental commands, unregistered until the preview graduates. - if experimental { - cmd.AddCommand(buildServiceMetricsCmd()) + if app.Experimental { + cmd.AddCommand(buildServiceMetricsCmd(app)) } return cmd diff --git a/internal/cmd/service_create.go b/internal/cmd/service_create.go index f922dd9b..5142de95 100644 --- a/internal/cmd/service_create.go +++ b/internal/cmd/service_create.go @@ -15,7 +15,7 @@ import ( ) // serviceCreateCmd represents the create command under service -func buildServiceCreateCmd() *cobra.Command { +func buildServiceCreateCmd(app *common.App) *cobra.Command { var createServiceName string var createAddons []string var createRegionCode string @@ -116,13 +116,12 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.SilenceUsage = true - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return err } @@ -153,7 +152,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth } else { fmt.Fprintf(statusOutput, "🚀 Creating service '%s' (auto-generated name)...\n", createServiceName) } - resp, err := cfg.Client.CreateServiceWithResponse(ctx, cfg.ProjectID, serviceCreateReq) + resp, err := client.CreateServiceWithResponse(ctx, projectID, serviceCreateReq) if err != nil { return fmt.Errorf("failed to create Service: %w", err) } @@ -174,11 +173,11 @@ Note: You can specify both CPU and memory together, or specify only one (the oth // Save password immediately after service creation, before any waiting // This ensures users have access even if they interrupt the wait or it fails - passwordSaved := handlePasswordSaving(cfg.Config, service, util.Deref(service.InitialPassword), statusOutput) + passwordSaved := handlePasswordSaving(cfg, service, util.Deref(service.InitialPassword), statusOutput) // Set as default service unless --no-set-default is specified if !createNoSetDefault { - if err := setDefaultService(cfg.Config, serviceID, statusOutput); err != nil { + if err := setDefaultService(cfg, serviceID, statusOutput); err != nil { // Log warning but don't fail the command fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err) } @@ -192,8 +191,8 @@ Note: You can specify both CPU and memory together, or specify only one (the oth // Wait for service to be ready fmt.Fprintf(statusOutput, "⏳ Waiting for service to be ready (wait timeout: %v)...\n", createWaitTimeout) if waitErr = common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -210,7 +209,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth } } - if err := outputService(cmd, cfg.Config, service, cfg.Output, createWithPassword, false); err != nil { + if err := outputService(cmd, cfg, service, cfg.Output, createWithPassword, false); err != nil { fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) } diff --git a/internal/cmd/service_delete.go b/internal/cmd/service_delete.go index db326975..116d88a8 100644 --- a/internal/cmd/service_delete.go +++ b/internal/cmd/service_delete.go @@ -14,7 +14,7 @@ import ( ) // buildServiceDeleteCmd creates the delete subcommand -func buildServiceDeleteCmd() *cobra.Command { +func buildServiceDeleteCmd(app *common.App) *cobra.Command { var deleteNoWait bool var deleteWaitTimeout time.Duration var deleteConfirm bool @@ -42,7 +42,7 @@ Examples: # Delete service with custom wait timeout tiger service delete svc-12345 --wait-timeout 15m`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { // Require explicit service ID for safety if len(args) < 1 { @@ -52,14 +52,14 @@ Examples: cmd.SilenceUsage = true - // Load config before the confirmation prompt so read-only mode - // refuses without asking the user to type the service ID. - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + // Check read-only mode before the confirmation prompt, so it refuses + // without asking the user to type the service ID. + cfg, client, projectID, err := app.GetAll() if err != nil { return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return err } @@ -83,9 +83,9 @@ Examples: } // Make the delete request - resp, err := cfg.Client.DeleteServiceWithResponse( + resp, err := client.DeleteServiceWithResponse( cmd.Context(), - api.ProjectId(cfg.ProjectID), + api.ProjectId(projectID), api.ServiceId(serviceID), ) if err != nil { @@ -107,8 +107,8 @@ Examples: // Wait for deletion to complete if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.DeletionWaitHandler{ ServiceID: serviceID, diff --git a/internal/cmd/service_fork.go b/internal/cmd/service_fork.go index cf2aa132..ef309fc4 100644 --- a/internal/cmd/service_fork.go +++ b/internal/cmd/service_fork.go @@ -15,7 +15,7 @@ import ( ) // buildServiceForkCmd creates the fork subcommand -func buildServiceForkCmd() *cobra.Command { +func buildServiceForkCmd(app *common.App) *cobra.Command { var forkServiceName string var forkNoWait bool var forkNoSetDefault bool @@ -71,7 +71,7 @@ Examples: # Fork with custom wait timeout tiger service fork svc-12345 --now --wait-timeout 45m`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { // Validate timing flags first - exactly one must be specified timingFlagsSet := 0 @@ -99,20 +99,19 @@ Examples: return fmt.Errorf("environment must be either 'DEV' or 'PROD', got '%s'", forkEnvironment) } - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { cmd.SilenceUsage = true return err } // Determine source service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -175,7 +174,7 @@ Examples: } // Make API call to fork service - forkResp, err := cfg.Client.ForkServiceWithResponse(ctx, cfg.ProjectID, serviceID, forkReq) + forkResp, err := client.ForkServiceWithResponse(ctx, projectID, serviceID, forkReq) if err != nil { return fmt.Errorf("failed to fork Service: %w", err) } @@ -195,11 +194,11 @@ Examples: fmt.Fprintf(statusOutput, "📋 New Service ID: %s\n", forkedServiceID) // Save password immediately after service fork - passwordSaved := handlePasswordSaving(cfg.Config, forkedService, util.Deref(forkedService.InitialPassword), statusOutput) + passwordSaved := handlePasswordSaving(cfg, forkedService, util.Deref(forkedService.InitialPassword), statusOutput) // Set as default service unless --no-set-default is used if !forkNoSetDefault { - if err := setDefaultService(cfg.Config, forkedServiceID, statusOutput); err != nil { + if err := setDefaultService(cfg, forkedServiceID, statusOutput); err != nil { // Log warning but don't fail the command fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err) } @@ -213,8 +212,8 @@ Examples: // Wait for service to be ready fmt.Fprintf(statusOutput, "⏳ Waiting for fork to complete (timeout: %v)...\n", forkWaitTimeout) if waitErr = common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: forkedServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -231,7 +230,7 @@ Examples: } } - if err := outputService(cmd, cfg.Config, forkedService, cfg.Output, forkWithPassword, false); err != nil { + if err := outputService(cmd, cfg, forkedService, cfg.Output, forkWithPassword, false); err != nil { fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) } diff --git a/internal/cmd/service_get.go b/internal/cmd/service_get.go index 5e77e95f..ca0fc198 100644 --- a/internal/cmd/service_get.go +++ b/internal/cmd/service_get.go @@ -12,7 +12,7 @@ import ( ) // buildServiceGetCmd represents the get command under service -func buildServiceGetCmd() *cobra.Command { +func buildServiceGetCmd(app *common.App) *cobra.Command { var withPassword bool var output string @@ -39,17 +39,16 @@ Examples: # Get service details in YAML format tiger service get svc-12345 --output yaml`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } // Determine service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -60,7 +59,7 @@ Examples: ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServiceWithResponse(ctx, cfg.ProjectID, serviceID) + resp, err := client.GetServiceWithResponse(ctx, projectID, serviceID) if err != nil { return fmt.Errorf("failed to get service details: %w", err) } @@ -76,7 +75,7 @@ Examples: service := *resp.JSON200 // Output service in requested format - return outputService(cmd, cfg.Config, service, cfg.Output, withPassword, true) + return outputService(cmd, cfg, service, cfg.Output, withPassword, true) }, } diff --git a/internal/cmd/service_list.go b/internal/cmd/service_list.go index 5b533263..0af2865e 100644 --- a/internal/cmd/service_list.go +++ b/internal/cmd/service_list.go @@ -18,7 +18,7 @@ import ( ) // serviceListCmd represents the list command under service -func buildServiceListCmd() *cobra.Command { +func buildServiceListCmd(app *common.App) *cobra.Command { var output string cmd := &cobra.Command{ @@ -30,8 +30,7 @@ func buildServiceListCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { return err } @@ -40,7 +39,7 @@ func buildServiceListCmd() *cobra.Command { ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServicesWithResponse(ctx, cfg.ProjectID) + resp, err := client.GetServicesWithResponse(ctx, projectID) if err != nil { return fmt.Errorf("failed to list services: %w", err) } @@ -70,7 +69,7 @@ func buildServiceListCmd() *cobra.Command { } // Output services in requested format - return outputServices(cmd, cfg.Config, services, cfg.Output) + return outputServices(cmd, cfg, services, cfg.Output) }, } diff --git a/internal/cmd/service_logs.go b/internal/cmd/service_logs.go index a9379286..c693c6d0 100644 --- a/internal/cmd/service_logs.go +++ b/internal/cmd/service_logs.go @@ -14,7 +14,7 @@ import ( ) // buildServiceLogsCmd creates the logs command for viewing service logs -func buildServiceLogsCmd() *cobra.Command { +func buildServiceLogsCmd(app *common.App) *cobra.Command { var tail int var since time.Time var until time.Time @@ -52,17 +52,16 @@ Examples: # View last 1000 lines tiger service logs --tail 1000`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } // Determine service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -91,7 +90,15 @@ Examples: ctx, cancel := context.WithTimeout(cmd.Context(), time.Minute) defer cancel() - logs, err := common.FetchServiceLogs(ctx, cfg, serviceID, tail, sincePtr, untilPtr, nodePtr) + logs, err := common.FetchServiceLogs(ctx, common.FetchServiceLogsArgs{ + Client: client, + ProjectID: projectID, + ServiceID: serviceID, + Tail: tail, + Since: sincePtr, + Until: untilPtr, + Node: nodePtr, + }) if err != nil { return err } diff --git a/internal/cmd/service_metrics.go b/internal/cmd/service_metrics.go index 19995dc1..b47fd473 100644 --- a/internal/cmd/service_metrics.go +++ b/internal/cmd/service_metrics.go @@ -2,6 +2,8 @@ package cmd import ( "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" ) // buildServiceMetricsCmd creates the metrics subcommand group. The metrics @@ -10,13 +12,13 @@ import ( // gated on TIGER_EXPERIMENTAL in buildServiceCmd, so this builder is only // called when the env var is set; the tree doesn't include `metrics` at all // otherwise. -func buildServiceMetricsCmd() *cobra.Command { +func buildServiceMetricsCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "metrics", Short: "View service metrics", Long: `Commands for querying time-series metrics for a Tiger Cloud service.`, } - cmd.AddCommand(buildServiceMetricsAvailableSeriesCmd()) - cmd.AddCommand(buildServiceMetricsSeriesCmd()) + cmd.AddCommand(buildServiceMetricsAvailableSeriesCmd(app)) + cmd.AddCommand(buildServiceMetricsSeriesCmd(app)) return cmd } diff --git a/internal/cmd/service_metrics_available_series.go b/internal/cmd/service_metrics_available_series.go index 14f49042..52a37715 100644 --- a/internal/cmd/service_metrics_available_series.go +++ b/internal/cmd/service_metrics_available_series.go @@ -14,7 +14,7 @@ import ( ) // buildServiceMetricsAvailableSeriesCmd lists the metric series available for a service -func buildServiceMetricsAvailableSeriesCmd() *cobra.Command { +func buildServiceMetricsAvailableSeriesCmd(app *common.App) *cobra.Command { var output string cmd := &cobra.Command{ @@ -23,13 +23,13 @@ func buildServiceMetricsAvailableSeriesCmd() *cobra.Command { Long: `List the names of all metric series available for a service.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -39,7 +39,7 @@ func buildServiceMetricsAvailableSeriesCmd() *cobra.Command { ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServiceMetricsAvailableSeriesWithResponse(ctx, cfg.ProjectID, serviceID) + resp, err := client.GetServiceMetricsAvailableSeriesWithResponse(ctx, projectID, serviceID) if err != nil { return fmt.Errorf("failed to list metric series: %w", err) } diff --git a/internal/cmd/service_metrics_series.go b/internal/cmd/service_metrics_series.go index 66771fda..3f374bad 100644 --- a/internal/cmd/service_metrics_series.go +++ b/internal/cmd/service_metrics_series.go @@ -18,7 +18,7 @@ import ( ) // buildServiceMetricsSeriesCmd fetches time-series data for a named metric -func buildServiceMetricsSeriesCmd() *cobra.Command { +func buildServiceMetricsSeriesCmd(app *common.App) *cobra.Command { var metric string var from string var to string @@ -71,13 +71,13 @@ Examples: return err } - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -104,7 +104,7 @@ Examples: ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServiceMetricsSeriesWithResponse(ctx, cfg.ProjectID, serviceID, body) + resp, err := client.GetServiceMetricsSeriesWithResponse(ctx, projectID, serviceID, body) if err != nil { return fmt.Errorf("failed to fetch metric series: %w", err) } diff --git a/internal/cmd/service_resize.go b/internal/cmd/service_resize.go index c4f834cb..86255f01 100644 --- a/internal/cmd/service_resize.go +++ b/internal/cmd/service_resize.go @@ -13,7 +13,7 @@ import ( ) // buildServiceResizeCmd creates the resize subcommand -func buildServiceResizeCmd() *cobra.Command { +func buildServiceResizeCmd(app *common.App) *cobra.Command { var resizeCPU string var resizeMemory string var resizeNoWait bool @@ -57,22 +57,21 @@ Allowed CPU/Memory Configurations: Note: You can specify both CPU and memory together, or specify only one (the other will be automatically configured).`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { cmd.SilenceUsage = true return err } // Determine service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -104,7 +103,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := cfg.Client.ResizeServiceWithResponse(ctx, cfg.ProjectID, serviceID, resizeReq) + resp, err := client.ResizeServiceWithResponse(ctx, projectID, serviceID, resizeReq) if err != nil { return fmt.Errorf("failed to resize service: %w", err) } @@ -130,8 +129,8 @@ Note: You can specify both CPU and memory together, or specify only one (the oth // Wait for resize to complete fmt.Fprintf(statusOutput, "⏳ Waiting for resize to complete (timeout: %v)...\n", resizeWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", diff --git a/internal/cmd/service_start.go b/internal/cmd/service_start.go index 00baa7d3..929dedd9 100644 --- a/internal/cmd/service_start.go +++ b/internal/cmd/service_start.go @@ -13,7 +13,7 @@ import ( ) // buildServiceStartCmd creates the start subcommand -func buildServiceStartCmd() *cobra.Command { +func buildServiceStartCmd(app *common.App) *cobra.Command { var startNoWait bool var startWaitTimeout time.Duration @@ -33,23 +33,22 @@ Examples: # Start service with custom wait timeout tiger service start svc-12345 --wait-timeout 10m`, - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { cmd.SilenceUsage = true return err } // Determine source service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -57,9 +56,9 @@ Examples: cmd.SilenceUsage = true // Make the start request - resp, err := cfg.Client.StartServiceWithResponse( + resp, err := client.StartServiceWithResponse( context.Background(), - api.ProjectId(cfg.ProjectID), + api.ProjectId(projectID), api.ServiceId(serviceID), ) if err != nil { @@ -88,8 +87,8 @@ Examples: // Wait for service to become ready fmt.Fprintf(statusOutput, "⏳ Waiting for service to start (wait timeout: %v)...\n", startWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", diff --git a/internal/cmd/service_stop.go b/internal/cmd/service_stop.go index 8b7c9510..7205fc7a 100644 --- a/internal/cmd/service_stop.go +++ b/internal/cmd/service_stop.go @@ -13,7 +13,7 @@ import ( ) // buildServiceStopCmd creates the stop subcommand -func buildServiceStopCmd() *cobra.Command { +func buildServiceStopCmd(app *common.App) *cobra.Command { var stopNoWait bool var stopWaitTimeout time.Duration @@ -33,23 +33,22 @@ Examples: # Stop service with custom wait timeout tiger service stop svc-12345 --wait-timeout 10m`, - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { cmd.SilenceUsage = true return err } // Determine source service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -57,9 +56,9 @@ Examples: cmd.SilenceUsage = true // Make the stop request - resp, err := cfg.Client.StopServiceWithResponse( + resp, err := client.StopServiceWithResponse( context.Background(), - api.ProjectId(cfg.ProjectID), + api.ProjectId(projectID), api.ServiceId(serviceID), ) if err != nil { @@ -88,8 +87,8 @@ Examples: // Wait for service to become paused fmt.Fprintf(statusOutput, "⏳ Waiting for service to stop (timeout: %v)...\n", stopWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "PAUSED", diff --git a/internal/cmd/service_update_password.go b/internal/cmd/service_update_password.go index d0376c42..1183ecf7 100644 --- a/internal/cmd/service_update_password.go +++ b/internal/cmd/service_update_password.go @@ -14,7 +14,7 @@ import ( ) // buildServiceUpdatePasswordCmd creates a new update-password command -func buildServiceUpdatePasswordCmd() *cobra.Command { +func buildServiceUpdatePasswordCmd(app *common.App) *cobra.Command { var updatePasswordValue string var autoGenerate bool @@ -53,22 +53,21 @@ Examples: # Auto-generate a secure password tiger service update-password --auto-generate`, Args: cobra.MaximumNArgs(1), - ValidArgsFunction: serviceIDCompletion, + ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context(), cmd.Flags()) + cfg, client, projectID, err := app.GetAll() if err != nil { cmd.SilenceUsage = true return err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { cmd.SilenceUsage = true return err } // Determine service ID - serviceID, err := getServiceID(cfg.Config, args) + serviceID, err := getServiceID(cfg, args) if err != nil { return err } @@ -88,7 +87,7 @@ Examples: defer cancel() // Fetch service details - serviceResp, err := cfg.Client.GetServiceWithResponse(ctx, cfg.ProjectID, serviceID) + serviceResp, err := client.GetServiceWithResponse(ctx, projectID, serviceID) if err != nil { return fmt.Errorf("failed to get service details: %w", err) } @@ -111,7 +110,7 @@ Examples: if autoGenerate { // Auto-generate password using existing function - if _, err := resetServicePassword(ctx, cfg.Config, cfg.Client, service, "tsdbadmin", "", statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cfg, client, service, "tsdbadmin", "", statusOutput); err != nil { return err } } else if password == "" { @@ -121,9 +120,9 @@ Examples: } _, err := promptAndResetPassword( ctx, - cfg.Config, + cfg, statusOutput, - cfg.Client, + client, service, "tsdbadmin", ) @@ -131,7 +130,7 @@ Examples: return err } } else { - if _, err := resetServicePassword(ctx, cfg.Config, cfg.Client, service, "tsdbadmin", password, statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cfg, client, service, "tsdbadmin", password, statusOutput); err != nil { return err } } diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go index 90e3e9b7..4bf3772f 100644 --- a/internal/cmd/upgrade.go +++ b/internal/cmd/upgrade.go @@ -20,7 +20,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" - "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/version" ) @@ -45,7 +45,7 @@ func normalizeTag(v string) string { return "v" + strings.TrimPrefix(v, "v") } -func buildUpgradeCmd() *cobra.Command { +func buildUpgradeCmd(app *common.App) *cobra.Command { var force bool var requestedVersion string @@ -60,7 +60,7 @@ If Tiger CLI was installed via a package manager (Homebrew, apt, yum/dnf), the u ValidArgsFunction: cobra.NoFileCompletions, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - return runUpgrade(cmd, requestedVersion, force) + return runUpgrade(cmd, app, requestedVersion, force) }, } @@ -76,11 +76,8 @@ If Tiger CLI was installed via a package manager (Homebrew, apt, yum/dnf), the u return cmd } -func runUpgrade(cmd *cobra.Command, requestedVersion string, force bool) error { - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } +func runUpgrade(cmd *cobra.Command, app *common.App, requestedVersion string, force bool) error { + cfg := app.GetConfig() ctx := cmd.Context() releasesURL := strings.TrimRight(cfg.ReleasesURL, "/") diff --git a/internal/cmd/version.go b/internal/cmd/version.go index b7ecf8db..76b4b0a4 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -23,7 +23,7 @@ type VersionOutput struct { UpdateAvailable *bool `json:"update_available,omitempty"` } -func buildVersionCmd() *cobra.Command { +func buildVersionCmd(app *common.App) *cobra.Command { var checkVersion bool var outputFormat string @@ -46,10 +46,7 @@ func buildVersionCmd() *cobra.Command { updateAvailable := false if checkVersion { - cfg, err := config.Load(cmd.Flags()) - if err != nil { - return fmt.Errorf("Error loading config: %w", err) - } + cfg := app.GetConfig() result, err := version.CheckForUpdate(cfg) if err != nil { // A failed check shouldn't fail the version command; warn and diff --git a/internal/common/app.go b/internal/common/app.go new file mode 100644 index 00000000..00f4519e --- /dev/null +++ b/internal/common/app.go @@ -0,0 +1,158 @@ +package common + +import ( + "context" + "fmt" + "sync" + + "github.com/spf13/pflag" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/config" +) + +// App holds shared application state: the config and the API client built from +// it. For CLI commands it is populated once at the start of the wrapped RunE +// (see wrapCommands in internal/cmd) and shared by every command handler. For +// MCP requests, Load is called once per request by the analytics middleware, so +// config changes and logins/logouts made while the session is open take effect +// on the next request; handlers then read the loaded state via GetAll and +// friends. +// +// All state is unexported. Use Load or SetClient to populate it, and +// GetAll/TryGetAll/GetConfig/GetClient to read it. Concurrency is handled +// internally via a sync.RWMutex. +type App struct { + // Experimental gates preview-stage commands and MCP tools. Read once from + // TIGER_EXPERIMENTAL at startup; see CLAUDE.md's "Experimental Feature + // Gating". + Experimental bool + + flags *pflag.FlagSet + config *config.Config + client api.ClientWithResponsesInterface // nil if credentials are unavailable + projectID string + clientErr error // returned by GetClient/GetAll when client is nil + clientFactory ClientFactory // nil in production; set in tests + lock sync.RWMutex // protects config, client, projectID, clientErr +} + +// ClientFactory creates an API client from the loaded config. Tests use it to +// inject a client while letting Load run normally, so config resolution and flag +// precedence still go through the real code path. +type ClientFactory func(ctx context.Context, cfg *config.Config) (api.ClientWithResponsesInterface, string, error) + +// SetClientFactory sets a custom factory for API client creation. +// When set, Load calls this instead of [NewAPIClient]. +func (a *App) SetClientFactory(f ClientFactory) { + a.clientFactory = f +} + +// SetFlags stores the command's flag set for use by [config.Load]. Must be +// called before Load. +func (a *App) SetFlags(flags *pflag.FlagSet) { + a.flags = flags +} + +// Load loads (or reloads) the config and attempts to create the API client. +// Returns the config, API client, and project ID. Config errors are returned; +// API client errors are stored and surfaced by GetClient/GetAll instead (the +// returned client is simply nil), so commands that don't need the client still +// run when the user isn't logged in. +func (a *App) Load(ctx context.Context) (*config.Config, api.ClientWithResponsesInterface, string, error) { + a.lock.Lock() + defer a.lock.Unlock() + + cfg, err := config.Load(a.flags) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to load config: %w", err) + } + a.config = cfg + + a.client, a.projectID, a.clientErr = a.newAPIClient(ctx, a.config) + + return a.config, a.client, a.projectID, nil +} + +func (a *App) newAPIClient(ctx context.Context, cfg *config.Config) (api.ClientWithResponsesInterface, string, error) { + if a.clientFactory != nil { + return a.clientFactory(ctx, cfg) + } + client, projectID, err := NewAPIClient(ctx, cfg) + if err != nil { + // Return an untyped nil: wrapping the nil *ClientWithResponses in the + // interface would make a "client == nil" check false for callers. + return nil, "", err + } + return client, projectID, nil +} + +// SetClient stores an existing API client and project ID. Use it when a valid +// client already exists (e.g. after `tiger auth login` builds one to validate +// credentials) so later readers — analytics in particular — see the new +// credentials without re-reading them from storage. +func (a *App) SetClient(client api.ClientWithResponsesInterface, projectID string) { + a.lock.Lock() + defer a.lock.Unlock() + a.client = client + a.projectID = projectID + a.clientErr = nil +} + +// getAll is the locked primitive behind the exported accessors. It returns a +// snapshot of the App state: the returned values stay valid even if Load is +// called concurrently, because Load replaces pointers rather than mutating the +// objects they point to. The returned error is the stored client-creation +// error, nil when the client is available. +// +// Panics if the App has never been loaded. That's a programmer error: every +// code path that reads the App (commands, MCP request handlers, completion +// functions) must arrange for Load to run first. +func (a *App) getAll() (*config.Config, api.ClientWithResponsesInterface, string, error) { + a.lock.RLock() + defer a.lock.RUnlock() + + if a.config == nil { + panic("App.Load must be called before accessing the config or API client") + } + return a.config, a.client, a.projectID, a.clientErr +} + +// GetAll returns a snapshot of the config, API client, and project ID. Returns +// an error (and zero values) if the client is unavailable, e.g. because the +// user isn't logged in. Panics if the App has never been loaded (see getAll). +// +// Callers that only read the config but do reach the API later still use GetAll +// (discarding the client) so that a missing credential fails fast, before any +// prompting or other work. +func (a *App) GetAll() (*config.Config, api.ClientWithResponsesInterface, string, error) { + cfg, client, projectID, err := a.getAll() + if err != nil { + return nil, nil, "", err + } + return cfg, client, projectID, nil +} + +// TryGetAll returns a snapshot of the config, API client, and project ID like +// GetAll, but tolerates an unavailable client: the returned client is simply +// nil. Use it for best-effort work where the API call is optional (e.g. +// analytics). Panics if the App has never been loaded (see getAll). +func (a *App) TryGetAll() (*config.Config, api.ClientWithResponsesInterface, string) { + cfg, client, projectID, _ := a.getAll() // error dropped: best-effort access + return cfg, client, projectID +} + +// GetConfig returns a snapshot of the config. Panics if the App has never been +// loaded (see getAll). +func (a *App) GetConfig() *config.Config { + cfg, _, _, _ := a.getAll() + return cfg +} + +// GetClient returns a snapshot of the API client and project ID. Returns an +// error if the client is unavailable, e.g. because the user isn't logged in. +// Panics if the App has never been loaded (see getAll). +func (a *App) GetClient() (api.ClientWithResponsesInterface, string, error) { + _, client, projectID, err := a.getAll() + return client, projectID, err +} diff --git a/internal/common/client.go b/internal/common/client.go index 8ce69cdf..df837d06 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -83,7 +83,7 @@ func NewAPIClient(ctx context.Context, cfg *config.Config) (*api.ClientWithRespo // returns the caller's identity. It also identifies the user for the sake of // analytics. Only PAT credentials reach this path, so the response always // carries the apiKey branch. -func ValidateAPIKey(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) { +func ValidateAPIKey(ctx context.Context, cfg *config.Config, client api.ClientWithResponsesInterface) (*api.AuthInfo, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -122,7 +122,7 @@ func ValidateAPIKey(ctx context.Context, cfg *config.Config, client *api.ClientW // IdentifyOAuthUser sends an analytics Identify for an OAuth (PKCE) login, // using the token-authenticated client built during login. It fetches the // caller's identity via /auth/info. Best-effort. -func IdentifyOAuthUser(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses, projectID string) { +func IdentifyOAuthUser(ctx context.Context, cfg *config.Config, client api.ClientWithResponsesInterface, projectID string) { // Skip the /auth/info round-trip entirely when analytics is disabled. a := analytics.New(cfg, client, projectID) if !a.Enabled() { diff --git a/internal/common/config.go b/internal/common/config.go deleted file mode 100644 index ae851860..00000000 --- a/internal/common/config.go +++ /dev/null @@ -1,43 +0,0 @@ -package common - -import ( - "context" - "fmt" - - "github.com/spf13/pflag" - - "github.com/timescale/tiger-cli/internal/api" - "github.com/timescale/tiger-cli/internal/config" -) - -// Config is a convenience wrapper around [config.Config] that adds an API -// client and the current project ID. Since most commands require all of these -// to function, it is often easier to load them and pass them around together. -// Functions that only require a config but not a client (i.e. functions that -// do not make any API calls) should call [config.Load] directly instead. -type Config struct { - *config.Config - Client *api.ClientWithResponses `json:"-"` - ProjectID string `json:"-"` -} - -// LoadConfig loads the config and API client. The flag set of the command being -// run is passed through to [config.Load] so that CLI flags take precedence over -// env vars and the config file; it may be nil when there are no flags to apply. -func LoadConfig(ctx context.Context, flags *pflag.FlagSet) (*Config, error) { - cfg, err := config.Load(flags) - if err != nil { - return nil, fmt.Errorf("failed to load config: %w", err) - } - - client, projectID, err := NewAPIClient(ctx, cfg) - if err != nil { - return nil, err - } - - return &Config{ - Config: cfg, - Client: client, - ProjectID: projectID, - }, nil -} diff --git a/internal/common/logs.go b/internal/common/logs.go index fe356a1c..ed421e8f 100644 --- a/internal/common/logs.go +++ b/internal/common/logs.go @@ -10,24 +10,26 @@ import ( "github.com/timescale/tiger-cli/internal/api" ) +type FetchServiceLogsArgs struct { + Client api.ClientWithResponsesInterface + ProjectID string + ServiceID string + Tail int + Since *time.Time + Until *time.Time + + // Node selects a specific service node to fetch logs from, for services + // with HA replicas. If nil, the backend returns logs for the primary. + Node *int +} + // FetchServiceLogs fetches service logs with cursor-based pagination up to the specified // tail limit. Returns entries in ascending order by timestamp (oldest first, newest last). -// NOTE: The node parameter specifies the specific service node to fetch logs -// from, for services with HA replicas. If nil, the backend automatically -// returns logs for the primary. -func FetchServiceLogs( - ctx context.Context, - cfg *Config, - serviceID string, - tail int, - since *time.Time, - until *time.Time, - node *int, -) ([]api.ServiceLogEntry, error) { +func FetchServiceLogs(ctx context.Context, args FetchServiceLogsArgs) ([]api.ServiceLogEntry, error) { params := &api.GetServiceLogsParams{ - Node: node, - Since: since, - Until: until, + Node: args.Node, + Since: args.Since, + Until: args.Until, } // Fix the upper time bound so that all paginated requests share the same @@ -40,7 +42,7 @@ func FetchServiceLogs( var entries []api.ServiceLogEntry for { - resp, err := cfg.Client.GetServiceLogsWithResponse(ctx, cfg.ProjectID, serviceID, params) + resp, err := args.Client.GetServiceLogsWithResponse(ctx, args.ProjectID, args.ServiceID, params) if err != nil { return nil, fmt.Errorf("failed to fetch logs: %w", err) } @@ -58,7 +60,7 @@ func FetchServiceLogs( } // Stop when we have enough logs or the server signals no further pages. - if len(entries) >= tail || resp.JSON200.LastCursor == nil { + if len(entries) >= args.Tail || resp.JSON200.LastCursor == nil { break } @@ -66,8 +68,8 @@ func FetchServiceLogs( } // Trim to the requested tail count. - if len(entries) > tail { - entries = entries[:tail] + if len(entries) > args.Tail { + entries = entries[:args.Tail] } // Reverse: the API returns logs newest-first; terminal output is oldest-first. diff --git a/internal/common/wait.go b/internal/common/wait.go index 946ae587..0017d67a 100644 --- a/internal/common/wait.go +++ b/internal/common/wait.go @@ -30,7 +30,7 @@ type WaitHandler interface { } type WaitForServiceArgs struct { - Client *api.ClientWithResponses + Client api.ClientWithResponsesInterface ProjectID string ServiceID string Handler WaitHandler diff --git a/internal/mcp/db_execute_query.go b/internal/mcp/db_execute_query.go index 3406d331..46a3f085 100644 --- a/internal/mcp/db_execute_query.go +++ b/internal/mcp/db_execute_query.go @@ -151,8 +151,7 @@ WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL // handleDBExecuteQuery handles the db_execute_query MCP tool func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequest, input DBExecuteQueryInput) (*mcp.CallToolResult, DBExecuteQueryOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, DBExecuteQueryOutput{}, err } @@ -161,7 +160,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ timeout := time.Duration(input.TimeoutSeconds) * time.Second logging.Debug("MCP: Executing database query", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), zap.Duration("timeout", timeout), zap.String("role", input.Role), @@ -170,7 +169,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ ) // service_id may name a service or one of its read replicas. - target, err := common.ResolveConnectionTargetByID(ctx, cfg.Client, cfg.ProjectID, input.ServiceID) + target, err := common.ResolveConnectionTargetByID(ctx, client, projectID, input.ServiceID) if err != nil { return nil, DBExecuteQueryOutput{}, err } @@ -197,7 +196,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ } // Connect to database - conn, err := common.ConnectTarget(queryCtx, cfg.Config, target, common.ConnectionDetailsOptions{ + conn, err := common.ConnectTarget(queryCtx, cfg, target, common.ConnectionDetailsOptions{ Pooled: input.Pooled, Role: input.Role, WithPassword: true, diff --git a/internal/mcp/db_schema.go b/internal/mcp/db_schema.go index bed8715a..7aee5929 100644 --- a/internal/mcp/db_schema.go +++ b/internal/mcp/db_schema.go @@ -91,13 +91,13 @@ By default only user-facing schemas and objects are shown; view/routine definiti // handleDBSchema handles the db_schema MCP tool func (s *Server) handleDBSchema(ctx context.Context, req *mcp.CallToolRequest, input DBSchemaInput) (*mcp.CallToolResult, DBSchemaOutput, error) { - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, DBSchemaOutput{}, err } logging.Debug("MCP: Getting database schema", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), zap.String("schema", input.SchemaName), zap.Bool("internal", input.Internal), @@ -108,7 +108,7 @@ func (s *Server) handleDBSchema(ctx context.Context, req *mcp.CallToolRequest, i ) // service_id may name a service or one of its read replicas. - target, err := common.ResolveConnectionTargetByID(ctx, cfg.Client, cfg.ProjectID, input.ServiceID) + target, err := common.ResolveConnectionTargetByID(ctx, client, projectID, input.ServiceID) if err != nil { return nil, DBSchemaOutput{}, err } @@ -116,7 +116,7 @@ func (s *Server) handleDBSchema(ctx context.Context, req *mcp.CallToolRequest, i // A replica without a pooler connects directly; surface that as a warning. warning := common.ReplicaPoolerWarning(target, input.Pooled) - schema, err := common.FetchServiceSchema(ctx, cfg.Config, target, input.Role, input.Pooled, common.SchemaOptions{ + schema, err := common.FetchServiceSchema(ctx, cfg, target, input.Role, input.Pooled, common.SchemaOptions{ Schema: input.SchemaName, IncludeInternal: input.Internal, IncludeDefinitions: input.Definitions, diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index 09cbbccd..dd2dc32a 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -52,11 +52,7 @@ func isMethodNotFoundError(err error) bool { // the server. Does not connect if the docs MCP server is disabled in the // config or there is no URL in the config. func (s *Server) registerDocsProxy(ctx context.Context) { - cfg, err := config.Load(s.flags) - if err != nil { - logging.Error("Failed to load config", zap.Error(err)) - return - } + cfg := s.app.GetConfig() if !cfg.DocsMCP || cfg.DocsMCPURL == "" { logging.Debug("Docs MCP proxy is disabled") diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 34c7c282..0b46bc4c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -6,13 +6,10 @@ import ( "errors" "fmt" "net/http" - "os" "slices" - "strconv" "time" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/spf13/pflag" "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/analytics" @@ -48,11 +45,11 @@ type Server struct { mcpServer *mcp.Server docsProxyClient *ProxyClient - // flags is the flag set of the command that started the server. Tool - // handlers pass it to [common.LoadConfig] on every call so the flags given - // to `tiger mcp start` (e.g. --config-dir, --service-id) keep taking - // precedence over the config file, which is re-read per call. - flags *pflag.FlagSet + // app holds the config and API client. The analytics middleware reloads it + // once per request, so config changes and logins made while the session is + // open take effect on the next request; handlers then read that state via + // s.app.GetAll and friends. + app *common.App } // addTool registers an MCP tool, skipping readOnlyGatedTools in read-only mode. @@ -80,11 +77,12 @@ func buildServerInstructions(cfg *config.Config) string { "db_execute_query connects read-only, so writes and DDL are rejected by the server." } -// NewServer creates a new Tiger MCP server instance. The caller-supplied cfg -// is used only to render the read-only warning in server instructions and to -// gate tool registration; flags is retained so each tool call reloads the -// config with the same flag precedence (see Server.flags). -func NewServer(ctx context.Context, cfg *config.Config, flags *pflag.FlagSet) (*Server, error) { +// NewServer creates a new Tiger MCP server instance. The app must already be +// loaded: its config renders the read-only warning in the server instructions +// and gates which tools are registered, both evaluated once here at startup. +func NewServer(ctx context.Context, app *common.App) (*Server, error) { + cfg := app.GetConfig() + mcpServer := mcp.NewServer(&mcp.Implementation{ Name: ServerName, Title: serverTitle, @@ -93,16 +91,14 @@ func NewServer(ctx context.Context, cfg *config.Config, flags *pflag.FlagSet) (* server := &Server{ mcpServer: mcpServer, - flags: flags, + app: app, } // Register all tools (including proxied docs tools). readOnly and // experimental are captured here and threaded through registration only. // experimental follows the ghost pattern — env-var only, undocumented; see // CLAUDE.md's "Experimental Feature Gating". - readOnly := cfg != nil && cfg.ReadOnly - experimental, _ := strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL")) - server.registerTools(ctx, readOnly, experimental) + server.registerTools(ctx, cfg.ReadOnly, app.Experimental) // Add analytics tracking middleware server.mcpServer.AddReceivingMiddleware(server.analyticsMiddleware) @@ -174,14 +170,15 @@ func (s *Server) analyticsMiddleware(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, runErr error) { start := time.Now() - // Load config for analytics - cfg, err := config.Load(s.flags) + // Reload the config and API client for this request, so config changes + // and logins/logouts made while the session is open take effect. Handlers + // read the result via s.app. + cfg, client, projectID, err := s.app.Load(ctx) if err != nil { // If we can't load config, just skip analytics and continue return next(ctx, method, req) } - client, projectID, _ := common.NewAPIClient(ctx, cfg) a := analytics.New(cfg, client, projectID) switch r := req.(type) { diff --git a/internal/mcp/service_create.go b/internal/mcp/service_create.go index b60dd389..f38069ce 100644 --- a/internal/mcp/service_create.go +++ b/internal/mcp/service_create.go @@ -100,13 +100,12 @@ WARNING: Creates billable resources.`, // handleServiceCreate handles the service_create MCP tool func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolRequest, input ServiceCreateInput) (*mcp.CallToolResult, ServiceCreateOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceCreateOutput{}, err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return nil, ServiceCreateOutput{}, err } @@ -125,7 +124,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque } logging.Debug("MCP: Creating service", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("name", input.Name), zap.Strings("addons", input.Addons), zap.Stringp("region", input.Region), @@ -148,7 +147,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque createCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.CreateServiceWithResponse(createCtx, cfg.ProjectID, serviceCreateReq) + resp, err := client.CreateServiceWithResponse(createCtx, projectID, serviceCreateReq) if err != nil { return nil, ServiceCreateOutput{}, fmt.Errorf("failed to create service: %w", err) } @@ -179,7 +178,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque // This ensures the password is stored even if the wait fails or is interrupted var passwordStorage *common.PasswordStorageResult if service.InitialPassword != nil { - result, err := common.SavePasswordWithResult(cfg.Config, api.Service(service), *service.InitialPassword, "tsdbadmin") + result, err := common.SavePasswordWithResult(cfg, api.Service(service), *service.InitialPassword, "tsdbadmin") passwordStorage = &result if err != nil { logging.Debug("MCP: Password storage failed", zap.Error(err)) @@ -192,8 +191,8 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque message := "Service creation request accepted. The service may still be provisioning." if input.Wait { if err := common.WaitForService(ctx, common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -210,7 +209,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque // Convert service to output format (after wait so status is accurate) output := ServiceCreateOutput{ - Service: s.convertToServiceDetail(cfg.Config, service, input.WithPassword), + Service: s.convertToServiceDetail(cfg, service, input.WithPassword), Message: message, PasswordStorage: passwordStorage, } diff --git a/internal/mcp/service_fork.go b/internal/mcp/service_fork.go index eb54b177..e9307689 100644 --- a/internal/mcp/service_fork.go +++ b/internal/mcp/service_fork.go @@ -102,13 +102,12 @@ WARNING: Creates billable resources.`, // handleServiceFork handles the service_fork MCP tool func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest, input ServiceForkInput) (*mcp.CallToolResult, ServiceForkOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceForkOutput{}, err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return nil, ServiceForkOutput{}, err } @@ -135,7 +134,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest } logging.Debug("MCP: Forking service", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), zap.String("name", input.Name), zap.String("fork_strategy", string(input.ForkStrategy)), @@ -160,7 +159,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest forkCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.ForkServiceWithResponse(forkCtx, cfg.ProjectID, input.ServiceID, forkReq) + resp, err := client.ForkServiceWithResponse(forkCtx, projectID, input.ServiceID, forkReq) if err != nil { return nil, ServiceForkOutput{}, fmt.Errorf("failed to fork service: %w", err) } @@ -181,7 +180,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest // This ensures the password is stored even if the wait fails or is interrupted var passwordStorage *common.PasswordStorageResult if service.InitialPassword != nil { - result, err := common.SavePasswordWithResult(cfg.Config, api.Service(service), *service.InitialPassword, "tsdbadmin") + result, err := common.SavePasswordWithResult(cfg, api.Service(service), *service.InitialPassword, "tsdbadmin") passwordStorage = &result if err != nil { logging.Debug("MCP: Password storage failed", zap.Error(err)) @@ -204,8 +203,8 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest message := "Service fork request accepted. The forked service may still be provisioning." if input.Wait { if err := common.WaitForService(ctx, common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -222,7 +221,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest // Convert service to output format (after wait so status is accurate) output := ServiceForkOutput{ - Service: s.convertToServiceDetail(cfg.Config, service, input.WithPassword), + Service: s.convertToServiceDetail(cfg, service, input.WithPassword), Message: message, PasswordStorage: passwordStorage, } diff --git a/internal/mcp/service_get.go b/internal/mcp/service_get.go index 9938ccb7..91e18c25 100644 --- a/internal/mcp/service_get.go +++ b/internal/mcp/service_get.go @@ -56,21 +56,20 @@ func newServiceGetTool() *mcp.Tool { // handleServiceGet handles the service_get MCP tool func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, input ServiceGetInput) (*mcp.CallToolResult, ServiceGetOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceGetOutput{}, err } logging.Debug("MCP: Getting service details", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID)) // Make API call to get service details ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServiceWithResponse(ctx, cfg.ProjectID, input.ServiceID) + resp, err := client.GetServiceWithResponse(ctx, projectID, input.ServiceID) if err != nil { return nil, ServiceGetOutput{}, fmt.Errorf("failed to get service details: %w", err) } @@ -85,7 +84,7 @@ func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, } output := ServiceGetOutput{ - Service: s.convertToServiceDetail(cfg.Config, *resp.JSON200, input.WithPassword), + Service: s.convertToServiceDetail(cfg, *resp.JSON200, input.WithPassword), } // Check if password was requested but not available diff --git a/internal/mcp/service_list.go b/internal/mcp/service_list.go index f024fd15..2ecd524e 100644 --- a/internal/mcp/service_list.go +++ b/internal/mcp/service_list.go @@ -67,19 +67,18 @@ func newServiceListTool() *mcp.Tool { // handleServiceList handles the service_list MCP tool func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest, input ServiceListInput) (*mcp.CallToolResult, ServiceListOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + client, projectID, err := s.app.GetClient() if err != nil { return nil, ServiceListOutput{}, err } - logging.Debug("MCP: Listing services", zap.String("project_id", cfg.ProjectID)) + logging.Debug("MCP: Listing services", zap.String("project_id", projectID)) // Make API call to list services ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServicesWithResponse(ctx, cfg.ProjectID) + resp, err := client.GetServicesWithResponse(ctx, projectID) if err != nil { return nil, ServiceListOutput{}, fmt.Errorf("failed to list services: %w", err) } diff --git a/internal/mcp/service_logs.go b/internal/mcp/service_logs.go index 02ec89ec..27e10ba7 100644 --- a/internal/mcp/service_logs.go +++ b/internal/mcp/service_logs.go @@ -76,14 +76,13 @@ Supports filtering by time (via since/until parameters) and node (for services w // handleServiceLogs handles the service_logs MCP tool func (s *Server) handleServiceLogs(ctx context.Context, req *mcp.CallToolRequest, input ServiceLogsInput) (*mcp.CallToolResult, ServiceLogsOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + client, projectID, err := s.app.GetClient() if err != nil { return nil, ServiceLogsOutput{}, err } logging.Debug("MCP: Fetching service logs", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), zap.Intp("node", input.Node), zap.Int("tail", input.Tail), @@ -95,7 +94,15 @@ func (s *Server) handleServiceLogs(ctx context.Context, req *mcp.CallToolRequest logsCtx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() - entries, err := common.FetchServiceLogs(logsCtx, cfg, input.ServiceID, input.Tail, input.Since, input.Until, input.Node) + entries, err := common.FetchServiceLogs(logsCtx, common.FetchServiceLogsArgs{ + Client: client, + ProjectID: projectID, + ServiceID: input.ServiceID, + Tail: input.Tail, + Since: input.Since, + Until: input.Until, + Node: input.Node, + }) if err != nil { return nil, ServiceLogsOutput{}, err } diff --git a/internal/mcp/service_metrics_available.go b/internal/mcp/service_metrics_available.go index 65b4f5a9..7aabb46f 100644 --- a/internal/mcp/service_metrics_available.go +++ b/internal/mcp/service_metrics_available.go @@ -53,20 +53,20 @@ func newServiceMetricsAvailableTool() *mcp.Tool { // handleServiceMetricsAvailable handles the service_metrics_available MCP tool func (s *Server) handleServiceMetricsAvailable(ctx context.Context, req *mcp.CallToolRequest, input ServiceMetricsAvailableInput) (*mcp.CallToolResult, ServiceMetricsAvailableOutput, error) { - cfg, err := common.LoadConfig(ctx, s.flags) + client, projectID, err := s.app.GetClient() if err != nil { return nil, ServiceMetricsAvailableOutput{}, err } logging.Debug("MCP: Listing available metric series", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), ) ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServiceMetricsAvailableSeriesWithResponse(ctx, cfg.ProjectID, input.ServiceID) + resp, err := client.GetServiceMetricsAvailableSeriesWithResponse(ctx, projectID, input.ServiceID) if err != nil { return nil, ServiceMetricsAvailableOutput{}, fmt.Errorf("failed to list metric series: %w", err) } diff --git a/internal/mcp/service_metrics_series.go b/internal/mcp/service_metrics_series.go index eab3f247..bd8e42e4 100644 --- a/internal/mcp/service_metrics_series.go +++ b/internal/mcp/service_metrics_series.go @@ -105,13 +105,13 @@ Available metrics include: CPU usage/allocation, memory usage/total, disk usage, // handleServiceMetricsSeries handles the service_metrics_series MCP tool func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallToolRequest, input ServiceMetricsSeriesInput) (*mcp.CallToolResult, any, error) { - cfg, err := common.LoadConfig(ctx, s.flags) + client, projectID, err := s.app.GetClient() if err != nil { return nil, nil, err } logging.Debug("MCP: Fetching metric series", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), zap.String("metric", input.MetricName), zap.String("from", input.From), @@ -149,7 +149,7 @@ func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallTo ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.GetServiceMetricsSeriesWithResponse(ctx, cfg.ProjectID, input.ServiceID, body) + resp, err := client.GetServiceMetricsSeriesWithResponse(ctx, projectID, input.ServiceID, body) if err != nil { return nil, nil, fmt.Errorf("failed to fetch metric series: %w", err) } diff --git a/internal/mcp/service_resize.go b/internal/mcp/service_resize.go index ad13edfd..d3948c1a 100644 --- a/internal/mcp/service_resize.go +++ b/internal/mcp/service_resize.go @@ -73,18 +73,17 @@ WARNING: Creates billable resource changes. Increasing resources will increase c // handleServiceResize handles the service_resize MCP tool func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolRequest, input ServiceResizeInput) (*mcp.CallToolResult, ServiceResizeOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceResizeOutput{}, err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return nil, ServiceResizeOutput{}, err } logging.Debug("MCP: Resizing service", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID), zap.String("cpu_memory", input.CPUMemory), ) @@ -105,7 +104,7 @@ func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolReque ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.ResizeServiceWithResponse(ctx, cfg.ProjectID, input.ServiceID, resizeReq) + resp, err := client.ResizeServiceWithResponse(ctx, projectID, input.ServiceID, resizeReq) if err != nil { return nil, ServiceResizeOutput{}, fmt.Errorf("failed to resize service: %w", err) } @@ -125,8 +124,8 @@ func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolReque message := "Resize request accepted. The service may still be resizing." if input.Wait { if err := common.WaitForService(ctx, common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: input.ServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -142,7 +141,7 @@ func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolReque } // Return status, resources, and message (after wait so status is accurate) - detail := s.convertToServiceDetail(cfg.Config, service, false) + detail := s.convertToServiceDetail(cfg, service, false) output := ServiceResizeOutput{ Status: detail.Status, Resources: detail.Resources, diff --git a/internal/mcp/service_start.go b/internal/mcp/service_start.go index ef4b3615..1825e7b6 100644 --- a/internal/mcp/service_start.go +++ b/internal/mcp/service_start.go @@ -65,25 +65,24 @@ This operation starts a service that is currently in a stopped/paused state. The // handleServiceStart handles the service_start MCP tool func (s *Server) handleServiceStart(ctx context.Context, req *mcp.CallToolRequest, input ServiceStartInput) (*mcp.CallToolResult, ServiceStartOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceStartOutput{}, err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return nil, ServiceStartOutput{}, err } logging.Debug("MCP: Starting service", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID)) // Make API call to start service startCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.StartServiceWithResponse(startCtx, cfg.ProjectID, input.ServiceID) + resp, err := client.StartServiceWithResponse(startCtx, projectID, input.ServiceID) if err != nil { return nil, ServiceStartOutput{}, fmt.Errorf("failed to start service: %w", err) } @@ -103,8 +102,8 @@ func (s *Server) handleServiceStart(ctx context.Context, req *mcp.CallToolReques message := "Service start request accepted. The service may still be starting." if input.Wait { if err := common.WaitForService(ctx, common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: input.ServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", diff --git a/internal/mcp/service_stop.go b/internal/mcp/service_stop.go index 24d1950c..2fbee318 100644 --- a/internal/mcp/service_stop.go +++ b/internal/mcp/service_stop.go @@ -65,25 +65,24 @@ This operation stops a service that is currently running. The service will trans // handleServiceStop handles the service_stop MCP tool func (s *Server) handleServiceStop(ctx context.Context, req *mcp.CallToolRequest, input ServiceStopInput) (*mcp.CallToolResult, ServiceStopOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceStopOutput{}, err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return nil, ServiceStopOutput{}, err } logging.Debug("MCP: Stopping service", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID)) // Make API call to stop service stopCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := cfg.Client.StopServiceWithResponse(stopCtx, cfg.ProjectID, input.ServiceID) + resp, err := client.StopServiceWithResponse(stopCtx, projectID, input.ServiceID) if err != nil { return nil, ServiceStopOutput{}, fmt.Errorf("failed to stop service: %w", err) } @@ -103,8 +102,8 @@ func (s *Server) handleServiceStop(ctx context.Context, req *mcp.CallToolRequest message := "Service stop request accepted. The service may still be stopping." if input.Wait { if err := common.WaitForService(ctx, common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, + Client: client, + ProjectID: projectID, ServiceID: input.ServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "PAUSED", diff --git a/internal/mcp/service_update_password.go b/internal/mcp/service_update_password.go index dd100be4..477d4289 100644 --- a/internal/mcp/service_update_password.go +++ b/internal/mcp/service_update_password.go @@ -63,18 +63,17 @@ func newServiceUpdatePasswordTool() *mcp.Tool { // handleServiceUpdatePassword handles the service_update_password MCP tool func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallToolRequest, input ServiceUpdatePasswordInput) (*mcp.CallToolResult, ServiceUpdatePasswordOutput, error) { - // Load config and API client - cfg, err := common.LoadConfig(ctx, s.flags) + cfg, client, projectID, err := s.app.GetAll() if err != nil { return nil, ServiceUpdatePasswordOutput{}, err } - if err := common.CheckReadOnly(cfg.Config); err != nil { + if err := common.CheckReadOnly(cfg); err != nil { return nil, ServiceUpdatePasswordOutput{}, err } logging.Debug("MCP: Updating service password", - zap.String("project_id", cfg.ProjectID), + zap.String("project_id", projectID), zap.String("service_id", input.ServiceID)) // Prepare password update request @@ -87,7 +86,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT // Fetch first so we can reject read replicas and reuse the service for // password storage below. - serviceResp, err := cfg.Client.GetServiceWithResponse(ctx, cfg.ProjectID, input.ServiceID) + serviceResp, err := client.GetServiceWithResponse(ctx, projectID, input.ServiceID) if err != nil { return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("failed to get service details: %w", err) } @@ -103,7 +102,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT input.ServiceID, util.DerefStr(service.ForkedFrom.ServiceId)) } - resp, err := cfg.Client.UpdatePasswordWithResponse(ctx, cfg.ProjectID, input.ServiceID, updateReq) + resp, err := client.UpdatePasswordWithResponse(ctx, projectID, input.ServiceID, updateReq) if err != nil { return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("failed to update service password: %w", err) } @@ -112,7 +111,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT } // Save the new password using the service we already fetched. - result, saveErr := common.SavePasswordWithResult(cfg.Config, service, input.Password, "tsdbadmin") + result, saveErr := common.SavePasswordWithResult(cfg, service, input.Password, "tsdbadmin") passwordStorage := &result if saveErr != nil { logging.Debug("MCP: Password storage failed", zap.Error(saveErr)) From c43fc5a1f876b14eb5097b6b5927c1bfa7d5a857 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Wed, 5 Aug 2026 17:13:51 -0400 Subject: [PATCH 3/4] Fix potential bug where logout analytics event could restore deleted credentials --- internal/cmd/auth_logout.go | 12 ++++++-- internal/cmd/auth_logout_test.go | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/internal/cmd/auth_logout.go b/internal/cmd/auth_logout.go index aea76c09..58775c9c 100644 --- a/internal/cmd/auth_logout.go +++ b/internal/cmd/auth_logout.go @@ -24,7 +24,7 @@ func buildLogoutCmd(app *common.App) *cobra.Command { cfg := app.GetConfig() - revokeOAuthSession(cmd, cfg) + revokeOAuthSession(cmd, app, cfg) if err := cfg.RemoveCredentials(); err != nil { return fmt.Errorf("failed to remove credentials: %w", err) @@ -39,7 +39,14 @@ func buildLogoutCmd(app *common.App) *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, cfg *config.Config) { +// +// 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 @@ -48,6 +55,7 @@ func revokeOAuthSession(cmd *cobra.Command, cfg *config.Config) { if err != nil { return } + app.SetClient(client, stored.ProjectID) ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) defer cancel() diff --git a/internal/cmd/auth_logout_test.go b/internal/cmd/auth_logout_test.go index fafec377..c0987c51 100644 --- a/internal/cmd/auth_logout_test.go +++ b/internal/cmd/auth_logout_test.go @@ -1,7 +1,14 @@ package cmd import ( + "fmt" + "os" "testing" + "time" + + "golang.org/x/oauth2" + + "github.com/timescale/tiger-cli/internal/config" ) func TestAuthLogout_Success(t *testing.T) { @@ -35,3 +42,47 @@ func TestAuthLogout_Success(t *testing.T) { t.Fatal("Credentials should be removed after logout") } } + +// TestAuthLogout_OAuthCredentialsStayRemoved guards an edge case in the App's +// cached client: for an OAuth session that client persists refreshed tokens back +// to storage, and the analytics event deferred by wrapCommands runs *after* +// logout removed the credentials. If that event triggers a token refresh, the +// persist callback would write the credentials straight back. +func TestAuthLogout_OAuthCredentialsStayRemoved(t *testing.T) { + tmpDir := setupAuthTest(t) + + // The deferred analytics event has to actually be sent for this to be a + // real test, so enable analytics and neutralize the global opt-outs. + t.Setenv("TIGER_ANALYTICS", "true") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("NO_TELEMETRY", "") + t.Setenv("DISABLE_TELEMETRY", "") + + // The mock backs the refresh_token grant; everything else 404s, which is + // fine — the refresh happens before the request is issued. + mockServer := startMockOAuthServer(t, nil) + configContent := fmt.Sprintf("gateway_url: \"%s\"\napi_url: \"%s\"\n", mockServer.URL, mockServer.URL) + if err := os.WriteFile(config.GetConfigFile(tmpDir), []byte(configContent), 0o644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // An expired access token with a refresh token the mock still honors: the + // state where a logout triggers a refresh. + cfg := testConfig(t) + expired := &oauth2.Token{ + AccessToken: "stale-access-token", + RefreshToken: "mock-refresh-token-67890", + Expiry: time.Now().Add(-time.Hour), + } + if err := cfg.StoreOAuthCredentials(expired, "project-789"); err != nil { + t.Fatalf("Failed to store oauth credentials: %v", err) + } + + if _, err := executeAuthCommand(t.Context(), "auth", "logout"); err != nil { + t.Fatalf("Logout failed: %v", err) + } + + if creds, err := testConfig(t).GetStoredCredentials(); err == nil { + t.Fatalf("Credentials were resurrected after logout: %+v", creds) + } +} From 34049b60d98487a556bca8343a14126246f04a72 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Wed, 5 Aug 2026 17:42:59 -0400 Subject: [PATCH 4/4] Remove unnecessary flag vars, fix output bug in 'tiger version' command --- CLAUDE.md | 25 +++++++++-------- internal/cmd/auth_status.go | 3 +- internal/cmd/config_show.go | 3 +- internal/cmd/db_create_role.go | 3 +- internal/cmd/flag_helper.go | 27 ++++++++++++++++-- internal/cmd/mcp_get.go | 3 +- internal/cmd/mcp_list.go | 3 +- internal/cmd/root.go | 28 ++++++++----------- internal/cmd/service_create.go | 3 +- internal/cmd/service_fork.go | 3 +- internal/cmd/service_get.go | 3 +- internal/cmd/service_list.go | 3 +- internal/cmd/service_logs.go | 3 +- .../cmd/service_metrics_available_series.go | 3 +- internal/cmd/service_metrics_series.go | 3 +- internal/cmd/version.go | 8 +++--- internal/config/config.go | 2 +- internal/config/output.go | 19 ++++++------- 18 files changed, 74 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5020bd1..8f416f2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -648,11 +648,6 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) { // Per-invocation state, threaded through every builder app := &common.App{Experimental: experimental} - // Declare ALL flag variables locally within this function - var configDir string - var debug bool - // ... other flag variables - cmd := &cobra.Command{ Use: "tiger", Short: "Tiger CLI - Tiger Cloud Platform command-line interface", @@ -663,8 +658,9 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) { cmd.SetContext(ctx) // Set up persistent flags - cmd.PersistentFlags().StringVar(&configDir, "config-dir", config.GetDefaultConfigDir(), "config directory") - cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") + cmd.PersistentFlags().String("config-dir", config.GetDefaultConfigDir(), "config directory") + cmd.PersistentFlags().Bool("debug", false, "enable debug logging") + skipUpdateCheck := cmd.PersistentFlags().Bool("skip-update-check", false, "skip checking for updates on startup") // ... add remaining persistent flags // Add all subcommands (complete tree building) @@ -673,7 +669,7 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) { // ... add remaining subcommands // Wrap every RunE in the tree with the shared lifecycle - wrapCommands(cmd, app, &skipUpdateCheck) + wrapCommands(cmd, app, skipUpdateCheck) return cmd, nil } @@ -746,10 +742,15 @@ A flag that should override a config value needs no wiring in the command: the lifecycle wrapper already hands the command's flag set to `config.Load`, and the binding table in `internal/config/config.go` does the rest. +Don't bind such a flag to a variable — use `String`/`Bool`/`Var` rather than +`StringVar`/`BoolVar`/`VarP(&x, …)`. The command must read the value from the +config, and a variable in scope is an invitation to read the raw flag instead, +which silently bypasses the env var and config file. `tiger version` had exactly +that bug. Flag types that validate at parse time (`outputFlag` and friends in +`flag_helper.go`) still work: register them with `new(outputFlag)`. + ```go func buildMyConfigurableFlagCmd(app *common.App) *cobra.Command { - var output string - cmd := &cobra.Command{ Use: "my-command", Short: "Command with configurable flag", @@ -760,7 +761,7 @@ func buildMyConfigurableFlagCmd(app *common.App) *cobra.Command { }, } - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "output format") + cmd.Flags().VarP(new(outputFlag), "output", "o", "output format") return cmd } ``` @@ -914,7 +915,7 @@ When adding new commands to this architecture: 2. **Declare flags locally** within the builder function scope 3. **Use `RunE`** (not `Run`) so the command gets the shared lifecycle from `wrapCommands` 4. **Read config and client from the App** (`app.GetAll()`/`GetConfig()`/`GetClient()`) rather than loading them -5. **Add the flag to `flagBindings`** (in `internal/config/config.go`) if it should override a config value +5. **Add the flag to `flagBindings`** (in `internal/config/config.go`) if it should override a config value, and declare it without a variable so it can only be read back from the config 6. **Add to root command** by calling `cmd.AddCommand(buildXXXCmd(app))` in `buildRootCmd()` 7. **No init() function** required - everything goes through the root builder 8. **Test with `buildRootCmd(ctx)`** instead of recreating flag setup diff --git a/internal/cmd/auth_status.go b/internal/cmd/auth_status.go index 3041212e..6ce31dee 100644 --- a/internal/cmd/auth_status.go +++ b/internal/cmd/auth_status.go @@ -20,7 +20,6 @@ import ( ) func buildStatusCmd(app *common.App) *cobra.Command { - var output string cmd := &cobra.Command{ Use: "status", @@ -64,7 +63,7 @@ func buildStatusCmd(app *common.App) *cobra.Command { }, } - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "output format (json, yaml, table)") return cmd } diff --git a/internal/cmd/config_show.go b/internal/cmd/config_show.go index c4188af8..9bb0d46a 100644 --- a/internal/cmd/config_show.go +++ b/internal/cmd/config_show.go @@ -13,7 +13,6 @@ import ( ) func buildConfigShowCmd(app *common.App) *cobra.Command { - var output string var noDefaults bool var withEnv bool @@ -52,7 +51,7 @@ func buildConfigShowCmd(app *common.App) *cobra.Command { }, } - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "output format (json, yaml, table)") cmd.Flags().BoolVar(&noDefaults, "no-defaults", false, "do not show default values for unset fields") cmd.Flags().BoolVar(&withEnv, "with-env", false, "apply environment variable overrides") diff --git a/internal/cmd/db_create_role.go b/internal/cmd/db_create_role.go index 0bc945e7..330b761c 100644 --- a/internal/cmd/db_create_role.go +++ b/internal/cmd/db_create_role.go @@ -20,7 +20,6 @@ func buildDbCreateRoleCmd(app *common.App) *cobra.Command { var fromRoles []string var statementTimeout time.Duration var passwordFlag string - var output string cmd := &cobra.Command{ Use: "role [service-id]", @@ -158,7 +157,7 @@ PostgreSQL Configuration Parameters That May Be Set: cmd.Flags().StringSliceVar(&fromRoles, "from", []string{}, "Roles to inherit grants from (e.g., --from app_role --from readonly_role or --from app_role,readonly_role)") cmd.Flags().DurationVar(&statementTimeout, "statement-timeout", 0, "Set statement timeout for the role (e.g., 30s, 5m)") cmd.Flags().StringVar(&passwordFlag, "password", "", "Password for the role. If not provided, checks TIGER_NEW_PASSWORD environment variable, otherwise auto-generates a secure random password.") - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "output format (json, yaml, table)") cmd.MarkFlagRequired("name") diff --git a/internal/cmd/flag_helper.go b/internal/cmd/flag_helper.go index b8fb2dc4..06512636 100644 --- a/internal/cmd/flag_helper.go +++ b/internal/cmd/flag_helper.go @@ -4,11 +4,13 @@ import ( "github.com/timescale/tiger-cli/internal/config" ) -// outputFlag implements the [github.com/spf13/pflag.Value] interface. +// outputFlag implements the [github.com/spf13/pflag.Value] interface. These +// types only validate the value at parse time — commands read the result from +// cfg.Output — so they're registered with `new(outputFlag)` and no variable. type outputFlag string func (o *outputFlag) Set(val string) error { - if err := config.ValidateOutputFormat(val, false); err != nil { + if err := config.ValidateOutputFormat(val); err != nil { return err } *o = outputFlag(val) @@ -27,7 +29,7 @@ func (o *outputFlag) Type() string { type outputWithEnvFlag string func (o *outputWithEnvFlag) Set(val string) error { - if err := config.ValidateOutputFormat(val, true); err != nil { + if err := config.ValidateOutputFormat(val, "env"); err != nil { return err } *o = outputWithEnvFlag(val) @@ -41,3 +43,22 @@ func (o *outputWithEnvFlag) String() string { func (o *outputWithEnvFlag) Type() string { return "string" } + +// outputWithBareFlag implements the [github.com/spf13/pflag.Value] interface. +type outputWithBareFlag string + +func (o *outputWithBareFlag) Set(val string) error { + if err := config.ValidateOutputFormat(val, "bare"); err != nil { + return err + } + *o = outputWithBareFlag(val) + return nil +} + +func (o *outputWithBareFlag) String() string { + return string(*o) +} + +func (o *outputWithBareFlag) Type() string { + return "string" +} diff --git a/internal/cmd/mcp_get.go b/internal/cmd/mcp_get.go index 7c9e5483..09beeb77 100644 --- a/internal/cmd/mcp_get.go +++ b/internal/cmd/mcp_get.go @@ -18,7 +18,6 @@ import ( // buildMCPGetCmd creates the get subcommand for displaying detailed info on a specific MCP capability func buildMCPGetCmd(app *common.App) *cobra.Command { - var outputFormat string cmd := &cobra.Command{ Use: "get ", @@ -95,7 +94,7 @@ Examples: }, } - cmd.Flags().VarP((*outputFlag)(&outputFormat), "output", "o", "output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "output format (json, yaml, table)") return cmd } diff --git a/internal/cmd/mcp_list.go b/internal/cmd/mcp_list.go index 9b684ec4..0f095f5b 100644 --- a/internal/cmd/mcp_list.go +++ b/internal/cmd/mcp_list.go @@ -14,7 +14,6 @@ import ( // buildMCPListCmd creates the list subcommand for displaying available MCP capabilities func buildMCPListCmd(app *common.App) *cobra.Command { - var outputFormat string cmd := &cobra.Command{ Use: "list", @@ -70,7 +69,7 @@ Examples: }, } - cmd.Flags().VarP((*outputFlag)(&outputFormat), "output", "o", "output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "output format (json, yaml, table)") return cmd } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index afe708ee..d977ee01 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -32,14 +32,6 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) { Experimental: experimental, } - var configDir string - var debug bool - var serviceID string - var analyticsEnabled bool - var passwordStorage string - var skipUpdateCheck bool - var colorFlag bool - cmd := &cobra.Command{ Use: "tiger", Short: "Tiger CLI - Tiger Cloud Platform command-line interface", @@ -58,14 +50,16 @@ tiger auth login // executes — so handlers can use cmd.Context() for cancellation. cmd.SetContext(ctx) - // Add persistent flags - cmd.PersistentFlags().StringVar(&configDir, "config-dir", config.GetDefaultConfigDir(), "config directory") - cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging") - cmd.PersistentFlags().StringVar(&serviceID, "service-id", "", "service ID") - cmd.PersistentFlags().BoolVar(&analyticsEnabled, "analytics", true, "enable/disable usage analytics") - cmd.PersistentFlags().StringVar(&passwordStorage, "password-storage", config.DefaultPasswordStorage, "password storage method (keyring, pgpass, none)") - cmd.PersistentFlags().BoolVar(&skipUpdateCheck, "skip-update-check", false, "skip checking for updates on startup") - cmd.PersistentFlags().BoolVar(&colorFlag, "color", true, "enable colored output") + // Add persistent flags. Values are read back from the config (see + // flagBindings in internal/config) rather than from the flag variables, so + // only --skip-update-check — which isn't a config value — is captured here. + cmd.PersistentFlags().Bool("analytics", true, "enable/disable usage analytics") + cmd.PersistentFlags().Bool("color", true, "enable colored output") + cmd.PersistentFlags().String("config-dir", config.GetDefaultConfigDir(), "config directory") + cmd.PersistentFlags().Bool("debug", false, "enable debug logging") + cmd.PersistentFlags().String("password-storage", config.DefaultPasswordStorage, "password storage method (keyring, pgpass, none)") + cmd.PersistentFlags().String("service-id", "", "service ID") + skipUpdateCheck := cmd.PersistentFlags().Bool("skip-update-check", false, "skip checking for updates on startup") // Add all subcommands cmd.AddCommand(buildVersionCmd(app)) @@ -76,7 +70,7 @@ tiger auth login cmd.AddCommand(buildDbCmd(app)) cmd.AddCommand(buildMCPCmd(app)) - wrapCommands(cmd, app, &skipUpdateCheck) + wrapCommands(cmd, app, skipUpdateCheck) return cmd, nil } diff --git a/internal/cmd/service_create.go b/internal/cmd/service_create.go index 5142de95..05394df0 100644 --- a/internal/cmd/service_create.go +++ b/internal/cmd/service_create.go @@ -27,7 +27,6 @@ func buildServiceCreateCmd(app *common.App) *cobra.Command { var createNoSetDefault bool var createWithPassword bool var createEnvironment string - var output string cmd := &cobra.Command{ Use: "create", @@ -231,7 +230,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.Flags().DurationVar(&createWaitTimeout, "wait-timeout", 30*time.Minute, "Wait timeout duration (e.g., 30m, 1h30m, 90s)") cmd.Flags().BoolVar(&createNoSetDefault, "no-set-default", false, "Don't set this service as the default service") cmd.Flags().BoolVar(&createWithPassword, "with-password", false, "Include password in output") - cmd.Flags().VarP((*outputWithEnvFlag)(&output), "output", "o", "Output format (json, yaml, env, table)") + cmd.Flags().VarP(new(outputWithEnvFlag), "output", "o", "Output format (json, yaml, env, table)") return cmd } diff --git a/internal/cmd/service_fork.go b/internal/cmd/service_fork.go index ef309fc4..2b0d490d 100644 --- a/internal/cmd/service_fork.go +++ b/internal/cmd/service_fork.go @@ -27,7 +27,6 @@ func buildServiceForkCmd(app *common.App) *cobra.Command { var forkMemory string var forkWithPassword bool var forkEnvironment string - var output string cmd := &cobra.Command{ Use: "fork [service-id]", @@ -256,7 +255,7 @@ Examples: cmd.Flags().StringVar(&forkMemory, "memory", "", "Memory allocation in gigabytes (inherits from source if not specified)") cmd.Flags().StringVar(&forkEnvironment, "environment", "DEV", "Environment tag (DEV or PROD)") cmd.Flags().BoolVar(&forkWithPassword, "with-password", false, "Include password in output") - cmd.Flags().VarP((*outputWithEnvFlag)(&output), "output", "o", "Output format (json, yaml, env, table)") + cmd.Flags().VarP(new(outputWithEnvFlag), "output", "o", "Output format (json, yaml, env, table)") return cmd } diff --git a/internal/cmd/service_get.go b/internal/cmd/service_get.go index ca0fc198..ad0965cd 100644 --- a/internal/cmd/service_get.go +++ b/internal/cmd/service_get.go @@ -14,7 +14,6 @@ import ( // buildServiceGetCmd represents the get command under service func buildServiceGetCmd(app *common.App) *cobra.Command { var withPassword bool - var output string cmd := &cobra.Command{ Use: "get [service-id]", @@ -80,7 +79,7 @@ Examples: } cmd.Flags().BoolVar(&withPassword, "with-password", false, "Include password in output") - cmd.Flags().VarP((*outputWithEnvFlag)(&output), "output", "o", "Output format (json, yaml, env, table)") + cmd.Flags().VarP(new(outputWithEnvFlag), "output", "o", "Output format (json, yaml, env, table)") return cmd } diff --git a/internal/cmd/service_list.go b/internal/cmd/service_list.go index 0af2865e..b58a918d 100644 --- a/internal/cmd/service_list.go +++ b/internal/cmd/service_list.go @@ -19,7 +19,6 @@ import ( // serviceListCmd represents the list command under service func buildServiceListCmd(app *common.App) *cobra.Command { - var output string cmd := &cobra.Command{ Use: "list", @@ -73,7 +72,7 @@ func buildServiceListCmd(app *common.App) *cobra.Command { }, } - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "Output format (json, yaml, table)") return cmd } diff --git a/internal/cmd/service_logs.go b/internal/cmd/service_logs.go index c693c6d0..f97f6e4c 100644 --- a/internal/cmd/service_logs.go +++ b/internal/cmd/service_logs.go @@ -19,7 +19,6 @@ func buildServiceLogsCmd(app *common.App) *cobra.Command { var since time.Time var until time.Time var node int - var output string cmd := &cobra.Command{ Use: "logs [service-id]", @@ -139,7 +138,7 @@ Examples: cmd.Flags().TimeVar(&since, "since", time.Time{}, []string{time.RFC3339}, "Fetch logs after this timestamp (RFC3339 format, e.g., 2024-01-15T09:00:00Z)") cmd.Flags().TimeVar(&until, "until", time.Time{}, []string{time.RFC3339}, "Fetch logs before this timestamp (RFC3339 format, e.g., 2024-01-15T10:00:00Z)") cmd.Flags().IntVar(&node, "node", 0, "Specific service node to fetch logs from (for services with HA replicas, 0 is valid)") - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (text, json, yaml)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "Output format (text, json, yaml)") return cmd } diff --git a/internal/cmd/service_metrics_available_series.go b/internal/cmd/service_metrics_available_series.go index 52a37715..cbbc0a5d 100644 --- a/internal/cmd/service_metrics_available_series.go +++ b/internal/cmd/service_metrics_available_series.go @@ -15,7 +15,6 @@ import ( // buildServiceMetricsAvailableSeriesCmd lists the metric series available for a service func buildServiceMetricsAvailableSeriesCmd(app *common.App) *cobra.Command { - var output string cmd := &cobra.Command{ Use: "available-series [service-id]", @@ -69,6 +68,6 @@ func buildServiceMetricsAvailableSeriesCmd(app *common.App) *cobra.Command { }, } - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "Output format (json, yaml, table)") return cmd } diff --git a/internal/cmd/service_metrics_series.go b/internal/cmd/service_metrics_series.go index 3f374bad..7b2e806d 100644 --- a/internal/cmd/service_metrics_series.go +++ b/internal/cmd/service_metrics_series.go @@ -26,7 +26,6 @@ func buildServiceMetricsSeriesCmd(app *common.App) *cobra.Command { var filters []string var bucketSeconds int var fn string - var output string cmd := &cobra.Command{ Use: "series [service-id]", @@ -128,7 +127,7 @@ Examples: cmd.Flags().StringSliceVar(&filters, "filter", nil, "Arbitrary label filter as name=value (repeatable)") cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (optional; server auto-selects based on the time window when omitted, minimum 60s)") cmd.Flags().StringVar(&fn, "fn", "", "Aggregation function applied per bucket. One of: RATE, INCREASE, SUM, AVG, MIN, MAX, COUNT, P50, P90, P99, LAST. Rejected on the timescale_cloud_* resource/qps/connections/jobs metrics; omit to let the server pick the default") - cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + cmd.Flags().VarP(new(outputFlag), "output", "o", "Output format (json, yaml, table)") cmd.MarkFlagRequired("metric") cmd.MarkFlagRequired("from") diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 76b4b0a4..bff58626 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -25,7 +25,6 @@ type VersionOutput struct { func buildVersionCmd(app *common.App) *cobra.Command { var checkVersion bool - var outputFormat string cmd := &cobra.Command{ Use: "version", @@ -36,6 +35,8 @@ func buildVersionCmd(app *common.App) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true + cfg := app.GetConfig() + versionOutput := VersionOutput{ Version: config.Version, BuildTime: config.BuildTime, @@ -46,7 +47,6 @@ func buildVersionCmd(app *common.App) *cobra.Command { updateAvailable := false if checkVersion { - cfg := app.GetConfig() result, err := version.CheckForUpdate(cfg) if err != nil { // A failed check shouldn't fail the version command; warn and @@ -62,7 +62,7 @@ func buildVersionCmd(app *common.App) *cobra.Command { } output := cmd.OutOrStdout() - switch outputFormat { + switch cfg.Output { case "json": if err := util.SerializeToJSON(output, versionOutput); err != nil { return err @@ -87,7 +87,7 @@ func buildVersionCmd(app *common.App) *cobra.Command { } cmd.Flags().BoolVar(&checkVersion, "check", false, "Force checking for updates (regardless of last check time)") - cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format (table, json, yaml, bare)") + cmd.Flags().VarP(new(outputWithBareFlag), "output", "o", "Output format (table, json, yaml, bare)") return cmd } diff --git a/internal/config/config.go b/internal/config/config.go index 7141be3e..0e7f75a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -372,7 +372,7 @@ func validateValue(key, value string) (any, error) { case "mcp_max_rows": return parsePositiveInt(key, value) case "output": - if err := ValidateOutputFormat(value, false); err != nil { + if err := ValidateOutputFormat(value); err != nil { return nil, err } return value, nil diff --git a/internal/config/output.go b/internal/config/output.go index 7b838728..79447dd5 100644 --- a/internal/config/output.go +++ b/internal/config/output.go @@ -2,21 +2,20 @@ package config import ( "fmt" + "slices" "strings" ) +// validOutputFormats are the formats every command supports. Commands that +// accept an extra format (`env`, `bare`) pass it to ValidateOutputFormat. var validOutputFormats = []string{"json", "yaml", "table"} -var validOutputFormatsWithEnv = append(validOutputFormats, "env") -func ValidateOutputFormat(format string, allowEnv bool) error { - formats := validOutputFormats - if allowEnv { - formats = validOutputFormatsWithEnv - } - for _, valid := range formats { - if format == valid { - return nil - } +// ValidateOutputFormat checks format against the universally supported formats +// plus any command-specific extras. +func ValidateOutputFormat(format string, extra ...string) error { + formats := append(slices.Clone(validOutputFormats), extra...) + if slices.Contains(formats, format) { + return nil } return fmt.Errorf("invalid output format: %s (must be one of: %s)", format, strings.Join(formats, ", ")) }