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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.npm-tmp/
npm/*/bin/xsql
npm/*/bin/xsql.exe
coverage.txt
194 changes: 194 additions & 0 deletions cmd/xsql/command_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@

func TestProfileCommands_ListAndShow(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "xsql.yaml")

Check failure on line 327 in cmd/xsql/command_unit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "xsql.yaml" 6 times.

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akccj0nDqkllVic&open=AZ0d5akccj0nDqkllVic&pullRequest=35
configContent := `
profiles:
dev:
Expand Down Expand Up @@ -598,6 +598,200 @@
}
}

func TestResolveProxyPort(t *testing.T) {

Check failure on line 601 in cmd/xsql/command_unit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akccj0nDqkllVid&open=AZ0d5akccj0nDqkllVid&pullRequest=35
t.Run("nil cmd returns config port", func(t *testing.T) {
port, fromConfig := resolveProxyPort(nil, &ProxyFlags{LocalPort: 5555}, 13306)
if port != 13306 {
t.Errorf("expected 13306, got %d", port)
}
if !fromConfig {
t.Error("expected fromConfig=true")
}
})

t.Run("nil cmd with zero config returns auto", func(t *testing.T) {
port, fromConfig := resolveProxyPort(nil, &ProxyFlags{}, 0)
if port != 0 {
t.Errorf("expected 0, got %d", port)
}
if fromConfig {
t.Error("expected fromConfig=false")
}
})

t.Run("cli flag takes priority", func(t *testing.T) {
cmd := NewProxyCommand(nil)
// Simulate setting the flag
_ = cmd.Flags().Set("local-port", "9999")
port, fromConfig := resolveProxyPort(cmd, &ProxyFlags{LocalPort: 9999}, 13306)
if port != 9999 {
t.Errorf("expected 9999, got %d", port)
}
if fromConfig {
t.Error("expected fromConfig=false")
}
})

t.Run("config port when cli not set", func(t *testing.T) {
cmd := NewProxyCommand(nil)
// Don't set the flag - use config port
port, fromConfig := resolveProxyPort(cmd, &ProxyFlags{}, 13306)
if port != 13306 {
t.Errorf("expected 13306, got %d", port)
}
if !fromConfig {
t.Error("expected fromConfig=true")
}
})
}

func TestConfigInitCommand(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "xsql.yaml")

GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
cmd := newConfigInitCommand(&w)
cmd.SetArgs([]string{"--path", path})
if err := cmd.Execute(); err != nil {
t.Fatalf("config init failed: %v", err)
}
if !json.Valid(out.Bytes()) {
t.Fatalf("expected json output, got %s", out.String())
}

// Verify file exists
if _, err := os.Stat(path); err != nil {
t.Errorf("config file should exist: %v", err)
}
}

func TestConfigInitCommand_FileExists(t *testing.T) {

Check warning on line 671 in cmd/xsql/command_unit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestConfigInitCommand_FileExists" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akccj0nDqkllVie&open=AZ0d5akccj0nDqkllVie&pullRequest=35
dir := t.TempDir()
path := filepath.Join(dir, "xsql.yaml")
if err := os.WriteFile(path, []byte("test"), 0600); err != nil {
t.Fatal(err)
}

GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
cmd := newConfigInitCommand(&w)
cmd.SetArgs([]string{"--path", path})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when file exists")
}
}

func TestConfigSetCommand(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "xsql.yaml")
if err := os.WriteFile(path, []byte("profiles: {}\nssh_proxies: {}\n"), 0600); err != nil {
t.Fatal(err)
}

GlobalConfig.ConfigStr = path
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
cmd := newConfigSetCommand(&w)
cmd.SetArgs([]string{"profile.dev.host", "localhost"})
if err := cmd.Execute(); err != nil {
t.Fatalf("config set failed: %v", err)
}
if !json.Valid(out.Bytes()) {
t.Fatalf("expected json output, got %s", out.String())
}

// Verify the config was updated
data, _ := os.ReadFile(path)
if !bytes.Contains(data, []byte("localhost")) {
t.Error("config should contain 'localhost'")
}
}

func TestConfigSetCommand_InvalidKey(t *testing.T) {

Check warning on line 717 in cmd/xsql/command_unit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestConfigSetCommand_InvalidKey" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akccj0nDqkllVif&open=AZ0d5akccj0nDqkllVif&pullRequest=35
dir := t.TempDir()
path := filepath.Join(dir, "xsql.yaml")
if err := os.WriteFile(path, []byte("profiles: {}\nssh_proxies: {}\n"), 0600); err != nil {
t.Fatal(err)
}

GlobalConfig.ConfigStr = path
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
cmd := newConfigSetCommand(&w)
cmd.SetArgs([]string{"badkey", "value"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for invalid key")
}
}

func TestConfigSetCommand_NoConfig(t *testing.T) {

Check warning on line 736 in cmd/xsql/command_unit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestConfigSetCommand_NoConfig" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akccj0nDqkllVig&open=AZ0d5akccj0nDqkllVig&pullRequest=35
GlobalConfig.ConfigStr = ""
GlobalConfig.FormatStr = "json"

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
cmd := newConfigSetCommand(&w)
cmd.SetArgs([]string{"profile.dev.host", "localhost"})

// Set HOME and work dir to temp dirs with no config files
origHome := os.Getenv("HOME")
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
defer func() { _ = os.Setenv("HOME", origHome) }()

origDir, _ := os.Getwd()
tmpWorkDir := t.TempDir()
_ = os.Chdir(tmpWorkDir)
defer func() { _ = os.Chdir(origDir) }()

err := cmd.Execute()
// FindConfigPath returns default home path, SetConfigValue creates the file.
// This should either succeed (creating new file) or fail.
// Since no config exists yet, it should succeed by creating a new one.
if err != nil {
// If it fails, that's okay too - we just want to verify it doesn't panic
t.Logf("error (acceptable): %v", err)
}
}

func TestRunProxy_WithConfigLocalPort(t *testing.T) {

Check warning on line 766 in cmd/xsql/command_unit_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "TestRunProxy_WithConfigLocalPort" to match the regular expression ^(_|[a-zA-Z0-9]+)$

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akccj0nDqkllVih&open=AZ0d5akccj0nDqkllVih&pullRequest=35
// Test that config local_port is used when --local-port is not set
GlobalConfig.ProfileStr = "dev"
GlobalConfig.FormatStr = "json"
GlobalConfig.Resolved.Profile = config.Profile{
DB: "mysql",
Host: "db.example.com",
Port: 3306,
LocalPort: 13306,
SSHConfig: &config.SSHProxy{
Host: "bastion.example.com",
Port: 22,
User: "user",
},
}

var out bytes.Buffer
w := output.New(&out, &bytes.Buffer{})
// This will fail at SSH connection, but we can verify the port resolution
err := runProxy(nil, &ProxyFlags{}, &w)
if err == nil {
t.Fatal("expected error (SSH not available)")
}
// The error should be about SSH, not port
if xe, ok := errors.As(err); ok && xe.Code == errors.CodePortInUse {
t.Error("should not get port-in-use error")
}
}

func TestValueIfSet(t *testing.T) {
if got := valueIfSet(false, "x"); got != "" {
t.Fatalf("expected empty when not set, got %q", got)
Expand Down
85 changes: 85 additions & 0 deletions cmd/xsql/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"github.com/spf13/cobra"

"github.com/zx06/xsql/internal/config"
"github.com/zx06/xsql/internal/errors"
"github.com/zx06/xsql/internal/output"
)

// NewConfigCommand creates the config command group
func NewConfigCommand(w *output.Writer) *cobra.Command {
configCmd := &cobra.Command{
Use: "config",
Short: "Manage configuration",
}

configCmd.AddCommand(newConfigInitCommand(w))
configCmd.AddCommand(newConfigSetCommand(w))

return configCmd
}

// newConfigInitCommand creates the config init command
func newConfigInitCommand(w *output.Writer) *cobra.Command {
var path string

cmd := &cobra.Command{
Use: "init",
Short: "Create a template configuration file",
RunE: func(cmd *cobra.Command, args []string) error {
format, err := parseOutputFormat(GlobalConfig.FormatStr)
if err != nil {
return err
}

cfgPath, xe := config.InitConfig(path)
if xe != nil {
return xe
}

return w.WriteOK(format, map[string]any{
"config_path": cfgPath,
})
},
}

cmd.Flags().StringVar(&path, "path", "", "Config file path (default: $HOME/.config/xsql/xsql.yaml)")

return cmd
}

// newConfigSetCommand creates the config set command
func newConfigSetCommand(w *output.Writer) *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value (e.g., profile.dev.host localhost)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key, value := args[0], args[1]

format, err := parseOutputFormat(GlobalConfig.FormatStr)
if err != nil {
return err
}

cfgPath := config.FindConfigPath(config.Options{
ConfigPath: GlobalConfig.ConfigStr,
})
if cfgPath == "" {
return errors.New(errors.CodeCfgNotFound, "no config file found; run 'xsql config init' first", nil)
}

if xe := config.SetConfigValue(cfgPath, key, value); xe != nil {
return xe
}

return w.WriteOK(format, map[string]any{
"config_path": cfgPath,
"key": key,
"value": value,
})
},
}
}
1 change: 1 addition & 0 deletions cmd/xsql/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func run() int {
root.AddCommand(NewSchemaCommand(&w))
root.AddCommand(NewMCPCommand())
root.AddCommand(NewProxyCommand(&w))
root.AddCommand(NewConfigCommand(&w))

// Execute and handle errors
if err := root.Execute(); err != nil {
Expand Down
64 changes: 63 additions & 1 deletion cmd/xsql/proxy.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package main

import (
"bufio"
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/zx06/xsql/internal/app"
"github.com/zx06/xsql/internal/errors"
Expand Down Expand Up @@ -44,8 +47,51 @@
return cmd
}

// resolveProxyPort determines the port to use with the following priority:
// CLI --local-port > profile.local_port > 0 (auto)
// Returns the port and whether it came from config (for conflict handling).
func resolveProxyPort(cmd *cobra.Command, flags *ProxyFlags, profileLocalPort int) (port int, fromConfig bool) {
if cmd != nil && cmd.Flags().Changed("local-port") {
return flags.LocalPort, false
}
if profileLocalPort > 0 {
return profileLocalPort, true
}
return 0, false
}

// handlePortConflict handles a port conflict when the port comes from config.
// In TTY mode, prompts the user to choose random port or quit.
// In non-TTY mode, returns an error.
func handlePortConflict(port int, host string) (int, *errors.XError) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return 0, errors.New(errors.CodePortInUse, "configured port is already in use",
map[string]any{"port": port, "host": host})
}

fmt.Fprintf(os.Stderr, "⚠ Port %d is already in use.\n", port)
fmt.Fprintf(os.Stderr, " [R] Use a random port\n")
fmt.Fprintf(os.Stderr, " [Q] Quit\n")
fmt.Fprintf(os.Stderr, "Choice [R/Q]: ")

reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))

switch input {
case "r", "":
return 0, nil // 0 means auto-assign
case "q":
return 0, errors.New(errors.CodePortInUse, "user chose to quit due to port conflict",
map[string]any{"port": port})
default:
return 0, errors.New(errors.CodePortInUse, "user chose to quit due to port conflict",
map[string]any{"port": port})

Check warning on line 89 in cmd/xsql/proxy.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This branch's code block is the same as the block for the branch on line 85.

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akpcj0nDqkllVii&open=AZ0d5akpcj0nDqkllVii&pullRequest=35
}
}

// runProxy executes the proxy command
func runProxy(cmd *cobra.Command, flags *ProxyFlags, w *output.Writer) error {

Check failure on line 94 in cmd/xsql/proxy.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=zx06_xsql&issues=AZ0d5akpcj0nDqkllVij&open=AZ0d5akpcj0nDqkllVij&pullRequest=35
if GlobalConfig.ProfileStr == "" {
return errors.New(errors.CodeCfgInvalid, "profile is required (use global -p/--profile flag)", nil)
}
Expand All @@ -64,6 +110,22 @@
return errors.New(errors.CodeCfgInvalid, "profile must have ssh_proxy configured for port forwarding", nil)
}

// Resolve port: CLI > config local_port > 0 (auto)
localPort, fromConfig := resolveProxyPort(cmd, flags, p.LocalPort)

// Check for port conflict if a specific port is configured
if localPort > 0 && !proxy.IsPortAvailable(flags.LocalHost, localPort) {
if fromConfig {
// Port from config: offer interactive choice
newPort, xe := handlePortConflict(localPort, flags.LocalHost)
if xe != nil {
return xe
}
localPort = newPort
}
// If port from CLI flag, let proxy.Start handle the error naturally
}

allowPlaintext := flags.AllowPlaintext || p.AllowPlaintext

ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -83,7 +145,7 @@

proxyOpts := proxy.Options{
LocalHost: flags.LocalHost,
LocalPort: flags.LocalPort,
LocalPort: localPort,
RemoteHost: p.Host,
RemotePort: p.Port,
Dialer: sshClient,
Expand Down
Loading
Loading