diff --git a/CLAUDE.md b/CLAUDE.md index 874d325c..d9d5ce19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,8 +505,8 @@ capabilities — the docs proxy being disabled, and each write tool skipped in read-only mode — so a client can see why a tool it expected is missing. Everything outside `internal/mcp` writes to stdout/stderr directly rather than -logging. Don't add log statements to CLI commands — print to -`cmd.OutOrStdout()`/`cmd.ErrOrStderr()`, or return an error. +logging. Don't add log statements to CLI commands — print with `cmd.Print*` / +`cmd.PrintErr*` (see "Output Streams"), or return an error. ### Dependencies @@ -963,6 +963,42 @@ This architecture ensures Tiger CLI remains maintainable and testable as it grow Tiger CLI follows established command-line interface patterns, particularly inspired by the GitHub CLI (`gh`) for consistency with modern CLI tools. +### Output Streams + +`buildRootCmd` calls `cmd.SetOut(os.Stdout)` and `cmd.SetErr(os.Stderr)`. This is +required: cobra's `cmd.Print*` helpers write to `OutOrStderr()`, which falls back +to **stderr** when no out writer is set, so without it every `cmd.Printf` would +land on the wrong stream. + +Commands print with the cobra helpers — `cmd.Print`/`Printf`/`Println` for +stdout, `cmd.PrintErr`/`PrintErrf`/`PrintErrln` for stderr. Don't use +`fmt.Print*` (bypasses the command's writers entirely, so tests can't capture it) +and don't spell out `fmt.Fprintf(cmd.OutOrStdout(), …)`. Reach for +`cmd.OutOrStdout()`/`cmd.ErrOrStderr()` only where an `io.Writer` is genuinely +required: `util.SerializeToJSON`/`SerializeToYAML`, `tablewriter.NewWriter`, +`tea.WithOutput`, and helpers in other packages (see below). Likewise use +`cmd.InOrStdin()` rather than `os.Stdin`. + +What goes where: + +1. **stdout** — the command's primary output: the data payload (table, JSON, + YAML, env vars) and, in the plain-text path, the result text itself. +2. **stderr** — errors and warnings; interactive UI (confirmation prompts, + password prompts, spinners); and progress/status messages that accompany + structured output, so they never pollute a piped stream. `tiger service + create` is the model: every status line is `cmd.PrintErrf` and only the final + service payload goes to stdout. + +Helper functions inside `internal/cmd` take the `*cobra.Command` and print +through it. The exception is a helper whose whole job is rendering into a writer +— `outputServiceTable`, `outputVersionTable`, `outputCapabilitiesTable` — which +keeps an `io.Writer` parameter because `tablewriter` needs one anyway. + +Code in other packages (`internal/common`, `internal/version`) must not import +cobra. Those take an `io.Writer` (`common.WaitForServiceArgs.Output`, +`common.NewSpinner`, `version.PrintUpdateWarning`) and the caller in +`internal/cmd` passes `cmd.ErrOrStderr()` or `cmd.OutOrStdout()`. + ### Boolean Flag Patterns When implementing boolean flags that can be enabled or disabled, follow the GitHub CLI pattern: @@ -1012,7 +1048,7 @@ if len(args) < 1 { // Interactive confirmation unless --confirm if !confirmFlag { - fmt.Fprintf(cmd.ErrOrStderr(), "Type the service ID '%s' to confirm: ", serviceID) + cmd.PrintErrf("Type the service ID '%s' to confirm: ", serviceID) var confirmation string fmt.Scanln(&confirmation) if confirmation != serviceID { diff --git a/internal/cmd/auth_login.go b/internal/cmd/auth_login.go index b105cdbd..47ae6cf8 100644 --- a/internal/cmd/auth_login.go +++ b/internal/cmd/auth_login.go @@ -105,7 +105,7 @@ Examples: authURL: cfg.ConsoleURL + "/oauth/authorize", tokenURL: cfg.GatewayURL + "/idp/external/cli/token", successURL: cfg.ConsoleURL + "/oauth/code/success", - out: cmd.OutOrStdout(), + out: cmd.ErrOrStderr(), } token, client, projectID, err := l.loginWithOAuth(cmd.Context()) @@ -124,7 +124,7 @@ Examples: finishLogin(cmd, projectID) return nil } else if creds.publicKey == "" || creds.secretKey == "" { - creds, err = promptForCredentials(cmd.Context(), cfg.ConsoleURL, creds) + creds, err = promptForCredentials(cmd, cfg.ConsoleURL, creds) if err != nil { return fmt.Errorf("failed to get credentials: %w", err) } @@ -139,7 +139,7 @@ Examples: return fmt.Errorf("failed to create client: %w", err) } - fmt.Fprintln(cmd.OutOrStdout(), "Validating API key...") + cmd.PrintErrln("Validating API key...") authInfo, err := validateAPIKey(cmd.Context(), cfg, client) if err != nil { return fmt.Errorf("API key validation failed: %w", err) @@ -162,8 +162,8 @@ Examples: } func finishLogin(cmd *cobra.Command, projectID string) { - fmt.Fprintf(cmd.OutOrStdout(), "Successfully logged in (project: %s)\n", projectID) - fmt.Fprint(cmd.OutOrStdout(), nextStepsMessage) + cmd.Printf("Successfully logged in (project: %s)\n", projectID) + cmd.Print(nextStepsMessage) } func flagOrEnvVar(flagVal, envVarName string) string { @@ -173,17 +173,18 @@ func flagOrEnvVar(flagVal, envVarName string) string { return os.Getenv(envVarName) } -func promptForCredentials(ctx context.Context, consoleURL string, creds credentials) (credentials, error) { +func promptForCredentials(cmd *cobra.Command, consoleURL string, creds credentials) (credentials, error) { if !util.IsTerminal(os.Stdin) { return credentials{}, fmt.Errorf("TTY not detected - credentials required. Use flags (--public-key, --secret-key) or environment variables (TIGER_PUBLIC_KEY, TIGER_SECRET_KEY)") } - fmt.Printf("You can find your API credentials at: %s/dashboard/settings\n\n", consoleURL) + ctx := cmd.Context() + cmd.PrintErrf("You can find your API credentials at: %s/dashboard/settings\n\n", consoleURL) reader := bufio.NewReader(os.Stdin) if creds.publicKey == "" { - fmt.Print("Enter your public key: ") + cmd.PrintErr("Enter your public key: ") publicKey, err := readString(ctx, func() (string, error) { return reader.ReadString('\n') }) if err != nil { return credentials{}, err @@ -192,7 +193,7 @@ func promptForCredentials(ctx context.Context, consoleURL string, creds credenti } if creds.secretKey == "" { - fmt.Print("Enter your secret key: ") + cmd.PrintErr("Enter your secret key: ") password, err := readString(ctx, func() (string, error) { val, err := term.ReadPassword(int(os.Stdin.Fd())) return string(val), err @@ -200,7 +201,7 @@ func promptForCredentials(ctx context.Context, consoleURL string, creds credenti if err != nil { return credentials{}, err } - fmt.Println() + cmd.PrintErrln() creds.secretKey = password } diff --git a/internal/cmd/auth_logout.go b/internal/cmd/auth_logout.go index 58775c9c..6fcb6a00 100644 --- a/internal/cmd/auth_logout.go +++ b/internal/cmd/auth_logout.go @@ -30,7 +30,7 @@ func buildLogoutCmd(app *common.App) *cobra.Command { return fmt.Errorf("failed to remove credentials: %w", err) } - fmt.Fprintln(cmd.OutOrStdout(), "Successfully logged out and removed stored credentials") + cmd.Println("Successfully logged out and removed stored credentials") return nil }, } @@ -65,6 +65,6 @@ func revokeOAuthSession(cmd *cobra.Command, app *common.App, cfg *config.Config) body.RefreshToken = &rt } if _, err := client.LogoutWithResponse(ctx, body); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "warning: server-side logout failed: %v\n", err) + cmd.PrintErrf("warning: server-side logout failed: %v\n", err) } } diff --git a/internal/cmd/auth_status.go b/internal/cmd/auth_status.go index 6ce31dee..4d8a5100 100644 --- a/internal/cmd/auth_status.go +++ b/internal/cmd/auth_status.go @@ -70,6 +70,7 @@ func buildStatusCmd(app *common.App) *cobra.Command { // outputAuthInfo formats and outputs authentication information based on the specified format func outputAuthInfo(cmd *cobra.Command, authInfo api.AuthInfo, format string) error { + outputWriter := cmd.OutOrStdout() switch strings.ToLower(format) { diff --git a/internal/cmd/config_reset.go b/internal/cmd/config_reset.go index e0500ed4..61894574 100644 --- a/internal/cmd/config_reset.go +++ b/internal/cmd/config_reset.go @@ -24,7 +24,7 @@ func buildConfigResetCmd(app *common.App) *cobra.Command { return fmt.Errorf("failed to reset config: %w", err) } - fmt.Fprintln(cmd.OutOrStdout(), "Configuration reset to defaults") + cmd.Println("Configuration reset to defaults") return nil }, } diff --git a/internal/cmd/config_set.go b/internal/cmd/config_set.go index 662edea3..72a9637d 100644 --- a/internal/cmd/config_set.go +++ b/internal/cmd/config_set.go @@ -25,7 +25,7 @@ func buildConfigSetCmd(app *common.App) *cobra.Command { return fmt.Errorf("failed to set config: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %s\n", key, value) + cmd.Printf("Set %s = %s\n", key, value) return nil }, } diff --git a/internal/cmd/config_unset.go b/internal/cmd/config_unset.go index 8d5ef34b..e134c4b6 100644 --- a/internal/cmd/config_unset.go +++ b/internal/cmd/config_unset.go @@ -25,7 +25,7 @@ func buildConfigUnsetCmd(app *common.App) *cobra.Command { return fmt.Errorf("failed to unset config: %w", err) } - fmt.Fprintf(cmd.OutOrStdout(), "Unset %s\n", key) + cmd.Printf("Unset %s\n", key) return nil }, } diff --git a/internal/cmd/db.go b/internal/cmd/db.go index c700c136..8934849d 100644 --- a/internal/cmd/db.go +++ b/internal/cmd/db.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "fmt" "time" "github.com/spf13/cobra" @@ -57,7 +56,7 @@ func lookupConnectionTarget(cmd *cobra.Command, app *common.App, args []string) // any. It is a no-op for a primary target or when there's nothing to warn. func warnReplicaPooler(cmd *cobra.Command, target *common.ConnectionTarget, pooled bool) { if warning := common.ReplicaPoolerWarning(target, pooled); warning != "" { - fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", warning) + cmd.PrintErrf("⚠️ Warning: %s\n", warning) } } diff --git a/internal/cmd/db_connect.go b/internal/cmd/db_connect.go index ef78485c..2c5017c7 100644 --- a/internal/cmd/db_connect.go +++ b/internal/cmd/db_connect.go @@ -191,7 +191,7 @@ func selectConnection( 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) + cmd.PrintErrf("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 { @@ -212,7 +212,7 @@ func selectConnection( } if chosen.IsReplica { - fmt.Fprintf(cmd.ErrOrStderr(), "Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name)) + cmd.PrintErrf("Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name)) } return details, nil } @@ -380,7 +380,7 @@ func connectWithPasswordMenu( storage := common.GetPasswordStorage(cfg) storedPassword, err := storage.Get(service, details.Role) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve stored password: %v\n", err) + cmd.PrintErrf("Warning: could not retrieve stored password: %v\n", err) } // Try to connect with stored password first @@ -396,7 +396,7 @@ func connectWithPasswordMenu( 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()) + cmd.PrintErrf("%s\nStored password is likely invalid or expired.\n\n", err.Error()) // Check if we're in a TTY for interactive menu if !checkStdinIsTTY() { @@ -415,14 +415,14 @@ func connectWithPasswordMenu( switch option { case optionEnterPassword: // Prompt for password - fmt.Fprint(cmd.ErrOrStderr(), "Enter password: ") + cmd.PrintErr("Enter password: ") password, err := readString(ctx, readPasswordFromTerminal) - fmt.Fprintln(cmd.ErrOrStderr()) // newline after password entry + cmd.PrintErrln() // 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) + cmd.PrintErrf("Error reading password: %v\n\n", err) continue } @@ -430,7 +430,7 @@ func connectWithPasswordMenu( details.Password = password if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, cfg, details, psqlPath, psqlFlags, service); err != nil { if isAuthenticationError(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Password incorrect. Please try again.\n\n") + cmd.PrintErrf("Password incorrect. Please try again.\n\n") continue } return fmt.Errorf("connection failed: %w", err) @@ -439,15 +439,15 @@ func connectWithPasswordMenu( case optionResetPassword: // Prompt and reset - password, err := promptAndResetPassword(ctx, cfg, cmd.ErrOrStderr(), client, service, details.Role) + password, err := promptAndResetPassword(ctx, cmd, cfg, 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) + cmd.PrintErrf("Error resetting password: %v\n\n", err) continue } - fmt.Fprintf(cmd.ErrOrStderr(), "✅ Master password for '%s' user updated successfully\n", details.Role) + cmd.PrintErrf("✅ Master password for '%s' user updated successfully\n", details.Role) // Launch psql (password is now in storage) details.Password = password return launchPsql(cfg, details, psqlPath, psqlFlags, service, cmd) @@ -613,9 +613,9 @@ func testSaveAndLaunchPsqlWithPassword( // Password works! Save it result, saveErr := common.SavePasswordWithResult(cfg, service, details.Password, details.Role) if saveErr != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save password: %v\n", saveErr) + cmd.PrintErrf("Warning: could not save password: %v\n", saveErr) } else if result.Success { - fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", result.Message) + cmd.PrintErrf("%s\n", result.Message) } // Launch psql diff --git a/internal/cmd/db_connection_string.go b/internal/cmd/db_connection_string.go index 8f87c11c..42db96f2 100644 --- a/internal/cmd/db_connection_string.go +++ b/internal/cmd/db_connection_string.go @@ -80,7 +80,7 @@ Examples: return fmt.Errorf("password not available to include in connection string") } - fmt.Fprintln(cmd.OutOrStdout(), details.String()) + cmd.Println(details.String()) return nil }, } diff --git a/internal/cmd/db_create_role.go b/internal/cmd/db_create_role.go index 330b761c..68252c2f 100644 --- a/internal/cmd/db_create_role.go +++ b/internal/cmd/db_create_role.go @@ -141,9 +141,9 @@ PostgreSQL Configuration Parameters That May Be Set: // Save password to storage with the new role name result, err := common.SavePasswordWithResult(cfg, service, rolePassword, roleName) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message) + cmd.PrintErrf("⚠️ Warning: %s\n", result.Message) } else if !result.Success { - fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message) + cmd.PrintErrf("⚠️ Warning: %s\n", result.Message) } // Output result in requested format @@ -342,15 +342,15 @@ func outputCreateRoleResult(cmd *cobra.Command, roleName string, readOnly bool, case "yaml": return util.SerializeToYAML(outputWriter, result) default: // table format - fmt.Fprintf(outputWriter, "✓ Role '%s' created successfully\n", roleName) + cmd.Printf("✓ Role '%s' created successfully\n", roleName) if readOnly { - fmt.Fprintf(outputWriter, " Read-only enforcement: enabled (permanent, role-based)\n") + cmd.Printf(" Read-only enforcement: enabled (permanent, role-based)\n") } if statementTimeout > 0 { - fmt.Fprintf(outputWriter, " Statement timeout: %s\n", statementTimeout) + cmd.Printf(" Statement timeout: %s\n", statementTimeout) } if len(fromRoles) > 0 { - fmt.Fprintf(outputWriter, " Inherits from: %s\n", strings.Join(fromRoles, ", ")) + cmd.Printf(" Inherits from: %s\n", strings.Join(fromRoles, ", ")) } return nil } diff --git a/internal/cmd/db_save_password.go b/internal/cmd/db_save_password.go index fd8740fe..4c74cd1f 100644 --- a/internal/cmd/db_save_password.go +++ b/internal/cmd/db_save_password.go @@ -82,12 +82,12 @@ Examples: return fmt.Errorf("TTY not detected - password required. Use --password flag or TIGER_NEW_PASSWORD environment variable") } - fmt.Fprint(cmd.OutOrStdout(), "Enter password: ") + cmd.PrintErr("Enter password: ") passwordToSave, err = readString(cmd.Context(), readPasswordFromTerminal) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - fmt.Fprintln(cmd.OutOrStdout()) // Print newline after hidden input + cmd.PrintErrln() // Print newline after hidden input if passwordToSave == "" { return fmt.Errorf("password cannot be empty") } @@ -100,10 +100,10 @@ Examples: } if target.IsReplica { - fmt.Fprintf(cmd.ErrOrStderr(), "Read replicas share the primary's credentials; saving against primary %s.\n", + cmd.PrintErrf("Read replicas share the primary's credentials; saving against primary %s.\n", *service.ServiceId) } - fmt.Fprintf(cmd.ErrOrStderr(), "Password saved successfully for service %s (role: %s)\n", + cmd.PrintErrf("Password saved successfully for service %s (role: %s)\n", *service.ServiceId, dbSavePasswordRole) return nil }, diff --git a/internal/cmd/db_schema.go b/internal/cmd/db_schema.go index 41834795..6336acb8 100644 --- a/internal/cmd/db_schema.go +++ b/internal/cmd/db_schema.go @@ -1,8 +1,6 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" "github.com/timescale/tiger-cli/internal/common" @@ -74,7 +72,7 @@ Examples: return err } - fmt.Fprint(cmd.OutOrStdout(), common.FormatSchema(schema)) + cmd.Print(common.FormatSchema(schema)) return nil }, } diff --git a/internal/cmd/db_test_connection.go b/internal/cmd/db_test_connection.go index ae1c8d29..b0a16154 100644 --- a/internal/cmd/db_test_connection.go +++ b/internal/cmd/db_test_connection.go @@ -53,9 +53,10 @@ Examples: Args: cobra.MaximumNArgs(1), ValidArgsFunction: serviceIDCompletion(app), RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + cfg, _, _, err := app.GetAll() if err != nil { - cmd.SilenceUsage = true return common.ExitWithCode(common.ExitInvalidParameters, err) } @@ -94,6 +95,9 @@ Examples: // testDatabaseConnection tests the database connection and returns appropriate exit codes func testDatabaseConnection(ctx context.Context, connectionString string, timeout time.Duration, cmd *cobra.Command) error { + // Every failure below is reported here, so don't let cobra print it again. + cmd.SilenceErrors = true + // Create context with timeout if specified var cancel context.CancelFunc if timeout > 0 { @@ -107,17 +111,17 @@ func testDatabaseConnection(ctx context.Context, connectionString string, timeou if err != nil { // Determine the appropriate exit code based on error type if isContextDeadlineExceeded(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Connection timeout after %v\n", timeout) + cmd.PrintErrf("Connection timeout after %v\n", timeout) return common.ExitWithCode(common.ExitTimeout, err) // Connection timeout } // Check if it's a connection rejection vs unreachable if isConnectionRejected(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Connection rejected: %v\n", err) + cmd.PrintErrf("Connection rejected: %v\n", err) return common.ExitWithCode(common.ExitGeneralError, err) // Server is rejecting connections } - fmt.Fprintf(cmd.ErrOrStderr(), "Connection failed: %v\n", err) + cmd.PrintErrf("Connection failed: %v\n", err) return common.ExitWithCode(2, err) // No response to connection attempt } defer conn.Close(ctx) @@ -127,22 +131,22 @@ func testDatabaseConnection(ctx context.Context, connectionString string, timeou if err != nil { // Determine the appropriate exit code based on error type if isContextDeadlineExceeded(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Connection timeout after %v\n", timeout) + cmd.PrintErrf("Connection timeout after %v\n", timeout) return common.ExitWithCode(common.ExitTimeout, err) // Connection timeout } // Check if it's a connection rejection vs unreachable if isConnectionRejected(err) { - fmt.Fprintf(cmd.ErrOrStderr(), "Connection rejected: %v\n", err) + cmd.PrintErrf("Connection rejected: %v\n", err) return common.ExitWithCode(common.ExitGeneralError, err) // Server is rejecting connections } - fmt.Fprintf(cmd.ErrOrStderr(), "Connection failed: %v\n", err) + cmd.PrintErrf("Connection failed: %v\n", err) return common.ExitWithCode(2, err) // No response to connection attempt } // Connection successful - fmt.Fprintf(cmd.OutOrStdout(), "Connection successful\n") + cmd.Printf("Connection successful\n") return nil // Server is accepting connections normally } diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go index 40d87ca1..a2448148 100644 --- a/internal/cmd/main_test.go +++ b/internal/cmd/main_test.go @@ -2,9 +2,11 @@ package cmd import ( "context" + "io" "os" "testing" + "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/timescale/tiger-cli/internal/api" @@ -12,6 +14,16 @@ import ( "github.com/timescale/tiger-cli/internal/config" ) +// discardCmd returns a bare command whose output streams are discarded, for +// tests that call a helper taking a *cobra.Command without caring what it +// prints. +func discardCmd() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return cmd +} + func TestMain(m *testing.M) { // Clean up any global state before tests code := m.Run() diff --git a/internal/cmd/mcp_get.go b/internal/cmd/mcp_get.go index 3ea35ca9..a3475323 100644 --- a/internal/cmd/mcp_get.go +++ b/internal/cmd/mcp_get.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "io" "slices" "strings" @@ -80,13 +79,13 @@ Examples: default: switch c := capability.(type) { case *mcpsdk.Tool: - return outputToolText(output, c) + return outputToolText(cmd, c) case *mcpsdk.Prompt: - return outputPromptText(output, c) + return outputPromptText(cmd, c) case *mcpsdk.Resource: - return outputResourceText(output, c) + return outputResourceText(cmd, c) case *mcpsdk.ResourceTemplate: - return outputResourceTemplateText(output, c) + return outputResourceTemplateText(cmd, c) default: return fmt.Errorf("unsupported capability type: %T", c) } @@ -100,7 +99,7 @@ Examples: } // outputToolText outputs a tool in text format -func outputToolText(output io.Writer, tool *mcpsdk.Tool) error { +func outputToolText(cmd *cobra.Command, tool *mcpsdk.Tool) error { var lines []string // Title line with annotation tags @@ -187,12 +186,12 @@ func outputToolText(output io.Writer, tool *mcpsdk.Tool) error { } // Write output - _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) - return err + cmd.Println(strings.Join(lines, "\n")) + return nil } // outputPromptText outputs a prompt in text format -func outputPromptText(output io.Writer, prompt *mcpsdk.Prompt) error { +func outputPromptText(cmd *cobra.Command, prompt *mcpsdk.Prompt) error { var lines []string // Title line @@ -223,12 +222,12 @@ func outputPromptText(output io.Writer, prompt *mcpsdk.Prompt) error { } // Write output - _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) - return err + cmd.Println(strings.Join(lines, "\n")) + return nil } // outputResourceText outputs a resource in text format -func outputResourceText(output io.Writer, resource *mcpsdk.Resource) error { +func outputResourceText(cmd *cobra.Command, resource *mcpsdk.Resource) error { var lines []string // Title line @@ -293,12 +292,12 @@ func outputResourceText(output io.Writer, resource *mcpsdk.Resource) error { } // Write output - _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) - return err + cmd.Println(strings.Join(lines, "\n")) + return nil } // outputResourceTemplateText outputs a resource template in text format -func outputResourceTemplateText(output io.Writer, template *mcpsdk.ResourceTemplate) error { +func outputResourceTemplateText(cmd *cobra.Command, template *mcpsdk.ResourceTemplate) error { var lines []string // Title line @@ -358,8 +357,8 @@ func outputResourceTemplateText(output io.Writer, template *mcpsdk.ResourceTempl } // Write output - _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) - return err + cmd.Println(strings.Join(lines, "\n")) + return nil } // formatSchemaType recursively formats a JSON schema type into TypeScript-style syntax diff --git a/internal/cmd/mcp_install.go b/internal/cmd/mcp_install.go index fcf3656b..1f3e5e46 100644 --- a/internal/cmd/mcp_install.go +++ b/internal/cmd/mcp_install.go @@ -81,7 +81,7 @@ Examples: clientName = args[0] } - return installTigerMCPForClient(clientName, !noBackup, configPath) + return installTigerMCPForClient(cmd, clientName, !noBackup, configPath) }, } @@ -345,7 +345,7 @@ func InstallMCPForClient(opts InstallOptions) error { // installTigerMCPForClient installs the Tiger MCP server configuration for the specified client. // This is the Tiger-specific wrapper used by the CLI that handles defaults and success messages. -func installTigerMCPForClient(clientName string, createBackup bool, customConfigPath string) error { +func installTigerMCPForClient(cmd *cobra.Command, clientName string, createBackup bool, customConfigPath string) error { // Get the Tiger executable path command, err := getTigerExecutablePath() if err != nil { @@ -374,33 +374,33 @@ func installTigerMCPForClient(clientName string, createBackup bool, customConfig } } - fmt.Printf("✅ Successfully installed Tiger MCP server configuration for %s\n", clientName) + cmd.Printf("✅ Successfully installed Tiger MCP server configuration for %s\n", clientName) if configPath != "" { - fmt.Printf("📁 Configuration file: %s\n", configPath) + cmd.Printf("📁 Configuration file: %s\n", configPath) } else { - fmt.Printf("⚙️ Configuration managed by %s\n", clientName) - } - - fmt.Printf("\n💡 Next steps:\n") - fmt.Printf(" 1. Restart %s to load the new configuration\n", clientName) - fmt.Printf(" 2. The Tiger MCP server will be available as '%s'\n", mcp.ServerName) - fmt.Printf("\n🤖 Try asking your AI assistant:\n") - fmt.Printf("\n 📊 List and manage your Tiger Cloud services:\n") - fmt.Printf(" • \"List my Tiger Cloud services\"\n") - fmt.Printf(" • \"Show me details for service xyz-123\"\n") - fmt.Printf(" • \"Create a new database service called my-app-db\"\n") - fmt.Printf(" • \"Update the password for my database service\"\n") - fmt.Printf(" • \"What Tiger Cloud services do I have access to?\"\n") - fmt.Printf("\n 📚 Ask questions from the PostgreSQL and Tiger Cloud documentation:\n") - fmt.Printf(" • \"Show me Tiger Cloud documentation about hypertables?\"\n") - fmt.Printf(" • \"What are the best practices for PostgreSQL indexing?\"\n") - fmt.Printf(" • \"What is the command for renaming a table?\"\n") - fmt.Printf(" • \"Help me optimize my PostgreSQL queries\"\n") - fmt.Printf("\n 📋 Make use of our optimized AI guides for common workflows:\n") - fmt.Printf(" • \"Help me create a new database schema for my application\"\n") - fmt.Printf(" • \"Help me set up hypertables for the device_readings table\"\n") - fmt.Printf(" • \"Help me figure out which tables should be hypertables\"\n") - fmt.Printf(" • \"What's the best way to structure time-series data?\"\n") + cmd.Printf("⚙️ Configuration managed by %s\n", clientName) + } + + cmd.Printf("\n💡 Next steps:\n") + cmd.Printf(" 1. Restart %s to load the new configuration\n", clientName) + cmd.Printf(" 2. The Tiger MCP server will be available as '%s'\n", mcp.ServerName) + cmd.Printf("\n🤖 Try asking your AI assistant:\n") + cmd.Printf("\n 📊 List and manage your Tiger Cloud services:\n") + cmd.Printf(" • \"List my Tiger Cloud services\"\n") + cmd.Printf(" • \"Show me details for service xyz-123\"\n") + cmd.Printf(" • \"Create a new database service called my-app-db\"\n") + cmd.Printf(" • \"Update the password for my database service\"\n") + cmd.Printf(" • \"What Tiger Cloud services do I have access to?\"\n") + cmd.Printf("\n 📚 Ask questions from the PostgreSQL and Tiger Cloud documentation:\n") + cmd.Printf(" • \"Show me Tiger Cloud documentation about hypertables?\"\n") + cmd.Printf(" • \"What are the best practices for PostgreSQL indexing?\"\n") + cmd.Printf(" • \"What is the command for renaming a table?\"\n") + cmd.Printf(" • \"Help me optimize my PostgreSQL queries\"\n") + cmd.Printf("\n 📋 Make use of our optimized AI guides for common workflows:\n") + cmd.Printf(" • \"Help me create a new database schema for my application\"\n") + cmd.Printf(" • \"Help me set up hypertables for the device_readings table\"\n") + cmd.Printf(" • \"Help me figure out which tables should be hypertables\"\n") + cmd.Printf(" • \"What's the best way to structure time-series data?\"\n") return nil } diff --git a/internal/cmd/mcp_install_test.go b/internal/cmd/mcp_install_test.go index 46957a12..56bd0afa 100644 --- a/internal/cmd/mcp_install_test.go +++ b/internal/cmd/mcp_install_test.go @@ -833,7 +833,7 @@ func TestInstallMCPForEditor_Integration(t *testing.T) { require.NoError(t, err, "should create initial config file") // Call installTigerMCPForClient to install Tiger MCP server - err = installTigerMCPForClient("cursor", false, configPath) + err = installTigerMCPForClient(discardCmd(), "cursor", false, configPath) require.NoError(t, err, "installTigerMCPForClient should succeed") // Verify the config file was modified @@ -869,7 +869,7 @@ func TestInstallMCPForEditor_Integration(t *testing.T) { require.NoError(t, err) // Call installTigerMCPForClient with backup enabled for Cursor - err = installTigerMCPForClient("cursor", true, configPath) + err = installTigerMCPForClient(discardCmd(), "cursor", true, configPath) require.NoError(t, err, "installTigerMCPForClient should succeed with backup") // Check that a backup file was created @@ -898,7 +898,7 @@ func TestInstallMCPForEditor_Integration(t *testing.T) { }) t.Run("handles unsupported editor", func(t *testing.T) { - err := installTigerMCPForClient("unsupported-editor", false, "") + err := installTigerMCPForClient(discardCmd(), "unsupported-editor", false, "") assert.Error(t, err, "should error for unsupported editor") assert.Contains(t, err.Error(), "unsupported client", "error should mention unsupported client") }) @@ -928,7 +928,7 @@ func TestInstallMCPForEditor_Integration(t *testing.T) { require.NoError(t, err) // First installation (should update existing tiger entry) - err = installTigerMCPForClient("cursor", false, configPath) + err = installTigerMCPForClient(discardCmd(), "cursor", false, configPath) require.NoError(t, err, "first installation should succeed") // Read config after first installation @@ -953,7 +953,7 @@ func TestInstallMCPForEditor_Integration(t *testing.T) { assert.Equal(t, "start", args[1], "second arg should be 'start'") // Second installation (should be idempotent, no changes) - err = installTigerMCPForClient("cursor", false, configPath) + err = installTigerMCPForClient(discardCmd(), "cursor", false, configPath) require.NoError(t, err, "second installation should succeed") // Read config after second installation diff --git a/internal/cmd/password_helper.go b/internal/cmd/password_helper.go index 8e9e9144..1444e917 100644 --- a/internal/cmd/password_helper.go +++ b/internal/cmd/password_helper.go @@ -3,7 +3,8 @@ package cmd import ( "context" "fmt" - "io" + + "github.com/spf13/cobra" "github.com/timescale/tiger-cli/internal/api" "github.com/timescale/tiger-cli/internal/common" @@ -15,12 +16,12 @@ import ( // It handles the API call and password storage. func updateAndSaveServicePassword( ctx context.Context, + cmd *cobra.Command, cfg *config.Config, client api.ClientWithResponsesInterface, service api.Service, newPassword string, role string, - statusOut io.Writer, ) error { // Call API to update password updateReq := api.UpdatePasswordInput{Password: newPassword} @@ -35,28 +36,28 @@ func updateAndSaveServicePassword( // Save password locally if result, err := common.SavePasswordWithResult(cfg, service, newPassword, role); err != nil { - fmt.Fprintf(statusOut, "Warning: could not save password: %v\n", err) + cmd.PrintErrf("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)) + cmd.PrintErrf("%s\n", result.Message) + cmd.PrintErrf("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, cfg *config.Config, client api.ClientWithResponsesInterface, service api.Service, role string, newPassword string, statusOut io.Writer) (string, error) { +func resetServicePassword(ctx context.Context, cmd *cobra.Command, cfg *config.Config, client api.ClientWithResponsesInterface, service api.Service, role string, newPassword string) (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") + cmd.PrintErrf("Successfully generated a new password.\n") } // Update and save password - if err := updateAndSaveServicePassword(ctx, cfg, client, service, newPassword, role, statusOut); err != nil { + if err := updateAndSaveServicePassword(ctx, cmd, cfg, client, service, newPassword, role); err != nil { return "", err } return newPassword, nil @@ -67,18 +68,18 @@ func resetServicePassword(ctx context.Context, cfg *config.Config, client api.Cl // Returns the new password on success. func promptAndResetPassword( ctx context.Context, + cmd *cobra.Command, cfg *config.Config, - out io.Writer, client api.ClientWithResponsesInterface, service api.Service, role string, ) (string, error) { - fmt.Fprint(out, "Enter new password (leave empty to generate): ") + cmd.PrintErr("Enter new password (leave empty to generate): ") newPassword, err := readString(ctx, readPasswordFromTerminal) - fmt.Fprintln(out) // newline after password entry + cmd.PrintErrln() // newline after password entry if err != nil { return "", fmt.Errorf("error reading password: %w", err) } - return resetServicePassword(ctx, cfg, client, service, role, newPassword, out) + return resetServicePassword(ctx, cmd, cfg, client, service, role, newPassword) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index acdfbab3..a7792199 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,6 +48,12 @@ tiger auth login // executes — so handlers can use cmd.Context() for cancellation. cmd.SetContext(ctx) + // Wire up the output streams explicitly. Cobra's cmd.Print* helpers write to + // OutOrStderr(), which falls back to stderr when no out writer is set, so + // without this every cmd.Printf would land on stderr. + cmd.SetOut(os.Stdout) + cmd.SetErr(os.Stderr) + // Add persistent flags. Values are read back from the config (see // flagBindings in internal/config) rather than from the flag variables, so // only --skip-update-check — which isn't a config value — is captured here. @@ -172,8 +178,7 @@ func versionCheck(cmd *cobra.Command, cfg *config.Config, skipUpdateCheck bool) return } - output := cmd.ErrOrStderr() - version.PrintUpdateWarning(res.result, cfg, &output) + version.PrintUpdateWarning(res.result, cfg, cmd.ErrOrStderr()) } } diff --git a/internal/cmd/service.go b/internal/cmd/service.go index f495e93c..cf28e56d 100644 --- a/internal/cmd/service.go +++ b/internal/cmd/service.go @@ -57,7 +57,7 @@ type OutputService struct { // outputService formats and outputs a single service based on the specified format func outputService(cmd *cobra.Command, cfg *config.Config, service api.Service, format string, withPassword bool, strict bool) error { // Prepare the output service with computed fields - outputSvc := prepareServiceForOutput(cfg, service, withPassword, cmd.ErrOrStderr()) + outputSvc := prepareServiceForOutput(cmd, cfg, service, withPassword) if strict && withPassword && outputSvc.Password == "" { return fmt.Errorf("password requested but not available for service %s", util.Deref(outputSvc.ServiceId)) } @@ -69,20 +69,20 @@ func outputService(cmd *cobra.Command, cfg *config.Config, service api.Service, case "yaml": return util.SerializeToYAML(outputWriter, outputSvc) case "env": - return outputServiceEnv(outputSvc, outputWriter) + return outputServiceEnv(cmd, outputSvc) default: // table format (default) return outputServiceTable(outputSvc, 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) - fmt.Fprintf(output, "PGPORT=%d\n", service.Port) - fmt.Fprintf(output, "PGDATABASE=%s\n", service.Database) - fmt.Fprintf(output, "PGUSER=%s\n", service.Role) +func outputServiceEnv(cmd *cobra.Command, service OutputService) error { + cmd.Printf("PGHOST=%s\n", service.Host) + cmd.Printf("PGPORT=%d\n", service.Port) + cmd.Printf("PGDATABASE=%s\n", service.Database) + cmd.Printf("PGUSER=%s\n", service.Role) if service.Password != "" { - fmt.Fprintf(output, "PGPASSWORD=%s\n", service.Password) + cmd.Printf("PGPASSWORD=%s\n", service.Password) } return nil } @@ -179,7 +179,9 @@ func outputServiceTable(service OutputService, output io.Writer) error { return table.Render() } -func prepareServiceForOutput(cfg *config.Config, service api.Service, withPassword bool, output io.Writer) OutputService { +// prepareServiceForOutput builds the output view of a service. cmd may be nil, +// in which case the connection-details warning is dropped rather than printed. +func prepareServiceForOutput(cmd *cobra.Command, cfg *config.Config, service api.Service, withPassword bool) OutputService { outputSvc := OutputService{ Service: service, } @@ -192,8 +194,8 @@ func prepareServiceForOutput(cfg *config.Config, service api.Service, withPasswo } if connectionDetails, err := common.GetConnectionDetails(cfg, service, opts); err != nil { - if output != nil { - fmt.Fprintf(output, "⚠️ Warning: Failed to get connection details: %v\n", err) + if cmd != nil { + cmd.PrintErrf("⚠️ Warning: Failed to get connection details: %v\n", err) } } else { outputSvc.ConnectionDetails = *connectionDetails @@ -209,7 +211,7 @@ func prepareServiceForOutput(cfg *config.Config, service api.Service, withPasswo // handlePasswordSaving handles saving password using the configured storage // method and displaying appropriate messages. Returns true if the password was // successfully saved, or false if not. -func handlePasswordSaving(cfg *config.Config, service api.Service, initialPassword string, output io.Writer) bool { +func handlePasswordSaving(cmd *cobra.Command, cfg *config.Config, service api.Service, initialPassword string) bool { // Note: We don't fail the service creation if password saving fails // The error is handled by displaying the appropriate message below result, _ := common.SavePasswordWithResult(cfg, service, initialPassword, "tsdbadmin") @@ -221,36 +223,36 @@ func handlePasswordSaving(cfg *config.Config, service api.Service, initialPasswo // Output the message with appropriate emoji if result.Success { - fmt.Fprintf(output, "🔐 %s\n", result.Message) + cmd.PrintErrf("🔐 %s\n", result.Message) return true } else if result.Method == "none" { - fmt.Fprintf(output, "💡 %s\n", result.Message) + cmd.PrintErrf("💡 %s\n", result.Message) } else { - fmt.Fprintf(output, "⚠️ %s\n", result.Message) + cmd.PrintErrf("⚠️ %s\n", result.Message) } return false } // setDefaultService sets the given service as the default service in the configuration -func setDefaultService(cfg *config.Config, serviceID string, output io.Writer) error { +func setDefaultService(cmd *cobra.Command, cfg *config.Config, serviceID string) error { if err := cfg.Set("service_id", serviceID); err != nil { return fmt.Errorf("failed to save config: %w", err) } - fmt.Fprintf(output, "🎯 Set service '%s' as default service.\n", serviceID) + cmd.PrintErrf("🎯 Set service '%s' as default service.\n", serviceID) return nil } -func printConnectMessage(output io.Writer, passwordSaved, noSetDefault bool, serviceID string) { +func printConnectMessage(cmd *cobra.Command, passwordSaved, noSetDefault bool, serviceID string) { if !passwordSaved { // We can't connect if no password was saved, so don't show message return } else if noSetDefault { // If the service wasn't set as the default, include the serviceID in the command - fmt.Fprintf(output, "🔌 Run 'tiger db connect %s' to connect to your new service\n", serviceID) + cmd.PrintErrf("🔌 Run 'tiger db connect %s' to connect to your new service\n", serviceID) } else { // If the service was set as the default, no need to include the serviceID in the command - fmt.Fprintf(output, "🔌 Run 'tiger db connect' to connect to your new service\n") + cmd.PrintErrf("🔌 Run 'tiger db connect' to connect to your new service\n") } } diff --git a/internal/cmd/service_create.go b/internal/cmd/service_create.go index 05394df0..e81cbb01 100644 --- a/internal/cmd/service_create.go +++ b/internal/cmd/service_create.go @@ -144,12 +144,10 @@ Note: You can specify both CPU and memory together, or specify only one (the oth defer cancel() // All status messages go to stderr - statusOutput := cmd.ErrOrStderr() - if cmd.Flags().Changed("name") { - fmt.Fprintf(statusOutput, "🚀 Creating service '%s'...\n", createServiceName) + cmd.PrintErrf("🚀 Creating service '%s'...\n", createServiceName) } else { - fmt.Fprintf(statusOutput, "🚀 Creating service '%s' (auto-generated name)...\n", createServiceName) + cmd.PrintErrf("🚀 Creating service '%s' (auto-generated name)...\n", createServiceName) } resp, err := client.CreateServiceWithResponse(ctx, projectID, serviceCreateReq) if err != nil { @@ -167,28 +165,28 @@ Note: You can specify both CPU and memory together, or specify only one (the oth service := *resp.JSON202 serviceID := util.Deref(service.ServiceId) - fmt.Fprintf(statusOutput, "✅ Service creation request accepted!\n") - fmt.Fprintf(statusOutput, "📋 Service ID: %s\n", serviceID) + cmd.PrintErrf("✅ Service creation request accepted!\n") + cmd.PrintErrf("📋 Service ID: %s\n", serviceID) // Save password immediately after service creation, before any waiting // This ensures users have access even if they interrupt the wait or it fails - passwordSaved := handlePasswordSaving(cfg, service, util.Deref(service.InitialPassword), statusOutput) + passwordSaved := handlePasswordSaving(cmd, cfg, service, util.Deref(service.InitialPassword)) // Set as default service unless --no-set-default is specified if !createNoSetDefault { - if err := setDefaultService(cfg, serviceID, statusOutput); err != nil { + if err := setDefaultService(cmd, cfg, serviceID); err != nil { // Log warning but don't fail the command - fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err) + cmd.PrintErrf("⚠️ Warning: Failed to set service as default: %v\n", err) } } // Handle wait behavior var waitErr error if createNoWait { - fmt.Fprintf(statusOutput, "⏳ Service is being created. Use 'tiger service list' to check status.\n") + cmd.PrintErrf("⏳ Service is being created. Use 'tiger service list' to check status.\n") } else { // Wait for service to be ready - fmt.Fprintf(statusOutput, "⏳ Waiting for service to be ready (wait timeout: %v)...\n", createWaitTimeout) + cmd.PrintErrf("⏳ Waiting for service to be ready (wait timeout: %v)...\n", createWaitTimeout) if waitErr = common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ Client: client, ProjectID: projectID, @@ -197,19 +195,19 @@ Note: You can specify both CPU and memory together, or specify only one (the oth TargetStatus: "READY", Service: &service, }, - Output: statusOutput, + Output: cmd.ErrOrStderr(), Timeout: createWaitTimeout, TimeoutMsg: "service may still be provisioning", }); waitErr != nil { - fmt.Fprintf(statusOutput, "❌ Error: %s\n", waitErr) + cmd.PrintErrf("❌ Error: %s\n", waitErr) } else { - fmt.Fprintf(statusOutput, "🎉 Service is ready and running!\n") - printConnectMessage(statusOutput, passwordSaved, createNoSetDefault, serviceID) + cmd.PrintErrf("🎉 Service is ready and running!\n") + printConnectMessage(cmd, passwordSaved, createNoSetDefault, serviceID) } } if err := outputService(cmd, cfg, service, cfg.Output, createWithPassword, false); err != nil { - fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) + cmd.PrintErrf("⚠️ Warning: Failed to output service details: %v\n", err) } // Return error for sake of exit code, but silence it since it was already output above diff --git a/internal/cmd/service_delete.go b/internal/cmd/service_delete.go index 116d88a8..49a2a797 100644 --- a/internal/cmd/service_delete.go +++ b/internal/cmd/service_delete.go @@ -63,12 +63,10 @@ Examples: return err } - statusOutput := cmd.ErrOrStderr() - // Prompt for confirmation unless --confirm is used if !deleteConfirm { - fmt.Fprintf(statusOutput, "Are you sure you want to delete service '%s'? This operation cannot be undone.\n", serviceID) - fmt.Fprintf(statusOutput, "Type the service ID '%s' to confirm: ", serviceID) + cmd.PrintErrf("Are you sure you want to delete service '%s'? This operation cannot be undone.\n", serviceID) + cmd.PrintErrf("Type the service ID '%s' to confirm: ", serviceID) confirmation, err := readString(cmd.Context(), func() (string, error) { reader := bufio.NewReader(os.Stdin) return reader.ReadString('\n') @@ -77,7 +75,7 @@ Examples: return fmt.Errorf("failed to read confirmation: %w", err) } if confirmation != serviceID { - fmt.Fprintln(statusOutput, "❌ Delete operation cancelled.") + cmd.PrintErrln("❌ Delete operation cancelled.") return nil } } @@ -97,11 +95,11 @@ Examples: return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } - fmt.Fprintf(statusOutput, "🗑️ Delete request accepted for service '%s'.\n", serviceID) + cmd.PrintErrf("🗑️ Delete request accepted for service '%s'.\n", serviceID) // If not waiting, return early if deleteNoWait { - fmt.Fprintln(statusOutput, "💡 Use 'tiger service list' to check deletion status.") + cmd.PrintErrln("💡 Use 'tiger service list' to check deletion status.") return nil } @@ -113,17 +111,17 @@ Examples: Handler: &common.DeletionWaitHandler{ ServiceID: serviceID, }, - Output: statusOutput, + Output: cmd.ErrOrStderr(), Timeout: deleteWaitTimeout, TimeoutMsg: "service may still be deleting", }); err != nil { // Return error for sake of exit code, but log ourselves for sake of icon - fmt.Fprintf(statusOutput, "❌ Error: %s\n", err) + cmd.PrintErrf("❌ Error: %s\n", err) cmd.SilenceErrors = true return err } - fmt.Fprintf(statusOutput, "✅ Service '%s' has been successfully deleted.\n", serviceID) + cmd.PrintErrf("✅ Service '%s' has been successfully deleted.\n", serviceID) return nil }, } diff --git a/internal/cmd/service_fork.go b/internal/cmd/service_fork.go index 2b0d490d..a8922215 100644 --- a/internal/cmd/service_fork.go +++ b/internal/cmd/service_fork.go @@ -154,8 +154,7 @@ Examples: if !cmd.Flags().Changed("name") { displayName = "(auto-generated)" } - statusOutput := cmd.ErrOrStderr() - fmt.Fprintf(statusOutput, "🍴 Forking service '%s' to create '%s' at %s...\n", serviceID, displayName, strategyDesc) + cmd.PrintErrf("🍴 Forking service '%s' to create '%s' at %s...\n", serviceID, displayName, strategyDesc) // Create ForkServiceCreate request environmentTag := api.EnvironmentTag(forkEnvironment) @@ -189,27 +188,27 @@ Examples: forkedService := *forkResp.JSON202 forkedServiceID := util.DerefStr(forkedService.ServiceId) - fmt.Fprintf(statusOutput, "✅ Fork request accepted!\n") - fmt.Fprintf(statusOutput, "📋 New Service ID: %s\n", forkedServiceID) + cmd.PrintErrf("✅ Fork request accepted!\n") + cmd.PrintErrf("📋 New Service ID: %s\n", forkedServiceID) // Save password immediately after service fork - passwordSaved := handlePasswordSaving(cfg, forkedService, util.Deref(forkedService.InitialPassword), statusOutput) + passwordSaved := handlePasswordSaving(cmd, cfg, forkedService, util.Deref(forkedService.InitialPassword)) // Set as default service unless --no-set-default is used if !forkNoSetDefault { - if err := setDefaultService(cfg, forkedServiceID, statusOutput); err != nil { + if err := setDefaultService(cmd, cfg, forkedServiceID); err != nil { // Log warning but don't fail the command - fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err) + cmd.PrintErrf("⚠️ Warning: Failed to set service as default: %v\n", err) } } // Handle wait behavior var waitErr error if forkNoWait { - fmt.Fprintf(statusOutput, "⏳ Service is being forked. Use 'tiger service list' to check status.\n") + cmd.PrintErrf("⏳ Service is being forked. Use 'tiger service list' to check status.\n") } else { // Wait for service to be ready - fmt.Fprintf(statusOutput, "⏳ Waiting for fork to complete (timeout: %v)...\n", forkWaitTimeout) + cmd.PrintErrf("⏳ Waiting for fork to complete (timeout: %v)...\n", forkWaitTimeout) if waitErr = common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ Client: client, ProjectID: projectID, @@ -218,19 +217,19 @@ Examples: TargetStatus: "READY", Service: &forkedService, }, - Output: statusOutput, + Output: cmd.ErrOrStderr(), Timeout: forkWaitTimeout, TimeoutMsg: "service may still be provisioning", }); waitErr != nil { - fmt.Fprintf(statusOutput, "❌ Error: %s\n", waitErr) + cmd.PrintErrf("❌ Error: %s\n", waitErr) } else { - fmt.Fprintf(statusOutput, "🎉 Service fork completed successfully!\n") - printConnectMessage(statusOutput, passwordSaved, forkNoSetDefault, forkedServiceID) + cmd.PrintErrf("🎉 Service fork completed successfully!\n") + printConnectMessage(cmd, passwordSaved, forkNoSetDefault, forkedServiceID) } } if err := outputService(cmd, cfg, forkedService, cfg.Output, forkWithPassword, false); err != nil { - fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) + cmd.PrintErrf("⚠️ Warning: Failed to output service details: %v\n", err) } // Return error for sake of exit code, but silence it since it was already output above diff --git a/internal/cmd/service_list.go b/internal/cmd/service_list.go index b58a918d..53bb36de 100644 --- a/internal/cmd/service_list.go +++ b/internal/cmd/service_list.go @@ -43,8 +43,6 @@ func buildServiceListCmd(app *common.App) *cobra.Command { return fmt.Errorf("failed to list services: %w", err) } - statusOutput := cmd.ErrOrStderr() - // Handle API response if resp.StatusCode() != http.StatusOK { return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) @@ -56,14 +54,14 @@ func buildServiceListCmd(app *common.App) *cobra.Command { services := *resp.JSON200 if len(services) == 0 { - fmt.Fprintln(statusOutput, "🏜️ No services found! Your project is looking a bit empty.") - fmt.Fprintln(statusOutput, "🚀 Ready to get started? Create your first service with: tiger service create") + cmd.PrintErrln("🏜️ No services found! Your project is looking a bit empty.") + cmd.PrintErrln("🚀 Ready to get started? Create your first service with: tiger service create") return nil } if resp.JSON200 == nil { - fmt.Fprintln(statusOutput, "🏜️ No services found! Your project is looking a bit empty.") - fmt.Fprintln(statusOutput, "🚀 Ready to get started? Create your first service with: tiger service create") + cmd.PrintErrln("🏜️ No services found! Your project is looking a bit empty.") + cmd.PrintErrln("🚀 Ready to get started? Create your first service with: tiger service create") return nil } @@ -79,7 +77,8 @@ func buildServiceListCmd(app *common.App) *cobra.Command { // outputServices formats and outputs the services list based on the specified format func outputServices(cmd *cobra.Command, cfg *config.Config, services []api.Service, format string) error { - outputServices := prepareServicesForOutput(cfg, services, cmd.ErrOrStderr()) + outputServices := prepareServicesForOutput(cmd, cfg, services) + outputWriter := cmd.OutOrStdout() switch strings.ToLower(format) { @@ -95,10 +94,10 @@ func outputServices(cmd *cobra.Command, cfg *config.Config, services []api.Servi } // prepareServicesForOutput creates copies of services with sensitive fields removed -func prepareServicesForOutput(cfg *config.Config, services []api.Service, output io.Writer) []OutputService { +func prepareServicesForOutput(cmd *cobra.Command, cfg *config.Config, services []api.Service) []OutputService { prepared := make([]OutputService, len(services)) for i, service := range services { - prepared[i] = prepareServiceForOutput(cfg, service, false, output) + prepared[i] = prepareServiceForOutput(cmd, cfg, service, false) } return prepared } diff --git a/internal/cmd/service_list_test.go b/internal/cmd/service_list_test.go index feb1660c..ba5cae5f 100644 --- a/internal/cmd/service_list_test.go +++ b/internal/cmd/service_list_test.go @@ -195,7 +195,7 @@ func TestSanitizeServicesForOutput(t *testing.T) { } // Sanitize the services - sanitized := prepareServicesForOutput(testConfig(t), services, nil) + sanitized := prepareServicesForOutput(nil, testConfig(t), services) // Verify that we have the same number of services if len(sanitized) != len(services) { diff --git a/internal/cmd/service_logs.go b/internal/cmd/service_logs.go index f97f6e4c..1a998f7e 100644 --- a/internal/cmd/service_logs.go +++ b/internal/cmd/service_logs.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "fmt" "strings" "time" @@ -125,7 +124,7 @@ Examples: // Local timezone for terminal output; MCP and public API use UTC. line = entry.Timestamp.Local().Format("2006-01-02 15:04:05 MST") + " " + line } - fmt.Fprintln(outputWriter, colorizeLogEntry(line, entry.Severity, shouldColorize)) + cmd.Println(colorizeLogEntry(line, entry.Severity, shouldColorize)) } } diff --git a/internal/cmd/service_metrics_available_series.go b/internal/cmd/service_metrics_available_series.go index cbbc0a5d..3303ec78 100644 --- a/internal/cmd/service_metrics_available_series.go +++ b/internal/cmd/service_metrics_available_series.go @@ -52,8 +52,8 @@ func buildServiceMetricsAvailableSeriesCmd(app *common.App) *cobra.Command { } series := *resp.JSON200 - out := cmd.OutOrStdout() + out := cmd.OutOrStdout() switch strings.ToLower(cfg.Output) { case "json": return util.SerializeToJSON(out, series) @@ -61,7 +61,7 @@ func buildServiceMetricsAvailableSeriesCmd(app *common.App) *cobra.Command { return util.SerializeToYAML(out, series) default: for _, s := range series { - fmt.Fprintln(out, s) + cmd.Println(s) } } return nil diff --git a/internal/cmd/service_metrics_series.go b/internal/cmd/service_metrics_series.go index 7b2e806d..e64a04c0 100644 --- a/internal/cmd/service_metrics_series.go +++ b/internal/cmd/service_metrics_series.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "io" "net/http" "sort" "strings" @@ -116,7 +115,7 @@ Examples: return fmt.Errorf("empty response from API") } - return renderMetricSeries(cmd.OutOrStdout(), cfg.Output, *resp.JSON200) + return renderMetricSeries(cmd, cfg.Output, *resp.JSON200) }, } @@ -172,7 +171,9 @@ func labelString(labels map[string]string) string { return "{" + strings.Join(parts, ",") + "}" } -func renderMetricSeries(out io.Writer, output string, series []api.MetricSeries) error { +func renderMetricSeries(cmd *cobra.Command, output string, series []api.MetricSeries) error { + out := cmd.OutOrStdout() + switch strings.ToLower(output) { case "json": return util.SerializeToJSON(out, series) @@ -180,7 +181,7 @@ func renderMetricSeries(out io.Writer, output string, series []api.MetricSeries) return util.SerializeToYAML(out, series) default: if len(series) == 0 { - fmt.Fprintln(out, "No metric data returned for the requested window.") + cmd.Println("No metric data returned for the requested window.") return nil } table := tablewriter.NewWriter(out) diff --git a/internal/cmd/service_resize.go b/internal/cmd/service_resize.go index 86255f01..8a14242a 100644 --- a/internal/cmd/service_resize.go +++ b/internal/cmd/service_resize.go @@ -90,8 +90,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.SilenceUsage = true // Display resize information - statusOutput := cmd.ErrOrStderr() - fmt.Fprintf(statusOutput, "📐 Resizing service '%s' to %s...\n", serviceID, cpuMemoryCfg) + cmd.PrintErrf("📐 Resizing service '%s' to %s...\n", serviceID, cpuMemoryCfg) // Prepare resize request resizeReq := api.ResizeInput{ @@ -118,16 +117,16 @@ Note: You can specify both CPU and memory together, or specify only one (the oth } service := *resp.JSON202 - fmt.Fprintf(statusOutput, "✅ Resize request accepted for service '%s'!\n", serviceID) + cmd.PrintErrf("✅ Resize request accepted for service '%s'!\n", serviceID) // If not waiting, return early if resizeNoWait { - fmt.Fprintln(statusOutput, "💡 Use 'tiger service get' to check service status.") + cmd.PrintErrln("💡 Use 'tiger service get' to check service status.") return nil } // Wait for resize to complete - fmt.Fprintf(statusOutput, "⏳ Waiting for resize to complete (timeout: %v)...\n", resizeWaitTimeout) + cmd.PrintErrf("⏳ Waiting for resize to complete (timeout: %v)...\n", resizeWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ Client: client, ProjectID: projectID, @@ -136,17 +135,17 @@ Note: You can specify both CPU and memory together, or specify only one (the oth TargetStatus: "READY", Service: &service, }, - Output: statusOutput, + Output: cmd.ErrOrStderr(), Timeout: resizeWaitTimeout, TimeoutMsg: "service may still be resizing", }); err != nil { // Return error for sake of exit code, but silence since we already output it - fmt.Fprintf(statusOutput, "❌ Error: %s\n", err) + cmd.PrintErrf("❌ Error: %s\n", err) cmd.SilenceErrors = true return err } - fmt.Fprintf(statusOutput, "🎉 Service '%s' has been successfully resized to %s!\n", serviceID, cpuMemoryCfg) + cmd.PrintErrf("🎉 Service '%s' has been successfully resized to %s!\n", serviceID, cpuMemoryCfg) return nil }, } diff --git a/internal/cmd/service_start.go b/internal/cmd/service_start.go index 929dedd9..00b76ecd 100644 --- a/internal/cmd/service_start.go +++ b/internal/cmd/service_start.go @@ -75,17 +75,16 @@ Examples: } service := *resp.JSON202 - statusOutput := cmd.ErrOrStderr() - fmt.Fprintf(statusOutput, "▶️ Start request accepted for service '%s'.\n", serviceID) + cmd.PrintErrf("▶️ Start request accepted for service '%s'.\n", serviceID) // If not waiting, return early if startNoWait { - fmt.Fprintln(statusOutput, "💡 Use 'tiger service get' to check service status.") + cmd.PrintErrln("💡 Use 'tiger service get' to check service status.") return nil } // Wait for service to become ready - fmt.Fprintf(statusOutput, "⏳ Waiting for service to start (wait timeout: %v)...\n", startWaitTimeout) + cmd.PrintErrf("⏳ Waiting for service to start (wait timeout: %v)...\n", startWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ Client: client, ProjectID: projectID, @@ -94,17 +93,17 @@ Examples: TargetStatus: "READY", Service: &service, }, - Output: statusOutput, + Output: cmd.ErrOrStderr(), Timeout: startWaitTimeout, TimeoutMsg: "service may still be starting", }); err != nil { // Return error for sake of exit code, but log ourselves for sake of icon - fmt.Fprintf(statusOutput, "❌ Error: %s\n", err) + cmd.PrintErrf("❌ Error: %s\n", err) cmd.SilenceErrors = true return err } - fmt.Fprintf(statusOutput, "✅ Service has been successfully started!\n") + cmd.PrintErrf("✅ Service has been successfully started!\n") return nil }, } diff --git a/internal/cmd/service_stop.go b/internal/cmd/service_stop.go index 7205fc7a..ac820456 100644 --- a/internal/cmd/service_stop.go +++ b/internal/cmd/service_stop.go @@ -75,17 +75,16 @@ Examples: } service := *resp.JSON202 - statusOutput := cmd.ErrOrStderr() - fmt.Fprintf(statusOutput, "⏹️ Stop request accepted for service '%s'.\n", serviceID) + cmd.PrintErrf("⏹️ Stop request accepted for service '%s'.\n", serviceID) // If not waiting, return early if stopNoWait { - fmt.Fprintln(statusOutput, "💡 Use 'tiger service get' to check service status.") + cmd.PrintErrln("💡 Use 'tiger service get' to check service status.") return nil } // Wait for service to become paused - fmt.Fprintf(statusOutput, "⏳ Waiting for service to stop (timeout: %v)...\n", stopWaitTimeout) + cmd.PrintErrf("⏳ Waiting for service to stop (timeout: %v)...\n", stopWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ Client: client, ProjectID: projectID, @@ -94,17 +93,17 @@ Examples: TargetStatus: "PAUSED", Service: &service, }, - Output: statusOutput, + Output: cmd.ErrOrStderr(), Timeout: stopWaitTimeout, TimeoutMsg: "service may still be stopping", }); err != nil { // Return error for sake of exit code, but log ourselves for sake of icon - fmt.Fprintf(statusOutput, "❌ Error: %s\n", err) + cmd.PrintErrf("❌ Error: %s\n", err) cmd.SilenceErrors = true return err } - fmt.Fprintf(statusOutput, "✅ Service has been successfully stopped!\n") + cmd.PrintErrf("✅ Service has been successfully stopped!\n") return nil }, } diff --git a/internal/cmd/service_test.go b/internal/cmd/service_test.go index 90870baf..25f38dbe 100644 --- a/internal/cmd/service_test.go +++ b/internal/cmd/service_test.go @@ -487,7 +487,7 @@ func TestPrepareServiceForOutput_WithoutPassword(t *testing.T) { cmd.SetErr(buf) // Prepare service for output without password - outputSvc := prepareServiceForOutput(testConfig(t), service, false, cmd.ErrOrStderr()) + outputSvc := prepareServiceForOutput(cmd, testConfig(t), service, false) // Verify that password is removed if outputSvc.InitialPassword != nil { @@ -531,7 +531,7 @@ func TestPrepareServiceForOutput_WithPassword(t *testing.T) { cmd.SetErr(buf) // Prepare service for output with password - outputSvc := prepareServiceForOutput(testConfig(t), service, true, cmd.ErrOrStderr()) + outputSvc := prepareServiceForOutput(cmd, testConfig(t), service, true) // Verify that password is preserved if outputSvc.InitialPassword != nil { diff --git a/internal/cmd/service_update_password.go b/internal/cmd/service_update_password.go index 1183ecf7..d12d8c34 100644 --- a/internal/cmd/service_update_password.go +++ b/internal/cmd/service_update_password.go @@ -106,11 +106,9 @@ Examples: serviceID, util.DerefStr(service.ForkedFrom.ServiceId)) } - statusOutput := cmd.ErrOrStderr() - if autoGenerate { // Auto-generate password using existing function - if _, err := resetServicePassword(ctx, cfg, client, service, "tsdbadmin", "", statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cmd, cfg, client, service, "tsdbadmin", ""); err != nil { return err } } else if password == "" { @@ -118,24 +116,17 @@ Examples: if !checkStdinIsTTY() { return fmt.Errorf("TTY not detected - use --new-password flag, --auto-generate flag, or TIGER_NEW_PASSWORD environment variable") } - _, err := promptAndResetPassword( - ctx, - cfg, - statusOutput, - client, - service, - "tsdbadmin", - ) + _, err := promptAndResetPassword(ctx, cmd, cfg, client, service, "tsdbadmin") if err != nil { return err } } else { - if _, err := resetServicePassword(ctx, cfg, client, service, "tsdbadmin", password, statusOutput); err != nil { + if _, err := resetServicePassword(ctx, cmd, cfg, client, service, "tsdbadmin", password); err != nil { return err } } - fmt.Fprintf(statusOutput, "✅ Master password for 'tsdbadmin' user updated successfully\n") + cmd.PrintErrf("✅ Master password for 'tsdbadmin' user updated successfully\n") return nil }, } diff --git a/internal/cmd/version.go b/internal/cmd/version.go index bff58626..91f4bb4e 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -51,13 +51,13 @@ func buildVersionCmd(app *common.App) *cobra.Command { if err != nil { // A failed check shouldn't fail the version command; warn and // continue printing the local version info. - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to check for updates: %v\n", err) + cmd.PrintErrf("Warning: failed to check for updates: %v\n", err) } else if result != nil { versionOutput.LatestVersion = result.LatestVersion versionOutput.UpdateAvailable = &result.UpdateAvailable updateAvailable = result.UpdateAvailable // Print warning _after_ other output - defer version.PrintUpdateWarning(result, cfg, util.Ptr(cmd.ErrOrStderr())) + defer version.PrintUpdateWarning(result, cfg, cmd.ErrOrStderr()) } } @@ -72,7 +72,7 @@ func buildVersionCmd(app *common.App) *cobra.Command { return err } case "bare": - fmt.Fprintln(output, versionOutput.Version) + cmd.Println(versionOutput.Version) default: if err := outputVersionTable(output, versionOutput); err != nil { return err diff --git a/internal/version/check.go b/internal/version/check.go index 34ba12a9..8a62bec9 100644 --- a/internal/version/check.go +++ b/internal/version/check.go @@ -218,8 +218,9 @@ func CheckForUpdate(cfg *config.Config) (*CheckResult, error) { return checkVersionForUpdate(config.Version, cfg) } -// PrintUpdateWarning prints a warning message to stderr if an update is available -func PrintUpdateWarning(result *CheckResult, cfg *config.Config, output *io.Writer) { +// PrintUpdateWarning writes a warning to output if an update is available. +// Callers pass the command's stderr writer. +func PrintUpdateWarning(result *CheckResult, cfg *config.Config, output io.Writer) { if result == nil || output == nil { return } @@ -228,12 +229,12 @@ func PrintUpdateWarning(result *CheckResult, cfg *config.Config, output *io.Writ } // need to set color.NoColor correctly for the `output` (stderr) - if cfg.Color && util.IsTerminal(*output) { + if cfg.Color && util.IsTerminal(output) { original := color.NoColor defer func() { color.NoColor = original }() color.NoColor = false } - fmt.Fprintf(*output, "\n\n%s %s → %s\nTo upgrade: %s\n", + fmt.Fprintf(output, "\n\n%s %s → %s\nTo upgrade: %s\n", color.YellowString("A new release of tiger-cli is available:"), color.CyanString(result.CurrentVersion), color.CyanString(result.LatestVersion),