From 675a0df609c1736200a1f5e23eebb21c2daebe03 Mon Sep 17 00:00:00 2001 From: David Levy Date: Thu, 5 Feb 2026 18:34:12 -0600 Subject: [PATCH 1/4] feat(open): add sqlcmd open vscode and sqlcmd open ssms commands --- .gitignore | 1 + README.md | 39 ++- cmd/modern/root/open.go | 6 +- cmd/modern/root/open/ads_test.go | 15 +- cmd/modern/root/open/clipboard.go | 37 ++ cmd/modern/root/open/clipboard_test.go | 90 +++++ cmd/modern/root/open/ssms.go | 98 ++++++ cmd/modern/root/open/ssms_test.go | 213 ++++++++++++ cmd/modern/root/open/ssms_unix.go | 38 ++ cmd/modern/root/open/ssms_windows.go | 22 ++ cmd/modern/root/open/vscode.go | 331 ++++++++++++++++++ cmd/modern/root/open/vscode_platform.go | 25 ++ cmd/modern/root/open/vscode_test.go | 438 ++++++++++++++++++++++++ internal/pal/clipboard.go | 10 + internal/pal/clipboard_darwin.go | 15 + internal/pal/clipboard_linux.go | 52 +++ internal/pal/clipboard_test.go | 18 + internal/pal/clipboard_windows.go | 17 + internal/tools/tool/interface.go | 1 + internal/tools/tool/ssms.go | 34 ++ internal/tools/tool/ssms_test.go | 15 + internal/tools/tool/ssms_unix.go | 18 + internal/tools/tool/ssms_windows.go | 37 ++ internal/tools/tool/tool.go | 28 +- internal/tools/tool/tool_linux.go | 10 +- internal/tools/tool/tool_test.go | 12 +- internal/tools/tool/vscode.go | 47 +++ internal/tools/tool/vscode_darwin.go | 36 ++ internal/tools/tool/vscode_linux.go | 44 +++ internal/tools/tool/vscode_test.go | 15 + internal/tools/tool/vscode_windows.go | 45 +++ internal/tools/tools.go | 2 + 32 files changed, 1789 insertions(+), 20 deletions(-) create mode 100644 cmd/modern/root/open/clipboard.go create mode 100644 cmd/modern/root/open/clipboard_test.go create mode 100644 cmd/modern/root/open/ssms.go create mode 100644 cmd/modern/root/open/ssms_test.go create mode 100644 cmd/modern/root/open/ssms_unix.go create mode 100644 cmd/modern/root/open/ssms_windows.go create mode 100644 cmd/modern/root/open/vscode.go create mode 100644 cmd/modern/root/open/vscode_platform.go create mode 100644 cmd/modern/root/open/vscode_test.go create mode 100644 internal/pal/clipboard.go create mode 100644 internal/pal/clipboard_darwin.go create mode 100644 internal/pal/clipboard_linux.go create mode 100644 internal/pal/clipboard_test.go create mode 100644 internal/pal/clipboard_windows.go create mode 100644 internal/tools/tool/ssms.go create mode 100644 internal/tools/tool/ssms_test.go create mode 100644 internal/tools/tool/ssms_unix.go create mode 100644 internal/tools/tool/ssms_windows.go create mode 100644 internal/tools/tool/vscode.go create mode 100644 internal/tools/tool/vscode_darwin.go create mode 100644 internal/tools/tool/vscode_linux.go create mode 100644 internal/tools/tool/vscode_test.go create mode 100644 internal/tools/tool/vscode_windows.go diff --git a/.gitignore b/.gitignore index f713869e..d8da873d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ linux-s390x/sqlcmd # Build artifacts in root /sqlcmd /sqlcmd_binary +/modern # certificates used for local testing *.der diff --git a/README.md b/README.md index e4a1e35d..2b468474 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,51 @@ The Homebrew package manager may be used on Linux and Windows Subsystem for Linu Use `sqlcmd` to create SQL Server and Azure SQL Edge instances using a local container runtime (e.g. [Docker][] or [Podman][]) -### Create SQL Server instance using local container runtime and connect using Azure Data Studio +### Create SQL Server instance using local container runtime -To create a local SQL Server instance with the AdventureWorksLT database restored, query it, and connect to it using Azure Data Studio, run: +To create a local SQL Server instance with the AdventureWorksLT database restored, run: ``` sqlcmd create mssql --accept-eula --using https://aka.ms/AdventureWorksLT.bak sqlcmd query "SELECT DB_NAME()" -sqlcmd open ads ``` Use `sqlcmd --help` to view all the available sub-commands. Use `sqlcmd -?` to view the original ODBC `sqlcmd` flags. +### Connect using Visual Studio Code + +Use `sqlcmd open vscode` to open Visual Studio Code with a connection profile configured for the current context: + +``` +sqlcmd open vscode +``` + +This command will: +1. **Create a connection profile** in VS Code's user settings with the current context name +2. **Copy the password to clipboard** so you can paste it when prompted +3. **Launch VS Code** ready to connect + +To also install the MSSQL extension (if not already installed), add the `--install-extension` flag: + +``` +sqlcmd open vscode --install-extension +``` + +Once VS Code opens, use the MSSQL extension's Object Explorer to connect using the profile. When you connect to the container, VS Code will automatically detect it as a Docker container and provide additional container management features (start/stop/delete) directly from the Object Explorer. + +### Connect using SQL Server Management Studio (Windows) + +On Windows, use `sqlcmd open ssms` to open SQL Server Management Studio pre-configured to connect to the current context: + +``` +sqlcmd open ssms +``` + +This command will: +1. **Copy the password to clipboard** so you can paste it in the login dialog +2. **Launch SSMS** with the server and username pre-filled +3. You'll be prompted for the password - just paste from clipboard (Ctrl+V) + ### The ~/.sqlcmd/sqlconfig file Each time `sqlcmd create` completes, a new context is created (e.g. mssql, mssql2, mssql3 etc.). A context contains the endpoint and user configuration detail. To switch between contexts, run `sqlcmd config use `, to view name of the current context, run `sqlcmd config current-context`, to list all contexts, run `sqlcmd config get-contexts`. diff --git a/cmd/modern/root/open.go b/cmd/modern/root/open.go index d209db81..d8c879f9 100644 --- a/cmd/modern/root/open.go +++ b/cmd/modern/root/open.go @@ -17,7 +17,7 @@ type Open struct { func (c *Open) DefineCommand(...cmdparser.CommandOptions) { options := cmdparser.CommandOptions{ Use: "open", - Short: localizer.Sprintf("Open tools (e.g Azure Data Studio) for current context"), + Short: localizer.Sprintf("Open tools (e.g., Visual Studio Code, SSMS) for current context"), SubCommands: c.SubCommands(), } @@ -25,11 +25,13 @@ func (c *Open) DefineCommand(...cmdparser.CommandOptions) { } // SubCommands sets up the sub-commands for `sqlcmd open` such as -// `sqlcmd open ads` +// `sqlcmd open ads`, `sqlcmd open vscode`, and `sqlcmd open ssms` func (c *Open) SubCommands() []cmdparser.Command { dependencies := c.Dependencies() return []cmdparser.Command{ cmdparser.New[*open.Ads](dependencies), + cmdparser.New[*open.VSCode](dependencies), + cmdparser.New[*open.Ssms](dependencies), } } diff --git a/cmd/modern/root/open/ads_test.go b/cmd/modern/root/open/ads_test.go index 29f50369..68c2b77c 100644 --- a/cmd/modern/root/open/ads_test.go +++ b/cmd/modern/root/open/ads_test.go @@ -4,17 +4,24 @@ package open import ( + "runtime" + "testing" + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" "github.com/microsoft/go-sqlcmd/internal/cmdparser" "github.com/microsoft/go-sqlcmd/internal/config" - "runtime" - "testing" + "github.com/microsoft/go-sqlcmd/internal/tools" ) -// TestOpen runs a sanity test of `sqlcmd open` +// TestAds runs a sanity test of `sqlcmd open ads` func TestAds(t *testing.T) { if runtime.GOOS != "windows" { - t.Skip("Ads support only on Windows at this time") + t.Skip("ADS support only on Windows at this time") + } + + tool := tools.NewTool("ads") + if !tool.IsInstalled() { + t.Skip("Azure Data Studio is not installed") } cmdparser.TestSetup(t) diff --git a/cmd/modern/root/open/clipboard.go b/cmd/modern/root/open/clipboard.go new file mode 100644 index 00000000..69e861e6 --- /dev/null +++ b/cmd/modern/root/open/clipboard.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" + "github.com/microsoft/go-sqlcmd/internal/config" + "github.com/microsoft/go-sqlcmd/internal/localizer" + "github.com/microsoft/go-sqlcmd/internal/output" + "github.com/microsoft/go-sqlcmd/internal/pal" +) + +// copyPasswordToClipboard copies the password for the current context to the clipboard +// if the user is using SQL authentication. Returns true if a password was copied. +func copyPasswordToClipboard(user *sqlconfig.User, out *output.Output) bool { + if user == nil || user.AuthenticationType != "basic" { + return false + } + + // Get the decrypted password from the current context + _, _, password := config.GetCurrentContextInfo() + + if password == "" { + return false + } + + err := pal.CopyToClipboard(password) + if err != nil { + // Don't fail the command if clipboard copy fails, just warn the user + out.Warn(localizer.Sprintf("Could not copy password to clipboard: %s", err.Error())) + return false + } + + out.Info(localizer.Sprintf("Password copied to clipboard - paste it when prompted")) + return true +} diff --git a/cmd/modern/root/open/clipboard_test.go b/cmd/modern/root/open/clipboard_test.go new file mode 100644 index 00000000..0e0d45d1 --- /dev/null +++ b/cmd/modern/root/open/clipboard_test.go @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "runtime" + "testing" + + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" + "github.com/microsoft/go-sqlcmd/internal/cmdparser" +) + +func TestCopyPasswordToClipboardWithNoUser(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + result := copyPasswordToClipboard(nil, nil) + if result { + t.Error("Expected false when user is nil") + } +} + +func TestCopyPasswordToClipboardWithNonBasicAuth(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + user := &sqlconfig.User{ + AuthenticationType: "windows", + Name: "test-user", + } + + result := copyPasswordToClipboard(user, nil) + if result { + t.Error("Expected false when auth type is not 'basic'") + } +} + +func TestCopyPasswordToClipboardWithEmptyPassword(t *testing.T) { + user := &sqlconfig.User{ + AuthenticationType: "basic", + BasicAuth: &sqlconfig.BasicAuthDetails{ + Username: "sa", + PasswordEncryption: "", + Password: "", + }, + } + + if !userShouldCopyPassword(user) { + t.Error("userShouldCopyPassword should return true for basic auth user") + } +} + +func TestCopyPasswordToClipboardLogic(t *testing.T) { + if userShouldCopyPassword(nil) { + t.Error("Should not copy password when user is nil") + } + + user := &sqlconfig.User{ + AuthenticationType: "integrated", + } + if userShouldCopyPassword(user) { + t.Error("Should not copy password when auth type is not basic") + } + + user = &sqlconfig.User{ + AuthenticationType: "basic", + BasicAuth: &sqlconfig.BasicAuthDetails{ + Username: "sa", + Password: "test", + }, + } + if !userShouldCopyPassword(user) { + t.Error("Should copy password when auth type is basic") + } +} + +// userShouldCopyPassword is a helper that tests the condition logic +func userShouldCopyPassword(user *sqlconfig.User) bool { + if user == nil || user.AuthenticationType != "basic" { + return false + } + return true +} diff --git a/cmd/modern/root/open/ssms.go b/cmd/modern/root/open/ssms.go new file mode 100644 index 00000000..87fd8c87 --- /dev/null +++ b/cmd/modern/root/open/ssms.go @@ -0,0 +1,98 @@ +//go:build windows + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "fmt" + "strings" + + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/config" + "github.com/microsoft/go-sqlcmd/internal/container" + "github.com/microsoft/go-sqlcmd/internal/localizer" + "github.com/microsoft/go-sqlcmd/internal/tools" +) + +// Ssms implements the `sqlcmd open ssms` command. It opens +// SQL Server Management Studio and connects to the current context using the +// credentials specified in the context. +func (c *Ssms) DefineCommand(...cmdparser.CommandOptions) { + options := cmdparser.CommandOptions{ + Use: "ssms", + Short: localizer.Sprintf("Open SQL Server Management Studio and connect to current context"), + Examples: []cmdparser.ExampleOptions{{ + Description: localizer.Sprintf("Open SSMS and connect using the current context"), + Steps: []string{"sqlcmd open ssms"}}}, + Run: c.run, + } + + c.Cmd.DefineCommand(options) +} + +// Launch SSMS and connect to the current context +func (c *Ssms) run() { + endpoint, user := config.CurrentContext() + + // Check if this is a local container connection + isLocalConnection := isLocalEndpoint(endpoint) + + // If the context has a local container, ensure it is running, otherwise bail out + if asset := endpoint.AssetDetails; asset != nil && asset.ContainerDetails != nil { + c.ensureContainerIsRunning(asset.Id) + } + + // Launch SSMS with connection parameters + c.launchSsms(endpoint.Address, endpoint.Port, user, isLocalConnection) +} + +func (c *Ssms) ensureContainerIsRunning(containerID string) { + output := c.Output() + controller := container.NewController() + if !controller.ContainerRunning(containerID) { + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("To start the container"), localizer.Sprintf("sqlcmd start")}, + }, localizer.Sprintf("Container is not running")) + } +} + +// launchSsms launches SQL Server Management Studio using the specified server and user credentials. +func (c *Ssms) launchSsms(host string, port int, user *sqlconfig.User, isLocalConnection bool) { + output := c.Output() + + // Build server connection string + serverArg := fmt.Sprintf("%s,%d", host, port) + + args := []string{ + "-S", serverArg, + "-nosplash", + } + + // Only add -C (trust server certificate) for local connections with self-signed certs + if isLocalConnection { + args = append(args, "-C") + } + + // Use SQL authentication if configured (commonly used for SQL Server containers) + if user != nil && user.AuthenticationType == "basic" && user.BasicAuth != nil { + // Escape double quotes in username (SQL Server allows " in login names) + username := strings.ReplaceAll(user.BasicAuth.Username, `"`, `\"`) + args = append(args, "-U", username) + // Note: -P parameter was removed in SSMS 18+ for security reasons + // Copy password to clipboard so user can paste it in the login dialog + copyPasswordToClipboard(user, output) + } + + tool := tools.NewTool("ssms") + if !tool.IsInstalled() { + output.Fatal(tool.HowToInstall()) + } + + c.displayPreLaunchInfo() + + _, err := tool.Run(args) + c.CheckErr(err) +} diff --git a/cmd/modern/root/open/ssms_test.go b/cmd/modern/root/open/ssms_test.go new file mode 100644 index 00000000..fc5dab60 --- /dev/null +++ b/cmd/modern/root/open/ssms_test.go @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "runtime" + "strconv" + "strings" + "testing" + + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/config" + "github.com/microsoft/go-sqlcmd/internal/tools" +) + +// TestSsms runs a sanity test of `sqlcmd open ssms` +func TestSsms(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("SSMS is only available on Windows") + } + + // Skip if SSMS is not installed + tool := tools.NewTool("ssms") + if !tool.IsInstalled() { + t.Skip("SSMS is not installed") + } + + cmdparser.TestSetup(t) + config.AddEndpoint(sqlconfig.Endpoint{ + AssetDetails: nil, + EndpointDetails: sqlconfig.EndpointDetails{ + Address: "localhost", + Port: 1433, + }, + Name: "endpoint", + }) + config.AddContext(sqlconfig.Context{ + ContextDetails: sqlconfig.ContextDetails{ + Endpoint: "endpoint", + User: nil, + }, + Name: "context", + }) + config.SetCurrentContextName("context") + + cmdparser.TestCmd[*Ssms]() +} + +func TestSsmsCommandLineArgs(t *testing.T) { + // Test server argument format + host := "localhost" + port := 1433 + serverArg := buildServerArg(host, port) + + expected := "localhost,1433" + if serverArg != expected { + t.Errorf("Expected server arg '%s', got '%s'", expected, serverArg) + } + + // Test with non-default port + port = 2000 + serverArg = buildServerArg(host, port) + + expected = "localhost,2000" + if serverArg != expected { + t.Errorf("Expected server arg '%s', got '%s'", expected, serverArg) + } + + // Test with different host + host = "myserver.database.windows.net" + serverArg = buildServerArg(host, port) + + expected = "myserver.database.windows.net,2000" + if serverArg != expected { + t.Errorf("Expected server arg '%s', got '%s'", expected, serverArg) + } +} + +// TestSsmsUsernameEscaping tests that special characters in usernames are escaped +func TestSsmsUsernameEscaping(t *testing.T) { + // Test escaping double quotes in username + username := `admin"user` + escaped := strings.ReplaceAll(username, `"`, `\"`) + + expected := `admin\"user` + if escaped != expected { + t.Errorf("Expected escaped username '%s', got '%s'", expected, escaped) + } + + // Test username without special characters + username = "sa" + escaped = strings.ReplaceAll(username, `"`, `\"`) + + if escaped != "sa" { + t.Errorf("Expected unchanged username 'sa', got '%s'", escaped) + } + + // Test username with multiple quotes + username = `user"with"quotes` + escaped = strings.ReplaceAll(username, `"`, `\"`) + + expected = `user\"with\"quotes` + if escaped != expected { + t.Errorf("Expected escaped username '%s', got '%s'", expected, escaped) + } +} + +// TestSsmsContextWithUser tests SSMS setup with user credentials +func TestSsmsContextWithUser(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("SSMS is only available on Windows") + } + + cmdparser.TestSetup(t) + + // Set up context with SQL authentication user + config.AddEndpoint(sqlconfig.Endpoint{ + AssetDetails: nil, + EndpointDetails: sqlconfig.EndpointDetails{ + Address: "localhost", + Port: 1433, + }, + Name: "ssms-test-endpoint", + }) + + config.AddUser(sqlconfig.User{ + AuthenticationType: "basic", + BasicAuth: &sqlconfig.BasicAuthDetails{ + Username: "sa", + PasswordEncryption: "", + Password: "TestPassword123", + }, + Name: "ssms-test-user", + }) + + config.AddContext(sqlconfig.Context{ + ContextDetails: sqlconfig.ContextDetails{ + Endpoint: "ssms-test-endpoint", + User: strPtr("ssms-test-user"), + }, + Name: "ssms-test-context", + }) + config.SetCurrentContextName("ssms-test-context") + + // Verify context is set up correctly + endpoint, user := config.CurrentContext() + + if endpoint.Address != "localhost" { + t.Errorf("Expected address 'localhost', got '%s'", endpoint.Address) + } + + if endpoint.Port != 1433 { + t.Errorf("Expected port 1433, got %d", endpoint.Port) + } + + if user == nil { + t.Fatal("Expected user to be set") + } + + if user.AuthenticationType != "basic" { + t.Errorf("Expected auth type 'basic', got '%s'", user.AuthenticationType) + } + + if user.BasicAuth.Username != "sa" { + t.Errorf("Expected username 'sa', got '%s'", user.BasicAuth.Username) + } +} + +// TestSsmsContextWithoutUser tests SSMS setup without user credentials +func TestSsmsContextWithoutUser(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("SSMS is only available on Windows") + } + + cmdparser.TestSetup(t) + + // Set up context without user (e.g., for Windows authentication scenarios) + config.AddEndpoint(sqlconfig.Endpoint{ + AssetDetails: nil, + EndpointDetails: sqlconfig.EndpointDetails{ + Address: "myserver", + Port: 1433, + }, + Name: "ssms-no-user-endpoint", + }) + + config.AddContext(sqlconfig.Context{ + ContextDetails: sqlconfig.ContextDetails{ + Endpoint: "ssms-no-user-endpoint", + User: nil, + }, + Name: "ssms-no-user-context", + }) + config.SetCurrentContextName("ssms-no-user-context") + + // Verify context is set up correctly + endpoint, user := config.CurrentContext() + + if endpoint.Address != "myserver" { + t.Errorf("Expected address 'myserver', got '%s'", endpoint.Address) + } + + if user != nil { + t.Error("Expected user to be nil") + } +} + +// Helper function to build server argument string +func buildServerArg(host string, port int) string { + return host + "," + strconv.Itoa(port) +} diff --git a/cmd/modern/root/open/ssms_unix.go b/cmd/modern/root/open/ssms_unix.go new file mode 100644 index 00000000..3eb42952 --- /dev/null +++ b/cmd/modern/root/open/ssms_unix.go @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//go:build !windows + +package open + +import ( + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/localizer" +) + +// Type Ssms is used to implement the "open ssms" which launches SQL Server +// Management Studio and establishes a connection to the SQL Server for the current +// context +type Ssms struct { + cmdparser.Cmd +} + +// DefineCommand sets up the ssms command for non-Windows platforms +func (c *Ssms) DefineCommand(...cmdparser.CommandOptions) { + options := cmdparser.CommandOptions{ + Use: "ssms", + Short: localizer.Sprintf("Open SQL Server Management Studio and connect to current context"), + Examples: []cmdparser.ExampleOptions{{ + Description: localizer.Sprintf("Open SSMS and connect using the current context"), + Steps: []string{"sqlcmd open ssms"}}}, + Run: c.run, + } + + c.Cmd.DefineCommand(options) +} + +// run fails immediately on non-Windows platforms +func (c *Ssms) run() { + output := c.Output() + output.Fatal(localizer.Sprintf("SSMS is only available on Windows. Use 'sqlcmd open vscode' instead.")) +} diff --git a/cmd/modern/root/open/ssms_windows.go b/cmd/modern/root/open/ssms_windows.go new file mode 100644 index 00000000..f0d7462b --- /dev/null +++ b/cmd/modern/root/open/ssms_windows.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/localizer" +) + +// Type Ssms is used to implement the "open ssms" which launches SQL Server +// Management Studio and establishes a connection to the SQL Server for the current +// context +type Ssms struct { + cmdparser.Cmd +} + +// On Windows, display info before launching +func (c *Ssms) displayPreLaunchInfo() { + output := c.Output() + output.Info(localizer.Sprintf("Launching SQL Server Management Studio...")) +} diff --git a/cmd/modern/root/open/vscode.go b/cmd/modern/root/open/vscode.go new file mode 100644 index 00000000..9a19adc8 --- /dev/null +++ b/cmd/modern/root/open/vscode.go @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/config" + "github.com/microsoft/go-sqlcmd/internal/container" + "github.com/microsoft/go-sqlcmd/internal/localizer" + "github.com/microsoft/go-sqlcmd/internal/tools" + "github.com/microsoft/go-sqlcmd/internal/tools/tool" +) + +// VSCode implements the `sqlcmd open vscode` command. It opens +// Visual Studio Code and configures a connection profile for the +// current context using the MSSQL extension. +func (c *VSCode) DefineCommand(...cmdparser.CommandOptions) { + options := cmdparser.CommandOptions{ + Use: "vscode", + Short: localizer.Sprintf("Open Visual Studio Code and configure connection for current context"), + Examples: []cmdparser.ExampleOptions{ + { + Description: localizer.Sprintf("Open VS Code and configure connection using the current context"), + Steps: []string{"sqlcmd open vscode"}, + }, + { + Description: localizer.Sprintf("Open VS Code and install the MSSQL extension if needed"), + Steps: []string{"sqlcmd open vscode --install-extension"}, + }, + }, + Run: c.run, + } + + c.Cmd.DefineCommand(options) + + c.AddFlag(cmdparser.FlagOptions{ + Bool: &c.installExtension, + Name: "install-extension", + Usage: localizer.Sprintf("Install the MSSQL extension in VS Code if not already installed"), + }) +} + +// Launch VS Code and configure connection profile for the current context. +// The connection profile will be added to VS Code's user settings to work +// with the MSSQL extension. +func (c *VSCode) run() { + endpoint, user := config.CurrentContext() + + // Check if this is a local container connection + isLocalConnection := isLocalEndpoint(endpoint) + + // If the context has a local container, ensure it is running, otherwise bail out + if asset := endpoint.AssetDetails; asset != nil && asset.ContainerDetails != nil { + c.ensureContainerIsRunning(asset.Id) + } + + // Create or update connection profile in VS Code settings + c.createConnectionProfile(endpoint, user, isLocalConnection) + + // Copy password to clipboard if using SQL authentication + copyPasswordToClipboard(user, c.Output()) + + // Launch VS Code + c.launchVSCode() +} + +func (c *VSCode) ensureContainerIsRunning(containerID string) { + output := c.Output() + controller := container.NewController() + if !controller.ContainerRunning(containerID) { + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("To start the container"), localizer.Sprintf("sqlcmd start")}, + }, localizer.Sprintf("Container is not running")) + } +} + +// launchVSCode launches Visual Studio Code +func (c *VSCode) launchVSCode() { + output := c.Output() + + tool := tools.NewTool("vscode") + if !tool.IsInstalled() { + output.Fatal(tool.HowToInstall()) + } + + // Install the MSSQL extension if explicitly requested + if c.installExtension { + output.Info(localizer.Sprintf("Installing MSSQL extension...")) + _, err := tool.Run([]string{"--install-extension", "ms-mssql.mssql", "--force"}) + if err != nil { + output.Warn(localizer.Sprintf("Could not install MSSQL extension: %s", err.Error())) + } else { + output.Info(localizer.Sprintf("MSSQL extension installed successfully")) + } + } else { + // Check if MSSQL extension is installed, warn if not + if !c.isMssqlExtensionInstalled(tool) { + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("To install the MSSQL extension"), "sqlcmd open vscode --install-extension"}, + }, localizer.Sprintf("The MSSQL extension (ms-mssql.mssql) is not installed in VS Code")) + } + } + + c.displayPreLaunchInfo() + + // Open VS Code + _, err := tool.Run([]string{}) + c.CheckErr(err) +} + +// createConnectionProfile creates or updates a connection profile in VS Code's user settings +func (c *VSCode) createConnectionProfile(endpoint sqlconfig.Endpoint, user *sqlconfig.User, isLocalConnection bool) { + output := c.Output() + + settingsPath := c.getVSCodeSettingsPath() + + // Ensure the directory exists + dir := filepath.Dir(settingsPath) + if err := os.MkdirAll(dir, 0755); err != nil { + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("Error"), err.Error()}, + }, localizer.Sprintf("Failed to create VS Code settings directory")) + } + + // Read existing settings or create new + settings := c.readSettings(settingsPath) + + // Create connection profile + profile := c.createProfile(endpoint, user, isLocalConnection) + + // Add or update the connection profile + connections := c.getConnectionsArray(settings) + connections = c.updateOrAddProfile(connections, profile) + settings["mssql.connections"] = connections + + // Write settings back + c.writeSettings(settingsPath, settings) + + output.Info(localizer.Sprintf("Connection profile created in VS Code settings")) +} + +func (c *VSCode) readSettings(path string) map[string]interface{} { + settings := make(map[string]interface{}) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return settings + } + output := c.Output() + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("Error"), err.Error()}, + }, localizer.Sprintf("Failed to read VS Code settings")) + } + + if len(data) > 0 { + if err := json.Unmarshal(data, &settings); err != nil { + output := c.Output() + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("Error"), err.Error()}, + }, localizer.Sprintf("Failed to parse VS Code settings")) + } + } + + return settings +} + +func (c *VSCode) writeSettings(path string, settings map[string]interface{}) { + output := c.Output() + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("Error"), err.Error()}, + }, localizer.Sprintf("Failed to encode VS Code settings")) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + output.FatalWithHintExamples([][]string{ + {localizer.Sprintf("Error"), err.Error()}, + }, localizer.Sprintf("Failed to write VS Code settings")) + } +} + +func (c *VSCode) getConnectionsArray(settings map[string]interface{}) []interface{} { + connections := []interface{}{} + if existing, ok := settings["mssql.connections"]; ok { + if arr, ok := existing.([]interface{}); ok { + connections = arr + } + } + return connections +} + +func (c *VSCode) createProfile(endpoint sqlconfig.Endpoint, user *sqlconfig.User, isLocalConnection bool) map[string]interface{} { + // Use context name as the profile name - this is the user's chosen identifier + // and matches what they use with sqlcmd commands + contextName := config.CurrentContextName() + + // Default to secure settings for production connections + encrypt := "Mandatory" + trustServerCertificate := false + + // Relax settings for local connections (containers, localhost) that commonly use + // self-signed certificates. Users can still adjust these values in VS Code settings. + if isLocalConnection { + encrypt = "Optional" + trustServerCertificate = true + } + + profile := map[string]interface{}{ + "server": fmt.Sprintf("%s,%d", endpoint.Address, endpoint.Port), + "profileName": contextName, + "encrypt": encrypt, + "trustServerCertificate": trustServerCertificate, + } + + if user != nil && user.AuthenticationType == "basic" && user.BasicAuth != nil { + profile["user"] = user.BasicAuth.Username + // SQL authentication contexts use SqlLogin + profile["authenticationType"] = "SqlLogin" + profile["savePassword"] = true + } + + return profile +} + +func (c *VSCode) updateOrAddProfile(connections []interface{}, newProfile map[string]interface{}) []interface{} { + profileName, ok := newProfile["profileName"].(string) + if !ok { + // If profileName is not a valid string, just append the profile + return append(connections, newProfile) + } + + // Check if profile with same name exists and update it + for i, conn := range connections { + if connMap, ok := conn.(map[string]interface{}); ok { + if name, ok := connMap["profileName"].(string); ok && name == profileName { + connections[i] = newProfile + return connections + } + } + } + + // Add new profile + return append(connections, newProfile) +} + +func (c *VSCode) getVSCodeSettingsPath() string { + var stableDir string + var insidersDir string + + getHomeDir := func() string { + if home := os.Getenv("HOME"); home != "" { + return home + } + if home, err := os.UserHomeDir(); err == nil { + return home + } + return "." + } + + switch runtime.GOOS { + case "windows": + base := os.Getenv("APPDATA") + if base == "" { + // Fallback to deriving APPDATA from user home + if home, err := os.UserHomeDir(); err == nil { + base = filepath.Join(home, "AppData", "Roaming") + } else { + base = "." + } + } + stableDir = filepath.Join(base, "Code", "User") + insidersDir = filepath.Join(base, "Code - Insiders", "User") + case "darwin": + base := filepath.Join(getHomeDir(), "Library", "Application Support") + stableDir = filepath.Join(base, "Code", "User") + insidersDir = filepath.Join(base, "Code - Insiders", "User") + default: // linux and others + base := filepath.Join(getHomeDir(), ".config") + stableDir = filepath.Join(base, "Code", "User") + insidersDir = filepath.Join(base, "Code - Insiders", "User") + } + + // Prefer VS Code Insiders settings if the directory exists, since the tool + // searches for and launches Insiders first. Fall back to stable Code. + configDir := stableDir + if info, err := os.Stat(insidersDir); err == nil && info.IsDir() { + configDir = insidersDir + } + + return filepath.Join(configDir, "settings.json") +} + +// isMssqlExtensionInstalled checks if the MSSQL extension is installed in VS Code +func (c *VSCode) isMssqlExtensionInstalled(t tool.Tool) bool { + output, _, err := t.RunWithOutput([]string{"--list-extensions"}) + if err != nil { + // If we can't list extensions, assume it's installed to avoid blocking the user, + // but emit a warning so the user is aware that verification failed. + c.Output().Warn(localizer.Sprintf("Could not verify MSSQL extension installation: %s", err.Error())) + return true + } + + // Check if the MSSQL extension is in the list (case-insensitive) + extensions := strings.ToLower(output) + return strings.Contains(extensions, "ms-mssql.mssql") +} + +// isLocalEndpoint checks if the endpoint is a local connection (container, localhost, etc.) +// This is used to determine whether to use relaxed TLS settings. +func isLocalEndpoint(endpoint sqlconfig.Endpoint) bool { + // Check if this is a container-based connection + if asset := endpoint.AssetDetails; asset != nil && asset.ContainerDetails != nil { + return true + } + + // Check for common local addresses + addr := strings.ToLower(endpoint.Address) + return addr == "localhost" || addr == "127.0.0.1" || addr == "::1" || addr == "host.docker.internal" +} diff --git a/cmd/modern/root/open/vscode_platform.go b/cmd/modern/root/open/vscode_platform.go new file mode 100644 index 00000000..522803b7 --- /dev/null +++ b/cmd/modern/root/open/vscode_platform.go @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/config" + "github.com/microsoft/go-sqlcmd/internal/localizer" +) + +// Type VSCode is used to implement the "open vscode" which launches Visual +// Studio Code and establishes a connection to the SQL Server for the current +// context +type VSCode struct { + cmdparser.Cmd + installExtension bool +} + +func (c *VSCode) displayPreLaunchInfo() { + output := c.Output() + + output.Info(localizer.Sprintf("Opening VS Code...")) + output.Info(localizer.Sprintf("Use the '%s' connection profile to connect", config.CurrentContextName())) +} diff --git a/cmd/modern/root/open/vscode_test.go b/cmd/modern/root/open/vscode_test.go new file mode 100644 index 00000000..4eff36b9 --- /dev/null +++ b/cmd/modern/root/open/vscode_test.go @@ -0,0 +1,438 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/microsoft/go-sqlcmd/cmd/modern/sqlconfig" + "github.com/microsoft/go-sqlcmd/internal/cmdparser" + "github.com/microsoft/go-sqlcmd/internal/config" + "github.com/microsoft/go-sqlcmd/internal/tools" +) + +// TestVSCode runs a sanity test of `sqlcmd open vscode` +func TestVSCode(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue") + } + + tool := tools.NewTool("vscode") + if !tool.IsInstalled() { + t.Skip("VS Code is not installed") + } + + cmdparser.TestSetup(t) + config.AddEndpoint(sqlconfig.Endpoint{ + AssetDetails: nil, + EndpointDetails: sqlconfig.EndpointDetails{ + Address: "localhost", + Port: 1433, + }, + Name: "endpoint", + }) + config.AddContext(sqlconfig.Context{ + ContextDetails: sqlconfig.ContextDetails{ + Endpoint: "endpoint", + User: nil, + }, + Name: "context", + }) + config.SetCurrentContextName("context") + + cmdparser.TestCmd[*VSCode]() +} + +// TestVSCodeCreateProfile tests that createProfile generates correct profile structure +func TestVSCodeCreateProfile(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + // Set up a context with user credentials + config.AddEndpoint(sqlconfig.Endpoint{ + AssetDetails: nil, + EndpointDetails: sqlconfig.EndpointDetails{ + Address: "localhost", + Port: 1433, + }, + Name: "test-endpoint", + }) + + config.AddUser(sqlconfig.User{ + AuthenticationType: "basic", + BasicAuth: &sqlconfig.BasicAuthDetails{ + Username: "sa", + PasswordEncryption: "", + Password: "testpassword", + }, + Name: "test-user", + }) + + config.AddContext(sqlconfig.Context{ + ContextDetails: sqlconfig.ContextDetails{ + Endpoint: "test-endpoint", + User: strPtr("test-user"), + }, + Name: "my-database", + }) + config.SetCurrentContextName("my-database") + + // Create a VSCode command instance and test profile creation + vscode := &VSCode{} + endpoint, user := config.CurrentContext() + + profile := vscode.createProfile(endpoint, user, true) // true for local connection + + // Verify profile structure + if profile["server"] != "localhost,1433" { + t.Errorf("Expected server 'localhost,1433', got '%v'", profile["server"]) + } + + if profile["profileName"] != "my-database" { + t.Errorf("Expected profileName 'my-database', got '%v'", profile["profileName"]) + } + + if profile["authenticationType"] != "SqlLogin" { + t.Errorf("Expected authenticationType 'SqlLogin', got '%v'", profile["authenticationType"]) + } + + if profile["user"] != "sa" { + t.Errorf("Expected user 'sa', got '%v'", profile["user"]) + } + + if profile["encrypt"] != "Optional" { + t.Errorf("Expected encrypt 'Optional', got '%v'", profile["encrypt"]) + } + + if profile["trustServerCertificate"] != true { + t.Errorf("Expected trustServerCertificate true, got '%v'", profile["trustServerCertificate"]) + } + + if profile["savePassword"] != true { + t.Errorf("Expected savePassword true, got '%v'", profile["savePassword"]) + } +} + +// TestVSCodeUpdateOrAddProfile tests profile update and add logic +func TestVSCodeUpdateOrAddProfile(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + vscode := &VSCode{} + + // Test adding a new profile to empty list + connections := []interface{}{} + newProfile := map[string]interface{}{ + "profileName": "test-profile", + "server": "localhost,1433", + } + + result := vscode.updateOrAddProfile(connections, newProfile) + if len(result) != 1 { + t.Errorf("Expected 1 connection, got %d", len(result)) + } + + // Test adding a second profile with different name + secondProfile := map[string]interface{}{ + "profileName": "another-profile", + "server": "server2,1434", + } + + result = vscode.updateOrAddProfile(result, secondProfile) + if len(result) != 2 { + t.Errorf("Expected 2 connections, got %d", len(result)) + } + + // Test updating existing profile (same name) + updatedProfile := map[string]interface{}{ + "profileName": "test-profile", + "server": "localhost,2000", + "user": "newuser", + } + + result = vscode.updateOrAddProfile(result, updatedProfile) + if len(result) != 2 { + t.Errorf("Expected 2 connections after update, got %d", len(result)) + } + + // Verify the profile was updated, not duplicated + found := false + for _, conn := range result { + if connMap, ok := conn.(map[string]interface{}); ok { + if connMap["profileName"] == "test-profile" { + found = true + if connMap["server"] != "localhost,2000" { + t.Errorf("Expected updated server 'localhost,2000', got '%v'", connMap["server"]) + } + if connMap["user"] != "newuser" { + t.Errorf("Expected updated user 'newuser', got '%v'", connMap["user"]) + } + } + } + } + if !found { + t.Error("Updated profile not found in connections") + } +} + +func TestVSCodeReadWriteSettings(t *testing.T) { + // Create a temporary directory for test settings + tempDir := t.TempDir() + settingsPath := filepath.Join(tempDir, "settings.json") + + // Test reading non-existent file (should not exist yet) + _, err := os.ReadFile(settingsPath) + if !os.IsNotExist(err) { + t.Error("Expected file to not exist") + } + + // Write some settings using direct JSON + settings := map[string]interface{}{ + "mssql.connections": []interface{}{ + map[string]interface{}{ + "profileName": "test", + "server": "localhost,1433", + }, + }, + "other.setting": "value", + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatalf("Failed to marshal settings: %v", err) + } + + if err := os.WriteFile(settingsPath, data, 0644); err != nil { + t.Fatalf("Failed to write settings: %v", err) + } + + // Verify file was created + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Error("Settings file was not created") + } + + // Read settings back + readData, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("Failed to read settings: %v", err) + } + + var readSettings map[string]interface{} + if err := json.Unmarshal(readData, &readSettings); err != nil { + t.Fatalf("Failed to unmarshal settings: %v", err) + } + + if readSettings["other.setting"] != "value" { + t.Errorf("Expected 'other.setting' to be 'value', got '%v'", readSettings["other.setting"]) + } + + connections, ok := readSettings["mssql.connections"].([]interface{}) + if !ok || len(connections) != 1 { + t.Error("Expected 1 mssql connection in read settings") + } +} + +// TestVSCodeGetConnectionsArray tests extracting connections array from settings +func TestVSCodeGetConnectionsArray(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + vscode := &VSCode{} + + // Test with no connections key + settings := map[string]interface{}{} + connections := vscode.getConnectionsArray(settings) + if len(connections) != 0 { + t.Errorf("Expected empty array, got %d items", len(connections)) + } + + // Test with connections array + settings["mssql.connections"] = []interface{}{ + map[string]interface{}{"profileName": "test1"}, + map[string]interface{}{"profileName": "test2"}, + } + connections = vscode.getConnectionsArray(settings) + if len(connections) != 2 { + t.Errorf("Expected 2 connections, got %d", len(connections)) + } + + // Test with wrong type (should return empty array) + settings["mssql.connections"] = "not an array" + connections = vscode.getConnectionsArray(settings) + if len(connections) != 0 { + t.Errorf("Expected empty array for invalid type, got %d items", len(connections)) + } +} + +// TestVSCodeGetSettingsPath tests that settings path is correctly determined +func TestVSCodeGetSettingsPath(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + vscode := &VSCode{} + path := vscode.getVSCodeSettingsPath() + + // Verify path ends with settings.json + if filepath.Base(path) != "settings.json" { + t.Errorf("Expected path to end with 'settings.json', got '%s'", filepath.Base(path)) + } + + // Verify path contains expected directory components + switch runtime.GOOS { + case "windows": + if !strings.Contains(path, "Code") { + t.Errorf("Expected path to contain 'Code' on Windows, got '%s'", path) + } + case "darwin": + if !strings.Contains(path, "Application Support") { + t.Errorf("Expected path to contain 'Application Support' on macOS, got '%s'", path) + } + } +} + +// TestVSCodeProfileWithoutUser tests profile creation when no user is configured +func TestVSCodeProfileWithoutUser(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + config.AddEndpoint(sqlconfig.Endpoint{ + AssetDetails: nil, + EndpointDetails: sqlconfig.EndpointDetails{ + Address: "myserver", + Port: 1433, + }, + Name: "no-user-endpoint", + }) + + config.AddContext(sqlconfig.Context{ + ContextDetails: sqlconfig.ContextDetails{ + Endpoint: "no-user-endpoint", + User: nil, + }, + Name: "no-user-context", + }) + config.SetCurrentContextName("no-user-context") + + vscode := &VSCode{} + endpoint, user := config.CurrentContext() + + profile := vscode.createProfile(endpoint, user, false) // false for non-local connection + + // Verify profile doesn't have user field when no user is configured + if _, hasUser := profile["user"]; hasUser { + t.Error("Expected profile to not have 'user' field when no user configured") + } + + // Verify other fields are still set correctly + if profile["profileName"] != "no-user-context" { + t.Errorf("Expected profileName 'no-user-context', got '%v'", profile["profileName"]) + } + + // Verify secure TLS settings for non-local connections + if profile["encrypt"] != "Mandatory" { + t.Errorf("Expected encrypt 'Mandatory' for non-local connection, got '%v'", profile["encrypt"]) + } + + if profile["trustServerCertificate"] != false { + t.Errorf("Expected trustServerCertificate false for non-local connection, got '%v'", profile["trustServerCertificate"]) + } +} + +func TestVSCodeSettingsPreservesOtherKeys(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("Skipping on Linux due to ADS tool initialization issue in tools factory") + } + + cmdparser.TestSetup(t) + + vscode := &VSCode{} + tempDir := t.TempDir() + settingsPath := filepath.Join(tempDir, "settings.json") + + // Write initial settings with various keys + initialSettings := map[string]interface{}{ + "editor.fontSize": 14, + "workbench.theme": "Dark+", + "mssql.connections": []interface{}{}, + } + + data, err := json.MarshalIndent(initialSettings, "", " ") + if err != nil { + t.Fatalf("Failed to marshal initial settings: %v", err) + } + if err := os.WriteFile(settingsPath, data, 0644); err != nil { + t.Fatalf("Failed to write settings: %v", err) + } + + // Read settings back using direct JSON (simulating what readSettings does) + readData, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("Failed to read settings: %v", err) + } + var settings map[string]interface{} + if err := json.Unmarshal(readData, &settings); err != nil { + t.Fatalf("Failed to unmarshal settings: %v", err) + } + + // Get connections and add a new profile + connections := vscode.getConnectionsArray(settings) + newProfile := map[string]interface{}{ + "profileName": "new-profile", + "server": "localhost,1433", + } + connections = vscode.updateOrAddProfile(connections, newProfile) + settings["mssql.connections"] = connections + + // Write back using direct JSON (simulating what writeSettings does) + writeData, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatalf("Failed to marshal settings: %v", err) + } + if err := os.WriteFile(settingsPath, writeData, 0644); err != nil { + t.Fatalf("Failed to write settings: %v", err) + } + + // Read back and verify other keys are preserved + finalData, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("Failed to read final settings: %v", err) + } + var finalSettings map[string]interface{} + if err := json.Unmarshal(finalData, &finalSettings); err != nil { + t.Fatalf("Failed to unmarshal final settings: %v", err) + } + + if finalSettings["editor.fontSize"].(float64) != 14 { + t.Errorf("Expected editor.fontSize to be preserved as 14, got %v", finalSettings["editor.fontSize"]) + } + + if finalSettings["workbench.theme"] != "Dark+" { + t.Errorf("Expected workbench.theme to be preserved as 'Dark+', got %v", finalSettings["workbench.theme"]) + } +} + +// Helper to create string pointer +func strPtr(s string) *string { + return &s +} diff --git a/internal/pal/clipboard.go b/internal/pal/clipboard.go new file mode 100644 index 00000000..d3e78e0b --- /dev/null +++ b/internal/pal/clipboard.go @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package pal + +// CopyToClipboard copies the given text to the system clipboard. +// Returns an error if the clipboard operation fails. +func CopyToClipboard(text string) error { + return copyToClipboard(text) +} diff --git a/internal/pal/clipboard_darwin.go b/internal/pal/clipboard_darwin.go new file mode 100644 index 00000000..d6012f22 --- /dev/null +++ b/internal/pal/clipboard_darwin.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package pal + +import ( + "os/exec" + "strings" +) + +func copyToClipboard(text string) error { + cmd := exec.Command("pbcopy") + cmd.Stdin = strings.NewReader(text) + return cmd.Run() +} diff --git a/internal/pal/clipboard_linux.go b/internal/pal/clipboard_linux.go new file mode 100644 index 00000000..8d5d4384 --- /dev/null +++ b/internal/pal/clipboard_linux.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package pal + +import ( + "fmt" + "os/exec" + "strings" +) + +func copyToClipboard(text string) error { + // Try xclip first, then xsel, then wl-copy as fallbacks. + // These are common clipboard utilities on Linux. + + var attempts []string + + // Helper to try a single command and record any errors. + tryCmd := func(name string, args ...string) bool { + if _, err := exec.LookPath(name); err != nil { + attempts = append(attempts, fmt.Sprintf("%s not found", name)) + return false + } + + cmd := exec.Command(name, args...) + cmd.Stdin = strings.NewReader(text) + if err := cmd.Run(); err != nil { + attempts = append(attempts, fmt.Sprintf("%s failed: %v", name, err)) + return false + } + + return true + } + + // Try xclip + if tryCmd("xclip", "-selection", "clipboard") { + return nil + } + + // Try xsel as fallback + if tryCmd("xsel", "--clipboard", "--input") { + return nil + } + + // Try wl-copy for Wayland + if tryCmd("wl-copy") { + return nil + } + + // All attempts failed - return combined error message + return fmt.Errorf("failed to copy to clipboard; tried xclip, xsel, wl-copy: %s", strings.Join(attempts, "; ")) +} diff --git a/internal/pal/clipboard_test.go b/internal/pal/clipboard_test.go new file mode 100644 index 00000000..96b73971 --- /dev/null +++ b/internal/pal/clipboard_test.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package pal + +import ( + "testing" +) + +func TestCopyToClipboard(t *testing.T) { + // This test just ensures the function doesn't panic + // Actual clipboard testing would require platform-specific validation + err := CopyToClipboard("test password") + if err != nil { + // Don't fail on Linux headless environments where clipboard tools may not exist + t.Logf("CopyToClipboard returned error (may be expected in headless environment): %v", err) + } +} diff --git a/internal/pal/clipboard_windows.go b/internal/pal/clipboard_windows.go new file mode 100644 index 00000000..1bdb4c01 --- /dev/null +++ b/internal/pal/clipboard_windows.go @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package pal + +import ( + "os/exec" + "strings" +) + +// copyToClipboard copies text to the Windows clipboard using the built-in clip.exe command. +// This is simpler and safer than using Win32 API calls directly. +func copyToClipboard(text string) error { + cmd := exec.Command("clip") + cmd.Stdin = strings.NewReader(text) + return cmd.Run() +} diff --git a/internal/tools/tool/interface.go b/internal/tools/tool/interface.go index a8910175..57b29104 100644 --- a/internal/tools/tool/interface.go +++ b/internal/tools/tool/interface.go @@ -7,6 +7,7 @@ type Tool interface { Init() Name() (name string) Run(args []string) (exitCode int, err error) + RunWithOutput(args []string) (output string, exitCode int, err error) IsInstalled() bool HowToInstall() string } diff --git a/internal/tools/tool/ssms.go b/internal/tools/tool/ssms.go new file mode 100644 index 00000000..df53afe1 --- /dev/null +++ b/internal/tools/tool/ssms.go @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import ( + "github.com/microsoft/go-sqlcmd/internal/io/file" + "github.com/microsoft/go-sqlcmd/internal/test" +) + +type SSMS struct { + tool +} + +func (t *SSMS) Init() { + t.SetToolDescription(Description{ + Name: "ssms", + Purpose: "SQL Server Management Studio (SSMS) is an integrated environment for managing SQL Server infrastructure.", + InstallText: t.installText()}) + + for _, location := range t.searchLocations() { + if file.Exists(location) { + t.SetExePathAndName(location) + break + } + } +} + +func (t *SSMS) Run(args []string) (int, error) { + if !test.IsRunningInTestExecutor() { + return t.tool.Run(args) + } + return 0, nil +} diff --git a/internal/tools/tool/ssms_test.go b/internal/tools/tool/ssms_test.go new file mode 100644 index 00000000..a60343aa --- /dev/null +++ b/internal/tools/tool/ssms_test.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import "testing" + +func TestSSMS(t *testing.T) { + tool := SSMS{} + tool.Init() + + if tool.Name() != "ssms" { + t.Errorf("Expected name to be 'ssms', got %s", tool.Name()) + } +} diff --git a/internal/tools/tool/ssms_unix.go b/internal/tools/tool/ssms_unix.go new file mode 100644 index 00000000..e8fcb2dc --- /dev/null +++ b/internal/tools/tool/ssms_unix.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//go:build !windows + +package tool + +func (t *SSMS) searchLocations() []string { + return []string{} +} + +func (t *SSMS) installText() string { + return `SQL Server Management Studio (SSMS) is only available on Windows. + +Please use: +- Visual Studio Code with the MSSQL extension: sqlcmd open vscode +- Azure Data Studio: sqlcmd open ads` +} diff --git a/internal/tools/tool/ssms_windows.go b/internal/tools/tool/ssms_windows.go new file mode 100644 index 00000000..6b43ecfb --- /dev/null +++ b/internal/tools/tool/ssms_windows.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import ( + "os" + "path/filepath" +) + +func (t *SSMS) searchLocations() []string { + programFiles := os.Getenv("ProgramFiles") + programFilesX86 := os.Getenv("ProgramFiles(x86)") + + return []string{ + filepath.Join(programFiles, "Microsoft SQL Server Management Studio 20\\Common7\\IDE\\Ssms.exe"), + filepath.Join(programFilesX86, "Microsoft SQL Server Management Studio 20\\Common7\\IDE\\Ssms.exe"), + filepath.Join(programFiles, "Microsoft SQL Server Management Studio 19\\Common7\\IDE\\Ssms.exe"), + filepath.Join(programFilesX86, "Microsoft SQL Server Management Studio 19\\Common7\\IDE\\Ssms.exe"), + filepath.Join(programFiles, "Microsoft SQL Server Management Studio 18\\Common7\\IDE\\Ssms.exe"), + filepath.Join(programFilesX86, "Microsoft SQL Server Management Studio 18\\Common7\\IDE\\Ssms.exe"), + } +} + +func (t *SSMS) installText() string { + return `Install using a package manager: + + winget install Microsoft.SQLServerManagementStudio + # or + choco install sql-server-management-studio + +Or download the latest version from: + + https://aka.ms/ssmsfullsetup + +Note: SSMS is only available on Windows.` +} diff --git a/internal/tools/tool/tool.go b/internal/tools/tool/tool.go index ee4d5db4..a8dab7bb 100644 --- a/internal/tools/tool/tool.go +++ b/internal/tools/tool/tool.go @@ -32,7 +32,8 @@ func (t *tool) IsInstalled() bool { } t.installed = new(bool) - if file.Exists(t.exeName) { + // Handle case where tool wasn't found during Init (exeName is empty) + if t.exeName != "" && file.Exists(t.exeName) { *t.installed = true } else { *t.installed = false @@ -54,11 +55,32 @@ func (t *tool) HowToInstall() string { func (t *tool) Run(args []string) (int, error) { if t.installed == nil { - panic("Call IsInstalled before Run") + return 1, fmt.Errorf("internal error: Call IsInstalled before Run") } cmd := t.generateCommandLine(args) err := cmd.Run() - return cmd.ProcessState.ExitCode(), err + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + return exitCode, err +} + +func (t *tool) RunWithOutput(args []string) (string, int, error) { + if t.installed == nil { + return "", 1, fmt.Errorf("internal error: Call IsInstalled before RunWithOutput") + } + + cmd := t.generateCommandLine(args) + output, err := cmd.Output() + + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + + return string(output), exitCode, err } diff --git a/internal/tools/tool/tool_linux.go b/internal/tools/tool/tool_linux.go index 4344e37b..a5658959 100644 --- a/internal/tools/tool/tool_linux.go +++ b/internal/tools/tool/tool_linux.go @@ -4,9 +4,17 @@ package tool import ( + "bytes" "os/exec" ) func (t *tool) generateCommandLine(args []string) *exec.Cmd { - panic("Not yet implemented") + var stdout, stderr bytes.Buffer + cmd := &exec.Cmd{ + Path: t.exeName, + Args: append([]string{t.exeName}, args...), + Stdout: &stdout, + Stderr: &stderr, + } + return cmd } diff --git a/internal/tools/tool/tool_test.go b/internal/tools/tool/tool_test.go index 659b8fa1..d869f931 100644 --- a/internal/tools/tool/tool_test.go +++ b/internal/tools/tool/tool_test.go @@ -4,11 +4,12 @@ package tool import ( - "github.com/stretchr/testify/assert" "os" "runtime" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestInit(t *testing.T) { @@ -94,12 +95,9 @@ func TestHowToInstall(t *testing.T) { func TestRunWhenNotInstalled(t *testing.T) { tool := &tool{} - assert.Panics(t, func() { - _, err := tool.Run([]string{}) - if err != nil { - return - } - }) + _, err := tool.Run([]string{}) + assert.Error(t, err, "Run should return error when IsInstalled was not called first") + assert.Contains(t, err.Error(), "Call IsInstalled before Run") } func TestRun(t *testing.T) { diff --git a/internal/tools/tool/vscode.go b/internal/tools/tool/vscode.go new file mode 100644 index 00000000..17258856 --- /dev/null +++ b/internal/tools/tool/vscode.go @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import ( + "github.com/microsoft/go-sqlcmd/internal/io/file" + "github.com/microsoft/go-sqlcmd/internal/test" +) + +type VSCode struct { + tool +} + +func (t *VSCode) Init() { + t.SetToolDescription(Description{ + Name: "vscode", + Purpose: "Visual Studio Code is a code editor with support for database management through the MSSQL extension.", + InstallText: t.installText()}) + + for _, location := range t.searchLocations() { + if file.Exists(location) { + t.SetExePathAndName(location) + break + } + } +} + +func (t *VSCode) Run(args []string) (int, error) { + if !test.IsRunningInTestExecutor() { + return t.tool.Run(args) + } + return 0, nil +} + +func (t *VSCode) RunWithOutput(args []string) (string, int, error) { + if !test.IsRunningInTestExecutor() { + return t.tool.RunWithOutput(args) + } + // In test mode, simulate extension list output + for _, arg := range args { + if arg == "--list-extensions" { + return "ms-mssql.mssql\n", 0, nil + } + } + return "", 0, nil +} diff --git a/internal/tools/tool/vscode_darwin.go b/internal/tools/tool/vscode_darwin.go new file mode 100644 index 00000000..eeba8097 --- /dev/null +++ b/internal/tools/tool/vscode_darwin.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import ( + "os" + "path/filepath" +) + +func (t *VSCode) searchLocations() []string { + userProfile := os.Getenv("HOME") + + return []string{ + filepath.Join("/", "Applications", "Visual Studio Code - Insiders.app"), + filepath.Join(userProfile, "Downloads", "Visual Studio Code - Insiders.app"), + filepath.Join("/", "Applications", "Visual Studio Code.app"), + filepath.Join(userProfile, "Downloads", "Visual Studio Code.app"), + } +} + +func (t *VSCode) installText() string { + return `Install using Homebrew: + + brew install --cask visual-studio-code + +Or download the latest version from: + + https://code.visualstudio.com/download + +After installation, install the MSSQL extension: + + sqlcmd open vscode --install-extension + +Or install it directly in VS Code via Extensions (Cmd+Shift+X) and search for "SQL Server (mssql)"` +} diff --git a/internal/tools/tool/vscode_linux.go b/internal/tools/tool/vscode_linux.go new file mode 100644 index 00000000..bccbeefa --- /dev/null +++ b/internal/tools/tool/vscode_linux.go @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import ( + "os" + "path/filepath" +) + +func (t *VSCode) searchLocations() []string { + userProfile := os.Getenv("HOME") + + return []string{ + filepath.Join("/", "usr", "bin", "code-insiders"), + filepath.Join("/", "usr", "bin", "code"), + filepath.Join(userProfile, ".local", "bin", "code-insiders"), + filepath.Join(userProfile, ".local", "bin", "code"), + filepath.Join("/", "snap", "bin", "code"), + } +} + +func (t *VSCode) installText() string { + return `Install using a package manager: + + # Debian/Ubuntu + sudo apt install code + + # Fedora/RHEL + sudo dnf install code + + # Snap + sudo snap install code --classic + +Or download the latest version from: + + https://code.visualstudio.com/download + +After installation, install the MSSQL extension: + + sqlcmd open vscode --install-extension + +Or install it directly in VS Code via Extensions (Ctrl+Shift+X) and search for "SQL Server (mssql)"` +} diff --git a/internal/tools/tool/vscode_test.go b/internal/tools/tool/vscode_test.go new file mode 100644 index 00000000..2c35beeb --- /dev/null +++ b/internal/tools/tool/vscode_test.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import "testing" + +func TestVSCode(t *testing.T) { + tool := VSCode{} + tool.Init() + + if tool.Name() != "vscode" { + t.Errorf("Expected name to be 'vscode', got %s", tool.Name()) + } +} diff --git a/internal/tools/tool/vscode_windows.go b/internal/tools/tool/vscode_windows.go new file mode 100644 index 00000000..106a8b8c --- /dev/null +++ b/internal/tools/tool/vscode_windows.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package tool + +import ( + "os" + "path/filepath" +) + +// Search in this order +// +// User Insiders Install +// System Insiders Install +// User non-Insiders install +// System non-Insiders install +func (t *VSCode) searchLocations() []string { + userProfile := os.Getenv("USERPROFILE") + programFiles := os.Getenv("ProgramFiles") + + return []string{ + filepath.Join(userProfile, "AppData\\Local\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe"), + filepath.Join(programFiles, "Microsoft VS Code Insiders\\Code - Insiders.exe"), + filepath.Join(userProfile, "AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe"), + filepath.Join(programFiles, "Microsoft VS Code\\Code.exe"), + } +} + +func (t *VSCode) installText() string { + return `Install using a package manager: + + winget install Microsoft.VisualStudioCode + # or + choco install vscode + +Or download the latest version from: + + https://code.visualstudio.com/download + +After installation, install the MSSQL extension: + + sqlcmd open vscode --install-extension + +Or install it directly in VS Code via Extensions (Ctrl+Shift+X) and search for "SQL Server (mssql)"` +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index d60d7fee..cb4431e7 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -9,4 +9,6 @@ import ( var tools = []tool.Tool{ &tool.AzureDataStudio{}, + &tool.VSCode{}, + &tool.SSMS{}, } From 2f099deff66906ff6b1e7585529005264aa4005b Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 17 Apr 2026 11:58:41 -0500 Subject: [PATCH 2/4] fix: handle JSONC in VS Code settings.json and use atomic write VS Code settings.json is JSONC (JSON with Comments) which allows line comments (//), block comments (/* */), and trailing commas. The previous implementation used plain json.Unmarshal which fails on any JSONC input. Add stripJSONC() to remove comments and trailing commas before parsing, preserving comment-like content inside string literals. Also make writeSettings() atomic: write to a temp file then rename, with fallback to direct write if rename fails. This prevents settings.json corruption if the process is interrupted mid-write. --- cmd/modern/root/open/jsonc.go | 76 ++++++++++++++++ cmd/modern/root/open/jsonc_test.go | 139 +++++++++++++++++++++++++++++ cmd/modern/root/open/vscode.go | 27 +++++- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 cmd/modern/root/open/jsonc.go create mode 100644 cmd/modern/root/open/jsonc_test.go diff --git a/cmd/modern/root/open/jsonc.go b/cmd/modern/root/open/jsonc.go new file mode 100644 index 00000000..30f2a00e --- /dev/null +++ b/cmd/modern/root/open/jsonc.go @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +// stripJSONC removes comments (// and /* */) and trailing commas from JSONC +// data, producing valid JSON. String literals are preserved as-is. +func stripJSONC(data []byte) []byte { + var result []byte + i := 0 + n := len(data) + + for i < n { + // String literal: copy verbatim, respecting escape sequences + if data[i] == '"' { + result = append(result, data[i]) + i++ + for i < n && data[i] != '"' { + if data[i] == '\\' && i+1 < n { + result = append(result, data[i], data[i+1]) + i += 2 + continue + } + result = append(result, data[i]) + i++ + } + if i < n { + result = append(result, data[i]) // closing " + i++ + } + continue + } + + // Line comment: skip to end of line + if i+1 < n && data[i] == '/' && data[i+1] == '/' { + i += 2 + for i < n && data[i] != '\n' { + i++ + } + continue + } + + // Block comment: skip to closing */ + if i+1 < n && data[i] == '/' && data[i+1] == '*' { + i += 2 + for i+1 < n { + if data[i] == '*' && data[i+1] == '/' { + i += 2 + break + } + i++ + } + continue + } + + result = append(result, data[i]) + i++ + } + + // Second pass: remove trailing commas before ] or } + cleaned := make([]byte, 0, len(result)) + for i := 0; i < len(result); i++ { + if result[i] == ',' { + j := i + 1 + for j < len(result) && (result[j] == ' ' || result[j] == '\t' || result[j] == '\n' || result[j] == '\r') { + j++ + } + if j < len(result) && (result[j] == ']' || result[j] == '}') { + continue // skip trailing comma + } + } + cleaned = append(cleaned, result[i]) + } + + return cleaned +} diff --git a/cmd/modern/root/open/jsonc_test.go b/cmd/modern/root/open/jsonc_test.go new file mode 100644 index 00000000..41e7d2f8 --- /dev/null +++ b/cmd/modern/root/open/jsonc_test.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package open + +import ( + "encoding/json" + "testing" +) + +func TestStripJSONC_LineComments(t *testing.T) { + input := []byte(`{ + // This is a comment + "key": "value" // inline comment +}`) + result := stripJSONC(input) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result) + } + if m["key"] != "value" { + t.Errorf("Expected 'value', got %v", m["key"]) + } +} + +func TestStripJSONC_BlockComments(t *testing.T) { + input := []byte(`{ + /* block comment */ + "key": "value", + /* + * multi-line + * block comment + */ + "other": 42 +}`) + result := stripJSONC(input) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result) + } + if m["key"] != "value" { + t.Errorf("Expected 'value', got %v", m["key"]) + } + if m["other"] != float64(42) { + t.Errorf("Expected 42, got %v", m["other"]) + } +} + +func TestStripJSONC_TrailingCommas(t *testing.T) { + input := []byte(`{ + "a": 1, + "b": [1, 2, 3,], + "c": {"x": 1,}, +}`) + result := stripJSONC(input) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result) + } + if m["a"] != float64(1) { + t.Errorf("Expected 1, got %v", m["a"]) + } +} + +func TestStripJSONC_CommentsInStringsPreserved(t *testing.T) { + input := []byte(`{ + "url": "http://example.com", + "note": "has // slashes and /* stars */", + "path": "C:\\Users\\test" +}`) + result := stripJSONC(input) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("Failed to parse stripped JSONC: %v\nResult: %s", err, result) + } + if m["url"] != "http://example.com" { + t.Errorf("URL mangled: %v", m["url"]) + } + if m["note"] != "has // slashes and /* stars */" { + t.Errorf("String with comment-like content mangled: %v", m["note"]) + } + if m["path"] != `C:\Users\test` { + t.Errorf("Escaped path mangled: %v", m["path"]) + } +} + +func TestStripJSONC_RealWorldVSCodeSettings(t *testing.T) { + // Realistic VS Code settings.json with JSONC features + input := []byte(`{ + // Editor settings + "editor.fontSize": 14, + "editor.tabSize": 2, + + /* Database connections */ + "mssql.connections": [ + { + "server": "localhost,1433", + "profileName": "my-db", + "encrypt": "Optional", + "trustServerCertificate": true, + }, + ], + + // Terminal settings + "terminal.integrated.fontSize": 12, +}`) + result := stripJSONC(input) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("Failed to parse real-world JSONC: %v\nResult: %s", err, result) + } + if m["editor.fontSize"] != float64(14) { + t.Errorf("Expected fontSize 14, got %v", m["editor.fontSize"]) + } + conns, ok := m["mssql.connections"].([]interface{}) + if !ok || len(conns) != 1 { + t.Fatalf("Expected 1 connection, got %v", m["mssql.connections"]) + } +} + +func TestStripJSONC_EmptyInput(t *testing.T) { + result := stripJSONC([]byte{}) + if len(result) != 0 { + t.Errorf("Expected empty result, got %s", result) + } +} + +func TestStripJSONC_PureJSON(t *testing.T) { + // No comments, no trailing commas - should pass through cleanly + input := []byte(`{"key": "value", "num": 42}`) + result := stripJSONC(input) + var m map[string]interface{} + if err := json.Unmarshal(result, &m); err != nil { + t.Fatalf("Failed to parse pure JSON: %v", err) + } + if m["key"] != "value" || m["num"] != float64(42) { + t.Errorf("Values changed: %v", m) + } +} diff --git a/cmd/modern/root/open/vscode.go b/cmd/modern/root/open/vscode.go index 9a19adc8..c59c8f4e 100644 --- a/cmd/modern/root/open/vscode.go +++ b/cmd/modern/root/open/vscode.go @@ -163,7 +163,10 @@ func (c *VSCode) readSettings(path string) map[string]interface{} { } if len(data) > 0 { - if err := json.Unmarshal(data, &settings); err != nil { + // VS Code settings.json is JSONC (allows comments and trailing commas). + // Strip those before parsing so standard json.Unmarshal succeeds. + clean := stripJSONC(data) + if err := json.Unmarshal(clean, &settings); err != nil { output := c.Output() output.FatalWithHintExamples([][]string{ {localizer.Sprintf("Error"), err.Error()}, @@ -184,6 +187,28 @@ func (c *VSCode) writeSettings(path string, settings map[string]interface{}) { }, localizer.Sprintf("Failed to encode VS Code settings")) } + // Append a final newline for consistency with VS Code's own formatting + data = append(data, '\n') + + // Atomic write: write to a temp file in the same directory, then rename. + // If rename fails (e.g. another process holds the file), fall back to + // a direct write so the command still succeeds. + dir := filepath.Dir(path) + tmp, tmpErr := os.CreateTemp(dir, ".settings-*.tmp") + if tmpErr == nil { + tmpPath := tmp.Name() + _, writeErr := tmp.Write(data) + closeErr := tmp.Close() + if writeErr != nil || closeErr != nil { + os.Remove(tmpPath) + } else if renameErr := os.Rename(tmpPath, path); renameErr != nil { + os.Remove(tmpPath) + } else { + return // atomic write succeeded + } + } + + // Fallback: direct write if err := os.WriteFile(path, data, 0600); err != nil { output.FatalWithHintExamples([][]string{ {localizer.Sprintf("Error"), err.Error()}, From 2d6ab4f15c69800fca8c903e3d4dfa4708a78c5e Mon Sep 17 00:00:00 2001 From: David Levy Date: Fri, 17 Apr 2026 12:23:27 -0500 Subject: [PATCH 3/4] fix: prevent TestVSCode from writing to real settings.json TestVSCode runs the full command via TestCmd which calls createConnectionProfile -> getVSCodeSettingsPath, resolving to the real %APPDATA%\Code\User\settings.json. This silently mutates the developer's actual VS Code settings on every test run. Add testSettingsPathOverride hook so TestVSCode redirects writes to t.TempDir(). The hook is cleared in t.Cleanup so other tests like TestVSCodeGetSettingsPath still exercise the real path resolution. --- cmd/modern/root/open/vscode.go | 8 ++++++++ cmd/modern/root/open/vscode_test.go | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/cmd/modern/root/open/vscode.go b/cmd/modern/root/open/vscode.go index c59c8f4e..ec6066bf 100644 --- a/cmd/modern/root/open/vscode.go +++ b/cmd/modern/root/open/vscode.go @@ -20,6 +20,10 @@ import ( "github.com/microsoft/go-sqlcmd/internal/tools/tool" ) +// testSettingsPathOverride, when non-empty, overrides getVSCodeSettingsPath +// so tests never touch the real VS Code settings.json. +var testSettingsPathOverride string + // VSCode implements the `sqlcmd open vscode` command. It opens // Visual Studio Code and configures a connection profile for the // current context using the MSSQL extension. @@ -281,6 +285,10 @@ func (c *VSCode) updateOrAddProfile(connections []interface{}, newProfile map[st } func (c *VSCode) getVSCodeSettingsPath() string { + if testSettingsPathOverride != "" { + return testSettingsPathOverride + } + var stableDir string var insidersDir string diff --git a/cmd/modern/root/open/vscode_test.go b/cmd/modern/root/open/vscode_test.go index 4eff36b9..b44db052 100644 --- a/cmd/modern/root/open/vscode_test.go +++ b/cmd/modern/root/open/vscode_test.go @@ -28,6 +28,11 @@ func TestVSCode(t *testing.T) { t.Skip("VS Code is not installed") } + // Redirect settings writes to a temp directory so the test never + // touches the real VS Code settings.json. + testSettingsPathOverride = filepath.Join(t.TempDir(), "settings.json") + t.Cleanup(func() { testSettingsPathOverride = "" }) + cmdparser.TestSetup(t) config.AddEndpoint(sqlconfig.Endpoint{ AssetDetails: nil, From b37c2c3dc39765ed118c7f444ea5e02db4728ac3 Mon Sep 17 00:00:00 2001 From: David Levy Date: Sat, 18 Apr 2026 10:55:37 -0500 Subject: [PATCH 4/4] fix: suppress errcheck lint for best-effort os.Remove on cleanup paths --- cmd/modern/root/open/vscode.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/modern/root/open/vscode.go b/cmd/modern/root/open/vscode.go index ec6066bf..0fa57d51 100644 --- a/cmd/modern/root/open/vscode.go +++ b/cmd/modern/root/open/vscode.go @@ -204,9 +204,9 @@ func (c *VSCode) writeSettings(path string, settings map[string]interface{}) { _, writeErr := tmp.Write(data) closeErr := tmp.Close() if writeErr != nil || closeErr != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) } else if renameErr := os.Rename(tmpPath, path); renameErr != nil { - os.Remove(tmpPath) + _ = os.Remove(tmpPath) } else { return // atomic write succeeded }