Skip to content

Commit b6ac0d4

Browse files
Add Windsurf IDE support to brev open command (#242)
* Add Windsurf IDE support to brev open command - Add EditorWindsurf constant and validation logic - Add Windsurf utility functions following Cursor pattern - Add openWindsurf function with vscode-remote URI support - Add Windsurf extension installation support - Update help text and examples to include Windsurf - Add Windows and macOS installation paths for Windsurf - Convert if-else chains to switch statements for better code quality Co-Authored-By: Alec Fong <[email protected]> * Refactor openEditorWithSSH and runOpenCommand to fix lint issues - Extract getEditorName helper to eliminate duplicated switch statements - Extract handlePathError helper to reduce repeated UpdateUser pattern - Extract openEditorByType helper to simplify editor opening logic - Extract validateRemoteWorkspace helper to reduce function complexity - Reduce openEditorWithSSH from 43 to ~35 statements - Reduce runOpenCommand cyclomatic complexity from 18 to ~12 - Maintain all existing Windsurf IDE functionality - Fix gofumpt formatting issues Fixes lint errors: - Function 'openEditorWithSSH' has too many statements (43 > 40) (funlen) - cyclomatic complexity 18 of func runOpenCommand is high (> 16) (gocyclo) Co-Authored-By: Alec Fong <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alec Fong <[email protected]>
1 parent 2cab86b commit b6ac0d4

File tree

2 files changed

+183
-75
lines changed

2 files changed

+183
-75
lines changed

pkg/cmd/open/open.go

Lines changed: 124 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ import (
3030
)
3131

3232
const (
33-
EditorVSCode = "code"
34-
EditorCursor = "cursor"
33+
EditorVSCode = "code"
34+
EditorCursor = "cursor"
35+
EditorWindsurf = "windsurf"
3536
)
3637

3738
var (
38-
openLong = "[command in beta] This will open VS Code or Cursor SSH-ed in to your instance. You must have the editor installed in your path."
39-
openExample = "brev open instance_id_or_name\nbrev open instance\nbrev open instance code\nbrev open instance cursor\nbrev open --set-default cursor"
39+
openLong = "[command in beta] This will open VS Code, Cursor, or Windsurf SSH-ed in to your instance. You must have the editor installed in your path."
40+
openExample = "brev open instance_id_or_name\nbrev open instance\nbrev open instance code\nbrev open instance cursor\nbrev open instance windsurf\nbrev open --set-default cursor\nbrev open --set-default windsurf"
4041
)
4142

4243
type OpenStore interface {
@@ -62,7 +63,7 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto
6263
Annotations: map[string]string{"ssh": ""},
6364
Use: "open",
6465
DisableFlagsInUseLine: true,
65-
Short: "[beta] open VSCode or Cursor to your instance",
66+
Short: "[beta] open VSCode, Cursor, or Windsurf to your instance",
6667
Long: openLong,
6768
Example: openExample,
6869
Args: cmderrors.TransformToValidationError(func(cmd *cobra.Command, args []string) error {
@@ -98,14 +99,14 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto
9899
cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container")
99100
cmd.Flags().BoolVarP(&waitForSetupToFinish, "wait", "w", false, "wait for setup to finish")
100101
cmd.Flags().StringVarP(&directory, "dir", "d", "", "directory to open")
101-
cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code or cursor)")
102+
cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code, cursor, or windsurf)")
102103

103104
return cmd
104105
}
105106

106107
func handleSetDefault(t *terminal.Terminal, editorType string) error {
107-
if editorType != EditorVSCode && editorType != EditorCursor {
108-
return fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType)
108+
if editorType != EditorVSCode && editorType != EditorCursor && editorType != EditorWindsurf {
109+
return fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', or 'windsurf'", editorType)
109110
}
110111

111112
homeDir, err := os.UserHomeDir()
@@ -129,8 +130,8 @@ func handleSetDefault(t *terminal.Terminal, editorType string) error {
129130
func determineEditorType(args []string) (string, error) {
130131
if len(args) == 2 {
131132
editorType := args[1]
132-
if editorType != EditorVSCode && editorType != EditorCursor {
133-
return "", fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType)
133+
if editorType != EditorVSCode && editorType != EditorCursor && editorType != EditorWindsurf {
134+
return "", fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', or 'windsurf'", editorType)
134135
}
135136
return editorType, nil
136137
}
@@ -204,31 +205,15 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
204205
if err != nil {
205206
if strings.Contains(err.Error(), `"code": executable file not found in $PATH`) {
206207
errMsg := "code\": executable file not found in $PATH\n\nadd 'code' to your $PATH to open VS Code from the terminal\n\texport PATH=\"/Applications/Visual Studio Code.app/Contents/Resources/app/bin:$PATH\""
207-
_, errStore := tstore.UpdateUser(
208-
workspace.CreatedByUserID,
209-
&entity.UpdateUser{
210-
OnboardingData: map[string]interface{}{
211-
"pathErrorTS": time.Now().UTC().Unix(),
212-
},
213-
})
214-
if errStore != nil {
215-
return errors.New(errMsg + "\n" + errStore.Error())
216-
}
217-
return errors.New(errMsg)
208+
return handlePathError(tstore, workspace, errMsg)
218209
}
219210
if strings.Contains(err.Error(), `"cursor": executable file not found in $PATH`) {
220211
errMsg := "cursor\": executable file not found in $PATH\n\nadd 'cursor' to your $PATH to open Cursor from the terminal"
221-
_, errStore := tstore.UpdateUser(
222-
workspace.CreatedByUserID,
223-
&entity.UpdateUser{
224-
OnboardingData: map[string]interface{}{
225-
"pathErrorTS": time.Now().UTC().Unix(),
226-
},
227-
})
228-
if errStore != nil {
229-
return errors.New(errMsg + "\n" + errStore.Error())
230-
}
231-
return errors.New(errMsg)
212+
return handlePathError(tstore, workspace, errMsg)
213+
}
214+
if strings.Contains(err.Error(), `"windsurf": executable file not found in $PATH`) {
215+
errMsg := "windsurf\": executable file not found in $PATH\n\nadd 'windsurf' to your $PATH to open Windsurf from the terminal"
216+
return handlePathError(tstore, workspace, errMsg)
232217
}
233218
return breverrors.WrapAndTrace(err)
234219
}
@@ -337,7 +322,92 @@ func tryToInstallCursorExtensions(
337322
}
338323
}
339324

325+
func tryToInstallWindsurfExtensions(
326+
t *terminal.Terminal,
327+
extIDs []string,
328+
) {
329+
for _, extID := range extIDs {
330+
extInstalled, err0 := uutil.IsWindsurfExtensionInstalled(extID)
331+
if !extInstalled {
332+
err1 := uutil.InstallWindsurfExtension(extID)
333+
isRemoteInstalled, err2 := uutil.IsWindsurfExtensionInstalled(extID)
334+
if !isRemoteInstalled {
335+
err := multierror.Append(err0, err1, err2)
336+
t.Print(t.Red("Couldn't install the necessary Windsurf extension automatically.\nError: " + err.Error()))
337+
t.Print("\tPlease install Windsurf and the following Windsurf extension: " + t.Yellow(extID) + ".\n")
338+
_ = terminal.PromptGetInput(terminal.PromptContent{
339+
Label: "Hit enter when finished:",
340+
ErrorMsg: "error",
341+
AllowEmpty: true,
342+
})
343+
}
344+
}
345+
}
346+
}
347+
340348
// Opens code editor. Attempts to install code in path if not installed already
349+
func getEditorName(editorType string) string {
350+
switch editorType {
351+
case EditorCursor:
352+
return "Cursor"
353+
case EditorWindsurf:
354+
return "Windsurf"
355+
default:
356+
return "VSCode"
357+
}
358+
}
359+
360+
func handlePathError(tstore OpenStore, workspace *entity.Workspace, errMsg string) error {
361+
_, errStore := tstore.UpdateUser(
362+
workspace.CreatedByUserID,
363+
&entity.UpdateUser{
364+
OnboardingData: map[string]interface{}{
365+
"pathErrorTS": time.Now().UTC().Unix(),
366+
},
367+
})
368+
if errStore != nil {
369+
return errors.New(errMsg + "\n" + errStore.Error())
370+
}
371+
return errors.New(errMsg)
372+
}
373+
374+
func openEditorByType(t *terminal.Terminal, editorType string, sshAlias string, path string, tstore OpenStore) error {
375+
extensions := []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"}
376+
switch editorType {
377+
case EditorCursor:
378+
tryToInstallCursorExtensions(t, extensions)
379+
return openCursor(sshAlias, path, tstore)
380+
case EditorWindsurf:
381+
tryToInstallWindsurfExtensions(t, extensions)
382+
return openWindsurf(sshAlias, path, tstore)
383+
default:
384+
tryToInstallExtensions(t, extensions)
385+
return openVsCode(sshAlias, path, tstore)
386+
}
387+
}
388+
389+
func validateRemoteWorkspace(t *terminal.Terminal, tstore OpenStore, editorType string, originalErr error) error {
390+
err := mo.TupleToResult(tstore.IsWorkspace()).Match(
391+
func(value bool) (bool, error) {
392+
if value {
393+
return true, errors.New("you are in a remote brev instance; brev open is not supported. Please run brev open locally instead")
394+
}
395+
return false, breverrors.WrapAndTrace(originalErr)
396+
},
397+
func(err2 error) (bool, error) {
398+
return false, multierror.Append(originalErr, err2)
399+
},
400+
).Error()
401+
if err != nil {
402+
if strings.Contains(err.Error(), "you are in a remote brev instance;") {
403+
return breverrors.WrapAndTrace(err)
404+
}
405+
editorName := getEditorName(editorType)
406+
return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open %s, try adding it to PATH\n"), editorName))
407+
}
408+
return nil
409+
}
410+
341411
func openEditorWithSSH(
342412
t *terminal.Terminal,
343413
sshAlias string,
@@ -346,12 +416,12 @@ func openEditorWithSSH(
346416
_ string,
347417
editorType string,
348418
) error {
349-
// infinite for loop:
350419
res := refresh.RunRefreshAsync(tstore)
351420
err := res.Await()
352421
if err != nil {
353422
return breverrors.WrapAndTrace(err)
354423
}
424+
355425
s := t.NewSpinner()
356426
s.Start()
357427
s.Suffix = " checking if your instance is ready..."
@@ -360,57 +430,18 @@ func openEditorWithSSH(
360430
return breverrors.WrapAndTrace(err)
361431
}
362432

363-
// todo: add it here
364-
editorName := "VS Code"
365-
if editorType == EditorCursor {
366-
editorName = "Cursor"
367-
}
433+
editorName := getEditorName(editorType)
368434
s.Suffix = fmt.Sprintf(" Instance is ready. Opening %s 🤙", editorName)
369435
time.Sleep(250 * time.Millisecond)
370436
s.Stop()
371437
t.Vprintf("\n")
372438

373-
if editorType == EditorCursor {
374-
tryToInstallCursorExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
375-
err = openCursor(sshAlias, path, tstore)
376-
} else {
377-
tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
378-
err = openVsCode(sshAlias, path, tstore)
379-
}
439+
err = openEditorByType(t, editorType, sshAlias, path, tstore)
380440
if err != nil {
381441
return breverrors.WrapAndTrace(err)
382442
}
383443

384-
// check if we are in a brev environment, if so transform the error message
385-
// to indicate that the user should run brev open locally instead of in
386-
// the cloud and that we intend on supporting this in the future
387-
// if there is an error getting the workspace, append that error with
388-
// multierror,
389-
// otherwise, just return the error
390-
err = mo.TupleToResult(tstore.IsWorkspace()).Match(
391-
func(value bool) (bool, error) {
392-
if value {
393-
// todo log original error to sentry
394-
return true, errors.New("you are in a remote brev instance; brev open is not supported. Please run brev open locally instead")
395-
}
396-
return false, breverrors.WrapAndTrace(err)
397-
},
398-
func(err2 error) (bool, error) {
399-
return false, multierror.Append(err, err2)
400-
},
401-
).Error()
402-
if err != nil {
403-
if strings.Contains(err.Error(), "you are in a remote brev instance;") {
404-
return breverrors.WrapAndTrace(err)
405-
}
406-
editorName := "VSCode"
407-
if editorType == EditorCursor {
408-
editorName = "Cursor"
409-
}
410-
return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open %s, try adding it to PATH\n"), editorName))
411-
} else {
412-
return nil
413-
}
444+
return validateRemoteWorkspace(t, tstore, editorType, err)
414445
}
415446

416447
func waitForSSHToBeAvailable(t *terminal.Terminal, s *spinner.Spinner, sshAlias string) error {
@@ -477,3 +508,21 @@ func getWindowsCursorPaths(store vscodePathStore) []string {
477508
paths := append([]string{}, fmt.Sprintf("%s/AppData/Local/Programs/Cursor/Cursor.exe", wd), fmt.Sprintf("%s/AppData/Local/Programs/Cursor/bin/cursor", wd))
478509
return paths
479510
}
511+
512+
func openWindsurf(sshAlias string, path string, store OpenStore) error {
513+
windsurfString := fmt.Sprintf("vscode-remote://ssh-remote+%s%s", sshAlias, path)
514+
windsurfString = shellescape.QuoteCommand([]string{windsurfString})
515+
516+
windowsPaths := getWindowsWindsurfPaths(store)
517+
_, err := uutil.TryRunWindsurfCommand([]string{"--folder-uri", windsurfString}, windowsPaths...)
518+
if err != nil {
519+
return breverrors.WrapAndTrace(err)
520+
}
521+
return nil
522+
}
523+
524+
func getWindowsWindsurfPaths(store vscodePathStore) []string {
525+
wd, _ := store.GetWindowsDir()
526+
paths := append([]string{}, fmt.Sprintf("%s/AppData/Local/Programs/Windsurf/Windsurf.exe", wd), fmt.Sprintf("%s/AppData/Local/Programs/Windsurf/bin/windsurf", wd))
527+
return paths
528+
}

pkg/util/util.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ func IsCursorExtensionInstalled(extensionID string) (bool, error) {
123123
return strings.Contains(string(out), extensionID), nil
124124
}
125125

126+
func InstallWindsurfExtension(extensionID string) error {
127+
_, err := TryRunWindsurfCommand([]string{"--install-extension", extensionID, "--force"})
128+
if err != nil {
129+
return breverrors.WrapAndTrace(err)
130+
}
131+
return nil
132+
}
133+
134+
func IsWindsurfExtensionInstalled(extensionID string) (bool, error) {
135+
out, err := TryRunWindsurfCommand([]string{"--list-extensions"})
136+
if err != nil {
137+
return false, breverrors.WrapAndTrace(err)
138+
}
139+
return strings.Contains(string(out), extensionID), nil
140+
}
141+
126142
func TryRunVsCodeCommand(args []string, extraPaths ...string) ([]byte, error) {
127143
extraPaths = append(commonVSCodePaths, extraPaths...)
128144
out, err := runManyVsCodeCommand(extraPaths, args)
@@ -141,6 +157,15 @@ func TryRunCursorCommand(args []string, extraPaths ...string) ([]byte, error) {
141157
return out, nil
142158
}
143159

160+
func TryRunWindsurfCommand(args []string, extraPaths ...string) ([]byte, error) {
161+
extraPaths = append(commonWindsurfPaths, extraPaths...)
162+
out, err := runManyWindsurfCommand(extraPaths, args)
163+
if err != nil {
164+
return nil, breverrors.WrapAndTrace(err)
165+
}
166+
return out, nil
167+
}
168+
144169
func runManyVsCodeCommand(vscodepaths []string, args []string) ([]byte, error) {
145170
errs := multierror.Append(nil)
146171
for _, vscodepath := range vscodepaths {
@@ -185,6 +210,28 @@ func runCursorCommand(cursorpath string, args []string) ([]byte, error) {
185210
return res, nil
186211
}
187212

213+
func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, error) {
214+
errs := multierror.Append(nil)
215+
for _, windsurfpath := range windsurfpaths {
216+
out, err := runWindsurfCommand(windsurfpath, args)
217+
if err != nil {
218+
errs = multierror.Append(errs, err)
219+
} else {
220+
return out, nil
221+
}
222+
}
223+
return nil, breverrors.WrapAndTrace(errs.ErrorOrNil())
224+
}
225+
226+
func runWindsurfCommand(windsurfpath string, args []string) ([]byte, error) {
227+
cmd := exec.Command(windsurfpath, args...) // #nosec G204
228+
res, err := cmd.CombinedOutput()
229+
if err != nil {
230+
return nil, breverrors.WrapAndTrace(err)
231+
}
232+
return res, nil
233+
}
234+
188235
var commonVSCodePaths = []string{
189236
"code",
190237
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
@@ -209,3 +256,15 @@ var commonCursorPaths = []string{
209256
"/usr/local/share/cursor/bin/cursor",
210257
"/usr/share/cursor/bin/cursor",
211258
}
259+
260+
var commonWindsurfPaths = []string{
261+
"windsurf",
262+
"/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf",
263+
"/mnt/c/Program Files/Windsurf/Windsurf.exe",
264+
"/mnt/c/Users/*/AppData/Local/Programs/Windsurf/bin/windsurf",
265+
"/usr/bin/windsurf",
266+
"/usr/local/bin/windsurf",
267+
"/snap/bin/windsurf",
268+
"/usr/local/share/windsurf/bin/windsurf",
269+
"/usr/share/windsurf/bin/windsurf",
270+
}

0 commit comments

Comments
 (0)