diff --git a/CLAUDE.md b/CLAUDE.md index b94a2207..41b38353 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,10 +271,9 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da CLI commands (auth, service, db, config, mcp, version, upgrade, completion). Each command lives in its own file, named to match the command in snake_case (see "One File Per Command" below). `root.go` holds the root command, global - flags, and configuration initialization. Helper files hold shared utilities - (`completion.go`, `flag.go`) and self-contained interactive flows - (`oauth.go`, `read_replica.go`, `password_recovery.go`, `mcp_install.go`). - - `read_replica.go` - Read replica selection flow for `db connect`/`psql`: in an interactive terminal, when the service has one or more active read replicas (listed via the `/replicaSets` API), prompts to connect to the primary or one of the replicas. Skipped when stdin is not a TTY, when `--no-replica-prompt` is set, or when the service has no read replicas. + flags, and configuration initialization. Files ending in `_helper.go` hold + cross-group helpers rather than commands — see "Where Helpers Go" below. + - `db_connect.go` - The whole `db connect`/`psql` flow, including read replica selection: in an interactive terminal, when the service has one or more active read replicas (listed via the `/replicaSets` API), prompts to connect to the primary or one of the replicas. Skipped when stdin is not a TTY, when `--no-replica-prompt` is set, or when the service has no read replicas. Also handles password recovery when the stored password is rejected. - `upgrade.go` - Self-update command (download latest release, verify checksum, replace running binary in place) - **Configuration**: `internal/config/config.go` - Centralized config with Viper integration - **Logging**: `internal/logging/logging.go` - Structured logging with zap @@ -292,7 +291,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da - Error handling and exit code utilities - Service detail conversion helpers - Log fetching with pagination (FetchServiceLogs) -- **Utilities**: `internal/util/` - Small utility functions with minimal dependencies +- **Utilities**: `internal/util/` - Small utility functions with minimal dependencies (formatting, validation, password generation) ### Configuration System @@ -477,7 +476,7 @@ tiger-cli/ │ └── release.yml # Release workflow (runs on semver tags) ├── bin/ # Built binaries (created during build) ├── openapi.yaml # OpenAPI 3.0 specification for Tiger API -├── .goreleaser.yml # GoReleaser configuration for building releases +├── .goreleaser.yaml # GoReleaser configuration for building releases ├── tools.go # Build-time dependencies ├── README.md # User-facing documentation └── CLAUDE.md # Developer guidance for Claude Code @@ -549,9 +548,32 @@ Within a command's file: 2. The `build*Cmd()` function comes next 3. Helper functions used only by that command follow it -Helpers shared by several commands in a group live in the group's file (e.g. the -service ID completion and output helpers live in `service.go`). Larger -self-contained flows keep their own non-command file. +### Where Helpers Go + +Place a helper by who calls it, working down this list until one matches: + +1. **One command** → that command's file. +2. **Several commands in one group** → the group file (`service.go`, `db.go`). +3. **Across groups** → a package-level `_helper.go` file: + `completion_helper.go`, `flag_helper.go`, `terminal_helper.go`, + `password_helper.go`. +4. **A genuine standalone utility** — small and isolated, with no notion of a + command (`util.GenerateSecurePassword`) → `internal/util`. Anything shaped + around the CLI stays in `cmd` even if its signature looks generic. +5. **Used by both CLI and MCP** → `internal/common`. + +The `_helper.go` suffix is reserved for rule 3, so every other file in +`internal/cmd` is named after a command and contains a `build*Cmd()`. + +Shell completion functions are an exception to rule 1: they all live in +`completion_helper.go`, however many commands use them. + +Apply rule 1 even when the helper is large. `db_connect.go` holds the whole +`db connect` flow — argument splitting, read replica selection, password +recovery, and the psql handoff, bubbletea models and all — because nothing else +calls into it. `auth_login.go` likewise holds the entire OAuth flow. A long file +whose contents all serve one command is easier to follow than several short files +with entry points scattered across them. Tests mirror this layout: `service_create.go` → `service_create_test.go`. Package-wide test scaffolding (`TestMain`, auth mocks, shared command runners) @@ -682,7 +704,7 @@ func buildMyConfigurableFlagCmd() *cobra.Command { } ``` -The `bindFlags()` helper (defined in `internal/cmd/flag.go`) automatically converts flag names to config keys (e.g., `"new-password"` → `"new_password"`) and supports binding multiple flags: `bindFlags("output", "new-password")`. +The `bindFlags()` helper (defined in `internal/cmd/flag_helper.go`) automatically converts flag names to config keys (e.g., `"new-password"` → `"new_password"`) and supports binding multiple flags: `bindFlags("output", "new-password")`. **Why bind flags in PreRunE?** @@ -991,7 +1013,7 @@ VERSION=1.2.3 && git tag -a v${VERSION} -m "${VERSION}" && git push origin v${VE 3. **S3 Bucket** - Uploads binaries to `tiger-cli-releases` S3 bucket (behind `https://cli.tigerdata.com` CloudFront CDN) for install script and Homebrew downloads 4. **PackageCloud** - Publishes Debian (.deb) and RPM packages to `timescale/tiger-cli` repository -**Build Tool:** Uses [GoReleaser](https://goreleaser.com) to build and publish across all platforms. Configuration is in `.goreleaser.yml`. +**Build Tool:** Uses [GoReleaser](https://goreleaser.com) to build and publish across all platforms. Configuration is in `.goreleaser.yaml`. ## Specifications diff --git a/internal/cmd/auth_login.go b/internal/cmd/auth_login.go index a3a0c260..a854619e 100644 --- a/internal/cmd/auth_login.go +++ b/internal/cmd/auth_login.go @@ -3,10 +3,23 @@ package cmd import ( "bufio" "context" + "encoding/base64" + "errors" "fmt" + "io" + "math/rand" + "net" + "net/http" "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" + "golang.org/x/oauth2" "golang.org/x/term" "github.com/timescale/tiger-cli/internal/api" @@ -27,6 +40,14 @@ const nextStepsMessage = ` • Enable read-only mode: tiger config set read_only true ` +var ( + // openBrowser can be overridden for testing + openBrowser = openBrowserImpl + + // selectProjectInteractively can be overridden for testing + selectProjectInteractively = selectProjectInteractivelyImpl +) + type credentials struct { publicKey string secretKey string @@ -180,3 +201,337 @@ func promptForCredentials(ctx context.Context, consoleURL string, creds credenti return creds, nil } + +type oauthLogin struct { + cfg *config.Config + authURL string + tokenURL string + successURL string + out io.Writer +} + +func (l *oauthLogin) loginWithOAuth(ctx context.Context) (*oauth2.Token, *api.ClientWithResponses, string, error) { + token, err := l.getOAuthToken(ctx) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to authenticate via OAuth: %w", err) + } + + // Build the token-authenticated client once and reuse it for the + // subsequent authenticated requests. + client, err := api.NewTigerClientWithToken(l.cfg, token, nil) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to create API client: %w", err) + } + + projectID, err := l.selectProjectID(ctx, client) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to select project: %w", err) + } + + return token, client, projectID, nil +} + +func (l *oauthLogin) getOAuthToken(ctx context.Context) (*oauth2.Token, error) { + codeVerifier := oauth2.GenerateVerifier() + + // Random state guards against CSRF on the OAuth callback. + state, err := l.generateRandomState(32) + if err != nil { + return nil, fmt.Errorf("failed to generate random state: %w", err) + } + + server, err := l.startOAuthServer(state, codeVerifier) + if err != nil { + return nil, fmt.Errorf("failed to create local server: %w", err) + } + defer func() { + if err := server.server.Shutdown(ctx); err != nil { + fmt.Fprintf(l.out, "Failed to close local server: %s\n", err) + } + }() + + authURL := server.oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)) + fmt.Fprintf(l.out, "Auth URL is: %s\n", authURL) + fmt.Fprintln(l.out, "Opening browser for authentication...") + if err := openBrowser(authURL); err != nil { + fmt.Fprintf(l.out, "Failed to open browser: %s\nPlease manually navigate to the Auth URL.", err) + } + + select { + case result := <-server.resultChan: + return result.token, result.err + case <-time.After(5 * time.Minute): + return nil, fmt.Errorf("authorization timeout - no callback received within 5 minutes") + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (l *oauthLogin) generateRandomState(length int) (string, error) { + data := make([]byte, length) + if _, err := rand.Read(data); err != nil { + return "", err + } + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data)[:length], nil +} + +type oauthServer struct { + server *http.Server + oauthCfg oauth2.Config + resultChan <-chan oauthResult +} + +type oauthResult struct { + token *oauth2.Token + err error +} + +func (l *oauthLogin) startOAuthServer(expectedState, codeVerifier string) (*oauthServer, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, fmt.Errorf("failed to listen on local port: %w", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + oauthCfg := oauth2.Config{ + ClientID: config.TigerCLIClientID, + Endpoint: oauth2.Endpoint{ + AuthURL: l.authURL, + TokenURL: l.tokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", port), + } + + // Start local HTTP server for callback + resultChan := make(chan oauthResult, 1) + mux := http.NewServeMux() + mux.Handle("GET /callback", &oauthCallback{ + oauthCfg: oauthCfg, + expectedState: expectedState, + codeVerifier: codeVerifier, + successURL: l.successURL, + resultChan: resultChan, + }) + + server := &http.Server{Handler: mux} + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + resultChan <- oauthResult{ + err: fmt.Errorf("failed to serve requests: %w", err), + } + } + }() + + return &oauthServer{ + server: server, + oauthCfg: oauthCfg, + resultChan: resultChan, + }, nil +} + +type oauthCallback struct { + oauthCfg oauth2.Config + expectedState string + codeVerifier string + successURL string + resultChan chan<- oauthResult +} + +// userAgentTransport sets the CLI User-Agent on outgoing requests. +type userAgentTransport struct{} + +func (userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", config.UserAgent()) + return http.DefaultTransport.RoundTrip(req) +} + +func (c *oauthCallback) ServeHTTP(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + // Validate state parameter + state := query.Get("state") + if state != c.expectedState { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Invalid state parameter") + c.sendError(fmt.Errorf("invalid state parameter")) + return + } + + // Get authorization code + code := query.Get("code") + if code == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Missing authorization code") + c.sendError(fmt.Errorf("missing authorization code in callback")) + return + } + + // The exchange's User-Agent is recorded as the CLI session's user_agent. + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, + &http.Client{Transport: userAgentTransport{}}) + token, err := c.oauthCfg.Exchange(ctx, code, oauth2.VerifierOption(c.codeVerifier)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "Failed to exchange authorization code for tokens") + c.sendError(fmt.Errorf("failed to exchange code for tokens: %w", err)) + return + } + + // Redirect to success page + http.Redirect(w, r, c.successURL, http.StatusTemporaryRedirect) + + c.resultChan <- oauthResult{ + token: token, + } +} + +func (c *oauthCallback) sendError(err error) { + c.resultChan <- oauthResult{err: err} +} + +func openBrowserImpl(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "windows": + // Escape '&' so cmd.exe doesn't treat it as a command separator + cmd = exec.Command("cmd", "/c", "start", strings.ReplaceAll(url, "&", "^&")) + case "darwin": + cmd = exec.Command("open", url) + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = exec.Command("xdg-open", url) + } + + return cmd.Start() +} + +func (l *oauthLogin) selectProjectID(ctx context.Context, client *api.ClientWithResponses) (string, error) { + resp, err := client.GetProjectsWithResponse(ctx) + if err != nil { + return "", fmt.Errorf("failed to get user projects: %w", err) + } + if resp.JSON200 == nil { + return "", common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + projects := *resp.JSON200 + + switch len(projects) { + case 0: + return "", fmt.Errorf("user has no accessible projects") + case 1: + return projects[0].Id, nil + default: + return selectProjectInteractively(projects, l.out) + } +} + +// selectProjectInteractivelyImpl is the default implementation for project selection using Bubble Tea +func selectProjectInteractivelyImpl(projects []api.Project, out io.Writer) (string, error) { + model := projectSelectModel{ + projects: projects, + cursor: 0, + } + + program := tea.NewProgram(model, tea.WithOutput(out)) + finalModel, err := program.Run() + if err != nil { + return "", fmt.Errorf("failed to run project selection: %w", err) + } + + result := finalModel.(projectSelectModel) + if result.selected == "" { + return "", fmt.Errorf("no project selected") + } + + return result.selected, nil +} + +type projectSelectModel struct { + projects []api.Project + cursor int + selected string + numberBuffer string +} + +func (m projectSelectModel) Init() tea.Cmd { + return nil +} + +func (m projectSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + // Clear buffer when using arrows + m.numberBuffer = "" + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + // Clear buffer when using arrows + m.numberBuffer = "" + if m.cursor < len(m.projects)-1 { + m.cursor++ + } + case "enter", " ": + m.selected = m.projects[m.cursor].Id + return m, tea.Quit + case "backspace": + // Handle backspace to remove last character from buffer + if len(m.numberBuffer) > 0 { + m.updateNumberBuffer(m.numberBuffer[:len(m.numberBuffer)-1]) + } + case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": + // Add digit to buffer and update cursor position + m.updateNumberBuffer(m.numberBuffer + msg.String()) + case "ctrl+w", "esc": + // Clear buffer on escape + m.numberBuffer = "" + } + } + return m, nil +} + +// updateNumberBuffer moves the cursor to the project matching the number buffer +func (m *projectSelectModel) updateNumberBuffer(newBuffer string) { + if newBuffer == "" { + m.numberBuffer = newBuffer + return + } + + // Parse the buffer as a number + num, err := strconv.Atoi(newBuffer) + if err != nil { + return + } + + // Convert from 1-based to 0-based index and validate bounds + index := num - 1 + if index >= 0 && index < len(m.projects) { + m.numberBuffer = newBuffer + m.cursor = index + } +} + +func (m projectSelectModel) View() string { + s := "Select a project:\n\n" + + for i, project := range m.projects { + cursor := " " + if m.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %d. %s (%s)\n", cursor, i+1, project.Name, project.Id) + } + + // Show the current number buffer if user is typing + if m.numberBuffer != "" { + s += fmt.Sprintf("\nTyping: %s", m.numberBuffer) + } + + s += "\nUse ↑/↓ arrows or number keys to navigate, enter to select, q to quit" + return s +} diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go deleted file mode 100644 index 98460c7a..00000000 --- a/internal/cmd/completion.go +++ /dev/null @@ -1,16 +0,0 @@ -package cmd - -import "strings" - -// filterCompletionsByPrefix filters a slice of strings to only include items -// that start with the given prefix. This is used by shell completion functions -// to narrow down suggestions based on what the user has typed so far. -func filterCompletionsByPrefix(items []string, prefix string) []string { - var filtered []string - for _, item := range items { - if strings.HasPrefix(item, prefix) { - filtered = append(filtered, item) - } - } - return filtered -} diff --git a/internal/cmd/completion_helper.go b/internal/cmd/completion_helper.go new file mode 100644 index 00000000..17269204 --- /dev/null +++ b/internal/cmd/completion_helper.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/mcp" +) + +func serviceIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Service ID is always first positional argument + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + services, err := listServices(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + results := make([]string, 0, len(services)) + for _, service := range services { + if service.ServiceId != nil && strings.HasPrefix(*service.ServiceId, toComplete) { + results = append(results, cobra.CompletionWithDesc(*service.ServiceId, *service.Name)) + } + } + return results, cobra.ShellCompDirectiveNoFileComp +} + +func listServices(cmd *cobra.Command) ([]api.Service, error) { + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) + if err != nil { + return nil, err + } + + // Make API call to list services + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetServicesWithResponse(ctx, cfg.ProjectID) + if err != nil { + return nil, fmt.Errorf("failed to list services: %w", err) + } + + // Handle API response + if resp.StatusCode() != http.StatusOK { + return nil, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil || len(*resp.JSON200) == 0 { + return []api.Service{}, nil + } + + return *resp.JSON200, nil +} + +func configOptionCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Config option is always first positional argument + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return filterCompletionsByPrefix(config.ValidConfigOptions(), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// mcpGetCompletion provides custom completions for the get command +func mcpGetCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Capability name is always first positional argument + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + cfg, err := config.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Create MCP server to get capabilities + server, err := mcp.NewServer(cmd.Context(), cfg) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer server.Close() + + capabilities, err := server.ListCapabilities(cmd.Context()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Close the MCP server when finished + if err := server.Close(); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return filterCompletionsByPrefix(capabilities.Names(), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// filterCompletionsByPrefix filters a slice of strings to only include items +// that start with the given prefix. This is used by shell completion functions +// to narrow down suggestions based on what the user has typed so far. +func filterCompletionsByPrefix(items []string, prefix string) []string { + var filtered []string + for _, item := range items { + if strings.HasPrefix(item, prefix) { + filtered = append(filtered, item) + } + } + return filtered +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 9b74f835..d4a88d0f 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -2,8 +2,6 @@ package cmd import ( "github.com/spf13/cobra" - - "github.com/timescale/tiger-cli/internal/config" ) func buildConfigCmd() *cobra.Command { @@ -20,12 +18,3 @@ func buildConfigCmd() *cobra.Command { return cmd } - -func configOptionCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Config option is always first positional argument - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - return filterCompletionsByPrefix(config.ValidConfigOptions(), toComplete), cobra.ShellCompDirectiveNoFileComp -} diff --git a/internal/cmd/db.go b/internal/cmd/db.go index 306eb8ce..c7c78d2a 100644 --- a/internal/cmd/db.go +++ b/internal/cmd/db.go @@ -2,38 +2,17 @@ package cmd import ( "context" - "crypto/rand" - "encoding/base64" "fmt" - "os" "time" "github.com/spf13/cobra" - "golang.org/x/term" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" - "github.com/timescale/tiger-cli/internal/util" ) -var ( - // getServiceDetailsFunc can be overridden for testing - getServiceDetailsFunc = getServiceDetails - - // checkStdinIsTTY can be overridden for testing to bypass TTY detection - checkStdinIsTTY = func() bool { - return util.IsTerminal(os.Stdin) - } - - // readPasswordFromTerminal can be overridden for testing to inject password input - readPasswordFromTerminal = func() (string, error) { - val, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - return "", err - } - return string(val), nil - } -) +// getServiceDetailsFunc can be overridden for testing +var getServiceDetailsFunc = getServiceDetails func buildDbCmd() *cobra.Command { cmd := &cobra.Command{ @@ -102,22 +81,3 @@ func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (a } return *service, nil } - -// generateSecurePassword generates a cryptographically secure random password -func generateSecurePassword(length int) (string, error) { - // Generate random bytes - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate random password: %w", err) - } - - // Encode as base64 (URL-safe variant to avoid special characters that might need escaping) - encodedPassword := base64.URLEncoding.EncodeToString(bytes) - - // Trim to desired length (base64 encoding makes it slightly longer) - if len(encodedPassword) > length { - encodedPassword = encodedPassword[:length] - } - - return encodedPassword, nil -} diff --git a/internal/cmd/db_connect.go b/internal/cmd/db_connect.go index a0c41630..27ba2331 100644 --- a/internal/cmd/db_connect.go +++ b/internal/cmd/db_connect.go @@ -1,14 +1,23 @@ package cmd import ( + "context" + "errors" "fmt" + "io" + "net/http" "os" "os/exec" + "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/spf13/cobra" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" ) func buildDbConnectCmd() *cobra.Command { @@ -155,6 +164,452 @@ func separateServiceAndPsqlArgs(cmd ArgsLenAtDashProvider, args []string) ([]str return serviceArgs, psqlFlags } +// selectConnection returns the connection details for `tiger db connect`. A +// replica target connects straight through; a primary in an interactive +// terminal is offered a menu to pick the primary or one of its replicas (nil +// details means the user cancelled). +func selectConnection( + ctx context.Context, + cmd *cobra.Command, + client *api.ClientWithResponses, + projectID string, + target *common.ConnectionTarget, + opts common.ConnectionDetailsOptions, + noReplicaPrompt bool, +) (*common.ConnectionDetails, error) { + // chosen is what we connect to; the menu below may replace it with a replica. + chosen := target + + // Offer the replica menu only for a primary on an interactive terminal. + if !target.IsReplica && !noReplicaPrompt && checkStdinIsTTY() { + primary := target.ConnectionService + replicas, err := fetchReplicaSets(ctx, client, projectID, util.DerefStr(primary.ServiceId)) + if err != nil { + // Don't block the connection if we can't list replicas. + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not list read replicas: %v\n", err) + } else if connectable := connectableReplicas(replicas); len(connectable) > 0 { + choice, err := selectConnectTargetOption(cmd.ErrOrStderr(), primary, connectable) + if err != nil { + return nil, err + } + switch choice.kind { + case targetCancel: + return nil, nil + case targetReplica: + chosen = common.NewReplicaConnectionTarget(primary, *choice.replica) + } + } + } + + details, err := buildConnectionDetailsForTarget(cmd, chosen, opts) + if err != nil { + return nil, err + } + if chosen.IsReplica { + fmt.Fprintf(cmd.ErrOrStderr(), "Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name)) + } + return details, nil +} + +// connectableReplicas filters to active read replicas that expose an endpoint. +func connectableReplicas(replicas []api.ReadReplicaSet) []api.ReadReplicaSet { + var out []api.ReadReplicaSet + for _, r := range replicas { + if r.Status != nil && *r.Status == api.ReadReplicaSetStatusActive && r.Endpoint != nil { + out = append(out, r) + } + } + return out +} + +// fetchReplicaSets retrieves the read replica sets for a service. +func fetchReplicaSets(ctx context.Context, client *api.ClientWithResponses, projectID, serviceID string) ([]api.ReadReplicaSet, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := client.GetReplicaSetsWithResponse(ctx, projectID, serviceID) + if err != nil { + return nil, err + } + + if resp.StatusCode() != http.StatusOK { + return nil, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil { + return nil, nil + } + + return *resp.JSON200, nil +} + +// connectTargetKind enumerates the choices in the connect target menu. +type connectTargetKind int + +const ( + targetPrimary connectTargetKind = iota + targetReplica + targetCancel +) + +// connectTargetChoice is one menu entry: its display label and the action it represents. +type connectTargetChoice struct { + kind connectTargetKind + replica *api.ReadReplicaSet + label string +} + +// connectTargetModel is the Bubble Tea model for selecting a connection target. +type connectTargetModel struct { + choices []connectTargetChoice + cursor int + chosen connectTargetChoice +} + +func newConnectTargetModel(primary api.Service, replicas []api.ReadReplicaSet) connectTargetModel { + choices := []connectTargetChoice{{ + kind: targetPrimary, + label: fmt.Sprintf("Connect to primary service (%s)", util.DerefStr(primary.ServiceId)), + }} + + for i := range replicas { + choices = append(choices, connectTargetChoice{ + kind: targetReplica, + replica: &replicas[i], + label: fmt.Sprintf("Connect to read replica '%s'", util.DerefStr(replicas[i].Name)), + }) + } + + choices = append(choices, connectTargetChoice{kind: targetCancel, label: "Cancel"}) + + return connectTargetModel{ + choices: choices, + // Default to cancel so quitting (ctrl+c/q) is a no-op connection. + chosen: connectTargetChoice{kind: targetCancel}, + } +} + +func (m connectTargetModel) Init() tea.Cmd { + return nil +} + +func (m connectTargetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch key := msg.String(); key { + case "ctrl+c", "q": + m.chosen = connectTargetChoice{kind: targetCancel} + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + case "enter", " ": + m.chosen = m.choices[m.cursor] + return m, tea.Quit + default: + // Number keys jump straight to that option ('1' -> first, etc.). + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { + if idx := int(key[0] - '1'); idx < len(m.choices) { + m.cursor = idx + m.chosen = m.choices[idx] + return m, tea.Quit + } + } + } + } + return m, nil +} + +func (m connectTargetModel) View() string { + s := "How would you like to connect?\n\n" + + for i, choice := range m.choices { + cursor := " " + if m.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %d. %s\n", cursor, i+1, choice.label) + } + + s += "\nUse ↑/↓ arrows or number keys to select, enter to confirm, q to cancel" + return s +} + +// selectConnectTargetOption shows the interactive menu for choosing a +// connection target. +func selectConnectTargetOption(out io.Writer, primary api.Service, replicas []api.ReadReplicaSet) (connectTargetChoice, error) { + model := newConnectTargetModel(primary, replicas) + + program := tea.NewProgram(model, tea.WithOutput(out)) + finalModel, err := program.Run() + if err != nil { + return connectTargetChoice{kind: targetCancel}, fmt.Errorf("failed to run connect menu: %w", err) + } + + return finalModel.(connectTargetModel).chosen, nil +} + +// connectWithPasswordMenu handles the connection flow if the stored password is invalid +// Offers an interactive menu to enter the password manually or reset it +func connectWithPasswordMenu( + ctx context.Context, + cmd *cobra.Command, + client *api.ClientWithResponses, + service api.Service, + details *common.ConnectionDetails, + psqlPath string, + psqlFlags []string, +) error { + // Interactive mode: Get stored password (if any) + storage := common.GetPasswordStorage() + storedPassword, err := storage.Get(service, details.Role) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve stored password: %v\n", err) + } + + // Try to connect with stored password first + err = testConnectionWithPassword(ctx, details, storedPassword) + if err == nil { + // Password works, launch psql + return launchPsql(details, psqlPath, psqlFlags, service, cmd) + } + + // Check if it's an auth error + if !isAuthenticationError(err) { + // Non-auth error (network, timeout, etc.) - report it directly + return err + } + // Auth failed with stored password, continue to recovery menu + fmt.Fprintf(cmd.ErrOrStderr(), "%s\nStored password is likely invalid or expired.\n\n", err.Error()) + + // Check if we're in a TTY for interactive menu + if !checkStdinIsTTY() { + return fmt.Errorf("authentication failed and no TTY available for interactive password entry") + } + + // Interactive recovery loop + // Only allow password reset for admin role + canResetPassword := details.Role == "tsdbadmin" + for { + option, err := selectPasswordRecoveryOption(cmd.ErrOrStderr(), canResetPassword) + if err != nil { + return err + } + + switch option { + case optionEnterPassword: + // Prompt for password + fmt.Fprint(cmd.ErrOrStderr(), "Enter password: ") + password, err := readString(ctx, readPasswordFromTerminal) + fmt.Fprintln(cmd.ErrOrStderr()) // newline after password entry + if err != nil { + if errors.Is(err, context.Canceled) { + return nil // user cancelled + } + fmt.Fprintf(cmd.ErrOrStderr(), "Error reading password: %v\n\n", err) + continue + } + + // Test, save, and launch + details.Password = password + if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, details, psqlPath, psqlFlags, service); err != nil { + if isAuthenticationError(err) { + fmt.Fprintf(cmd.ErrOrStderr(), "Password incorrect. Please try again.\n\n") + continue + } + return fmt.Errorf("connection failed: %w", err) + } + return nil + + case optionResetPassword: + // Prompt and reset + password, err := promptAndResetPassword(ctx, cmd.ErrOrStderr(), client, service, details.Role) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil // user cancelled + } + fmt.Fprintf(cmd.ErrOrStderr(), "Error resetting password: %v\n\n", err) + continue + } + fmt.Fprintf(cmd.ErrOrStderr(), "✅ Master password for '%s' user updated successfully\n", details.Role) + // Launch psql (password is now in storage) + details.Password = password + return launchPsql(details, psqlPath, psqlFlags, service, cmd) + + case optionExit: + return nil + } + } +} + +// testConnectionWithPassword tests database connectivity with a specific password +// Returns nil on success, error on failure +func testConnectionWithPassword(ctx context.Context, details *common.ConnectionDetails, password string) error { + // copy details with provided password + copyDetails := *details + copyDetails.Password = password + connStr := copyDetails.String() + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return err + } + return conn.Close(ctx) +} + +// isAuthenticationError checks if the error is a PostgreSQL authentication failure +func isAuthenticationError(err error) bool { + if err == nil { + return false + } + // Check for PostgreSQL error code 28P01 (invalid_password) or 28000 (invalid_authorization_specification) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "28P01" || pgErr.Code == "28000" + } + return false +} + +// passwordRecoveryOption represents the user's choice in the password recovery menu +type passwordRecoveryOption int + +const ( + optionEnterPassword passwordRecoveryOption = iota + optionResetPassword + optionExit +) + +// passwordRecoveryModel is the Bubble Tea model for password recovery selection +type passwordRecoveryModel struct { + options []string + optionMap []passwordRecoveryOption // maps cursor position to option enum + cursor int + selected passwordRecoveryOption + canResetPassword bool +} + +func newPasswordRecoveryModel(canResetPassword bool) passwordRecoveryModel { + options := []string{"Enter password manually"} + optionMap := []passwordRecoveryOption{optionEnterPassword} + + if canResetPassword { + options = append(options, "Update/reset password") + optionMap = append(optionMap, optionResetPassword) + } + + options = append(options, "Exit") + optionMap = append(optionMap, optionExit) + + return passwordRecoveryModel{ + options: options, + optionMap: optionMap, + cursor: 0, + canResetPassword: canResetPassword, + } +} + +func (m passwordRecoveryModel) Init() tea.Cmd { + return nil +} + +func (m passwordRecoveryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.selected = optionExit + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter", " ": + m.selected = m.optionMap[m.cursor] + return m, tea.Quit + default: + // Handle number keys based on available options + if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' { + idx := int(msg.String()[0] - '1') // '1' -> 0, '2' -> 1, etc. + if idx >= 0 && idx < len(m.options) { + m.cursor = idx + m.selected = m.optionMap[idx] + return m, tea.Quit + } + } + } + } + return m, nil +} + +func (m passwordRecoveryModel) View() string { + s := "What would you like to do?\n\n" + + for i, option := range m.options { + cursor := " " + if m.cursor == i { + cursor = ">" + } + s += fmt.Sprintf("%s %d. %s\n", cursor, i+1, option) + } + + s += "\nUse ↑/↓ arrows or number keys to select, enter to confirm, q to quit" + return s +} + +// selectPasswordRecoveryOption shows the interactive menu for password recovery +// canResetPassword controls whether the "Update/reset password" option is shown +func selectPasswordRecoveryOption(out io.Writer, canResetPassword bool) (passwordRecoveryOption, error) { + model := newPasswordRecoveryModel(canResetPassword) + + program := tea.NewProgram(model, tea.WithOutput(out)) + finalModel, err := program.Run() + if err != nil { + return optionExit, fmt.Errorf("failed to run password recovery menu: %w", err) + } + + result := finalModel.(passwordRecoveryModel) + return result.selected, nil +} + +// testSaveAndLaunchPsqlWithPassword tests a password, saves it if valid, and launches psql. +// Returns a retryable error if authentication fails, or a fatal error otherwise. +func testSaveAndLaunchPsqlWithPassword( + ctx context.Context, + cmd *cobra.Command, + details *common.ConnectionDetails, + psqlPath string, + psqlFlags []string, + service api.Service, +) error { + // Test the password + if err := testConnectionWithPassword(ctx, details, details.Password); err != nil { + return err + } + + // Password works! Save it + result, saveErr := common.SavePasswordWithResult(service, details.Password, details.Role) + if saveErr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save password: %v\n", saveErr) + } else if result.Success { + fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", result.Message) + } + + // Launch psql + return launchPsql(details, psqlPath, psqlFlags, service, cmd) +} + // launchPsql launches psql using the connection string and additional flags. // It retrieves the password from storage and sets PGPASSWORD environment variable. func launchPsql(details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) error { diff --git a/internal/cmd/db_connect_test.go b/internal/cmd/db_connect_test.go index 065d20d3..d94d5cde 100644 --- a/internal/cmd/db_connect_test.go +++ b/internal/cmd/db_connect_test.go @@ -2,15 +2,22 @@ package cmd import ( "bytes" + "context" + "io" + "net/http" + "net/http/httptest" "strings" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/jackc/pgx/v5/pgconn" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/util" ) func TestDBConnect_NoServiceID(t *testing.T) { @@ -189,6 +196,184 @@ func equalStringSlices(a, b []string) bool { return true } +func testPrimary() api.Service { + return api.Service{ + ServiceId: util.Ptr("svc-primary"), + Name: util.Ptr("my-db"), + } +} + +func testReplicas() []api.ReadReplicaSet { + return []api.ReadReplicaSet{ + {Id: util.Ptr("rep-1"), Name: util.Ptr("replica-a")}, + {Id: util.Ptr("rep-2"), Name: util.Ptr("replica-b")}, + } +} + +func TestNewConnectTargetModel_Options(t *testing.T) { + // No replicas: primary, cancel. + m := newConnectTargetModel(testPrimary(), nil) + if len(m.choices) != 2 { + t.Fatalf("expected 2 choices with no replicas, got %d: %v", len(m.choices), m.choices) + } + if m.choices[0].kind != targetPrimary { + t.Errorf("expected first choice to be primary") + } + if m.choices[1].kind != targetCancel { + t.Errorf("expected last choice to be cancel") + } + + // Two replicas: primary, replica-a, replica-b, cancel. + m = newConnectTargetModel(testPrimary(), testReplicas()) + if len(m.choices) != 4 { + t.Fatalf("expected 4 choices with two replicas, got %d: %v", len(m.choices), m.choices) + } + if m.choices[1].kind != targetReplica || m.choices[1].replica == nil || *m.choices[1].replica.Id != "rep-1" { + t.Errorf("expected second choice to be replica rep-1, got %+v", m.choices[1]) + } + if m.choices[2].kind != targetReplica || *m.choices[2].replica.Id != "rep-2" { + t.Errorf("expected third choice to be replica rep-2, got %+v", m.choices[2]) + } + if m.choices[3].kind != targetCancel { + t.Errorf("expected last choice to be cancel when replicas exist, got %v", m.choices[3].kind) + } +} + +func TestConnectTargetModel_DefaultsToCancel(t *testing.T) { + m := newConnectTargetModel(testPrimary(), testReplicas()) + if m.chosen.kind != targetCancel { + t.Errorf("expected default chosen to be cancel, got %v", m.chosen.kind) + } +} + +func TestConnectTargetModel_KeySelection(t *testing.T) { + cases := []struct { + name string + key tea.KeyMsg + wantKind connectTargetKind + wantReplicaID string // checked only when set + }{ + {"q cancels", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, targetCancel, ""}, + {"enter selects primary (cursor starts at 0)", tea.KeyMsg{Type: tea.KeyEnter}, targetPrimary, ""}, + {"'2' selects the first replica", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}, targetReplica, "rep-1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newConnectTargetModel(testPrimary(), testReplicas()) + updated, _ := m.Update(tc.key) + choice := updated.(connectTargetModel).chosen + if choice.kind != tc.wantKind { + t.Fatalf("expected kind %v, got %v", tc.wantKind, choice.kind) + } + if tc.wantReplicaID != "" && (choice.replica == nil || *choice.replica.Id != tc.wantReplicaID) { + t.Errorf("expected replica %s, got %+v", tc.wantReplicaID, choice.replica) + } + }) + } +} + +// TestSelectConnection_NoReplicasSkipsPrompt verifies that, with no +// connectable replicas, selectConnection connects to the primary directly +// instead of showing a single-option menu (which would block on TTY input in +// this test). +func TestSelectConnection_NoReplicasSkipsPrompt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) // no replicas + })) + defer server.Close() + + client, err := api.NewClientWithResponses(server.URL) + if err != nil { + t.Fatalf("failed to build client: %v", err) + } + + host := "primary.example.com" + port := 5432 + primary := api.Service{ + ServiceId: util.Ptr("svc-primary"), + Name: util.Ptr("my-db"), + Endpoint: &api.Endpoint{Host: &host, Port: &port}, + } + + // Pretend we're on a TTY so the prompt would normally run. + orig := checkStdinIsTTY + checkStdinIsTTY = func() bool { return true } + defer func() { checkStdinIsTTY = orig }() + + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + target := &common.ConnectionTarget{ConnectionService: primary, CredentialService: primary} + details, err := selectConnection(context.Background(), cmd, client, "proj-1", target, + common.ConnectionDetailsOptions{Role: "tsdbadmin"}, false /*noReplicaPrompt*/) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if details == nil || details.Host != host { + t.Fatalf("expected to connect directly to primary %q, got %+v", host, details) + } +} + +func TestIsAuthenticationError(t *testing.T) { + testCases := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "PostgreSQL error code 28P01 (invalid_password)", + err: &pgconn.PgError{ + Code: "28P01", + Message: "password authentication failed for user \"test\"", + }, + expected: true, + }, + { + name: "PostgreSQL error code 28000 (invalid_authorization_specification)", + err: &pgconn.PgError{ + Code: "28000", + Message: "role \"nonexistent\" does not exist", + }, + expected: true, + }, + { + name: "PostgreSQL error code 57P03 (cannot_connect_now) - not auth error", + err: &pgconn.PgError{ + Code: "57P03", + Message: "the database system is starting up", + }, + expected: false, + }, + { + name: "PostgreSQL error code 3D000 (database does not exist) - not auth error", + err: &pgconn.PgError{ + Code: "3D000", + Message: "database \"nonexistent\" does not exist", + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := isAuthenticationError(tc.err) + + if result != tc.expected { + t.Errorf("Expected isAuthenticationError to return %v for error %v, got %v", + tc.expected, tc.err, result) + } + }) + } +} + func TestLaunchPsqlWithConnectionString(t *testing.T) { // This test verifies the psql launching logic without actually running psql diff --git a/internal/cmd/db_create_role.go b/internal/cmd/db_create_role.go index d653a8ae..6e68aa2d 100644 --- a/internal/cmd/db_create_role.go +++ b/internal/cmd/db_create_role.go @@ -310,7 +310,7 @@ func getPasswordForRole(passwordFlag string) (string, error) { } // Auto-generate secure password - return generateSecurePassword(32) + return util.GenerateSecurePassword(32) } // CreateRoleResult represents the output of a create role operation diff --git a/internal/cmd/flag.go b/internal/cmd/flag_helper.go similarity index 100% rename from internal/cmd/flag.go rename to internal/cmd/flag_helper.go diff --git a/internal/cmd/mcp_get.go b/internal/cmd/mcp_get.go index bb1400ea..a90c784d 100644 --- a/internal/cmd/mcp_get.go +++ b/internal/cmd/mcp_get.go @@ -514,35 +514,3 @@ func formatPromptArguments(arguments []*mcpsdk.PromptArgument) string { return strings.Join(lines, "\n") } - -// mcpGetCompletion provides custom completions for the get command -func mcpGetCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Capability name is always first positional argument - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - cfg, err := config.Load() - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - // Create MCP server to get capabilities - server, err := mcp.NewServer(cmd.Context(), cfg) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - defer server.Close() - - capabilities, err := server.ListCapabilities(cmd.Context()) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - // Close the MCP server when finished - if err := server.Close(); err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - return filterCompletionsByPrefix(capabilities.Names(), toComplete), cobra.ShellCompDirectiveNoFileComp -} diff --git a/internal/cmd/oauth.go b/internal/cmd/oauth.go deleted file mode 100644 index ed588c8b..00000000 --- a/internal/cmd/oauth.go +++ /dev/null @@ -1,366 +0,0 @@ -package cmd - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "io" - "math/rand" - "net" - "net/http" - "os/exec" - "runtime" - "strconv" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "golang.org/x/oauth2" - - "github.com/timescale/tiger-cli/internal/api" - "github.com/timescale/tiger-cli/internal/common" - "github.com/timescale/tiger-cli/internal/config" -) - -var ( - // openBrowser can be overridden for testing - openBrowser = openBrowserImpl - - // selectProjectInteractively can be overridden for testing - selectProjectInteractively = selectProjectInteractivelyImpl -) - -type oauthLogin struct { - cfg *config.Config - authURL string - tokenURL string - successURL string - out io.Writer -} - -func (l *oauthLogin) loginWithOAuth(ctx context.Context) (*oauth2.Token, *api.ClientWithResponses, string, error) { - token, err := l.getOAuthToken(ctx) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to authenticate via OAuth: %w", err) - } - - // Build the token-authenticated client once and reuse it for the - // subsequent authenticated requests. - client, err := api.NewTigerClientWithToken(l.cfg, token, nil) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to create API client: %w", err) - } - - projectID, err := l.selectProjectID(ctx, client) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to select project: %w", err) - } - - return token, client, projectID, nil -} - -func (l *oauthLogin) getOAuthToken(ctx context.Context) (*oauth2.Token, error) { - codeVerifier := oauth2.GenerateVerifier() - - // Random state guards against CSRF on the OAuth callback. - state, err := l.generateRandomState(32) - if err != nil { - return nil, fmt.Errorf("failed to generate random state: %w", err) - } - - server, err := l.startOAuthServer(state, codeVerifier) - if err != nil { - return nil, fmt.Errorf("failed to create local server: %w", err) - } - defer func() { - if err := server.server.Shutdown(ctx); err != nil { - fmt.Fprintf(l.out, "Failed to close local server: %s\n", err) - } - }() - - authURL := server.oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)) - fmt.Fprintf(l.out, "Auth URL is: %s\n", authURL) - fmt.Fprintln(l.out, "Opening browser for authentication...") - if err := openBrowser(authURL); err != nil { - fmt.Fprintf(l.out, "Failed to open browser: %s\nPlease manually navigate to the Auth URL.", err) - } - - select { - case result := <-server.resultChan: - return result.token, result.err - case <-time.After(5 * time.Minute): - return nil, fmt.Errorf("authorization timeout - no callback received within 5 minutes") - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -func (l *oauthLogin) generateRandomState(length int) (string, error) { - data := make([]byte, length) - if _, err := rand.Read(data); err != nil { - return "", err - } - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data)[:length], nil -} - -type oauthServer struct { - server *http.Server - oauthCfg oauth2.Config - resultChan <-chan oauthResult -} - -type oauthResult struct { - token *oauth2.Token - err error -} - -func (l *oauthLogin) startOAuthServer(expectedState, codeVerifier string) (*oauthServer, error) { - listener, err := net.Listen("tcp", ":0") - if err != nil { - return nil, fmt.Errorf("failed to listen on local port: %w", err) - } - - port := listener.Addr().(*net.TCPAddr).Port - oauthCfg := oauth2.Config{ - ClientID: config.TigerCLIClientID, - Endpoint: oauth2.Endpoint{ - AuthURL: l.authURL, - TokenURL: l.tokenURL, - AuthStyle: oauth2.AuthStyleInParams, - }, - RedirectURL: fmt.Sprintf("http://localhost:%d/callback", port), - } - - // Start local HTTP server for callback - resultChan := make(chan oauthResult, 1) - mux := http.NewServeMux() - mux.Handle("GET /callback", &oauthCallback{ - oauthCfg: oauthCfg, - expectedState: expectedState, - codeVerifier: codeVerifier, - successURL: l.successURL, - resultChan: resultChan, - }) - - server := &http.Server{Handler: mux} - go func() { - if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - resultChan <- oauthResult{ - err: fmt.Errorf("failed to serve requests: %w", err), - } - } - }() - - return &oauthServer{ - server: server, - oauthCfg: oauthCfg, - resultChan: resultChan, - }, nil -} - -type oauthCallback struct { - oauthCfg oauth2.Config - expectedState string - codeVerifier string - successURL string - resultChan chan<- oauthResult -} - -// userAgentTransport sets the CLI User-Agent on outgoing requests. -type userAgentTransport struct{} - -func (userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("User-Agent", config.UserAgent()) - return http.DefaultTransport.RoundTrip(req) -} - -func (c *oauthCallback) ServeHTTP(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - - // Validate state parameter - state := query.Get("state") - if state != c.expectedState { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "Invalid state parameter") - c.sendError(fmt.Errorf("invalid state parameter")) - return - } - - // Get authorization code - code := query.Get("code") - if code == "" { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "Missing authorization code") - c.sendError(fmt.Errorf("missing authorization code in callback")) - return - } - - // The exchange's User-Agent is recorded as the CLI session's user_agent. - ctx := context.WithValue(r.Context(), oauth2.HTTPClient, - &http.Client{Transport: userAgentTransport{}}) - token, err := c.oauthCfg.Exchange(ctx, code, oauth2.VerifierOption(c.codeVerifier)) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "Failed to exchange authorization code for tokens") - c.sendError(fmt.Errorf("failed to exchange code for tokens: %w", err)) - return - } - - // Redirect to success page - http.Redirect(w, r, c.successURL, http.StatusTemporaryRedirect) - - c.resultChan <- oauthResult{ - token: token, - } -} - -func (c *oauthCallback) sendError(err error) { - c.resultChan <- oauthResult{err: err} -} - -func openBrowserImpl(url string) error { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - // Escape '&' so cmd.exe doesn't treat it as a command separator - cmd = exec.Command("cmd", "/c", "start", strings.ReplaceAll(url, "&", "^&")) - case "darwin": - cmd = exec.Command("open", url) - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = exec.Command("xdg-open", url) - } - - return cmd.Start() -} - -func (l *oauthLogin) selectProjectID(ctx context.Context, client *api.ClientWithResponses) (string, error) { - resp, err := client.GetProjectsWithResponse(ctx) - if err != nil { - return "", fmt.Errorf("failed to get user projects: %w", err) - } - if resp.JSON200 == nil { - return "", common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) - } - projects := *resp.JSON200 - - switch len(projects) { - case 0: - return "", fmt.Errorf("user has no accessible projects") - case 1: - return projects[0].Id, nil - default: - return selectProjectInteractively(projects, l.out) - } -} - -// selectProjectInteractivelyImpl is the default implementation for project selection using Bubble Tea -func selectProjectInteractivelyImpl(projects []api.Project, out io.Writer) (string, error) { - model := projectSelectModel{ - projects: projects, - cursor: 0, - } - - program := tea.NewProgram(model, tea.WithOutput(out)) - finalModel, err := program.Run() - if err != nil { - return "", fmt.Errorf("failed to run project selection: %w", err) - } - - result := finalModel.(projectSelectModel) - if result.selected == "" { - return "", fmt.Errorf("no project selected") - } - - return result.selected, nil -} - -type projectSelectModel struct { - projects []api.Project - cursor int - selected string - numberBuffer string -} - -func (m projectSelectModel) Init() tea.Cmd { - return nil -} - -func (m projectSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "up", "k": - // Clear buffer when using arrows - m.numberBuffer = "" - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - // Clear buffer when using arrows - m.numberBuffer = "" - if m.cursor < len(m.projects)-1 { - m.cursor++ - } - case "enter", " ": - m.selected = m.projects[m.cursor].Id - return m, tea.Quit - case "backspace": - // Handle backspace to remove last character from buffer - if len(m.numberBuffer) > 0 { - m.updateNumberBuffer(m.numberBuffer[:len(m.numberBuffer)-1]) - } - case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": - // Add digit to buffer and update cursor position - m.updateNumberBuffer(m.numberBuffer + msg.String()) - case "ctrl+w", "esc": - // Clear buffer on escape - m.numberBuffer = "" - } - } - return m, nil -} - -// updateNumberBuffer moves the cursor to the project matching the number buffer -func (m *projectSelectModel) updateNumberBuffer(newBuffer string) { - if newBuffer == "" { - m.numberBuffer = newBuffer - return - } - - // Parse the buffer as a number - num, err := strconv.Atoi(newBuffer) - if err != nil { - return - } - - // Convert from 1-based to 0-based index and validate bounds - index := num - 1 - if index >= 0 && index < len(m.projects) { - m.numberBuffer = newBuffer - m.cursor = index - } -} - -func (m projectSelectModel) View() string { - s := "Select a project:\n\n" - - for i, project := range m.projects { - cursor := " " - if m.cursor == i { - cursor = ">" - } - s += fmt.Sprintf("%s %d. %s (%s)\n", cursor, i+1, project.Name, project.Id) - } - - // Show the current number buffer if user is typing - if m.numberBuffer != "" { - s += fmt.Sprintf("\nTyping: %s", m.numberBuffer) - } - - s += "\nUse ↑/↓ arrows or number keys to navigate, enter to select, q to quit" - return s -} diff --git a/internal/cmd/password_helper.go b/internal/cmd/password_helper.go new file mode 100644 index 00000000..7c0c263f --- /dev/null +++ b/internal/cmd/password_helper.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "fmt" + "io" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// updateAndSaveServicePassword updates a service password via API and saves it locally. +// It handles the API call and password storage. +func updateAndSaveServicePassword( + ctx context.Context, + client api.ClientWithResponsesInterface, + service api.Service, + newPassword string, + role string, + statusOut io.Writer, +) error { + // Call API to update password + updateReq := api.UpdatePasswordInput{Password: newPassword} + resp, err := client.UpdatePasswordWithResponse(ctx, *service.ProjectId, *service.ServiceId, updateReq) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + if resp.StatusCode() != 200 && resp.StatusCode() != 204 { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + // Save password locally + if result, err := common.SavePasswordWithResult(service, newPassword, role); err != nil { + fmt.Fprintf(statusOut, "Warning: could not save password: %v\n", err) + } else if result.Success { + fmt.Fprintf(statusOut, "%s\n", result.Message) + fmt.Fprintf(statusOut, "To view your new password, run: \n\t tiger service get %s --with-password\n", util.Deref(service.ServiceId)) + } + + return nil +} + +// resetServicePassword resets the password via API. If newPassword is empty, generates one. +func resetServicePassword(ctx context.Context, client api.ClientWithResponsesInterface, service api.Service, role string, newPassword string, statusOut io.Writer) (string, error) { + // Generate password if not provided + if newPassword == "" { + var err error + if newPassword, err = util.GenerateSecurePassword(32); err != nil { + return "", fmt.Errorf("failed to generate new password: %w", err) + } + fmt.Fprintf(statusOut, "Successfully generated a new password.\n") + } + + // Update and save password + if err := updateAndSaveServicePassword(ctx, client, service, newPassword, role, statusOut); err != nil { + return "", err + } + return newPassword, nil +} + +// promptAndResetPassword prompts for a new password and resets it via API. +// If the user leaves the password empty, a secure password is generated. +// Returns the new password on success. +func promptAndResetPassword( + ctx context.Context, + out io.Writer, + client api.ClientWithResponsesInterface, + service api.Service, + role string, +) (string, error) { + fmt.Fprint(out, "Enter new password (leave empty to generate): ") + newPassword, err := readString(ctx, readPasswordFromTerminal) + fmt.Fprintln(out) // newline after password entry + if err != nil { + return "", fmt.Errorf("error reading password: %w", err) + } + + return resetServicePassword(ctx, client, service, role, newPassword, out) +} diff --git a/internal/cmd/password_recovery.go b/internal/cmd/password_recovery.go deleted file mode 100644 index d1c23570..00000000 --- a/internal/cmd/password_recovery.go +++ /dev/null @@ -1,343 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "io" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/spf13/cobra" - "github.com/timescale/tiger-cli/internal/api" - "github.com/timescale/tiger-cli/internal/common" - "github.com/timescale/tiger-cli/internal/util" -) - -// connectWithPasswordMenu handles the connection flow if the stored password is invalid -// Offers an interactive menu to enter the password manually or reset it -func connectWithPasswordMenu( - ctx context.Context, - cmd *cobra.Command, - client *api.ClientWithResponses, - service api.Service, - details *common.ConnectionDetails, - psqlPath string, - psqlFlags []string, -) error { - // Interactive mode: Get stored password (if any) - storage := common.GetPasswordStorage() - storedPassword, err := storage.Get(service, details.Role) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve stored password: %v\n", err) - } - - // Try to connect with stored password first - err = testConnectionWithPassword(ctx, details, storedPassword) - if err == nil { - // Password works, launch psql - return launchPsql(details, psqlPath, psqlFlags, service, cmd) - } - - // Check if it's an auth error - if !isAuthenticationError(err) { - // Non-auth error (network, timeout, etc.) - report it directly - return err - } - // Auth failed with stored password, continue to recovery menu - fmt.Fprintf(cmd.ErrOrStderr(), "%s\nStored password is likely invalid or expired.\n\n", err.Error()) - - // Check if we're in a TTY for interactive menu - if !checkStdinIsTTY() { - return fmt.Errorf("authentication failed and no TTY available for interactive password entry") - } - - // Interactive recovery loop - // Only allow password reset for admin role - canResetPassword := details.Role == "tsdbadmin" - for { - option, err := selectPasswordRecoveryOption(cmd.ErrOrStderr(), canResetPassword) - if err != nil { - return err - } - - switch option { - case optionEnterPassword: - // Prompt for password - fmt.Fprint(cmd.ErrOrStderr(), "Enter password: ") - password, err := readString(ctx, readPasswordFromTerminal) - fmt.Fprintln(cmd.ErrOrStderr()) // newline after password entry - if err != nil { - if errors.Is(err, context.Canceled) { - return nil // user cancelled - } - fmt.Fprintf(cmd.ErrOrStderr(), "Error reading password: %v\n\n", err) - continue - } - - // Test, save, and launch - details.Password = password - if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, details, psqlPath, psqlFlags, service); err != nil { - if isAuthenticationError(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Password incorrect. Please try again.\n\n") - continue - } - return fmt.Errorf("connection failed: %w", err) - } - return nil - - case optionResetPassword: - // Prompt and reset - password, err := promptAndResetPassword(ctx, cmd.ErrOrStderr(), client, service, details.Role) - if err != nil { - if errors.Is(err, context.Canceled) { - return nil // user cancelled - } - fmt.Fprintf(cmd.ErrOrStderr(), "Error resetting password: %v\n\n", err) - continue - } - fmt.Fprintf(cmd.ErrOrStderr(), "✅ Master password for '%s' user updated successfully\n", details.Role) - // Launch psql (password is now in storage) - details.Password = password - return launchPsql(details, psqlPath, psqlFlags, service, cmd) - - case optionExit: - return nil - } - } -} - -// testConnectionWithPassword tests database connectivity with a specific password -// Returns nil on success, error on failure -func testConnectionWithPassword(ctx context.Context, details *common.ConnectionDetails, password string) error { - // copy details with provided password - copyDetails := *details - copyDetails.Password = password - connStr := copyDetails.String() - - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - conn, err := pgx.Connect(ctx, connStr) - if err != nil { - return err - } - return conn.Close(ctx) -} - -// passwordRecoveryOption represents the user's choice in the password recovery menu -type passwordRecoveryOption int - -const ( - optionEnterPassword passwordRecoveryOption = iota - optionResetPassword - optionExit -) - -// passwordRecoveryModel is the Bubble Tea model for password recovery selection -type passwordRecoveryModel struct { - options []string - optionMap []passwordRecoveryOption // maps cursor position to option enum - cursor int - selected passwordRecoveryOption - canResetPassword bool -} - -func newPasswordRecoveryModel(canResetPassword bool) passwordRecoveryModel { - options := []string{"Enter password manually"} - optionMap := []passwordRecoveryOption{optionEnterPassword} - - if canResetPassword { - options = append(options, "Update/reset password") - optionMap = append(optionMap, optionResetPassword) - } - - options = append(options, "Exit") - optionMap = append(optionMap, optionExit) - - return passwordRecoveryModel{ - options: options, - optionMap: optionMap, - cursor: 0, - canResetPassword: canResetPassword, - } -} - -func (m passwordRecoveryModel) Init() tea.Cmd { - return nil -} - -func (m passwordRecoveryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - m.selected = optionExit - return m, tea.Quit - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.options)-1 { - m.cursor++ - } - case "enter", " ": - m.selected = m.optionMap[m.cursor] - return m, tea.Quit - default: - // Handle number keys based on available options - if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' { - idx := int(msg.String()[0] - '1') // '1' -> 0, '2' -> 1, etc. - if idx >= 0 && idx < len(m.options) { - m.cursor = idx - m.selected = m.optionMap[idx] - return m, tea.Quit - } - } - } - } - return m, nil -} - -func (m passwordRecoveryModel) View() string { - s := "What would you like to do?\n\n" - - for i, option := range m.options { - cursor := " " - if m.cursor == i { - cursor = ">" - } - s += fmt.Sprintf("%s %d. %s\n", cursor, i+1, option) - } - - s += "\nUse ↑/↓ arrows or number keys to select, enter to confirm, q to quit" - return s -} - -// selectPasswordRecoveryOption shows the interactive menu for password recovery -// canResetPassword controls whether the "Update/reset password" option is shown -func selectPasswordRecoveryOption(out io.Writer, canResetPassword bool) (passwordRecoveryOption, error) { - model := newPasswordRecoveryModel(canResetPassword) - - program := tea.NewProgram(model, tea.WithOutput(out)) - finalModel, err := program.Run() - if err != nil { - return optionExit, fmt.Errorf("failed to run password recovery menu: %w", err) - } - - result := finalModel.(passwordRecoveryModel) - return result.selected, nil -} - -// updateAndSaveServicePassword updates a service password via API and saves it locally. -// It handles the API call and password storage. -func updateAndSaveServicePassword( - ctx context.Context, - client api.ClientWithResponsesInterface, - service api.Service, - newPassword string, - role string, - statusOut io.Writer, -) error { - // Call API to update password - updateReq := api.UpdatePasswordInput{Password: newPassword} - resp, err := client.UpdatePasswordWithResponse(ctx, *service.ProjectId, *service.ServiceId, updateReq) - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - if resp.StatusCode() != 200 && resp.StatusCode() != 204 { - return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) - } - - // Save password locally - if result, err := common.SavePasswordWithResult(service, newPassword, role); err != nil { - fmt.Fprintf(statusOut, "Warning: could not save password: %v\n", err) - } else if result.Success { - fmt.Fprintf(statusOut, "%s\n", result.Message) - fmt.Fprintf(statusOut, "To view your new password, run: \n\t tiger service get %s --with-password\n", util.Deref(service.ServiceId)) - } - - return nil -} - -// resetServicePassword resets the password via API. If newPassword is empty, generates one. -func resetServicePassword(ctx context.Context, client api.ClientWithResponsesInterface, service api.Service, role string, newPassword string, statusOut io.Writer) (string, error) { - // Generate password if not provided - if newPassword == "" { - var err error - if newPassword, err = generateSecurePassword(32); err != nil { - return "", fmt.Errorf("failed to generate new password: %w", err) - } - fmt.Fprintf(statusOut, "Successfully generated a new password.\n") - } - - // Update and save password - if err := updateAndSaveServicePassword(ctx, client, service, newPassword, role, statusOut); err != nil { - return "", err - } - return newPassword, nil -} - -// testSaveAndLaunchPsqlWithPassword tests a password, saves it if valid, and launches psql. -// Returns a retryable error if authentication fails, or a fatal error otherwise. -func testSaveAndLaunchPsqlWithPassword( - ctx context.Context, - cmd *cobra.Command, - details *common.ConnectionDetails, - psqlPath string, - psqlFlags []string, - service api.Service, -) error { - // Test the password - if err := testConnectionWithPassword(ctx, details, details.Password); err != nil { - return err - } - - // Password works! Save it - result, saveErr := common.SavePasswordWithResult(service, details.Password, details.Role) - if saveErr != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save password: %v\n", saveErr) - } else if result.Success { - fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", result.Message) - } - - // Launch psql - return launchPsql(details, psqlPath, psqlFlags, service, cmd) -} - -// promptAndResetPassword prompts for a new password and resets it via API. -// If the user leaves the password empty, a secure password is generated. -// Returns the new password on success. -func promptAndResetPassword( - ctx context.Context, - out io.Writer, - client api.ClientWithResponsesInterface, - service api.Service, - role string, -) (string, error) { - fmt.Fprint(out, "Enter new password (leave empty to generate): ") - newPassword, err := readString(ctx, readPasswordFromTerminal) - fmt.Fprintln(out) // newline after password entry - if err != nil { - return "", fmt.Errorf("error reading password: %w", err) - } - - return resetServicePassword(ctx, client, service, role, newPassword, out) -} - -// isAuthenticationError checks if the error is a PostgreSQL authentication failure -func isAuthenticationError(err error) bool { - if err == nil { - return false - } - // Check for PostgreSQL error code 28P01 (invalid_password) or 28000 (invalid_authorization_specification) - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - return pgErr.Code == "28P01" || pgErr.Code == "28000" - } - return false -} diff --git a/internal/cmd/password_recovery_test.go b/internal/cmd/password_recovery_test.go deleted file mode 100644 index 7f18eecd..00000000 --- a/internal/cmd/password_recovery_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package cmd - -import ( - "testing" - - "github.com/jackc/pgx/v5/pgconn" -) - -func TestIsAuthenticationError(t *testing.T) { - testCases := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "PostgreSQL error code 28P01 (invalid_password)", - err: &pgconn.PgError{ - Code: "28P01", - Message: "password authentication failed for user \"test\"", - }, - expected: true, - }, - { - name: "PostgreSQL error code 28000 (invalid_authorization_specification)", - err: &pgconn.PgError{ - Code: "28000", - Message: "role \"nonexistent\" does not exist", - }, - expected: true, - }, - { - name: "PostgreSQL error code 57P03 (cannot_connect_now) - not auth error", - err: &pgconn.PgError{ - Code: "57P03", - Message: "the database system is starting up", - }, - expected: false, - }, - { - name: "PostgreSQL error code 3D000 (database does not exist) - not auth error", - err: &pgconn.PgError{ - Code: "3D000", - Message: "database \"nonexistent\" does not exist", - }, - expected: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := isAuthenticationError(tc.err) - - if result != tc.expected { - t.Errorf("Expected isAuthenticationError to return %v for error %v, got %v", - tc.expected, tc.err, result) - } - }) - } -} diff --git a/internal/cmd/read_replica.go b/internal/cmd/read_replica.go deleted file mode 100644 index 06848f0a..00000000 --- a/internal/cmd/read_replica.go +++ /dev/null @@ -1,206 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "net/http" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - - "github.com/timescale/tiger-cli/internal/api" - "github.com/timescale/tiger-cli/internal/common" - "github.com/timescale/tiger-cli/internal/util" -) - -// selectConnection returns the connection details for `tiger db connect`. A -// replica target connects straight through; a primary in an interactive -// terminal is offered a menu to pick the primary or one of its replicas (nil -// details means the user cancelled). -func selectConnection( - ctx context.Context, - cmd *cobra.Command, - client *api.ClientWithResponses, - projectID string, - target *common.ConnectionTarget, - opts common.ConnectionDetailsOptions, - noReplicaPrompt bool, -) (*common.ConnectionDetails, error) { - // chosen is what we connect to; the menu below may replace it with a replica. - chosen := target - - // Offer the replica menu only for a primary on an interactive terminal. - if !target.IsReplica && !noReplicaPrompt && checkStdinIsTTY() { - primary := target.ConnectionService - replicas, err := fetchReplicaSets(ctx, client, projectID, util.DerefStr(primary.ServiceId)) - if err != nil { - // Don't block the connection if we can't list replicas. - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not list read replicas: %v\n", err) - } else if connectable := connectableReplicas(replicas); len(connectable) > 0 { - choice, err := selectConnectTargetOption(cmd.ErrOrStderr(), primary, connectable) - if err != nil { - return nil, err - } - switch choice.kind { - case targetCancel: - return nil, nil - case targetReplica: - chosen = common.NewReplicaConnectionTarget(primary, *choice.replica) - } - } - } - - details, err := buildConnectionDetailsForTarget(cmd, chosen, opts) - if err != nil { - return nil, err - } - if chosen.IsReplica { - fmt.Fprintf(cmd.ErrOrStderr(), "Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name)) - } - return details, nil -} - -// connectableReplicas filters to active read replicas that expose an endpoint. -func connectableReplicas(replicas []api.ReadReplicaSet) []api.ReadReplicaSet { - var out []api.ReadReplicaSet - for _, r := range replicas { - if r.Status != nil && *r.Status == api.ReadReplicaSetStatusActive && r.Endpoint != nil { - out = append(out, r) - } - } - return out -} - -// fetchReplicaSets retrieves the read replica sets for a service. -func fetchReplicaSets(ctx context.Context, client *api.ClientWithResponses, projectID, serviceID string) ([]api.ReadReplicaSet, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - resp, err := client.GetReplicaSetsWithResponse(ctx, projectID, serviceID) - if err != nil { - return nil, err - } - - if resp.StatusCode() != http.StatusOK { - return nil, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) - } - - if resp.JSON200 == nil { - return nil, nil - } - - return *resp.JSON200, nil -} - -// connectTargetKind enumerates the choices in the connect target menu. -type connectTargetKind int - -const ( - targetPrimary connectTargetKind = iota - targetReplica - targetCancel -) - -// connectTargetChoice is one menu entry: its display label and the action it represents. -type connectTargetChoice struct { - kind connectTargetKind - replica *api.ReadReplicaSet - label string -} - -// connectTargetModel is the Bubble Tea model for selecting a connection target. -type connectTargetModel struct { - choices []connectTargetChoice - cursor int - chosen connectTargetChoice -} - -func newConnectTargetModel(primary api.Service, replicas []api.ReadReplicaSet) connectTargetModel { - choices := []connectTargetChoice{{ - kind: targetPrimary, - label: fmt.Sprintf("Connect to primary service (%s)", util.DerefStr(primary.ServiceId)), - }} - - for i := range replicas { - choices = append(choices, connectTargetChoice{ - kind: targetReplica, - replica: &replicas[i], - label: fmt.Sprintf("Connect to read replica '%s'", util.DerefStr(replicas[i].Name)), - }) - } - - choices = append(choices, connectTargetChoice{kind: targetCancel, label: "Cancel"}) - - return connectTargetModel{ - choices: choices, - // Default to cancel so quitting (ctrl+c/q) is a no-op connection. - chosen: connectTargetChoice{kind: targetCancel}, - } -} - -func (m connectTargetModel) Init() tea.Cmd { - return nil -} - -func (m connectTargetModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch key := msg.String(); key { - case "ctrl+c", "q": - m.chosen = connectTargetChoice{kind: targetCancel} - return m, tea.Quit - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - case "enter", " ": - m.chosen = m.choices[m.cursor] - return m, tea.Quit - default: - // Number keys jump straight to that option ('1' -> first, etc.). - if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { - if idx := int(key[0] - '1'); idx < len(m.choices) { - m.cursor = idx - m.chosen = m.choices[idx] - return m, tea.Quit - } - } - } - } - return m, nil -} - -func (m connectTargetModel) View() string { - s := "How would you like to connect?\n\n" - - for i, choice := range m.choices { - cursor := " " - if m.cursor == i { - cursor = ">" - } - s += fmt.Sprintf("%s %d. %s\n", cursor, i+1, choice.label) - } - - s += "\nUse ↑/↓ arrows or number keys to select, enter to confirm, q to cancel" - return s -} - -// selectConnectTargetOption shows the interactive menu for choosing a -// connection target. -func selectConnectTargetOption(out io.Writer, primary api.Service, replicas []api.ReadReplicaSet) (connectTargetChoice, error) { - model := newConnectTargetModel(primary, replicas) - - program := tea.NewProgram(model, tea.WithOutput(out)) - finalModel, err := program.Run() - if err != nil { - return connectTargetChoice{kind: targetCancel}, fmt.Errorf("failed to run connect menu: %w", err) - } - - return finalModel.(connectTargetModel).chosen, nil -} diff --git a/internal/cmd/read_replica_test.go b/internal/cmd/read_replica_test.go deleted file mode 100644 index 40b112fd..00000000 --- a/internal/cmd/read_replica_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package cmd - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - - "github.com/timescale/tiger-cli/internal/api" - "github.com/timescale/tiger-cli/internal/common" - "github.com/timescale/tiger-cli/internal/util" -) - -func testPrimary() api.Service { - return api.Service{ - ServiceId: util.Ptr("svc-primary"), - Name: util.Ptr("my-db"), - } -} - -func testReplicas() []api.ReadReplicaSet { - return []api.ReadReplicaSet{ - {Id: util.Ptr("rep-1"), Name: util.Ptr("replica-a")}, - {Id: util.Ptr("rep-2"), Name: util.Ptr("replica-b")}, - } -} - -func TestNewConnectTargetModel_Options(t *testing.T) { - // No replicas: primary, cancel. - m := newConnectTargetModel(testPrimary(), nil) - if len(m.choices) != 2 { - t.Fatalf("expected 2 choices with no replicas, got %d: %v", len(m.choices), m.choices) - } - if m.choices[0].kind != targetPrimary { - t.Errorf("expected first choice to be primary") - } - if m.choices[1].kind != targetCancel { - t.Errorf("expected last choice to be cancel") - } - - // Two replicas: primary, replica-a, replica-b, cancel. - m = newConnectTargetModel(testPrimary(), testReplicas()) - if len(m.choices) != 4 { - t.Fatalf("expected 4 choices with two replicas, got %d: %v", len(m.choices), m.choices) - } - if m.choices[1].kind != targetReplica || m.choices[1].replica == nil || *m.choices[1].replica.Id != "rep-1" { - t.Errorf("expected second choice to be replica rep-1, got %+v", m.choices[1]) - } - if m.choices[2].kind != targetReplica || *m.choices[2].replica.Id != "rep-2" { - t.Errorf("expected third choice to be replica rep-2, got %+v", m.choices[2]) - } - if m.choices[3].kind != targetCancel { - t.Errorf("expected last choice to be cancel when replicas exist, got %v", m.choices[3].kind) - } -} - -func TestConnectTargetModel_DefaultsToCancel(t *testing.T) { - m := newConnectTargetModel(testPrimary(), testReplicas()) - if m.chosen.kind != targetCancel { - t.Errorf("expected default chosen to be cancel, got %v", m.chosen.kind) - } -} - -func TestConnectTargetModel_KeySelection(t *testing.T) { - cases := []struct { - name string - key tea.KeyMsg - wantKind connectTargetKind - wantReplicaID string // checked only when set - }{ - {"q cancels", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, targetCancel, ""}, - {"enter selects primary (cursor starts at 0)", tea.KeyMsg{Type: tea.KeyEnter}, targetPrimary, ""}, - {"'2' selects the first replica", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}, targetReplica, "rep-1"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - m := newConnectTargetModel(testPrimary(), testReplicas()) - updated, _ := m.Update(tc.key) - choice := updated.(connectTargetModel).chosen - if choice.kind != tc.wantKind { - t.Fatalf("expected kind %v, got %v", tc.wantKind, choice.kind) - } - if tc.wantReplicaID != "" && (choice.replica == nil || *choice.replica.Id != tc.wantReplicaID) { - t.Errorf("expected replica %s, got %+v", tc.wantReplicaID, choice.replica) - } - }) - } -} - -// TestSelectConnection_NoReplicasSkipsPrompt verifies that, with no -// connectable replicas, selectConnection connects to the primary directly -// instead of showing a single-option menu (which would block on TTY input in -// this test). -func TestSelectConnection_NoReplicasSkipsPrompt(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`[]`)) // no replicas - })) - defer server.Close() - - client, err := api.NewClientWithResponses(server.URL) - if err != nil { - t.Fatalf("failed to build client: %v", err) - } - - host := "primary.example.com" - port := 5432 - primary := api.Service{ - ServiceId: util.Ptr("svc-primary"), - Name: util.Ptr("my-db"), - Endpoint: &api.Endpoint{Host: &host, Port: &port}, - } - - // Pretend we're on a TTY so the prompt would normally run. - orig := checkStdinIsTTY - checkStdinIsTTY = func() bool { return true } - defer func() { checkStdinIsTTY = orig }() - - cmd := &cobra.Command{} - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - - target := &common.ConnectionTarget{ConnectionService: primary, CredentialService: primary} - details, err := selectConnection(context.Background(), cmd, client, "proj-1", target, - common.ConnectionDetailsOptions{Role: "tsdbadmin"}, false /*noReplicaPrompt*/) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if details == nil || details.Host != host { - t.Fatalf("expected to connect directly to primary %q, got %+v", host, details) - } -} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9b240825..f16cb710 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "strconv" - "strings" "time" "github.com/fatih/color" @@ -211,31 +210,3 @@ func Execute(ctx context.Context) error { return rootCmd.Execute() } - -func readString(ctx context.Context, readFn func() (string, error)) (string, error) { - valCh := make(chan string) - errCh := make(chan error) - defer func() { close(valCh); close(errCh) }() - go func() { - val, err := readFn() - if err != nil { - errCh <- err - return - } - select { - case <-ctx.Done(): // don't return an empty value if the context is already canceled - return - default: - } - valCh <- val - }() - - select { - case <-ctx.Done(): - return "", ctx.Err() - case err := <-errCh: - return "", err - case val := <-valCh: - return strings.TrimSpace(val), nil - } -} diff --git a/internal/cmd/service.go b/internal/cmd/service.go index 312b559f..93c1704a 100644 --- a/internal/cmd/service.go +++ b/internal/cmd/service.go @@ -1,12 +1,9 @@ package cmd import ( - "context" "fmt" "io" - "net/http" "strings" - "time" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" @@ -78,23 +75,6 @@ func outputService(cmd *cobra.Command, service api.Service, format string, withP } } -// outputServices formats and outputs the services list based on the specified format -func outputServices(cmd *cobra.Command, services []api.Service, format string) error { - outputServices := prepareServicesForOutput(services, cmd.ErrOrStderr()) - outputWriter := cmd.OutOrStdout() - - switch strings.ToLower(format) { - case "json": - return util.SerializeToJSON(outputWriter, outputServices) - case "yaml": - return util.SerializeToYAML(outputWriter, outputServices) - case "env": - return fmt.Errorf("environment variable output is not supported for multiple services") - default: // table format (default) - return outputServicesTable(outputServices, outputWriter) - } -} - // outputServiceEnv outputs service details in environment variable format func outputServiceEnv(service OutputService, output io.Writer) error { fmt.Fprintf(output, "PGHOST=%s\n", service.Host) @@ -199,25 +179,6 @@ func outputServiceTable(service OutputService, output io.Writer) error { return table.Render() } -// outputServicesTable outputs services in a formatted table using tablewriter -func outputServicesTable(services []OutputService, output io.Writer) error { - table := tablewriter.NewWriter(output) - table.Header("SERVICE ID", "NAME", "STATUS", "TYPE", "REGION", "CREATED") - - for _, service := range services { - table.Append( - util.Deref(service.ServiceId), - util.Deref(service.Name), - util.DerefStr(service.Status), - util.DerefStr(service.ServiceType), - util.Deref(service.RegionCode), - formatTimePtr(service.Created), - ) - } - - return table.Render() -} - func prepareServiceForOutput(service api.Service, withPassword bool, output io.Writer) OutputService { outputSvc := OutputService{ Service: service, @@ -248,23 +209,6 @@ func prepareServiceForOutput(service api.Service, withPassword bool, output io.W return outputSvc } -// prepareServicesForOutput creates copies of services with sensitive fields removed -func prepareServicesForOutput(services []api.Service, output io.Writer) []OutputService { - prepared := make([]OutputService, len(services)) - for i, service := range services { - prepared[i] = prepareServiceForOutput(service, false, output) - } - return prepared -} - -// formatTimePtr formats a time pointer, returning empty string if nil -func formatTimePtr(t *time.Time) string { - if t == nil { - return "" - } - return t.Format("2006-01-02 15:04") -} - // handlePasswordSaving handles saving password using the configured storage // method and displaying appropriate messages. Returns true if the password was // successfully saved, or false if not. @@ -313,54 +257,6 @@ func printConnectMessage(output io.Writer, passwordSaved, noSetDefault bool, ser } } -func serviceIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Service ID is always first positional argument - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - services, err := listServices(cmd) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - results := make([]string, 0, len(services)) - for _, service := range services { - if service.ServiceId != nil && strings.HasPrefix(*service.ServiceId, toComplete) { - results = append(results, cobra.CompletionWithDesc(*service.ServiceId, *service.Name)) - } - } - return results, cobra.ShellCompDirectiveNoFileComp -} - -func listServices(cmd *cobra.Command) ([]api.Service, error) { - // Load config and API client - cfg, err := common.LoadConfig(cmd.Context()) - if err != nil { - return nil, err - } - - // Make API call to list services - ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) - defer cancel() - - resp, err := cfg.Client.GetServicesWithResponse(ctx, cfg.ProjectID) - if err != nil { - return nil, fmt.Errorf("failed to list services: %w", err) - } - - // Handle API response - if resp.StatusCode() != http.StatusOK { - return nil, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) - } - - if resp.JSON200 == nil || len(*resp.JSON200) == 0 { - return []api.Service{}, nil - } - - return *resp.JSON200, nil -} - // getServiceID determines the service ID from args or config func getServiceID(cfg *config.Config, args []string) (string, error) { var serviceID string diff --git a/internal/cmd/service_list.go b/internal/cmd/service_list.go index c4527007..20689bca 100644 --- a/internal/cmd/service_list.go +++ b/internal/cmd/service_list.go @@ -3,12 +3,17 @@ package cmd import ( "context" "fmt" + "io" "net/http" + "strings" "time" + "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" + "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" ) // serviceListCmd represents the list command under service @@ -73,3 +78,56 @@ func buildServiceListCmd() *cobra.Command { return cmd } + +// outputServices formats and outputs the services list based on the specified format +func outputServices(cmd *cobra.Command, services []api.Service, format string) error { + outputServices := prepareServicesForOutput(services, cmd.ErrOrStderr()) + outputWriter := cmd.OutOrStdout() + + switch strings.ToLower(format) { + case "json": + return util.SerializeToJSON(outputWriter, outputServices) + case "yaml": + return util.SerializeToYAML(outputWriter, outputServices) + case "env": + return fmt.Errorf("environment variable output is not supported for multiple services") + default: // table format (default) + return outputServicesTable(outputServices, outputWriter) + } +} + +// prepareServicesForOutput creates copies of services with sensitive fields removed +func prepareServicesForOutput(services []api.Service, output io.Writer) []OutputService { + prepared := make([]OutputService, len(services)) + for i, service := range services { + prepared[i] = prepareServiceForOutput(service, false, output) + } + return prepared +} + +// outputServicesTable outputs services in a formatted table using tablewriter +func outputServicesTable(services []OutputService, output io.Writer) error { + table := tablewriter.NewWriter(output) + table.Header("SERVICE ID", "NAME", "STATUS", "TYPE", "REGION", "CREATED") + + for _, service := range services { + table.Append( + util.Deref(service.ServiceId), + util.Deref(service.Name), + util.DerefStr(service.Status), + util.DerefStr(service.ServiceType), + util.Deref(service.RegionCode), + formatTimePtr(service.Created), + ) + } + + return table.Render() +} + +// formatTimePtr formats a time pointer, returning empty string if nil +func formatTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("2006-01-02 15:04") +} diff --git a/internal/cmd/service_list_test.go b/internal/cmd/service_list_test.go index 7022e8ff..f1b8197d 100644 --- a/internal/cmd/service_list_test.go +++ b/internal/cmd/service_list_test.go @@ -1,10 +1,17 @@ package cmd import ( + "bytes" + "encoding/json" "os" "strings" "testing" + "time" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/config" ) @@ -71,3 +78,155 @@ func TestServiceList_OutputFlagAffectsCommandOnly(t *testing.T) { string(originalConfigBytes), string(newConfigBytes)) } } + +func TestOutputServices_JSON(t *testing.T) { + setupServiceTest(t) + + // Create test services + services := createTestServices() + + // Create test command + cmd := &cobra.Command{} + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Test JSON output + err := outputServices(cmd, services, "json") + if err != nil { + t.Fatalf("Failed to output JSON: %v", err) + } + + // Verify JSON is valid + var result []api.Service + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Invalid JSON Output: %v", err) + } + + if len(result) != len(services) { + t.Errorf("Expected %d services in JSON, got %d", len(services), len(result)) + } +} + +func TestOutputServices_YAML(t *testing.T) { + setupServiceTest(t) + + // Create test services + services := createTestServices() + + // Create test command + cmd := &cobra.Command{} + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Test YAML output + err := outputServices(cmd, services, "yaml") + if err != nil { + t.Fatalf("Failed to output YAML: %v", err) + } + + // Verify YAML is valid + var result []api.Service + if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("Invalid YAML Output: %v", err) + } + + if len(result) != len(services) { + t.Errorf("Expected %d services in YAML, got %d", len(services), len(result)) + } +} + +func TestOutputServices_Table(t *testing.T) { + setupServiceTest(t) + + // Create test services + services := createTestServices() + + // Create test command + cmd := &cobra.Command{} + buf := new(bytes.Buffer) + cmd.SetOut(buf) + + // Test table output + err := outputServices(cmd, services, "table") + if err != nil { + t.Fatalf("Failed to output table: %v", err) + } + + output := buf.String() + + // Verify table contains headers + if !strings.Contains(output, "SERVICE ID") { + t.Error("Table output should contain SERVICE ID header") + } + if !strings.Contains(output, "NAME") { + t.Error("Table output should contain NAME header") + } + if !strings.Contains(output, "STATUS") { + t.Error("Table output should contain STATUS header") + } + + // Verify table contains service data + if !strings.Contains(output, "test-service-1") { + t.Error("Table output should contain test service name") + } +} + +func TestSanitizeServicesForOutput(t *testing.T) { + // Create services with sensitive data + serviceID1 := "svc-12345" + serviceName1 := "test-service-1" + initialPassword1 := "secret-password-123" + + serviceID2 := "svc-67890" + serviceName2 := "test-service-2" + initialPassword2 := "another-secret-456" + + services := []api.Service{ + { + ServiceId: &serviceID1, + Name: &serviceName1, + InitialPassword: &initialPassword1, + }, + { + ServiceId: &serviceID2, + Name: &serviceName2, + InitialPassword: &initialPassword2, + }, + } + + // Sanitize the services + sanitized := prepareServicesForOutput(services, nil) + + // Verify that we have the same number of services + if len(sanitized) != len(services) { + t.Errorf("Expected %d sanitized services, got %d", len(services), len(sanitized)) + } + + // Verify that sensitive fields are removed from all services + for i, service := range sanitized { + if service.InitialPassword != nil { + t.Errorf("Expected InitialPassword to be nil in sanitized service %d", i) + } + if service.Password != "" { + t.Errorf("Expected Password to be empty in sanitized service %d", i) + } + + // Verify that other fields are preserved + if service.ServiceId == nil { + t.Errorf("Expected ServiceId to be preserved in sanitized service %d", i) + } + if service.Name == nil { + t.Errorf("Expected Name to be preserved in sanitized service %d", i) + } + } +} + +func TestFormatTimePtr(t *testing.T) { + testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + if formatTimePtr(&testTime) == "" { + t.Error("formatTimePtr should return formatted time string") + } + if formatTimePtr(nil) != "" { + t.Error("formatTimePtr should return empty string for nil") + } +} diff --git a/internal/cmd/service_test.go b/internal/cmd/service_test.go index bed9e665..875de9a6 100644 --- a/internal/cmd/service_test.go +++ b/internal/cmd/service_test.go @@ -167,98 +167,6 @@ func TestServiceCommandAliases(t *testing.T) { } } -func TestOutputServices_JSON(t *testing.T) { - setupServiceTest(t) - - // Create test services - services := createTestServices() - - // Create test command - cmd := &cobra.Command{} - buf := new(bytes.Buffer) - cmd.SetOut(buf) - - // Test JSON output - err := outputServices(cmd, services, "json") - if err != nil { - t.Fatalf("Failed to output JSON: %v", err) - } - - // Verify JSON is valid - var result []api.Service - if err := json.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Invalid JSON Output: %v", err) - } - - if len(result) != len(services) { - t.Errorf("Expected %d services in JSON, got %d", len(services), len(result)) - } -} - -func TestOutputServices_YAML(t *testing.T) { - setupServiceTest(t) - - // Create test services - services := createTestServices() - - // Create test command - cmd := &cobra.Command{} - buf := new(bytes.Buffer) - cmd.SetOut(buf) - - // Test YAML output - err := outputServices(cmd, services, "yaml") - if err != nil { - t.Fatalf("Failed to output YAML: %v", err) - } - - // Verify YAML is valid - var result []api.Service - if err := yaml.Unmarshal(buf.Bytes(), &result); err != nil { - t.Fatalf("Invalid YAML Output: %v", err) - } - - if len(result) != len(services) { - t.Errorf("Expected %d services in YAML, got %d", len(services), len(result)) - } -} - -func TestOutputServices_Table(t *testing.T) { - setupServiceTest(t) - - // Create test services - services := createTestServices() - - // Create test command - cmd := &cobra.Command{} - buf := new(bytes.Buffer) - cmd.SetOut(buf) - - // Test table output - err := outputServices(cmd, services, "table") - if err != nil { - t.Fatalf("Failed to output table: %v", err) - } - - output := buf.String() - - // Verify table contains headers - if !strings.Contains(output, "SERVICE ID") { - t.Error("Table output should contain SERVICE ID header") - } - if !strings.Contains(output, "NAME") { - t.Error("Table output should contain NAME header") - } - if !strings.Contains(output, "STATUS") { - t.Error("Table output should contain STATUS header") - } - - // Verify table contains service data - if !strings.Contains(output, "test-service-1") { - t.Error("Table output should contain test service name") - } -} - func TestOutputService_JSON(t *testing.T) { // Create a test service object serviceID := "svc-12345" @@ -647,66 +555,6 @@ func TestPrepareServiceForOutput_WithPassword(t *testing.T) { } } -func TestSanitizeServicesForOutput(t *testing.T) { - // Create services with sensitive data - serviceID1 := "svc-12345" - serviceName1 := "test-service-1" - initialPassword1 := "secret-password-123" - - serviceID2 := "svc-67890" - serviceName2 := "test-service-2" - initialPassword2 := "another-secret-456" - - services := []api.Service{ - { - ServiceId: &serviceID1, - Name: &serviceName1, - InitialPassword: &initialPassword1, - }, - { - ServiceId: &serviceID2, - Name: &serviceName2, - InitialPassword: &initialPassword2, - }, - } - - // Sanitize the services - sanitized := prepareServicesForOutput(services, nil) - - // Verify that we have the same number of services - if len(sanitized) != len(services) { - t.Errorf("Expected %d sanitized services, got %d", len(services), len(sanitized)) - } - - // Verify that sensitive fields are removed from all services - for i, service := range sanitized { - if service.InitialPassword != nil { - t.Errorf("Expected InitialPassword to be nil in sanitized service %d", i) - } - if service.Password != "" { - t.Errorf("Expected Password to be empty in sanitized service %d", i) - } - - // Verify that other fields are preserved - if service.ServiceId == nil { - t.Errorf("Expected ServiceId to be preserved in sanitized service %d", i) - } - if service.Name == nil { - t.Errorf("Expected Name to be preserved in sanitized service %d", i) - } - } -} - -func TestFormatTimePtr(t *testing.T) { - testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) - if formatTimePtr(&testTime) == "" { - t.Error("formatTimePtr should return formatted time string") - } - if formatTimePtr(nil) != "" { - t.Error("formatTimePtr should return empty string for nil") - } -} - func TestWaitForServiceReady_Timeout(t *testing.T) { tmpDir := setupServiceTest(t) diff --git a/internal/cmd/terminal_helper.go b/internal/cmd/terminal_helper.go new file mode 100644 index 00000000..5c8e1d09 --- /dev/null +++ b/internal/cmd/terminal_helper.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "os" + "strings" + + "golang.org/x/term" + + "github.com/timescale/tiger-cli/internal/util" +) + +var ( + // checkStdinIsTTY can be overridden for testing to bypass TTY detection + checkStdinIsTTY = func() bool { + return util.IsTerminal(os.Stdin) + } + + // readPasswordFromTerminal can be overridden for testing to inject password input + readPasswordFromTerminal = func() (string, error) { + val, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", err + } + return string(val), nil + } +) + +func readString(ctx context.Context, readFn func() (string, error)) (string, error) { + valCh := make(chan string) + errCh := make(chan error) + defer func() { close(valCh); close(errCh) }() + go func() { + val, err := readFn() + if err != nil { + errCh <- err + return + } + select { + case <-ctx.Done(): // don't return an empty value if the context is already canceled + return + default: + } + valCh <- val + }() + + select { + case <-ctx.Done(): + return "", ctx.Err() + case err := <-errCh: + return "", err + case val := <-valCh: + return strings.TrimSpace(val), nil + } +} diff --git a/internal/mcp/service_list.go b/internal/mcp/service_list.go index 1cdc0f96..4af22d66 100644 --- a/internal/mcp/service_list.go +++ b/internal/mcp/service_list.go @@ -10,6 +10,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "go.uber.org/zap" + "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" "github.com/timescale/tiger-cli/internal/logging" "github.com/timescale/tiger-cli/internal/util" @@ -31,6 +32,23 @@ func (ServiceListOutput) Schema() *jsonschema.Schema { return util.Must(jsonschema.For[ServiceListOutput](nil)) } +// ServiceInfo represents simplified service information for MCP output +type ServiceInfo struct { + ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` + Name string `json:"name"` + Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"` + Type string `json:"type"` + Region string `json:"region"` + Created string `json:"created,omitempty"` + Resources *ResourceInfo `json:"resources,omitempty"` +} + +func (ServiceInfo) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[ServiceInfo](nil)) + schema.Properties["type"].Enum = util.AnySlice(validServiceTypes()) + return schema +} + func newServiceListTool() *mcp.Tool { return &mcp.Tool{ Name: toolServiceList, @@ -86,3 +104,48 @@ func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest return nil, output, nil } + +// convertToServiceInfo converts an API Service to MCP ServiceInfo +func (s *Server) convertToServiceInfo(service api.Service) ServiceInfo { + info := ServiceInfo{ + ServiceID: util.Deref(service.ServiceId), + Name: util.Deref(service.Name), + Status: util.DerefStr(service.Status), + Type: util.DerefStr(service.ServiceType), + Region: util.Deref(service.RegionCode), + } + + // Add creation time if available + if service.Created != nil { + info.Created = service.Created.Format("2006-01-02T15:04:05Z") + } + + // Add resource information if available + if service.Resources != nil && len(*service.Resources) > 0 { + resource := (*service.Resources)[0] + if resource.Spec != nil { + info.Resources = &ResourceInfo{} + + if resource.Spec.CpuMillis != nil { + cpuCores := float64(*resource.Spec.CpuMillis) / 1000 + if cpuCores == float64(int(cpuCores)) { + info.Resources.CPU = fmt.Sprintf("%.0f cores", cpuCores) + } else { + info.Resources.CPU = fmt.Sprintf("%.1f cores", cpuCores) + } + } else { + // CPU is null - this indicates a free tier service + info.Resources.CPU = "shared" + } + + if resource.Spec.MemoryGbs != nil { + info.Resources.Memory = fmt.Sprintf("%d GB", *resource.Spec.MemoryGbs) + } else { + // Memory is null - this indicates a free tier service + info.Resources.Memory = "shared" + } + } + } + + return info +} diff --git a/internal/mcp/utils.go b/internal/mcp/utils.go index a10b2652..8976dd7d 100644 --- a/internal/mcp/utils.go +++ b/internal/mcp/utils.go @@ -47,23 +47,6 @@ func setWithPasswordSchemaProperties(schema *jsonschema.Schema) { schema.Properties["with_password"].Examples = []any{false, true} } -// ServiceInfo represents simplified service information for MCP output -type ServiceInfo struct { - ServiceID string `json:"id" jsonschema:"Service identifier (10-character alphanumeric string)"` - Name string `json:"name"` - Status string `json:"status" jsonschema:"Service status (e.g., READY, PAUSED, CONFIGURING, UPGRADING)"` - Type string `json:"type"` - Region string `json:"region"` - Created string `json:"created,omitempty"` - Resources *ResourceInfo `json:"resources,omitempty"` -} - -func (ServiceInfo) Schema() *jsonschema.Schema { - schema := util.Must(jsonschema.For[ServiceInfo](nil)) - schema.Properties["type"].Enum = util.AnySlice(validServiceTypes()) - return schema -} - // ResourceInfo represents resource allocation information type ResourceInfo struct { CPU string `json:"cpu,omitempty" jsonschema:"CPU allocation (e.g., '0.5 cores', '1 core')"` @@ -92,51 +75,6 @@ func (ServiceDetail) Schema() *jsonschema.Schema { return schema } -// convertToServiceInfo converts an API Service to MCP ServiceInfo -func (s *Server) convertToServiceInfo(service api.Service) ServiceInfo { - info := ServiceInfo{ - ServiceID: util.Deref(service.ServiceId), - Name: util.Deref(service.Name), - Status: util.DerefStr(service.Status), - Type: util.DerefStr(service.ServiceType), - Region: util.Deref(service.RegionCode), - } - - // Add creation time if available - if service.Created != nil { - info.Created = service.Created.Format("2006-01-02T15:04:05Z") - } - - // Add resource information if available - if service.Resources != nil && len(*service.Resources) > 0 { - resource := (*service.Resources)[0] - if resource.Spec != nil { - info.Resources = &ResourceInfo{} - - if resource.Spec.CpuMillis != nil { - cpuCores := float64(*resource.Spec.CpuMillis) / 1000 - if cpuCores == float64(int(cpuCores)) { - info.Resources.CPU = fmt.Sprintf("%.0f cores", cpuCores) - } else { - info.Resources.CPU = fmt.Sprintf("%.1f cores", cpuCores) - } - } else { - // CPU is null - this indicates a free tier service - info.Resources.CPU = "shared" - } - - if resource.Spec.MemoryGbs != nil { - info.Resources.Memory = fmt.Sprintf("%d GB", *resource.Spec.MemoryGbs) - } else { - // Memory is null - this indicates a free tier service - info.Resources.Memory = "shared" - } - } - } - - return info -} - // convertToServiceDetail converts an API Service to MCP ServiceDetail func (s *Server) convertToServiceDetail(service api.Service, withPassword bool) ServiceDetail { detail := ServiceDetail{ diff --git a/internal/util/password.go b/internal/util/password.go new file mode 100644 index 00000000..8d9177d2 --- /dev/null +++ b/internal/util/password.go @@ -0,0 +1,26 @@ +package util + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// GenerateSecurePassword generates a cryptographically secure random password +func GenerateSecurePassword(length int) (string, error) { + // Generate random bytes + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random password: %w", err) + } + + // Encode as base64 (URL-safe variant to avoid special characters that might need escaping) + encodedPassword := base64.URLEncoding.EncodeToString(bytes) + + // Trim to desired length (base64 encoding makes it slightly longer) + if len(encodedPassword) > length { + encodedPassword = encodedPassword[:length] + } + + return encodedPassword, nil +}