Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/cli/safeexec v1.0.1
github.com/fatih/color v1.18.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/google/jsonschema-go v0.2.3
github.com/jackc/pgx/v5 v5.7.5
github.com/modelcontextprotocol/go-sdk v0.5.0
Expand Down Expand Up @@ -80,7 +81,6 @@ require (
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
Expand Down
2 changes: 1 addition & 1 deletion internal/tiger/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func outputAuthInfo(cmd *cobra.Command, authInfo api.AuthInfo, format string) er
case "json":
return util.SerializeToJSON(outputWriter, authInfo)
case "yaml":
return util.SerializeToYAML(outputWriter, authInfo, true)
return util.SerializeToYAML(outputWriter, authInfo)
default: // table format (default)
return outputAuthInfoTable(authInfo, outputWriter)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/tiger/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func buildConfigShowCmd() *cobra.Command {
case "json":
return util.SerializeToJSON(output, cfgOut)
case "yaml":
return util.SerializeToYAML(output, cfgOut, false)
return util.SerializeToYAML(output, cfgOut)
default:
return outputTable(output, cfgOut)
}
Expand Down
28 changes: 9 additions & 19 deletions internal/tiger/cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,8 @@ func TestConfigShow_JSONOutput(t *testing.T) {
// Create config file with JSON output format
configContent := `api_url: https://json.api.com/v1
output: json
analytics: true
password_storage: none
version_check_interval: 1h
analytics: false
password_storage: keyring
version_check_last_time: ` + now.Format(time.RFC3339) + "\n"

configFile := config.GetConfigFile(tmpDir)
Expand Down Expand Up @@ -152,12 +151,12 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n"
"service_id": "",
"color": true,
"output": "json",
"analytics": true,
"password_storage": "none",
"analytics": false,
"password_storage": "keyring",
"debug": false,
"config_dir": tmpDir,
"releases_url": "https://cli.tigerdata.com",
"version_check_interval": float64(3600000000000), // JSON unmarshals time.Duration as nanoseconds (1 hour = 3600000000000ns)
"version_check_interval": "24h0m0s",
Copy link
Member Author

@nathanjcochran nathanjcochran Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though we had a test that checked for the integer representation when marshaling to JSON, I think it was the wrong behavior. Especially because it didn't match the test for the YAML representation below, which has always used the string representation. I made the JSON and YAML tests essentially 1:1 in this PR.

"version_check_last_time": now.Format(time.RFC3339),
}

Expand Down Expand Up @@ -216,22 +215,13 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n"
"debug": false,
"config_dir": tmpDir,
"releases_url": "https://cli.tigerdata.com",
"version_check_interval": "24h0m0s", // YAML serializes time.Duration as string
"version_check_last_time": now,
"version_check_interval": "24h0m0s",
"version_check_last_time": now.Format(time.RFC3339),
}

