Skip to content

Commit aceaabb

Browse files
fix: clean up vscode connection profile when sqlcmd delete runs
Removes the mssql.connections entry (and any inline password persisted for container-backed endpoints) from both stable and insiders VS Code settings.json when the underlying sqlcmd context is deleted. Best-effort: missing files or parse errors never fail the delete. JSONC comments and unrelated keys are preserved via hujson Patch.
1 parent 067d42a commit aceaabb

15 files changed

Lines changed: 485 additions & 197 deletions

File tree

cmd/modern/root/open/vscode.go

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ func (c *VSCode) readSettings(path string) ([]byte, map[string]interface{}) {
199199
}, localizer.Sprintf("Failed to read VS Code settings"))
200200
}
201201

202-
settings, err := parseJSONCSettings(data)
202+
settings, err := parseJSONCSettings(append([]byte(nil), data...))
203203
if err != nil {
204204
c.Output().FatalWithHintExamples([][]string{
205205
{localizer.Sprintf("Error"), err.Error()},
@@ -344,8 +344,27 @@ func ensureRootGroup(existing interface{}) []interface{} {
344344
}
345345

346346
func (c *VSCode) getVSCodeSettingsPath(build string) string {
347+
path, err := vsCodeSettingsPath(build)
348+
if err != nil {
349+
hint := [][]string{
350+
{localizer.Sprintf("Set the HOME environment variable"), "export HOME=/your/home"},
351+
}
352+
if runtime.GOOS == "windows" {
353+
hint = [][]string{
354+
{localizer.Sprintf("Set the USERPROFILE environment variable"), `set USERPROFILE=C:\Users\you`},
355+
}
356+
}
357+
c.Output().FatalWithHintExamples(hint, localizer.Sprintf("Could not resolve home directory: %s", err.Error()))
358+
}
359+
return path
360+
}
361+
362+
// vsCodeSettingsPath resolves the settings.json path for the given VS Code
363+
// build without exiting on failure, so callers like the uninstall cleanup
364+
// path can degrade gracefully when the home directory is unavailable.
365+
func vsCodeSettingsPath(build string) (string, error) {
347366
if testSettingsPathOverride != "" {
348-
return testSettingsPathOverride
367+
return testSettingsPathOverride, nil
349368
}
350369

351370
appName := "Code"
@@ -354,20 +373,11 @@ func (c *VSCode) getVSCodeSettingsPath(build string) string {
354373
}
355374

356375
home, err := os.UserHomeDir()
357-
if err != nil || home == "" {
358-
hint := [][]string{
359-
{localizer.Sprintf("Set the HOME environment variable"), "export HOME=/your/home"},
360-
}
361-
if runtime.GOOS == "windows" {
362-
hint = [][]string{
363-
{localizer.Sprintf("Set the USERPROFILE environment variable"), `set USERPROFILE=C:\Users\you`},
364-
}
365-
}
366-
reason := localizer.Sprintf("empty home directory")
367-
if err != nil {
368-
reason = err.Error()
369-
}
370-
c.Output().FatalWithHintExamples(hint, localizer.Sprintf("Could not resolve home directory: %s", reason))
376+
if err != nil {
377+
return "", err
378+
}
379+
if home == "" {
380+
return "", fmt.Errorf("empty home directory")
371381
}
372382

373383
var configDir string
@@ -384,7 +394,7 @@ func (c *VSCode) getVSCodeSettingsPath(build string) string {
384394
configDir = linuxVSCodeConfigDir(home, appName, build, vsCodeExePath(build), os.Getenv("XDG_CONFIG_HOME"))
385395
}
386396

387-
return filepath.Join(configDir, "settings.json")
397+
return filepath.Join(configDir, "settings.json"), nil
388398
}
389399

390400
// linuxVSCodeConfigDir resolves the VS Code User config directory on Linux.
@@ -446,3 +456,80 @@ func isLocalEndpoint(endpoint sqlconfig.Endpoint) bool {
446456
asset := endpoint.AssetDetails
447457
return asset != nil && asset.ContainerDetails != nil
448458
}
459+
460+
// RemoveContextFromVSCodeSettings deletes any mssql connection profile named
461+
// contextName from each known VS Code build's settings.json. It is best
462+
// effort: missing files, unresolvable home dirs, and parse/write errors are
463+
// swallowed so `sqlcmd delete` never fails on cleanup. Returns the list of
464+
// settings paths that were actually modified, so callers can report what
465+
// they cleaned up.
466+
func RemoveContextFromVSCodeSettings(contextName string) []string {
467+
if contextName == "" {
468+
return nil
469+
}
470+
var cleaned []string
471+
seen := map[string]bool{}
472+
for _, build := range []string{"stable", "insiders"} {
473+
path, err := vsCodeSettingsPath(build)
474+
if err != nil || path == "" || seen[path] {
475+
continue
476+
}
477+
seen[path] = true
478+
removed, err := removeProfileFromVSCodeSettings(path, contextName)
479+
if err == nil && removed {
480+
cleaned = append(cleaned, path)
481+
}
482+
}
483+
return cleaned
484+
}
485+
486+
// removeProfileFromVSCodeSettings rewrites settings.json with any
487+
// mssql.connections entry whose profileName matches contextName stripped out.
488+
// Returns (true, nil) when the file was modified, (false, nil) when no
489+
// matching profile was present or the file does not exist.
490+
func removeProfileFromVSCodeSettings(settingsPath, contextName string) (bool, error) {
491+
data, err := os.ReadFile(settingsPath)
492+
if err != nil {
493+
if os.IsNotExist(err) {
494+
return false, nil
495+
}
496+
return false, err
497+
}
498+
499+
// parseJSONCSettings may mutate the input bytes via hujson.Standardize,
500+
// so peek on a copy and keep the pristine original for the JSONC-aware
501+
// rewrite below.
502+
settings, err := parseJSONCSettings(append([]byte(nil), data...))
503+
if err != nil {
504+
return false, err
505+
}
506+
507+
existing, ok := settings["mssql.connections"].([]interface{})
508+
if !ok || len(existing) == 0 {
509+
return false, nil
510+
}
511+
512+
filtered := make([]interface{}, 0, len(existing))
513+
for _, conn := range existing {
514+
if connMap, ok := conn.(map[string]interface{}); ok {
515+
if name, _ := connMap["profileName"].(string); name == contextName {
516+
continue
517+
}
518+
}
519+
filtered = append(filtered, conn)
520+
}
521+
if len(filtered) == len(existing) {
522+
return false, nil
523+
}
524+
525+
out, err := applyJSONCSettingsUpdates(data, map[string]interface{}{
526+
"mssql.connections": filtered,
527+
})
528+
if err != nil {
529+
return false, err
530+
}
531+
if err := os.WriteFile(settingsPath, out, 0600); err != nil {
532+
return false, err
533+
}
534+
return true, nil
535+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
package open
5+
6+
import (
7+
"encoding/json"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
)
13+
14+
func TestRemoveContextFromVSCodeSettings(t *testing.T) {
15+
tmp := t.TempDir()
16+
path := filepath.Join(tmp, "settings.json")
17+
testSettingsPathOverride = path
18+
t.Cleanup(func() { testSettingsPathOverride = "" })
19+
20+
initial := `{
21+
// user comment that must survive
22+
"editor.fontSize": 14,
23+
"mssql.connections": [
24+
{ "profileName": "keep-me", "server": "kept,1433" },
25+
{ "profileName": "doomed-ctx", "server": "localhost,1433", "password": "shh" }
26+
]
27+
}
28+
`
29+
if err := os.WriteFile(path, []byte(initial), 0600); err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
cleaned := RemoveContextFromVSCodeSettings("doomed-ctx")
34+
if len(cleaned) != 1 || cleaned[0] != path {
35+
t.Fatalf("expected cleanup to report %s, got %v", path, cleaned)
36+
}
37+
38+
data, err := os.ReadFile(path)
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
43+
if !strings.Contains(string(data), "user comment that must survive") {
44+
t.Errorf("comment was stripped: %s", data)
45+
}
46+
47+
settings, err := parseJSONCSettings(data)
48+
if err != nil {
49+
t.Fatalf("settings no longer parse: %v\n%s", err, data)
50+
}
51+
52+
conns, _ := settings["mssql.connections"].([]interface{})
53+
if len(conns) != 1 {
54+
t.Fatalf("expected exactly 1 remaining connection, got %d: %s", len(conns), mustJSON(conns))
55+
}
56+
name, _ := conns[0].(map[string]interface{})["profileName"].(string)
57+
if name != "keep-me" {
58+
t.Errorf("wrong connection survived: %s", name)
59+
}
60+
61+
// Second call is a no-op once the entry is gone.
62+
if cleaned := RemoveContextFromVSCodeSettings("doomed-ctx"); len(cleaned) != 0 {
63+
t.Errorf("expected no cleanup on second call, got %v", cleaned)
64+
}
65+
}
66+
67+
func TestRemoveContextFromVSCodeSettings_MissingFile(t *testing.T) {
68+
testSettingsPathOverride = filepath.Join(t.TempDir(), "does-not-exist.json")
69+
t.Cleanup(func() { testSettingsPathOverride = "" })
70+
71+
if cleaned := RemoveContextFromVSCodeSettings("anything"); len(cleaned) != 0 {
72+
t.Errorf("expected no cleanup when file is missing, got %v", cleaned)
73+
}
74+
}
75+
76+
func TestRemoveContextFromVSCodeSettings_EmptyName(t *testing.T) {
77+
if cleaned := RemoveContextFromVSCodeSettings(""); cleaned != nil {
78+
t.Errorf("expected nil for empty context name, got %v", cleaned)
79+
}
80+
}
81+
82+
func mustJSON(v interface{}) string {
83+
b, _ := json.MarshalIndent(v, "", " ")
84+
return string(b)
85+
}

cmd/modern/root/uninstall.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"strings"
99

10+
"github.com/microsoft/go-sqlcmd/cmd/modern/root/open"
1011
"github.com/microsoft/go-sqlcmd/internal/cmdparser"
1112
"github.com/microsoft/go-sqlcmd/internal/config"
1213
"github.com/microsoft/go-sqlcmd/internal/container"
@@ -84,6 +85,7 @@ func (c *Uninstall) run() {
8485
}, localizer.Sprintf("No current context"))
8586
}
8687
if c.currentContextEndPointExists() {
88+
contextName := config.CurrentContextName()
8789
if config.CurrentContextEndpointHasContainer() {
8890
controller := container.NewController()
8991
id := config.ContainerId()
@@ -129,6 +131,10 @@ func (c *Uninstall) run() {
129131
config.RemoveCurrentContext()
130132
config.Save()
131133

134+
for _, path := range open.RemoveContextFromVSCodeSettings(contextName) {
135+
output.Info(localizer.Sprintf("Removed VS Code connection profile from %s", path))
136+
}
137+
132138
newContextName := config.CurrentContextName()
133139
if newContextName != "" {
134140
output.Info(localizer.Sprintf("Current context is now %s", newContextName))

0 commit comments

Comments
 (0)