diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5c5e1016..d520b224 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -93,6 +93,29 @@ nfpms: lintian_overrides: - statically-linked-binary +dockers_v2: + - id: docker + images: + - ghcr.io/timescale/tiger-cli + tags: + - "{{ .Version }}" + - '{{ if eq .Prerelease "" }}{{ .Major }}{{ end }}' + - '{{ if eq .Prerelease "" }}{{ .Major }}.{{ .Minor }}{{ end }}' + - '{{ if eq .Prerelease "" }}latest{{ end }}' + labels: + org.opencontainers.image.created: "{{.Date}}" + org.opencontainers.image.name: "{{.ProjectName}}" + org.opencontainers.image.title: "{{.ProjectName}}" + org.opencontainers.image.revision: "{{.FullCommit}}" + org.opencontainers.image.source: "https://github.com/timescale/tiger-cli" + org.opencontainers.image.url: "https://github.com/timescale/tiger-cli" + org.opencontainers.image.version: "{{.Version}}" + io.modelcontextprotocol.server.name: "io.github.timescale/tiger-cli" + build_args: + BINARY_SOURCE: release + platforms: + - linux/amd64 + - linux/arm64 # S3 Blob Storage Configuration blobs: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f7a1e98e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +ARG GO_VERSION=1.25 +ARG BINARY_SOURCE=builder + +# When performing a multi-platform build, leverage Go's built-in support for +# cross-compilation instead of relying on emulation (which is much slower). +# See: https://docs.docker.com/build/building/multi-platform/#cross-compiling-a-go-application +FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS builder +ARG TARGETOS +ARG TARGETARCH + +# Download dependencies to local module cache +WORKDIR /src +RUN --mount=type=bind,source=go.sum,target=go.sum \ + --mount=type=bind,source=go.mod,target=go.mod \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download -x + +# Build static executable +RUN --mount=type=bind,target=. \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -o /bin/tiger ./cmd/tiger + +# When building Docker images via GoReleaser, the binaries are built externally +# and copied in. See: https://goreleaser.com/customization/dockers_v2/ +FROM scratch AS release +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/tiger /bin/tiger + +# Either the 'builder' or 'release' stage, depending on whether we're building +# the binaries in Docker or outside via GoReleaser. +FROM ${BINARY_SOURCE} AS binary_source + +# Create final alpine image +FROM alpine:3.23 AS final + +# Install psql for sake of `tiger db connect` +RUN apk add --update --no-cache postgresql-client + +# Create non-root user/group +RUN addgroup -g 1000 tiger && adduser -u 1000 -G tiger -D tiger +USER tiger +WORKDIR /home/tiger + +# Set env vars to control default Tiger CLI behavior +ENV TIGER_PASSWORD_STORAGE=pgpass +ENV TIGER_CONFIG_DIR=/home/tiger/.config/tiger + +# Declare config file mount points +VOLUME /home/tiger/.config/tiger +VOLUME /home/tiger/.pgpass + +# Copy binary to final image +COPY --from=binary_source /bin/tiger /usr/local/bin/tiger + +ENTRYPOINT ["tiger"] +CMD ["mcp", "start"] diff --git a/README.md b/README.md index 4427c759..1d479f28 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ Environment variables override configuration file values. All variables use the - `TIGER_DOCS_MCP` - Enable/disable docs MCP proxy - `TIGER_OUTPUT` - Output format: `json`, `yaml`, or `table` - `TIGER_PASSWORD_STORAGE` - Password storage method: `keyring`, `pgpass`, or `none` +- `TIGER_PUBLIC_KEY` - Public key to use for authentication (takes priority over stored credentials) +- `TIGER_SECRET_KEY` - Secret key to use for authentication (takes priority over stored credentials) - `TIGER_SERVICE_ID` - Default service ID - `TIGER_VERSION_CHECK_INTERVAL` - How often the CLI will check for new versions, 0 to disable diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..57ce35cc --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,23 @@ +services: + tiger-cli: + build: + context: . + dockerfile: ./Dockerfile + develop: + watch: + - action: rebuild + path: ./ + image: ghcr.io/timescale/tiger-cli:${TAG:-latest} + container_name: tiger-cli + environment: + - TIGER_PUBLIC_KEY + - TIGER_SECRET_KEY + ports: + - 8080:8080 + volumes: + - type: bind + source: ${HOME}/.config/tiger + target: /home/tiger/.config/tiger + - type: bind + source: ${HOME}/.pgpass + target: /home/tiger/.pgpass diff --git a/internal/tiger/analytics/analytics.go b/internal/tiger/analytics/analytics.go index 5c00f5e9..ac64b9f4 100644 --- a/internal/tiger/analytics/analytics.go +++ b/internal/tiger/analytics/analytics.go @@ -37,7 +37,9 @@ type Analytics struct { client *api.ClientWithResponses } -// New initializes a new [Analytics] instance. +// 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 { return &Analytics{ config: cfg, @@ -46,26 +48,6 @@ func New(cfg *config.Config, client *api.ClientWithResponses, projectID string) } } -// TryInit tries to load credentials to initialize an [Analytics] -// instance. It returns an instance with a nil client if credentials do not -// exist or it otherwise fails to create a new client. This function is -// intended to be used when the caller does not otherwise need an API client to -// function, but would use one if available to track analytics events. -// Otherwise, call NewAnalytics directly. -func TryInit(cfg *config.Config) *Analytics { - apiKey, projectID, err := config.GetCredentials() - if err != nil { - return New(cfg, nil, "") - } - - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return New(cfg, nil, projectID) - } - - return New(cfg, client, projectID) -} - // Option is a function that modifies analytics event properties. Options are // passed to Track and Identify methods to customize the data sent with events. type Option func(properties map[string]any) diff --git a/internal/tiger/cmd/auth.go b/internal/tiger/cmd/auth.go index 85bcb6a4..efffe31e 100644 --- a/internal/tiger/cmd/auth.go +++ b/internal/tiger/cmd/auth.go @@ -3,6 +3,7 @@ package cmd import ( "bufio" "context" + "errors" "fmt" "io" "os" @@ -15,15 +16,14 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" - "github.com/timescale/tiger-cli/internal/tiger/analytics" "github.com/timescale/tiger-cli/internal/tiger/api" "github.com/timescale/tiger-cli/internal/tiger/common" "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/util" ) -// validateAndGetAuthInfo can be overridden for testing -var validateAndGetAuthInfo = validateAndGetAuthInfoImpl +// validateAPIKey can be overridden for testing +var validateAPIKey = common.ValidateAPIKey // nextStepsMessage is the message shown after successful login const nextStepsMessage = ` @@ -118,9 +118,15 @@ Examples: // Combine the keys in the format "public:secret" for storage apiKey := fmt.Sprintf("%s:%s", creds.publicKey, creds.secretKey) + // Create API client + client, err := api.NewTigerClient(cfg, apiKey) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + // Validate the API key and get auth info by calling the /auth/info endpoint fmt.Fprintln(cmd.OutOrStdout(), "Validating API key...") - authInfo, err := validateAndGetAuthInfo(cmd.Context(), cfg, apiKey) + authInfo, err := validateAPIKey(cmd.Context(), cfg, client) if err != nil { return fmt.Errorf("API key validation failed: %w", err) } @@ -178,28 +184,20 @@ func buildStatusCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - // Get config - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - apiKey, _, err := config.GetCredentials() + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { + if errors.Is(err, config.ErrNotLoggedIn) { + return common.ExitWithCode(common.ExitAuthenticationError, config.ErrNotLoggedIn) + } return err } - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return fmt.Errorf("failed to create API client: %w", err) - } - // Make API call to get auth information ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := client.GetAuthInfoWithResponse(ctx) + resp, err := cfg.Client.GetAuthInfoWithResponse(ctx) if err != nil { return fmt.Errorf("failed to get auth information: %w", err) } @@ -316,45 +314,3 @@ func promptForCredentials(ctx context.Context, consoleURL string, creds credenti return creds, nil } - -// validateAndGetAuthInfoImpl validates the API key and returns authentication information -// by calling the /auth/info endpoint. -func validateAndGetAuthInfoImpl(ctx context.Context, cfg *config.Config, apiKey string) (*api.AuthInfo, error) { - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return nil, fmt.Errorf("failed to create client: %w", err) - } - - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // Call the /auth/info endpoint to validate credentials and get auth info - resp, err := client.GetAuthInfoWithResponse(ctx) - if err != nil { - return nil, fmt.Errorf("API call failed: %w", err) - } - - // Check the response status - if resp.StatusCode() != 200 { - if resp.JSON4XX != nil { - return nil, resp.JSON4XX - } - return nil, fmt.Errorf("unexpected API response: %d", resp.StatusCode()) - } - - if resp.JSON200 == nil { - return nil, fmt.Errorf("empty response from API") - } - - authInfo := resp.JSON200 - - // Identify the user with analytics - a := analytics.New(cfg, client, authInfo.ApiKey.Project.Id) - a.Identify( - analytics.Property("userId", authInfo.ApiKey.IssuingUser.Id), - analytics.Property("email", string(authInfo.ApiKey.IssuingUser.Email)), - analytics.Property("planType", authInfo.ApiKey.Project.PlanType), - ) - - return authInfo, nil -} diff --git a/internal/tiger/cmd/auth_test.go b/internal/tiger/cmd/auth_test.go index 833844e5..bb459c18 100644 --- a/internal/tiger/cmd/auth_test.go +++ b/internal/tiger/cmd/auth_test.go @@ -19,7 +19,6 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/api" "github.com/timescale/tiger-cli/internal/tiger/config" - "github.com/timescale/tiger-cli/internal/tiger/logging" ) func setupAuthTest(t *testing.T) string { @@ -29,8 +28,8 @@ func setupAuthTest(t *testing.T) string { config.SetTestServiceName(t) // Mock the API key validation for testing - originalValidator := validateAndGetAuthInfo - validateAndGetAuthInfo = func(ctx context.Context, cfg *config.Config, apiKey string) (*api.AuthInfo, error) { + originalValidator := validateAPIKey + validateAPIKey = func(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) { // Always return success with test auth info authInfo := &api.AuthInfo{ Type: api.ApiKey, @@ -68,7 +67,7 @@ func setupAuthTest(t *testing.T) string { config.RemoveCredentials() // Reset global config and viper first config.ResetGlobalConfig() - validateAndGetAuthInfo = originalValidator // Restore original validator + validateAPIKey = originalValidator // Restore original validator // Remove config file explicitly configFile := config.GetConfigFile(tmpDir) os.Remove(configFile) @@ -690,129 +689,3 @@ func TestAuthLogout_Success(t *testing.T) { t.Fatal("Credentials should be removed after logout") } } - -func TestValidateAndGetAuthInfo(t *testing.T) { - // Initialize logger for analytics code - if err := logging.Init(false); err != nil { - t.Fatalf("Failed to initialize logging: %v", err) - } - - tests := []struct { - name string - setupServer func() *httptest.Server - expectedProjectID string - expectedPublicKey string - expectedError string - }{ - { - name: "valid API key - returns auth info", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/auth/info" { - authInfo := api.AuthInfo{ - Type: api.ApiKey, - } - authInfo.ApiKey.PublicKey = "test-access-key" - authInfo.ApiKey.Project.Id = "proj-12345" - authInfo.ApiKey.Project.Name = "Test Project" - authInfo.ApiKey.Project.PlanType = "FREE" - authInfo.ApiKey.Name = "Test Credentials" - authInfo.ApiKey.IssuingUser.Name = "Test User" - authInfo.ApiKey.IssuingUser.Email = "test@example.com" - authInfo.ApiKey.IssuingUser.Id = "user-123" - authInfo.ApiKey.Created = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(authInfo) - } else if r.URL.Path == "/analytics/identify" { - // Analytics identify endpoint (called after auth info) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status": "success"}`)) - } else { - t.Errorf("Unexpected path: %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - })) - }, - expectedProjectID: "proj-12345", - expectedPublicKey: "test-access-key", - expectedError: "", - }, - { - name: "invalid API key - 401 response", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"message": "Invalid or missing authentication credentials"}`)) - })) - }, - expectedProjectID: "", - expectedPublicKey: "", - expectedError: "Invalid or missing authentication credentials", - }, - { - name: "invalid API key - 403 response", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - w.Write([]byte(`{"message": "Invalid or missing authentication credentials"}`)) - })) - }, - expectedProjectID: "", - expectedPublicKey: "", - expectedError: "Invalid or missing authentication credentials", - }, - { - name: "unexpected response - 500", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - }, - expectedProjectID: "", - expectedPublicKey: "", - expectedError: "unexpected API response: 500", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := tt.setupServer() - defer server.Close() - - // Setup test config with the test server URL - cfg, err := config.UseTestConfig(t.TempDir(), map[string]any{ - "api_url": server.URL, - }) - if err != nil { - t.Fatalf("Failed to setup test config: %v", err) - } - - authInfo, err := validateAndGetAuthInfoImpl(context.Background(), cfg, "test-api-key") - - if tt.expectedError == "" { - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if authInfo == nil { - t.Fatal("Expected auth info to be returned, got nil") - } - if authInfo.ApiKey.Project.Id != tt.expectedProjectID { - t.Errorf("Expected project ID %q, got %q", tt.expectedProjectID, authInfo.ApiKey.Project.Id) - } - if authInfo.ApiKey.PublicKey != tt.expectedPublicKey { - t.Errorf("Expected access key %q, got %q", tt.expectedPublicKey, authInfo.ApiKey.PublicKey) - } - } else { - if err == nil { - t.Errorf("Expected error containing %q, got nil", tt.expectedError) - } else if err.Error() != tt.expectedError { - t.Errorf("Expected error %q, got %q", tt.expectedError, err.Error()) - } - } - }) - } -} diff --git a/internal/tiger/cmd/auth_validation_test.go b/internal/tiger/cmd/auth_validation_test.go index 7c57f485..a802a040 100644 --- a/internal/tiger/cmd/auth_validation_test.go +++ b/internal/tiger/cmd/auth_validation_test.go @@ -22,15 +22,15 @@ func TestAuthLogin_APIKeyValidationFailure(t *testing.T) { // Use a unique service name for this test config.SetTestServiceName(t) - originalValidator := validateAndGetAuthInfo + originalValidator := validateAPIKey // Mock the validator to return an error - validateAndGetAuthInfo = func(ctx context.Context, cfg *config.Config, apiKey string) (*api.AuthInfo, error) { + validateAPIKey = func(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) { return nil, errors.New("invalid API key: authentication failed") } defer func() { - validateAndGetAuthInfo = originalValidator + validateAPIKey = originalValidator }() // Initialize viper with test directory BEFORE calling RemoveCredentials() @@ -76,10 +76,10 @@ func TestAuthLogin_APIKeyValidationSuccess(t *testing.T) { // Use a unique service name for this test config.SetTestServiceName(t) - originalValidator := validateAndGetAuthInfo + originalValidator := validateAPIKey // Mock the validator to return success - validateAndGetAuthInfo = func(ctx context.Context, cfg *config.Config, apiKey string) (*api.AuthInfo, error) { + validateAPIKey = func(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) { authInfo := &api.AuthInfo{ Type: api.ApiKey, } @@ -89,7 +89,7 @@ func TestAuthLogin_APIKeyValidationSuccess(t *testing.T) { } defer func() { - validateAndGetAuthInfo = originalValidator + validateAPIKey = originalValidator }() // Initialize viper with test directory BEFORE calling RemoveCredentials() diff --git a/internal/tiger/cmd/config.go b/internal/tiger/cmd/config.go index 33cc123a..04882d26 100644 --- a/internal/tiger/cmd/config.go +++ b/internal/tiger/cmd/config.go @@ -90,8 +90,6 @@ func buildConfigSetCmd() *cobra.Command { Args: cobra.ExactArgs(2), ValidArgsFunction: configOptionCompletion, RunE: func(cmd *cobra.Command, args []string) error { - key, value := args[0], args[1] - cmd.SilenceUsage = true cfg, err := config.Load() @@ -99,6 +97,7 @@ func buildConfigSetCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } + key, value := args[0], args[1] if err := cfg.Set(key, value); err != nil { return fmt.Errorf("failed to set config: %w", err) } @@ -118,8 +117,6 @@ func buildConfigUnsetCmd() *cobra.Command { Args: cobra.ExactArgs(1), ValidArgsFunction: configOptionCompletion, RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - cmd.SilenceUsage = true cfg, err := config.Load() @@ -127,6 +124,7 @@ func buildConfigUnsetCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } + key := args[0] if err := cfg.Unset(key); err != nil { return fmt.Errorf("failed to unset config: %w", err) } diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index 72acf2a8..6153ed38 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -18,14 +18,10 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/api" "github.com/timescale/tiger-cli/internal/tiger/common" - "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/util" ) var ( - // getCredentialsForDB can be overridden for testing - getCredentialsForDB = config.GetCredentials - // getServiceDetailsFunc can be overridden for testing getServiceDetailsFunc = getServiceDetails @@ -79,11 +75,13 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, projectID, client, err := loadConfigAndApiClient() + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { + cmd.SilenceUsage = true return err } - service, err := getServiceDetails(cmd, cfg, projectID, client, args) + + service, err := getServiceDetailsFunc(cmd, cfg, args) if err != nil { return err } @@ -162,13 +160,16 @@ Examples: Args: cobra.ArbitraryArgs, ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - // Separate service ID from additional psql flags - serviceArgs, psqlFlags := separateServiceAndPsqlArgs(cmd, args) - cfg, projectID, client, err := loadConfigAndApiClient() + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { + cmd.SilenceUsage = true return err } - service, err := getServiceDetails(cmd, cfg, projectID, client, serviceArgs) + + // Separate service ID from additional psql flags + serviceArgs, psqlFlags := separateServiceAndPsqlArgs(cmd, args) + + service, err := getServiceDetailsFunc(cmd, cfg, serviceArgs) if err != nil { return err } @@ -191,7 +192,7 @@ Examples: return fmt.Errorf("connection pooler not available for this service") } - return connectWithPasswordMenu(cmd.Context(), cmd, client, service, details, psqlPath, psqlFlags) + return connectWithPasswordMenu(cmd.Context(), cmd, cfg.Client, service, details, psqlPath, psqlFlags) }, } @@ -240,11 +241,13 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, projectID, client, err := loadConfigAndApiClient() + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return err + cmd.SilenceUsage = true + return common.ExitWithCode(common.ExitInvalidParameters, err) } - service, err := getServiceDetails(cmd, cfg, projectID, client, args) + + service, err := getServiceDetailsFunc(cmd, cfg, args) if err != nil { return common.ExitWithCode(common.ExitInvalidParameters, err) } @@ -318,11 +321,13 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion, RunE: func(cmd *cobra.Command, args []string) error { - cfg, projectID, client, err := loadConfigAndApiClient() + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { + cmd.SilenceUsage = true return err } - service, err := getServiceDetailsFunc(cmd, cfg, projectID, client, args) + + service, err := getServiceDetailsFunc(cmd, cfg, args) if err != nil { return err } @@ -665,9 +670,14 @@ PostgreSQL Configuration Parameters That May Be Set: return fmt.Errorf("--name is required") } - cmd.SilenceUsage = true + cfg, err := common.LoadConfig(cmd.Context()) + if err != nil { + cmd.SilenceUsage = true + return err + } - cfg, projectID, client, err := loadConfigAndApiClient() + // Get service details + service, err := getServiceDetailsFunc(cmd, cfg, args) if err != nil { return err } @@ -678,12 +688,6 @@ PostgreSQL Configuration Parameters That May Be Set: return fmt.Errorf("failed to determine password: %w", err) } - // Get service details - service, err := getServiceDetails(cmd, cfg, projectID, client, args) - if err != nil { - return err - } - // Build connection string details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{ Pooled: false, @@ -763,33 +767,10 @@ func buildDbCmd() *cobra.Command { return cmd } -func loadConfigAndApiClient() (*config.Config, string, *api.ClientWithResponses, error) { - // Load config - cfg, err := config.Load() - if err != nil { - return nil, "", nil, fmt.Errorf("failed to load config: %w", err) - } - - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForDB() - if err != nil { - return nil, "", nil, common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return nil, "", nil, fmt.Errorf("failed to create API client: %w", err) - } - - return cfg, projectID, client, nil -} - // getServiceDetails is a helper that handles common service lookup logic and returns the service details -func getServiceDetails(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { - +func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { // Determine service ID - serviceID, err := getServiceID(cfg, args) + serviceID, err := getServiceID(cfg.Config, args) if err != nil { return api.Service{}, err } @@ -800,7 +781,7 @@ func getServiceDetails(cmd *cobra.Command, cfg *config.Config, projectID string, ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) + resp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, serviceID) if err != nil { return api.Service{}, fmt.Errorf("failed to fetch service details: %w", err) } diff --git a/internal/tiger/cmd/db_test.go b/internal/tiger/cmd/db_test.go index 84e137cf..410938e7 100644 --- a/internal/tiger/cmd/db_test.go +++ b/internal/tiger/cmd/db_test.go @@ -81,11 +81,11 @@ func TestDBConnectionString_NoServiceID(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db connection-string command without service ID _, err = executeDBCommand(t.Context(), "db", "connection-string") @@ -111,11 +111,11 @@ func TestDBConnectionString_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db connection-string command _, err = executeDBCommand(t.Context(), "db", "connection-string") @@ -174,11 +174,11 @@ func TestDBConnect_NoServiceID(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db connect command without service ID _, err = executeDBCommand(t.Context(), "db", "connect") @@ -204,11 +204,11 @@ func TestDBConnect_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db connect command _, err = executeDBCommand(t.Context(), "db", "connect") @@ -234,11 +234,11 @@ func TestDBConnect_PsqlNotFound(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Test that psql alias works the same as connect _, err1 := executeDBCommand(t.Context(), "db", "connect") @@ -540,11 +540,11 @@ func TestDBTestConnection_NoServiceID(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db test-connection command without service ID _, err = executeDBCommand(t.Context(), "db", "test-connection") @@ -570,11 +570,11 @@ func TestDBTestConnection_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db test-connection command _, err = executeDBCommand(t.Context(), "db", "test-connection") @@ -830,11 +830,11 @@ func TestDBTestConnection_TimeoutParsing(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute db test-connection command with timeout flag _, err = executeDBCommand(t.Context(), "db", "test-connection", "--timeout", tc.timeoutFlag) @@ -982,12 +982,12 @@ func TestDBSavePassword_ExplicitPassword(t *testing.T) { } originalGetServiceDetails := getServiceDetailsFunc - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { + defer func() { common.GetCredentials = originalGetCredentials }() + getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -1056,12 +1056,12 @@ func TestDBSavePassword_EnvironmentVariable(t *testing.T) { } originalGetServiceDetails := getServiceDetailsFunc - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { + defer func() { common.GetCredentials = originalGetCredentials }() + getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -1130,12 +1130,12 @@ func TestDBSavePassword_InteractivePrompt(t *testing.T) { } originalGetServiceDetails := getServiceDetailsFunc - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { + defer func() { common.GetCredentials = originalGetCredentials }() + getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -1211,12 +1211,12 @@ func TestDBSavePassword_InteractivePromptEmpty(t *testing.T) { } originalGetServiceDetails := getServiceDetailsFunc - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { + defer func() { common.GetCredentials = originalGetCredentials }() + getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -1285,12 +1285,12 @@ func TestDBSavePassword_CustomRole(t *testing.T) { } originalGetServiceDetails := getServiceDetailsFunc - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { + defer func() { common.GetCredentials = originalGetCredentials }() + getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() @@ -1342,11 +1342,11 @@ func TestDBSavePassword_NoServiceID(t *testing.T) { if err != nil { t.Fatalf("Failed to save test config: %v", err) } - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // No need to mock service details since it should fail before reaching getServiceDetailsFunc @@ -1375,11 +1375,11 @@ func TestDBSavePassword_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForDB = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute save-password command _, err = executeDBCommand(t.Context(), "db", "save-password", "--password=test-password") @@ -1427,12 +1427,12 @@ func TestDBSavePassword_PgpassStorage(t *testing.T) { } originalGetServiceDetails := getServiceDetailsFunc - originalGetCredentials := getCredentialsForDB - getCredentialsForDB = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForDB = originalGetCredentials }() - getServiceDetailsFunc = func(cmd *cobra.Command, cfg *config.Config, projectID string, client *api.ClientWithResponses, args []string) (api.Service, error) { + defer func() { common.GetCredentials = originalGetCredentials }() + getServiceDetailsFunc = func(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) { return mockService, nil } defer func() { getServiceDetailsFunc = originalGetServiceDetails }() diff --git a/internal/tiger/cmd/root.go b/internal/tiger/cmd/root.go index 950ea9a0..c8512b3d 100644 --- a/internal/tiger/cmd/root.go +++ b/internal/tiger/cmd/root.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/tiger/analytics" + "github.com/timescale/tiger-cli/internal/tiger/common" "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/logging" "github.com/timescale/tiger-cli/internal/tiger/version" @@ -143,7 +144,8 @@ func wrapCommandsWithAnalytics(cmd *cobra.Command) { // Reload credentials after command to account for credentials // changes during command (e.g. `tiger auth login` should // record an analytics event). - a := analytics.TryInit(cfg) + client, projectID, _ := common.NewAPIClient(cmd.Context(), cfg) + 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 analytics.Property("elapsed_seconds", time.Since(start).Seconds()), diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index a827bf4d..7bd125b4 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -19,11 +19,6 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/util" ) -var ( - // getCredentialsForService can be overridden for testing - getCredentialsForService = config.GetCredentials -) - // buildServiceCmd creates the main service command with all subcommands func buildServiceCmd() *cobra.Command { cmd := &cobra.Command{ @@ -77,37 +72,26 @@ Examples: ValidArgsFunction: serviceIDCompletion, PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { - // Get config - cfg, err := config.Load() + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + cmd.SilenceUsage = true + return err } // Determine service ID - serviceID, err := getServiceID(cfg, args) + serviceID, err := getServiceID(cfg.Config, args) if err != nil { return err } cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) - } - // Make API call to get service details ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) + resp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, serviceID) if err != nil { return fmt.Errorf("failed to get service details: %w", err) } @@ -146,31 +130,19 @@ func buildServiceListCmd() *cobra.Command { ValidArgsFunction: cobra.NoFileCompletions, PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { - // Get config - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) + return err } // Make API call to list services ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := client.GetProjectsProjectIdServicesWithResponse(ctx, projectID) + resp, err := cfg.Client.GetProjectsProjectIdServicesWithResponse(ctx, cfg.ProjectID) if err != nil { return fmt.Errorf("failed to list services: %w", err) } @@ -275,12 +247,6 @@ Note: You can specify both CPU and memory together, or specify only one (the oth ValidArgsFunction: cobra.NoFileCompletions, PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { - // Get config - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - // Auto-generate service name if not provided if createServiceName == "" { createServiceName = common.GenerateServiceName() @@ -314,16 +280,10 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) + return err } // Prepare service creation request @@ -353,7 +313,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 := client.PostProjectsProjectIdServicesWithResponse(ctx, projectID, serviceCreateReq) + resp, err := cfg.Client.PostProjectsProjectIdServicesWithResponse(ctx, cfg.ProjectID, serviceCreateReq) if err != nil { return fmt.Errorf("failed to create Service: %w", err) } @@ -378,7 +338,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth // Set as default service unless --no-set-default is specified if !createNoSetDefault { - if err := setDefaultService(cfg, serviceID, statusOutput); err != nil { + if err := setDefaultService(cfg.Config, 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) } @@ -392,8 +352,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: client, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -480,44 +440,32 @@ Examples: ValidArgsFunction: serviceIDCompletion, PreRunE: bindFlags("new-password"), RunE: func(cmd *cobra.Command, args []string) error { - // Get config - cfg, err := config.Load() + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + cmd.SilenceUsage = true + return err } // Determine service ID - serviceID, err := getServiceID(cfg, args) + serviceID, err := getServiceID(cfg.Config, args) if err != nil { return err } // Get password from flag or environment variable via viper password := viper.GetString("new_password") - if autoGenerate && password != "" { return fmt.Errorf("cannot use --auto-generate and --new-password together") } cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) - } - ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() // Fetch service details - serviceResp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) + serviceResp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, serviceID) if err != nil { return fmt.Errorf("failed to get service details: %w", err) } @@ -530,7 +478,7 @@ Examples: if autoGenerate { // Auto-generate password using existing function - if _, err := resetServicePassword(ctx, client, service, "tsdbadmin", "", statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cfg.Client, service, "tsdbadmin", "", statusOutput); err != nil { return err } } else if password == "" { @@ -541,7 +489,7 @@ Examples: _, err := promptAndResetPassword( ctx, statusOutput, - client, + cfg.Client, service, "tsdbadmin", ) @@ -549,7 +497,7 @@ Examples: return err } } else { - if _, err := resetServicePassword(ctx, client, service, "tsdbadmin", password, statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cfg.Client, service, "tsdbadmin", password, statusOutput); err != nil { return err } } @@ -874,18 +822,6 @@ Examples: cmd.SilenceUsage = true - // Load config - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - statusOutput := cmd.ErrOrStderr() // Prompt for confirmation unless --confirm is used @@ -905,16 +841,16 @@ Examples: } } - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) + return err } // Make the delete request - resp, err := client.DeleteProjectsProjectIdServicesServiceIdWithResponse( + resp, err := cfg.Client.DeleteProjectsProjectIdServicesServiceIdWithResponse( cmd.Context(), - api.ProjectId(projectID), + api.ProjectId(cfg.ProjectID), api.ServiceId(serviceID), ) if err != nil { @@ -936,8 +872,8 @@ Examples: // Wait for deletion to complete if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: client, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: serviceID, Handler: &common.DeletionWaitHandler{ ServiceID: serviceID, @@ -988,36 +924,25 @@ Examples: ValidArgsFunction: serviceIDCompletion, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Get config - cfg, err := config.Load() + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + cmd.SilenceUsage = true + return err } // Determine source service ID - serviceID, err := getServiceID(cfg, args) + serviceID, err := getServiceID(cfg.Config, args) if err != nil { return err } cmd.SilenceUsage = true - // Get API key - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) - } - // Make the start request - resp, err := client.PostProjectsProjectIdServicesServiceIdStartWithResponse( + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdStartWithResponse( context.Background(), - api.ProjectId(projectID), + api.ProjectId(cfg.ProjectID), api.ServiceId(serviceID), ) if err != nil { @@ -1042,8 +967,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: client, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -1095,36 +1020,25 @@ Examples: ValidArgsFunction: serviceIDCompletion, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Get config - cfg, err := config.Load() + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + cmd.SilenceUsage = true + return err } // Determine source service ID - serviceID, err := getServiceID(cfg, args) + serviceID, err := getServiceID(cfg.Config, args) if err != nil { return err } cmd.SilenceUsage = true - // Get API key - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) - } - // Make the stop request - resp, err := client.PostProjectsProjectIdServicesServiceIdStopWithResponse( + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdStopWithResponse( context.Background(), - api.ProjectId(projectID), + api.ProjectId(cfg.ProjectID), api.ServiceId(serviceID), ) if err != nil { @@ -1149,8 +1063,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: client, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "PAUSED", @@ -1264,32 +1178,21 @@ Examples: return fmt.Errorf("environment must be either 'DEV' or 'PROD', got '%s'", forkEnvironment) } - // Get config - cfg, err := config.Load() + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + cmd.SilenceUsage = true + return err } // Determine source service ID - serviceID, err := getServiceID(cfg, args) + serviceID, err := getServiceID(cfg.Config, args) if err != nil { return err } cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return fmt.Errorf("failed to create API Client: %w", err) - } - ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() @@ -1347,7 +1250,7 @@ Examples: } // Make API call to fork service - forkResp, err := client.PostProjectsProjectIdServicesServiceIdForkServiceWithResponse(ctx, projectID, serviceID, forkReq) + forkResp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdForkServiceWithResponse(ctx, cfg.ProjectID, serviceID, forkReq) if err != nil { return fmt.Errorf("failed to fork Service: %w", err) } @@ -1368,7 +1271,7 @@ Examples: // Set as default service unless --no-set-default is used if !forkNoSetDefault { - if err := setDefaultService(cfg, forkedServiceID, statusOutput); err != nil { + if err := setDefaultService(cfg.Config, 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) } @@ -1382,8 +1285,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: client, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: forkedServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -1452,29 +1355,17 @@ func serviceIDCompletion(cmd *cobra.Command, args []string, toComplete string) ( } func listServices(cmd *cobra.Command) ([]api.Service, error) { - // Load config - cfg, err := config.Load() - if err != nil { - return nil, fmt.Errorf("failed to load config: %w", err) - } - - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return nil, common.ExitWithCode(common.ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(cfg, apiKey) + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) if err != nil { - return nil, fmt.Errorf("failed to create API Client: %w", err) + return nil, err } // Make API call to list services ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() - resp, err := client.GetProjectsProjectIdServicesWithResponse(ctx, projectID) + resp, err := cfg.Client.GetProjectsProjectIdServicesWithResponse(ctx, cfg.ProjectID) if err != nil { return nil, fmt.Errorf("failed to list services: %w", err) } diff --git a/internal/tiger/cmd/service_test.go b/internal/tiger/cmd/service_test.go index 8109614d..fc231bf1 100644 --- a/internal/tiger/cmd/service_test.go +++ b/internal/tiger/cmd/service_test.go @@ -82,11 +82,11 @@ func TestServiceList_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service list command _, err, _ = executeServiceCommand(t.Context(), "service", "list") @@ -204,11 +204,11 @@ func TestServiceFork_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service fork command with required timing flag _, err, _ = executeServiceCommand(t.Context(), "service", "fork", "--now") @@ -233,11 +233,11 @@ func TestServiceFork_NoSourceService(t *testing.T) { } // Mock authentication success - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service fork command without providing service ID but with timing flag _, err, _ = executeServiceCommand(t.Context(), "service", "fork", "--now") @@ -263,11 +263,11 @@ func TestServiceFork_NoTimingFlag(t *testing.T) { } // Mock authentication success - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service fork command without any timing flag _, err, _ = executeServiceCommand(t.Context(), "service", "fork", "source-service-123") @@ -293,11 +293,11 @@ func TestServiceFork_MultipleTiming(t *testing.T) { } // Mock authentication success - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service fork command with multiple timing flags _, err, _ = executeServiceCommand(t.Context(), "service", "fork", "source-service-123", "--now", "--last-snapshot") @@ -323,11 +323,11 @@ func TestServiceFork_InvalidTimestamp(t *testing.T) { } // Mock authentication success - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service fork command with invalid timestamp _, err, _ = executeServiceCommand(t.Context(), "service", "fork", "source-service-123", "--to-timestamp", "invalid-timestamp") @@ -353,11 +353,11 @@ func TestServiceFork_CPUMemoryValidation(t *testing.T) { } // Mock authentication success - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Test with invalid CPU/memory combination (this would fail at API call stage) // Since we don't want to make real API calls, we expect the command to fail during validation @@ -396,11 +396,11 @@ func TestServiceCreate_ValidationErrors(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Test with no name (should auto-generate) - this should now work without error // Just test that it doesn't fail due to missing name @@ -432,11 +432,11 @@ func TestServiceCreate_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service create command with valid parameters (name will be auto-generated) _, err, _ = executeServiceCommand(t.Context(), "service", "create", "--addons", "none", "--region", "us-east-1", "--cpu", "1000", "--memory", "4", "--replicas", "1") @@ -497,11 +497,11 @@ func TestAutoGeneratedServiceName(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Test that service name is auto-generated when not provided // We expect this to fail at the API call stage, not at validation @@ -559,11 +559,11 @@ func TestServiceGet_NoServiceID(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service get command without service ID _, err, _ = executeServiceCommand(t.Context(), "service", "get") @@ -589,11 +589,11 @@ func TestServiceGet_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service get command _, err, _ = executeServiceCommand(t.Context(), "service", "get") @@ -977,11 +977,11 @@ func TestServiceUpdatePassword_NoServiceID(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service update-password command without service ID _, err, _ = executeServiceCommand(t.Context(), "service", "update-password", "--new-password", "new-password") @@ -1007,11 +1007,11 @@ func TestServiceUpdatePassword_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service update-password command _, err, _ = executeServiceCommand(t.Context(), "service", "update-password", "--new-password", "new-password") @@ -1037,11 +1037,11 @@ func TestServiceUpdatePassword_EnvironmentVariable(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Set environment variable BEFORE creating command (like root test does) originalEnv := os.Getenv("TIGER_NEW_PASSWORD") @@ -1088,11 +1088,11 @@ func TestServiceCreate_WaitTimeoutParsing(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() testCases := []struct { name string @@ -1187,11 +1187,11 @@ func TestWaitForServiceReady_Timeout(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Create API client client, err := api.NewTigerClient(cfg, "test-api-key") @@ -1328,11 +1328,11 @@ func TestServiceDelete_NoAuth(t *testing.T) { } // Mock authentication failure - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "", "", fmt.Errorf("not logged in") } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service delete command _, err, _ = executeServiceCommand(t.Context(), "service", "delete", "svc-12345", "--confirm") @@ -1357,11 +1357,11 @@ func TestServiceDelete_WithConfirmFlag(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service delete command with --confirm flag // This should fail due to network error (which is expected in tests) @@ -1391,11 +1391,11 @@ func TestServiceDelete_ConfirmationPrompt(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service delete command without --confirm flag // This should try to read from stdin for confirmation, which will fail in test environment @@ -1455,11 +1455,11 @@ func TestServiceDelete_FlagsValidation(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -1613,11 +1613,11 @@ func TestServiceCreate_OutputFlagDoesNotPersist(t *testing.T) { } // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Execute service create with -o json flag // This will fail due to network error (localhost:9999 doesn't exist), but that's OK @@ -1645,11 +1645,11 @@ func TestServiceList_OutputFlagAffectsCommandOnly(t *testing.T) { configFile := cfg.GetConfigFile() // Mock authentication - originalGetCredentials := getCredentialsForService - getCredentialsForService = func() (string, string, error) { + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { return "test-api-key", "test-project-123", nil } - defer func() { getCredentialsForService = originalGetCredentials }() + defer func() { common.GetCredentials = originalGetCredentials }() // Store original config file content originalConfigBytes, err := os.ReadFile(configFile) diff --git a/internal/tiger/cmd/version.go b/internal/tiger/cmd/version.go index 4727e779..21ab6b02 100644 --- a/internal/tiger/cmd/version.go +++ b/internal/tiger/cmd/version.go @@ -34,6 +34,8 @@ func buildVersionCmd() *cobra.Command { Args: cobra.NoArgs, ValidArgsFunction: cobra.NoFileCompletions, RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + versionOutput := VersionOutput{ Version: config.Version, BuildTime: config.BuildTime, @@ -76,7 +78,6 @@ func buildVersionCmd() *cobra.Command { } if updateAvailable { cmd.SilenceErrors = true - cmd.SilenceUsage = true return common.ExitWithCode(common.ExitUpdateAvailable, nil) } return nil diff --git a/internal/tiger/common/client.go b/internal/tiger/common/client.go new file mode 100644 index 00000000..408a04f1 --- /dev/null +++ b/internal/tiger/common/client.go @@ -0,0 +1,115 @@ +package common + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/timescale/tiger-cli/internal/tiger/analytics" + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/config" +) + +var ( + // GetCredentials can be overridden for testing + GetCredentials = config.GetCredentials + + // Cache of validated API Keys. Useful for avoided unnecessary calls to the + // /auth/info and /analytics/identify endpoints when the API client is + // loaded multiple times using credentials provided via the TIGER_PUBLIC_KEY + // and TIGER_SECRET_KEY env vars (e.g. when using the MCP server, which + // re-fetches the API client for each tool call). + validatedAPIKeyCache = map[string]*api.AuthInfo{} +) + +// NewAPIClient initializes a [api.ClientWithResponses] and returns it along +// with the current project ID. Credentials are pulled from the environment (if +// present), or loaded from storage (either the keyring or fallback file). When +// pulled from the environment, the credentials are first validated by hitting +// the /auth/info endpoint (which also allows us to fetch the project ID), and +// the user is identified for the sake of analytics by hitting the /analytics/identify +// endpoint. When credentials are pulled from storage, those operations should +// have already been performed via `tiger auth login`. +func NewAPIClient(ctx context.Context, cfg *config.Config) (*api.ClientWithResponses, string, error) { + // Credentials in the environment take priority + publicKey := os.Getenv("TIGER_PUBLIC_KEY") + secretKey := os.Getenv("TIGER_SECRET_KEY") + + // If there were no credentials in the environment, try to load stored credentials + if publicKey == "" && secretKey == "" { + apiKey, projectID, err := GetCredentials() + if err != nil { + return nil, "", ExitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) + } + + // Create API client + client, err := api.NewTigerClient(cfg, apiKey) + if err != nil { + return nil, "", fmt.Errorf("failed to create API client: %w", err) + } + + // Return immediately. Credentials were already verified and user was + // already identified for analytics via `tiger auth login`. + return client, projectID, nil + } + + // Create API client + apiKey := fmt.Sprintf("%s:%s", publicKey, secretKey) + client, err := api.NewTigerClient(cfg, apiKey) + if err != nil { + return nil, "", fmt.Errorf("failed to create API client: %w", err) + } + + // Check whether this API Key has already been validated, and use the + // cached auth info if so. Otherwise, validate it. + authInfo, ok := validatedAPIKeyCache[apiKey] + if !ok { + // Validate the API key and get auth info by calling the /auth/info endpoint + authInfo, err = ValidateAPIKey(ctx, cfg, client) + if err != nil { + return nil, "", fmt.Errorf("API key validation failed: %w", err) + } + validatedAPIKeyCache[apiKey] = authInfo + } + + return client, authInfo.ApiKey.Project.Id, nil +} + +// ValidateAPIKey validates the API key by calling the /auth/info endpoint, and +// returns authentication information. It also identifies the user for the sake +// of analytics. +func ValidateAPIKey(ctx context.Context, cfg *config.Config, client *api.ClientWithResponses) (*api.AuthInfo, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // Call the /auth/info endpoint to validate credentials and get auth info + resp, err := client.GetAuthInfoWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("API call failed: %w", err) + } + + // Check the response status + if resp.StatusCode() != 200 { + if resp.JSON4XX != nil { + return nil, resp.JSON4XX + } + return nil, fmt.Errorf("unexpected API response: %d", resp.StatusCode()) + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("empty response from API") + } + + authInfo := resp.JSON200 + + // Identify the user with analytics + a := analytics.New(cfg, client, authInfo.ApiKey.Project.Id) + a.Identify( + analytics.Property("userId", authInfo.ApiKey.IssuingUser.Id), + analytics.Property("email", string(authInfo.ApiKey.IssuingUser.Email)), + analytics.Property("planType", authInfo.ApiKey.Project.PlanType), + ) + + return authInfo, nil +} diff --git a/internal/tiger/common/client_test.go b/internal/tiger/common/client_test.go new file mode 100644 index 00000000..f3470e57 --- /dev/null +++ b/internal/tiger/common/client_test.go @@ -0,0 +1,144 @@ +package common + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/config" + "github.com/timescale/tiger-cli/internal/tiger/logging" +) + +func TestValidateAPIKey(t *testing.T) { + // Initialize logger for analytics code + if err := logging.Init(false); err != nil { + t.Fatalf("Failed to initialize logging: %v", err) + } + + tests := []struct { + name string + setupServer func() *httptest.Server + expectedProjectID string + expectedPublicKey string + expectedError string + }{ + { + name: "valid API key - returns auth info", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/auth/info" { + authInfo := api.AuthInfo{ + Type: api.ApiKey, + } + authInfo.ApiKey.PublicKey = "test-access-key" + authInfo.ApiKey.Project.Id = "proj-12345" + authInfo.ApiKey.Project.Name = "Test Project" + authInfo.ApiKey.Project.PlanType = "FREE" + authInfo.ApiKey.Name = "Test Credentials" + authInfo.ApiKey.IssuingUser.Name = "Test User" + authInfo.ApiKey.IssuingUser.Email = "test@example.com" + authInfo.ApiKey.IssuingUser.Id = "user-123" + authInfo.ApiKey.Created = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(authInfo) + } else if r.URL.Path == "/analytics/identify" { + // Analytics identify endpoint (called after auth info) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "success"}`)) + } else { + t.Errorf("Unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + }, + expectedProjectID: "proj-12345", + expectedPublicKey: "test-access-key", + expectedError: "", + }, + { + name: "invalid API key - 401 response", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message": "Invalid or missing authentication credentials"}`)) + })) + }, + expectedProjectID: "", + expectedPublicKey: "", + expectedError: "Invalid or missing authentication credentials", + }, + { + name: "invalid API key - 403 response", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message": "Invalid or missing authentication credentials"}`)) + })) + }, + expectedProjectID: "", + expectedPublicKey: "", + expectedError: "Invalid or missing authentication credentials", + }, + { + name: "unexpected response - 500", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + }, + expectedProjectID: "", + expectedPublicKey: "", + expectedError: "unexpected API response: 500", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.setupServer() + defer server.Close() + + // Setup test config with the test server URL + cfg, err := config.UseTestConfig(t.TempDir(), map[string]any{ + "api_url": server.URL, + }) + if err != nil { + t.Fatalf("Failed to setup test config: %v", err) + } + + client, err := api.NewTigerClient(cfg, "test-api-key") + require.NoError(t, err) + + authInfo, err := ValidateAPIKey(context.Background(), cfg, client) + + if tt.expectedError == "" { + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + if authInfo == nil { + t.Fatal("Expected auth info to be returned, got nil") + } + if authInfo.ApiKey.Project.Id != tt.expectedProjectID { + t.Errorf("Expected project ID %q, got %q", tt.expectedProjectID, authInfo.ApiKey.Project.Id) + } + if authInfo.ApiKey.PublicKey != tt.expectedPublicKey { + t.Errorf("Expected access key %q, got %q", tt.expectedPublicKey, authInfo.ApiKey.PublicKey) + } + } else { + if err == nil { + t.Errorf("Expected error containing %q, got nil", tt.expectedError) + } else if err.Error() != tt.expectedError { + t.Errorf("Expected error %q, got %q", tt.expectedError, err.Error()) + } + } + }) + } +} diff --git a/internal/tiger/common/config.go b/internal/tiger/common/config.go new file mode 100644 index 00000000..ca46ff17 --- /dev/null +++ b/internal/tiger/common/config.go @@ -0,0 +1,38 @@ +package common + +import ( + "context" + "fmt" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/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:"-"` +} + +func LoadConfig(ctx context.Context) (*Config, error) { + cfg, err := config.Load() + 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/tiger/common/errors.go b/internal/tiger/common/errors.go index c5a95573..9dbf7141 100644 --- a/internal/tiger/common/errors.go +++ b/internal/tiger/common/errors.go @@ -31,6 +31,10 @@ func (e ExitCodeError) ExitCode() int { return e.code } +func (e ExitCodeError) Unwrap() error { + return e.err +} + // ExitWithCode returns an error that will cause the program to exit with the specified code func ExitWithCode(code int, err error) error { return ExitCodeError{code: code, err: err} diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go index f32cf393..94009ae7 100644 --- a/internal/tiger/mcp/db_tools.go +++ b/internal/tiger/mcp/db_tools.go @@ -127,8 +127,8 @@ 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) { - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, DBExecuteQueryOutput{}, err } @@ -137,7 +137,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", projectID), + zap.String("project_id", cfg.ProjectID), zap.String("service_id", input.ServiceID), zap.Duration("timeout", timeout), zap.String("role", input.Role), @@ -145,7 +145,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ ) // Get service details to construct connection string - serviceResp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, input.ServiceID) + serviceResp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID) if err != nil { return nil, DBExecuteQueryOutput{}, fmt.Errorf("failed to get service details: %w", err) } diff --git a/internal/tiger/mcp/server.go b/internal/tiger/mcp/server.go index 9a72827d..2c11097f 100644 --- a/internal/tiger/mcp/server.go +++ b/internal/tiger/mcp/server.go @@ -12,7 +12,7 @@ import ( "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/tiger/analytics" - "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/common" "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/logging" ) @@ -80,29 +80,6 @@ func (s *Server) registerTools(ctx context.Context) { logging.Info("MCP tools registered successfully") } -// createAPIClient creates a new API client and returns it with the project ID -func (s *Server) createAPIClient() (*api.ClientWithResponses, string, error) { - // Load config - cfg, err := config.Load() - if err != nil { - return nil, "", fmt.Errorf("failed to load config: %w", err) - } - - // Get credentials (API key + project ID) - apiKey, projectID, err := config.GetCredentials() - if err != nil { - return nil, "", fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err) - } - - // Create API client with fresh credentials - apiClient, err := api.NewTigerClient(cfg, apiKey) - if err != nil { - return nil, "", fmt.Errorf("failed to create API client: %w", err) - } - - return apiClient, projectID, nil -} - // analyticsMiddleware tracks analytics for all MCP requests func (s *Server) analyticsMiddleware(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, runErr error) { @@ -115,7 +92,8 @@ func (s *Server) analyticsMiddleware(next mcp.MethodHandler) mcp.MethodHandler { return next(ctx, method, req) } - a := analytics.TryInit(cfg) + client, projectID, _ := common.NewAPIClient(ctx, cfg) + a := analytics.New(cfg, client, projectID) switch r := req.(type) { case *mcp.CallToolRequest: diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index d0fd11d4..223337b4 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -12,7 +12,6 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/api" "github.com/timescale/tiger-cli/internal/tiger/common" - "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/logging" "github.com/timescale/tiger-cli/internal/tiger/util" ) @@ -456,19 +455,19 @@ This operation stops a service that is currently running. The service will trans // handleServiceList handles the service_list MCP tool func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest, input ServiceListInput) (*mcp.CallToolResult, ServiceListOutput, error) { - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceListOutput{}, err } - logging.Debug("MCP: Listing services", zap.String("project_id", projectID)) + logging.Debug("MCP: Listing services", zap.String("project_id", cfg.ProjectID)) // Make API call to list services ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := apiClient.GetProjectsProjectIdServicesWithResponse(ctx, projectID) + resp, err := cfg.Client.GetProjectsProjectIdServicesWithResponse(ctx, cfg.ProjectID) if err != nil { return nil, ServiceListOutput{}, fmt.Errorf("failed to list services: %w", err) } @@ -496,21 +495,21 @@ func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest // handleServiceGet handles the service_get MCP tool func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, input ServiceGetInput) (*mcp.CallToolResult, ServiceGetOutput, error) { - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceGetOutput{}, err } logging.Debug("MCP: Getting service details", - zap.String("project_id", projectID), + zap.String("project_id", cfg.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 := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, input.ServiceID) + resp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID) if err != nil { return nil, ServiceGetOutput{}, fmt.Errorf("failed to get service details: %w", err) } @@ -534,14 +533,8 @@ func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, // 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 - cfg, err := config.Load() - if err != nil { - return nil, ServiceCreateOutput{}, fmt.Errorf("failed to load config: %w", err) - } - - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceCreateOutput{}, err } @@ -561,7 +554,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque } logging.Debug("MCP: Creating service", - zap.String("project_id", projectID), + zap.String("project_id", cfg.ProjectID), zap.String("name", input.Name), zap.Strings("addons", input.Addons), zap.Stringp("region", input.Region), @@ -584,7 +577,7 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque createCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := apiClient.PostProjectsProjectIdServicesWithResponse(createCtx, projectID, serviceCreateReq) + resp, err := cfg.Client.PostProjectsProjectIdServicesWithResponse(createCtx, cfg.ProjectID, serviceCreateReq) if err != nil { return nil, ServiceCreateOutput{}, fmt.Errorf("failed to create service: %w", err) } @@ -624,8 +617,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: apiClient, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -652,14 +645,8 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque // 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 - cfg, err := config.Load() - if err != nil { - return nil, ServiceForkOutput{}, fmt.Errorf("failed to load config: %w", err) - } - - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceForkOutput{}, err } @@ -687,7 +674,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest } logging.Debug("MCP: Forking service", - zap.String("project_id", projectID), + zap.String("project_id", cfg.ProjectID), zap.String("service_id", input.ServiceID), zap.String("name", input.Name), zap.String("fork_strategy", string(input.ForkStrategy)), @@ -712,7 +699,7 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest forkCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := apiClient.PostProjectsProjectIdServicesServiceIdForkServiceWithResponse(forkCtx, projectID, input.ServiceID, forkReq) + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdForkServiceWithResponse(forkCtx, cfg.ProjectID, input.ServiceID, forkReq) if err != nil { return nil, ServiceForkOutput{}, fmt.Errorf("failed to fork service: %w", err) } @@ -752,8 +739,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: apiClient, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: serviceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -780,14 +767,14 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest // handleServiceUpdatePassword handles the service_update_password MCP tool func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallToolRequest, input ServiceUpdatePasswordInput) (*mcp.CallToolResult, ServiceUpdatePasswordOutput, error) { - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceUpdatePasswordOutput{}, err } logging.Debug("MCP: Updating service password", - zap.String("project_id", projectID), + zap.String("project_id", cfg.ProjectID), zap.String("service_id", input.ServiceID)) // Prepare password update request @@ -799,7 +786,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - resp, err := apiClient.PostProjectsProjectIdServicesServiceIdUpdatePasswordWithResponse(ctx, projectID, input.ServiceID, updateReq) + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdUpdatePasswordWithResponse(ctx, cfg.ProjectID, input.ServiceID, updateReq) if err != nil { return nil, ServiceUpdatePasswordOutput{}, fmt.Errorf("failed to update service password: %w", err) } @@ -811,7 +798,7 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT // Get service details for password storage (similar to CLI implementation) var passwordStorage *common.PasswordStorageResult - serviceResp, err := apiClient.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, input.ServiceID) + serviceResp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID) if err == nil && serviceResp.StatusCode() == 200 && serviceResp.JSON200 != nil { // Save the new password using the shared util function result, err := common.SavePasswordWithResult(api.Service(*serviceResp.JSON200), input.Password, "tsdbadmin") @@ -833,21 +820,21 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT // handleServiceStart handles the service_start MCP tool func (s *Server) handleServiceStart(ctx context.Context, req *mcp.CallToolRequest, input ServiceStartInput) (*mcp.CallToolResult, ServiceStartOutput, error) { - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceStartOutput{}, err } logging.Debug("MCP: Starting service", - zap.String("project_id", projectID), + zap.String("project_id", cfg.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 := apiClient.PostProjectsProjectIdServicesServiceIdStartWithResponse(startCtx, projectID, input.ServiceID) + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdStartWithResponse(startCtx, cfg.ProjectID, input.ServiceID) if err != nil { return nil, ServiceStartOutput{}, fmt.Errorf("failed to start service: %w", err) } @@ -863,8 +850,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: apiClient, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: input.ServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "READY", @@ -890,21 +877,21 @@ func (s *Server) handleServiceStart(ctx context.Context, req *mcp.CallToolReques // handleServiceStop handles the service_stop MCP tool func (s *Server) handleServiceStop(ctx context.Context, req *mcp.CallToolRequest, input ServiceStopInput) (*mcp.CallToolResult, ServiceStopOutput, error) { - // Create fresh API client and get project ID - apiClient, projectID, err := s.createAPIClient() + // Load config and API client + cfg, err := common.LoadConfig(ctx) if err != nil { return nil, ServiceStopOutput{}, err } logging.Debug("MCP: Stopping service", - zap.String("project_id", projectID), + zap.String("project_id", cfg.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 := apiClient.PostProjectsProjectIdServicesServiceIdStopWithResponse(stopCtx, projectID, input.ServiceID) + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdStopWithResponse(stopCtx, cfg.ProjectID, input.ServiceID) if err != nil { return nil, ServiceStopOutput{}, fmt.Errorf("failed to stop service: %w", err) } @@ -920,8 +907,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: apiClient, - ProjectID: projectID, + Client: cfg.Client, + ProjectID: cfg.ProjectID, ServiceID: input.ServiceID, Handler: &common.StatusWaitHandler{ TargetStatus: "PAUSED", diff --git a/specs/spec.md b/specs/spec.md index 8781d388..4cf2a9bc 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -139,8 +139,8 @@ export TIGER_PUBLIC_KEY="your-public-key" export TIGER_SECRET_KEY="your-secret-key" tiger auth login -# Interactive login (will prompt for any missing credentials) -tiger auth login +# Interactive login (will prompt for missing credentials) +tiger auth login --public-key YOUR_PUBLIC_KEY # Show current authentication status and project ID tiger auth status @@ -152,7 +152,7 @@ tiger auth logout **Authentication Methods:** 1. OAuth flow (recommended): Opens browser for authentication, automatically creates API keys and detects project ID 2. Manual API key input: Provide `--public-key` and `--secret-key` flags (project ID auto-detected from API) -3. Environment variables: Set `TIGER_PUBLIC_KEY` and `TIGER_SECRET_KEY` (project ID auto-detected from API) +3. Environment variables: Set `TIGER_PUBLIC_KEY` and `TIGER_SECRET_KEY` (project ID auto-detected from API). Note that these env vars also work directly with any command without needing to run `tiger auth login` first. 4. Interactive prompt for any missing credentials (requires TTY) **Login Process:** diff --git a/specs/spec_mcp.md b/specs/spec_mcp.md index 30a4e14b..193c5b61 100644 --- a/specs/spec_mcp.md +++ b/specs/spec_mcp.md @@ -595,7 +595,7 @@ Common error codes: - The MCP server is embedded within the Tiger CLI binary - Shares the same API client library and configuration system as the CLI -- Uses the CLI's stored authentication (keyring or file-based credentials) +- Uses the CLI's stored authentication (keyring or file-based credentials) or environment variables (`TIGER_PUBLIC_KEY` and `TIGER_SECRET_KEY`) - Inherits the CLI's project ID from stored credentials and service ID from configuration - Implements proper graceful shutdown and signal handling - Uses structured logging compatible with the CLI logging system