for key, expectedValue := range expectedValues {
switch expectedValue.(type) {
case time.Time:
// YAML unmarshals time.Time as time.Time type, so we need to compare differently
if expectedValue.(time.Time).Format(time.RFC3339) != result[key].(time.Time).Format(time.RFC3339) {
t.Errorf("foo Expected %s '%v', got %v", key, expectedValue, result[key])
}
default:
// Other types can be compared directly
if result[key] != expectedValue {
t.Errorf("Expected %s '%v', got %v", key, expectedValue, result[key])
}
if result[key] != expectedValue {
t.Errorf("Expected %s '%v', got %v", key, expectedValue, result[key])
}
}

Expand Down
10 changes: 5 additions & 5 deletions internal/tiger/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,10 +530,10 @@ func getPasswordForRole(passwordFlag string) (string, error) {

// CreateRoleResult represents the output of a create role operation
type CreateRoleResult struct {
RoleName string `json:"role_name" yaml:"role_name"`
ReadOnly bool `json:"read_only,omitempty" yaml:"read_only,omitempty"`
StatementTimeout string `json:"statement_timeout,omitempty" yaml:"statement_timeout,omitempty"`
FromRoles []string `json:"from_roles,omitempty" yaml:"from_roles,omitempty"`
RoleName string `json:"role_name"`
ReadOnly bool `json:"read_only,omitempty"`
StatementTimeout string `json:"statement_timeout,omitempty"`
FromRoles []string `json:"from_roles,omitempty"`
}

// outputCreateRoleResult formats and outputs the create role result
Expand All @@ -557,7 +557,7 @@ func outputCreateRoleResult(cmd *cobra.Command, roleName string, readOnly bool,
case "json":
return util.SerializeToJSON(outputWriter, result)
case "yaml":
return util.SerializeToYAML(outputWriter, result, false)
return util.SerializeToYAML(outputWriter, result)
default: // table format
fmt.Fprintf(outputWriter, "✓ Role '%s' created successfully\n", roleName)
if readOnly {
Expand Down
2 changes: 1 addition & 1 deletion internal/tiger/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ Examples:
case "json":
return util.SerializeToJSON(output, capabilities)
case "yaml":
return util.SerializeToYAML(output, capabilities, true)
return util.SerializeToYAML(output, capabilities)
default:
return outputCapabilitiesTable(output, capabilities)
}
Expand Down
8 changes: 4 additions & 4 deletions internal/tiger/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,8 @@ Examples:
type OutputService struct {
api.Service
common.ConnectionDetails
ConnectionString string `json:"connection_string,omitempty" yaml:"connection_string,omitempty"`
ConsoleURL string `json:"console_url,omitempty" yaml:"console_url,omitempty"`
ConnectionString string `json:"connection_string,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
}

// outputService formats and outputs a single service based on the specified format
Expand All @@ -566,7 +566,7 @@ func outputService(cmd *cobra.Command, service api.Service, format string, withP
case "json":
return util.SerializeToJSON(outputWriter, outputSvc)
case "yaml":
return util.SerializeToYAML(outputWriter, outputSvc, true)
return util.SerializeToYAML(outputWriter, outputSvc)
case "env":
return outputServiceEnv(outputSvc, outputWriter)
default: // table format (default)
Expand All @@ -583,7 +583,7 @@ func outputServices(cmd *cobra.Command, services []api.Service, format string) e
case "json":
return util.SerializeToJSON(outputWriter, outputServices)
case "yaml":
return util.SerializeToYAML(outputWriter, outputServices, true)
return util.SerializeToYAML(outputWriter, outputServices)
case "env":
return fmt.Errorf("environment variable output is not supported for multiple services")
default: // table format (default)
Expand Down
16 changes: 8 additions & 8 deletions internal/tiger/cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import (
)

type VersionOutput struct {
Version string `json:"version" yaml:"version"`
BuildTime string `json:"build_time" yaml:"build_time"`
GitCommit string `json:"git_commit" yaml:"git_commit"`
GoVersion string `json:"go_version" yaml:"go_version"`
Platform string `json:"platform" yaml:"platform"`
LatestVersion string `json:"latest_version,omitempty" yaml:"latest_version,omitempty"`
UpdateAvailable *bool `json:"update_available,omitempty" yaml:"update_available,omitempty"`
Version string `json:"version"`
BuildTime string `json:"build_time"`
GitCommit string `json:"git_commit"`
GoVersion string `json:"go_version"`
Platform string `json:"platform"`
LatestVersion string `json:"latest_version,omitempty"`
UpdateAvailable *bool `json:"update_available,omitempty"`
}

func buildVersionCmd() *cobra.Command {
Expand Down Expand Up @@ -64,7 +64,7 @@ func buildVersionCmd() *cobra.Command {
return err
}
case "yaml":
if err := util.SerializeToYAML(output, versionOutput, true); err != nil {
if err := util.SerializeToYAML(output, versionOutput); err != nil {
return err
}
case "bare":
Expand Down
12 changes: 6 additions & 6 deletions internal/tiger/common/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ type ConnectionDetailsOptions struct {
}

type ConnectionDetails struct {
Role string `json:"role,omitempty" yaml:"role,omitempty"`
Password string `json:"password,omitempty" yaml:"password,omitempty"`
Host string `json:"host,omitempty" yaml:"host,omitempty"`
Port int `json:"port,omitempty" yaml:"port,omitempty"`
Database string `json:"database,omitempty" yaml:"database,omitempty"`
IsPooler bool `json:"is_pooler,omitempty" yaml:"is_pooler,omitempty"`
Role string `json:"role,omitempty"`
Password string `json:"password,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Database string `json:"database,omitempty"`
IsPooler bool `json:"is_pooler,omitempty"`
}

func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) {
Expand Down
70 changes: 37 additions & 33 deletions internal/tiger/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,47 +11,48 @@ import (
"strconv"
"time"

"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"

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

type Config struct {
APIURL string `mapstructure:"api_url" yaml:"api_url"`
Analytics bool `mapstructure:"analytics" yaml:"analytics"`
Color bool `mapstructure:"color" yaml:"color"`
ConfigDir string `mapstructure:"config_dir" yaml:"-"`
ConsoleURL string `mapstructure:"console_url" yaml:"console_url"`
Debug bool `mapstructure:"debug" yaml:"debug"`
DocsMCP bool `mapstructure:"docs_mcp" yaml:"docs_mcp"`
DocsMCPURL string `mapstructure:"docs_mcp_url" yaml:"docs_mcp_url"`
GatewayURL string `mapstructure:"gateway_url" yaml:"gateway_url"`
Output string `mapstructure:"output" yaml:"output"`
PasswordStorage string `mapstructure:"password_storage" yaml:"password_storage"`
ReleasesURL string `mapstructure:"releases_url" yaml:"releases_url"`
ServiceID string `mapstructure:"service_id" yaml:"service_id"`
VersionCheckInterval time.Duration `mapstructure:"version_check_interval" yaml:"version_check_interval"`
VersionCheckLastTime time.Time `mapstructure:"version_check_last_time" yaml:"version_check_last_time"`
viper *viper.Viper `mapstructure:"-" yaml:"-"`
APIURL string `mapstructure:"api_url"`
Analytics bool `mapstructure:"analytics"`
Color bool `mapstructure:"color"`
ConfigDir string `mapstructure:"config_dir"`
ConsoleURL string `mapstructure:"console_url"`
Debug bool `mapstructure:"debug"`
DocsMCP bool `mapstructure:"docs_mcp"`
DocsMCPURL string `mapstructure:"docs_mcp_url"`
GatewayURL string `mapstructure:"gateway_url"`
Output string `mapstructure:"output"`
PasswordStorage string `mapstructure:"password_storage"`
ReleasesURL string `mapstructure:"releases_url"`
ServiceID string `mapstructure:"service_id"`
VersionCheckInterval time.Duration `mapstructure:"version_check_interval"`
VersionCheckLastTime time.Time `mapstructure:"version_check_last_time"`
viper *viper.Viper `mapstructure:"-"`
}

type ConfigOutput struct {
APIURL *string `mapstructure:"api_url" json:"api_url,omitempty" yaml:"api_url,omitempty"`
Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty" yaml:"analytics,omitempty"`
Color *bool `mapstructure:"color" json:"color,omitempty" yaml:"color,omitempty"`
ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty" yaml:"config_dir,omitempty"`
ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty" yaml:"console_url,omitempty"`
Debug *bool `mapstructure:"debug" json:"debug,omitempty" yaml:"debug,omitempty"`
DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty" yaml:"docs_mcp,omitempty"`
DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty" yaml:"docs_mcp_url,omitempty"`
GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty" yaml:"gateway_url,omitempty"`
Output *string `mapstructure:"output" json:"output,omitempty" yaml:"output,omitempty"`
PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty" yaml:"password_storage,omitempty"`
ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty" yaml:"releases_url,omitempty"`
ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty" yaml:"service_id,omitempty"`
VersionCheckInterval *time.Duration `mapstructure:"version_check_interval" json:"version_check_interval,omitempty" yaml:"version_check_interval,omitempty"`
VersionCheckLastTime *time.Time `mapstructure:"version_check_last_time" json:"version_check_last_time,omitempty" yaml:"version_check_last_time,omitempty"`
APIURL *string `mapstructure:"api_url" json:"api_url,omitempty"`
Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty"`
Color *bool `mapstructure:"color" json:"color,omitempty"`
ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty"`
ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty"`
Debug *bool `mapstructure:"debug" json:"debug,omitempty"`
DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"`
DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"`
GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"`
Output *string `mapstructure:"output" json:"output,omitempty"`
PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"`
ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty"`
ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty"`
VersionCheckInterval *util.Duration `mapstructure:"version_check_interval" json:"version_check_interval,omitempty"` // [util.Duration] ensures value is marshaled in [time.Duration.String] format when output
VersionCheckLastTime *time.Time `mapstructure:"version_check_last_time" json:"version_check_last_time,omitempty"`
}

const (
Expand Down Expand Up @@ -83,7 +84,7 @@ var defaultValues = map[string]any{
"password_storage": DefaultPasswordStorage,
"releases_url": DefaultReleasesURL,
"service_id": "",
"version_check_interval": DefaultVersionCheckInterval,
"version_check_interval": DefaultVersionCheckInterval.String(), // String can be interpreted as either [time.Duration] (for [Config]) or [util.Duration] (for [ConfigOutput])
"version_check_last_time": time.Time{},
}

Expand Down Expand Up @@ -149,7 +150,10 @@ func ForOutputFromViper(v *viper.Viper) (*ConfigOutput, error) {
ConfigDir: &configDir,
}

if err := v.Unmarshal(cfg); err != nil {
if err := v.Unmarshal(cfg,
// Decode hook allows us to unmarshal a string into a [util.Duration] for the sake of VersionCheckInterval
viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()),
); err != nil {
return nil, fmt.Errorf("error unmarshaling config for output: %w", err)
}

Expand Down
35 changes: 35 additions & 0 deletions internal/tiger/util/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package util

import (
"encoding"
"time"
)

var (
_ encoding.TextMarshaler = Duration{}
_ encoding.TextUnmarshaler = (*Duration)(nil)
)

// Duration is a wrapper around time.Duration that allows it to be
// marshalled to/from JSON via the standard duration string format.
type Duration struct {
time.Duration
}

// Implements the [encoding.TextUnmarshaler] interface.
func (d *Duration) UnmarshalText(b []byte) error {
duration, err := time.ParseDuration(string(b))
if err != nil {
return err
}
*d = Duration{
Duration: duration,
}
return nil
}

// Implements the [encoding.TextMarshaler] interface.
func (d Duration) MarshalText() ([]byte, error) {
str := d.String()
return []byte(str), nil
}
22 changes: 13 additions & 9 deletions internal/tiger/util/serialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,22 @@ func SerializeToJSON(w io.Writer, v any) error {
return encoder.Encode(v)
}

func SerializeToYAML(w io.Writer, v any, omitNull bool) error {
// SerializeToYAML serializes data to YAML format.
//
// This function first marshals to JSON, then unmarshals and encodes to YAML.
// This approach ensures that:
// 1. Structs from third-party libraries or generated code that only include
// json: struct tags (without yaml: tags) are correctly marshaled
// 2. JSON and YAML marshaling produce consistent output, both respecting
// the same json: tags and omitempty directives
func SerializeToYAML(w io.Writer, v any) error {
encoder := yaml.NewEncoder(w)
defer encoder.Close()
encoder.SetIndent(2)

if omitNull {
if toOutput, err := toJSON(v); err != nil {
return err
} else {
return encoder.Encode(toOutput)
}
toOutput, err := toJSON(v)
if err != nil {
return err
}

return encoder.Encode(v)
return encoder.Encode(toOutput)
}