diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index ad2bab922..a6ed9510e 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -30,13 +30,14 @@ import ( ) const ( - EditorVSCode = "code" - EditorCursor = "cursor" + EditorVSCode = "code" + EditorCursor = "cursor" + EditorWindsurf = "windsurf" ) var ( - 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." - openExample = "brev open instance_id_or_name\nbrev open instance\nbrev open instance code\nbrev open instance cursor\nbrev open --set-default cursor" + 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." + 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" ) type OpenStore interface { @@ -62,7 +63,7 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto Annotations: map[string]string{"ssh": ""}, Use: "open", DisableFlagsInUseLine: true, - Short: "[beta] open VSCode or Cursor to your instance", + Short: "[beta] open VSCode, Cursor, or Windsurf to your instance", Long: openLong, Example: openExample, Args: cmderrors.TransformToValidationError(func(cmd *cobra.Command, args []string) error { @@ -98,14 +99,14 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container") cmd.Flags().BoolVarP(&waitForSetupToFinish, "wait", "w", false, "wait for setup to finish") cmd.Flags().StringVarP(&directory, "dir", "d", "", "directory to open") - cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code or cursor)") + cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code, cursor, or windsurf)") return cmd } func handleSetDefault(t *terminal.Terminal, editorType string) error { - if editorType != EditorVSCode && editorType != EditorCursor { - return fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType) + if editorType != EditorVSCode && editorType != EditorCursor && editorType != EditorWindsurf { + return fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', or 'windsurf'", editorType) } homeDir, err := os.UserHomeDir() @@ -129,8 +130,8 @@ func handleSetDefault(t *terminal.Terminal, editorType string) error { func determineEditorType(args []string) (string, error) { if len(args) == 2 { editorType := args[1] - if editorType != EditorVSCode && editorType != EditorCursor { - return "", fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType) + if editorType != EditorVSCode && editorType != EditorCursor && editorType != EditorWindsurf { + return "", fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', or 'windsurf'", editorType) } return editorType, nil } @@ -204,31 +205,15 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s if err != nil { if strings.Contains(err.Error(), `"code": executable file not found in $PATH`) { 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\"" - _, errStore := tstore.UpdateUser( - workspace.CreatedByUserID, - &entity.UpdateUser{ - OnboardingData: map[string]interface{}{ - "pathErrorTS": time.Now().UTC().Unix(), - }, - }) - if errStore != nil { - return errors.New(errMsg + "\n" + errStore.Error()) - } - return errors.New(errMsg) + return handlePathError(tstore, workspace, errMsg) } if strings.Contains(err.Error(), `"cursor": executable file not found in $PATH`) { errMsg := "cursor\": executable file not found in $PATH\n\nadd 'cursor' to your $PATH to open Cursor from the terminal" - _, errStore := tstore.UpdateUser( - workspace.CreatedByUserID, - &entity.UpdateUser{ - OnboardingData: map[string]interface{}{ - "pathErrorTS": time.Now().UTC().Unix(), - }, - }) - if errStore != nil { - return errors.New(errMsg + "\n" + errStore.Error()) - } - return errors.New(errMsg) + return handlePathError(tstore, workspace, errMsg) + } + if strings.Contains(err.Error(), `"windsurf": executable file not found in $PATH`) { + errMsg := "windsurf\": executable file not found in $PATH\n\nadd 'windsurf' to your $PATH to open Windsurf from the terminal" + return handlePathError(tstore, workspace, errMsg) } return breverrors.WrapAndTrace(err) } @@ -337,7 +322,92 @@ func tryToInstallCursorExtensions( } } +func tryToInstallWindsurfExtensions( + t *terminal.Terminal, + extIDs []string, +) { + for _, extID := range extIDs { + extInstalled, err0 := uutil.IsWindsurfExtensionInstalled(extID) + if !extInstalled { + err1 := uutil.InstallWindsurfExtension(extID) + isRemoteInstalled, err2 := uutil.IsWindsurfExtensionInstalled(extID) + if !isRemoteInstalled { + err := multierror.Append(err0, err1, err2) + t.Print(t.Red("Couldn't install the necessary Windsurf extension automatically.\nError: " + err.Error())) + t.Print("\tPlease install Windsurf and the following Windsurf extension: " + t.Yellow(extID) + ".\n") + _ = terminal.PromptGetInput(terminal.PromptContent{ + Label: "Hit enter when finished:", + ErrorMsg: "error", + AllowEmpty: true, + }) + } + } + } +} + // Opens code editor. Attempts to install code in path if not installed already +func getEditorName(editorType string) string { + switch editorType { + case EditorCursor: + return "Cursor" + case EditorWindsurf: + return "Windsurf" + default: + return "VSCode" + } +} + +func handlePathError(tstore OpenStore, workspace *entity.Workspace, errMsg string) error { + _, errStore := tstore.UpdateUser( + workspace.CreatedByUserID, + &entity.UpdateUser{ + OnboardingData: map[string]interface{}{ + "pathErrorTS": time.Now().UTC().Unix(), + }, + }) + if errStore != nil { + return errors.New(errMsg + "\n" + errStore.Error()) + } + return errors.New(errMsg) +} + +func openEditorByType(t *terminal.Terminal, editorType string, sshAlias string, path string, tstore OpenStore) error { + extensions := []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"} + switch editorType { + case EditorCursor: + tryToInstallCursorExtensions(t, extensions) + return openCursor(sshAlias, path, tstore) + case EditorWindsurf: + tryToInstallWindsurfExtensions(t, extensions) + return openWindsurf(sshAlias, path, tstore) + default: + tryToInstallExtensions(t, extensions) + return openVsCode(sshAlias, path, tstore) + } +} + +func validateRemoteWorkspace(t *terminal.Terminal, tstore OpenStore, editorType string, originalErr error) error { + err := mo.TupleToResult(tstore.IsWorkspace()).Match( + func(value bool) (bool, error) { + if value { + return true, errors.New("you are in a remote brev instance; brev open is not supported. Please run brev open locally instead") + } + return false, breverrors.WrapAndTrace(originalErr) + }, + func(err2 error) (bool, error) { + return false, multierror.Append(originalErr, err2) + }, + ).Error() + if err != nil { + if strings.Contains(err.Error(), "you are in a remote brev instance;") { + return breverrors.WrapAndTrace(err) + } + editorName := getEditorName(editorType) + return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open %s, try adding it to PATH\n"), editorName)) + } + return nil +} + func openEditorWithSSH( t *terminal.Terminal, sshAlias string, @@ -346,12 +416,12 @@ func openEditorWithSSH( _ string, editorType string, ) error { - // infinite for loop: res := refresh.RunRefreshAsync(tstore) err := res.Await() if err != nil { return breverrors.WrapAndTrace(err) } + s := t.NewSpinner() s.Start() s.Suffix = " checking if your instance is ready..." @@ -360,57 +430,18 @@ func openEditorWithSSH( return breverrors.WrapAndTrace(err) } - // todo: add it here - editorName := "VS Code" - if editorType == EditorCursor { - editorName = "Cursor" - } + editorName := getEditorName(editorType) s.Suffix = fmt.Sprintf(" Instance is ready. Opening %s 🤙", editorName) time.Sleep(250 * time.Millisecond) s.Stop() t.Vprintf("\n") - if editorType == EditorCursor { - tryToInstallCursorExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"}) - err = openCursor(sshAlias, path, tstore) - } else { - tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"}) - err = openVsCode(sshAlias, path, tstore) - } + err = openEditorByType(t, editorType, sshAlias, path, tstore) if err != nil { return breverrors.WrapAndTrace(err) } - // check if we are in a brev environment, if so transform the error message - // to indicate that the user should run brev open locally instead of in - // the cloud and that we intend on supporting this in the future - // if there is an error getting the workspace, append that error with - // multierror, - // otherwise, just return the error - err = mo.TupleToResult(tstore.IsWorkspace()).Match( - func(value bool) (bool, error) { - if value { - // todo log original error to sentry - return true, errors.New("you are in a remote brev instance; brev open is not supported. Please run brev open locally instead") - } - return false, breverrors.WrapAndTrace(err) - }, - func(err2 error) (bool, error) { - return false, multierror.Append(err, err2) - }, - ).Error() - if err != nil { - if strings.Contains(err.Error(), "you are in a remote brev instance;") { - return breverrors.WrapAndTrace(err) - } - editorName := "VSCode" - if editorType == EditorCursor { - editorName = "Cursor" - } - return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open %s, try adding it to PATH\n"), editorName)) - } else { - return nil - } + return validateRemoteWorkspace(t, tstore, editorType, err) } func waitForSSHToBeAvailable(t *terminal.Terminal, s *spinner.Spinner, sshAlias string) error { @@ -477,3 +508,21 @@ func getWindowsCursorPaths(store vscodePathStore) []string { paths := append([]string{}, fmt.Sprintf("%s/AppData/Local/Programs/Cursor/Cursor.exe", wd), fmt.Sprintf("%s/AppData/Local/Programs/Cursor/bin/cursor", wd)) return paths } + +func openWindsurf(sshAlias string, path string, store OpenStore) error { + windsurfString := fmt.Sprintf("vscode-remote://ssh-remote+%s%s", sshAlias, path) + windsurfString = shellescape.QuoteCommand([]string{windsurfString}) + + windowsPaths := getWindowsWindsurfPaths(store) + _, err := uutil.TryRunWindsurfCommand([]string{"--folder-uri", windsurfString}, windowsPaths...) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func getWindowsWindsurfPaths(store vscodePathStore) []string { + wd, _ := store.GetWindowsDir() + paths := append([]string{}, fmt.Sprintf("%s/AppData/Local/Programs/Windsurf/Windsurf.exe", wd), fmt.Sprintf("%s/AppData/Local/Programs/Windsurf/bin/windsurf", wd)) + return paths +} diff --git a/pkg/util/util.go b/pkg/util/util.go index fa23d530a..ddd558687 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -123,6 +123,22 @@ func IsCursorExtensionInstalled(extensionID string) (bool, error) { return strings.Contains(string(out), extensionID), nil } +func InstallWindsurfExtension(extensionID string) error { + _, err := TryRunWindsurfCommand([]string{"--install-extension", extensionID, "--force"}) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func IsWindsurfExtensionInstalled(extensionID string) (bool, error) { + out, err := TryRunWindsurfCommand([]string{"--list-extensions"}) + if err != nil { + return false, breverrors.WrapAndTrace(err) + } + return strings.Contains(string(out), extensionID), nil +} + func TryRunVsCodeCommand(args []string, extraPaths ...string) ([]byte, error) { extraPaths = append(commonVSCodePaths, extraPaths...) out, err := runManyVsCodeCommand(extraPaths, args) @@ -141,6 +157,15 @@ func TryRunCursorCommand(args []string, extraPaths ...string) ([]byte, error) { return out, nil } +func TryRunWindsurfCommand(args []string, extraPaths ...string) ([]byte, error) { + extraPaths = append(commonWindsurfPaths, extraPaths...) + out, err := runManyWindsurfCommand(extraPaths, args) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return out, nil +} + func runManyVsCodeCommand(vscodepaths []string, args []string) ([]byte, error) { errs := multierror.Append(nil) for _, vscodepath := range vscodepaths { @@ -185,6 +210,28 @@ func runCursorCommand(cursorpath string, args []string) ([]byte, error) { return res, nil } +func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, error) { + errs := multierror.Append(nil) + for _, windsurfpath := range windsurfpaths { + out, err := runWindsurfCommand(windsurfpath, args) + if err != nil { + errs = multierror.Append(errs, err) + } else { + return out, nil + } + } + return nil, breverrors.WrapAndTrace(errs.ErrorOrNil()) +} + +func runWindsurfCommand(windsurfpath string, args []string) ([]byte, error) { + cmd := exec.Command(windsurfpath, args...) // #nosec G204 + res, err := cmd.CombinedOutput() + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return res, nil +} + var commonVSCodePaths = []string{ "code", "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", @@ -209,3 +256,15 @@ var commonCursorPaths = []string{ "/usr/local/share/cursor/bin/cursor", "/usr/share/cursor/bin/cursor", } + +var commonWindsurfPaths = []string{ + "windsurf", + "/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf", + "/mnt/c/Program Files/Windsurf/Windsurf.exe", + "/mnt/c/Users/*/AppData/Local/Programs/Windsurf/bin/windsurf", + "/usr/bin/windsurf", + "/usr/local/bin/windsurf", + "/snap/bin/windsurf", + "/usr/local/share/windsurf/bin/windsurf", + "/usr/share/windsurf/bin/windsurf", +}