Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 124 additions & 75 deletions pkg/cmd/open/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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..."
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
59 changes: 59 additions & 0 deletions pkg/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
}
Loading