Skip to content

Commit 4874a3c

Browse files
authored
Add checks for IDE command and settings for serverless mode (#4674)
## Changes Check if cursor/code is on the PATH before calling it, print instructions on how to install it. ## Why Usually people have IDE isntalled, but not all have the CLI integrations on the PATH (especially when they run our `ssh` command in a standalone terminal) ## Tests Manually + existing and new unit tests <!-- If your PR needs to be included in the release notes for next release, add a separate entry in NEXT_CHANGELOG.md as part of your PR. -->
1 parent c8f42ce commit 4874a3c

File tree

5 files changed

+212
-71
lines changed

5 files changed

+212
-71
lines changed

experimental/ssh/internal/client/client.go

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,6 @@ const (
4545
sshServerTaskKey = "start_ssh_server"
4646
serverlessEnvironmentKey = "ssh_tunnel_serverless"
4747
minEnvironmentVersion = 4
48-
49-
VSCodeOption = "vscode"
50-
VSCodeCommand = "code"
51-
CursorOption = "cursor"
52-
CursorCommand = "cursor"
5348
)
5449

5550
type ClientOptions struct {
@@ -117,8 +112,8 @@ func (o *ClientOptions) Validate() error {
117112
if o.ConnectionName != "" && !connectionNameRegex.MatchString(o.ConnectionName) {
118113
return fmt.Errorf("connection name %q must consist of letters, numbers, dashes, and underscores", o.ConnectionName)
119114
}
120-
if o.IDE != "" && o.IDE != VSCodeOption && o.IDE != CursorOption {
121-
return fmt.Errorf("invalid IDE value: %q, expected %q or %q", o.IDE, VSCodeOption, CursorOption)
115+
if o.IDE != "" && o.IDE != vscode.VSCodeOption && o.IDE != vscode.CursorOption {
116+
return fmt.Errorf("invalid IDE value: %q, expected %q or %q", o.IDE, vscode.VSCodeOption, vscode.CursorOption)
122117
}
123118
if o.EnvironmentVersion > 0 && o.EnvironmentVersion < minEnvironmentVersion {
124119
return fmt.Errorf("environment version must be >= %d, got %d", minEnvironmentVersion, o.EnvironmentVersion)
@@ -212,6 +207,32 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt
212207
return errors.New("either --cluster or --name must be provided")
213208
}
214209

210+
if opts.IDE != "" && !opts.ProxyMode {
211+
if err := vscode.CheckIDECommand(opts.IDE); err != nil {
212+
return err
213+
}
214+
}
215+
216+
// Check and update IDE settings for serverless mode, where we must set up
217+
// desired server ports (or socket connection mode) for the connection to go through
218+
// (as the majority of the localhost ports on the remote side are blocked by iptable rules).
219+
// Plus the platform (always linux), and extensions (python and jupyter), to make the initial experience smoother.
220+
if opts.IDE != "" && opts.IsServerlessMode() && !opts.ProxyMode && !opts.SkipSettingsCheck && cmdio.IsPromptSupported(ctx) {
221+
err := vscode.CheckAndUpdateSettings(ctx, opts.IDE, opts.ConnectionName)
222+
if err != nil {
223+
cmdio.LogString(ctx, fmt.Sprintf("Failed to update IDE settings: %v", err))
224+
cmdio.LogString(ctx, vscode.GetManualInstructions(opts.IDE, opts.ConnectionName))
225+
cmdio.LogString(ctx, "Use --skip-settings-check to bypass IDE settings verification.")
226+
shouldProceed, promptErr := cmdio.AskYesOrNo(ctx, "Do you want to proceed with the connection?")
227+
if promptErr != nil {
228+
return fmt.Errorf("failed to prompt user: %w", promptErr)
229+
}
230+
if !shouldProceed {
231+
return errors.New("aborted: IDE settings need to be updated manually, user declined to proceed")
232+
}
233+
}
234+
}
235+
215236
// Only check cluster state for dedicated clusters
216237
if !opts.IsServerlessMode() {
217238
err := checkClusterState(ctx, client, opts.ClusterID, opts.AutoStartCluster)
@@ -242,26 +263,6 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt
242263
cmdio.LogString(ctx, "Using SSH key: "+keyPath)
243264
cmdio.LogString(ctx, fmt.Sprintf("Secrets scope: %s, key name: %s", secretScopeName, opts.ClientPublicKeyName))
244265

245-
// Check and update IDE settings for serverless mode, where we must set up
246-
// desired server ports (or socket connection mode) for the connection to go through
247-
// (as the majority of the localhost ports on the remote side are blocked by iptable rules).
248-
// Plus the platform (always linux), and extensions (python and jupyter), to make the initial experience smoother.
249-
if opts.IDE != "" && opts.IsServerlessMode() && !opts.ProxyMode && !opts.SkipSettingsCheck && cmdio.IsPromptSupported(ctx) {
250-
err = vscode.CheckAndUpdateSettings(ctx, opts.IDE, opts.ConnectionName)
251-
if err != nil {
252-
cmdio.LogString(ctx, fmt.Sprintf("Failed to update IDE settings: %v", err))
253-
cmdio.LogString(ctx, vscode.GetManualInstructions(opts.IDE, opts.ConnectionName))
254-
cmdio.LogString(ctx, "Use --skip-settings-check to bypass IDE settings verification.")
255-
shouldProceed, promptErr := cmdio.AskYesOrNo(ctx, "Do you want to proceed with the connection?")
256-
if promptErr != nil {
257-
return fmt.Errorf("failed to prompt user: %w", promptErr)
258-
}
259-
if !shouldProceed {
260-
return errors.New("aborted: IDE settings need to be updated manually, user declined to proceed")
261-
}
262-
}
263-
}
264-
265266
var userName string
266267
var serverPort int
267268
var clusterID string
@@ -330,7 +331,6 @@ func runIDE(ctx context.Context, client *databricks.WorkspaceClient, userName, k
330331
if err != nil {
331332
return fmt.Errorf("failed to get current user: %w", err)
332333
}
333-
databricksUserName := currentUser.UserName
334334

335335
// Ensure SSH config entry exists
336336
configPath, err := sshconfig.GetMainConfigPath(ctx)
@@ -343,23 +343,7 @@ func runIDE(ctx context.Context, client *databricks.WorkspaceClient, userName, k
343343
return fmt.Errorf("failed to ensure SSH config entry: %w", err)
344344
}
345345

346-
ideCommand := VSCodeCommand
347-
if opts.IDE == CursorOption {
348-
ideCommand = CursorCommand
349-
}
350-
351-
// Construct the remote SSH URI
352-
// Format: ssh-remote+<server_user_name>@<connection_name> /Workspace/Users/<databricks_user_name>/
353-
remoteURI := fmt.Sprintf("ssh-remote+%s@%s", userName, connectionName)
354-
remotePath := fmt.Sprintf("/Workspace/Users/%s/", databricksUserName)
355-
356-
cmdio.LogString(ctx, fmt.Sprintf("Launching %s with remote URI: %s and path: %s", opts.IDE, remoteURI, remotePath))
357-
358-
ideCmd := exec.CommandContext(ctx, ideCommand, "--remote", remoteURI, remotePath)
359-
ideCmd.Stdout = os.Stdout
360-
ideCmd.Stderr = os.Stderr
361-
362-
return ideCmd.Run()
346+
return vscode.LaunchIDE(ctx, opts.IDE, connectionName, userName, currentUser.UserName)
363347
}
364348

365349
func ensureSSHConfigEntry(ctx context.Context, configPath, hostName, userName, keyPath string, serverPort int, clusterID string, opts ClientOptions) error {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package vscode
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
9+
"github.com/databricks/cli/libs/cmdio"
10+
)
11+
12+
// Options as they can be set via --ide flag.
13+
const (
14+
VSCodeOption = "vscode"
15+
CursorOption = "cursor"
16+
)
17+
18+
type ideDescriptor struct {
19+
Option string
20+
Command string
21+
Name string
22+
InstallURL string
23+
AppName string
24+
}
25+
26+
var vsCodeIDE = ideDescriptor{
27+
Option: VSCodeOption,
28+
Command: "code",
29+
Name: "VS Code",
30+
InstallURL: "https://code.visualstudio.com/",
31+
AppName: "Code",
32+
}
33+
34+
var cursorIDE = ideDescriptor{
35+
Option: CursorOption,
36+
Command: "cursor",
37+
Name: "Cursor",
38+
InstallURL: "https://cursor.com/",
39+
AppName: "Cursor",
40+
}
41+
42+
func getIDE(option string) ideDescriptor {
43+
if option == CursorOption {
44+
return cursorIDE
45+
}
46+
return vsCodeIDE
47+
}
48+
49+
// CheckIDECommand verifies the IDE CLI command is available on PATH.
50+
func CheckIDECommand(option string) error {
51+
ide := getIDE(option)
52+
53+
if _, err := exec.LookPath(ide.Command); err != nil {
54+
return fmt.Errorf(
55+
"%q command not found on PATH. To fix this:\n"+
56+
"1. Install %s from %s\n"+
57+
"2. Open the Command Palette (Cmd+Shift+P / Ctrl+Shift+P) and run \"Shell Command: Install '%s' command\"\n"+
58+
"3. Restart your terminal",
59+
ide.Command, ide.Name, ide.InstallURL, ide.Command,
60+
)
61+
}
62+
return nil
63+
}
64+
65+
// LaunchIDE launches the IDE with a remote SSH connection.
66+
func LaunchIDE(ctx context.Context, ideOption, connectionName, userName, databricksUserName string) error {
67+
ide := getIDE(ideOption)
68+
69+
// Construct the remote SSH URI
70+
// Format: ssh-remote+<server_user_name>@<connection_name> /Workspace/Users/<databricks_user_name>/
71+
remoteURI := fmt.Sprintf("ssh-remote+%s@%s", userName, connectionName)
72+
remotePath := fmt.Sprintf("/Workspace/Users/%s/", databricksUserName)
73+
74+
cmdio.LogString(ctx, fmt.Sprintf("Launching %s with remote URI: %s and path: %s", ideOption, remoteURI, remotePath))
75+
76+
ideCmd := exec.CommandContext(ctx, ide.Command, "--remote", remoteURI, remotePath)
77+
ideCmd.Stdout = os.Stdout
78+
ideCmd.Stderr = os.Stderr
79+
80+
return ideCmd.Run()
81+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package vscode
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestCheckIDECommand_Missing(t *testing.T) {
14+
// Override PATH to ensure commands are not found
15+
t.Setenv("PATH", t.TempDir())
16+
17+
tests := []struct {
18+
name string
19+
ide string
20+
wantErrMsg string
21+
}{
22+
{
23+
name: "missing vscode command",
24+
ide: VSCodeOption,
25+
wantErrMsg: `"code" command not found on PATH`,
26+
},
27+
{
28+
name: "missing cursor command",
29+
ide: CursorOption,
30+
wantErrMsg: `"cursor" command not found on PATH`,
31+
},
32+
{
33+
name: "vscode error contains install instructions",
34+
ide: VSCodeOption,
35+
wantErrMsg: "https://code.visualstudio.com/",
36+
},
37+
{
38+
name: "cursor error contains install instructions",
39+
ide: CursorOption,
40+
wantErrMsg: "https://cursor.com/",
41+
},
42+
}
43+
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
err := CheckIDECommand(tt.ide)
47+
require.Error(t, err)
48+
assert.Contains(t, err.Error(), tt.wantErrMsg)
49+
})
50+
}
51+
}
52+
53+
func TestCheckIDECommand_Found(t *testing.T) {
54+
tmpDir := t.TempDir()
55+
t.Setenv("PATH", tmpDir)
56+
57+
tests := []struct {
58+
name string
59+
ide string
60+
command string
61+
}{
62+
{
63+
name: "vscode command found",
64+
ide: VSCodeOption,
65+
command: "code",
66+
},
67+
{
68+
name: "cursor command found",
69+
ide: CursorOption,
70+
command: "cursor",
71+
},
72+
}
73+
74+
for _, tt := range tests {
75+
t.Run(tt.name, func(t *testing.T) {
76+
// Create a fake executable in the temp directory.
77+
// On Windows, exec.LookPath requires a known extension (e.g. .exe).
78+
command := tt.command
79+
if runtime.GOOS == "windows" {
80+
command += ".exe"
81+
}
82+
fakePath := filepath.Join(tmpDir, command)
83+
err := os.WriteFile(fakePath, []byte("#!/bin/sh\n"), 0o755)
84+
require.NoError(t, err)
85+
86+
err = CheckIDECommand(tt.ide)
87+
assert.NoError(t, err)
88+
})
89+
}
90+
}

experimental/ssh/internal/vscode/settings.go

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,8 @@ const (
2626
remotePlatformKey = "remote.SSH.remotePlatform"
2727
defaultExtensionsKey = "remote.SSH.defaultExtensions"
2828
listenOnSocketKey = "remote.SSH.remoteServerListenOnSocket"
29-
vscodeIDE = "vscode"
30-
cursorIDE = "cursor"
31-
vscodeName = "VS Code"
32-
cursorName = "Cursor"
3329
)
3430

35-
func getIDEName(ide string) string {
36-
if ide == cursorIDE {
37-
return cursorName
38-
}
39-
return vscodeName
40-
}
41-
4231
type missingSettings struct {
4332
portRange bool
4433
platform bool
@@ -118,7 +107,7 @@ func CheckAndUpdateSettings(ctx context.Context, ide, connectionName string) err
118107
return fmt.Errorf("failed to save settings: %w", err)
119108
}
120109

121-
cmdio.LogString(ctx, fmt.Sprintf("Updated %s settings for '%s'", getIDEName(ide), connectionName))
110+
cmdio.LogString(ctx, fmt.Sprintf("Updated %s settings for '%s'", getIDE(ide).Name, connectionName))
122111
return nil
123112
}
124113

@@ -128,10 +117,7 @@ func getDefaultSettingsPath(ctx context.Context, ide string) (string, error) {
128117
return "", fmt.Errorf("failed to get home directory: %w", err)
129118
}
130119

131-
appName := "Code"
132-
if ide == cursorIDE {
133-
appName = "Cursor"
134-
}
120+
appName := getIDE(ide).AppName
135121

136122
var settingsDir string
137123
switch runtime.GOOS {
@@ -249,7 +235,7 @@ func settingsMessage(connectionName string, missing *missingSettings) string {
249235
func promptUserForUpdate(ctx context.Context, ide, connectionName string, missing *missingSettings) (bool, error) {
250236
question := fmt.Sprintf(
251237
"The following settings will be applied to %s for '%s':\n%s\nApply these settings?",
252-
getIDEName(ide), connectionName, settingsMessage(connectionName, missing))
238+
getIDE(ide).Name, connectionName, settingsMessage(connectionName, missing))
253239
return cmdio.AskYesOrNo(ctx, question)
254240
}
255241

@@ -286,7 +272,7 @@ func handleMissingFile(ctx context.Context, ide, connectionName, settingsPath st
286272
return fmt.Errorf("failed to save settings: %w", err)
287273
}
288274

289-
cmdio.LogString(ctx, fmt.Sprintf("Created %s settings at %s", getIDEName(ide), filepath.ToSlash(settingsPath)))
275+
cmdio.LogString(ctx, fmt.Sprintf("Created %s settings at %s", getIDE(ide).Name, filepath.ToSlash(settingsPath)))
290276
return nil
291277
}
292278

@@ -366,5 +352,5 @@ func GetManualInstructions(ide, connectionName string) string {
366352
}
367353
return fmt.Sprintf(
368354
"To ensure the remote connection works as expected, manually add these settings to your %s settings.json:\n%s",
369-
getIDEName(ide), settingsMessage(connectionName, missing))
355+
getIDE(ide).Name, settingsMessage(connectionName, missing))
370356
}

0 commit comments

Comments
 (0)