diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index 922d76202..5c0ce4fa5 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -3,6 +3,7 @@ package open import ( "errors" "fmt" + "os" "os/exec" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/files" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" uutil "github.com/brevdev/brev-cli/pkg/util" @@ -27,9 +29,14 @@ import ( "github.com/spf13/cobra" ) +const ( + EditorVSCode = "code" + EditorCursor = "cursor" +) + var ( - openLong = "[command in beta] This will open VS Code SSH-ed in to your workspace. You must have 'code' installed in your path." - openExample = "brev open workspace_id_or_name\nbrev open my-app\nbrev open h9fp5vxwe" + openLong = "[command in beta] This will open VS Code or Cursor SSH-ed in to your workspace. You must have the editor installed in your path." + openExample = "brev open workspace_id_or_name\nbrev open my-app\nbrev open my-app code\nbrev open my-app cursor\nbrev open --set-default cursor" ) type OpenStore interface { @@ -46,10 +53,10 @@ type OpenStore interface { } func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenStore) *cobra.Command { - var openWithCursor bool var waitForSetupToFinish bool var directory string var host bool + var setDefault string cmd := &cobra.Command{ Annotations: map[string]string{"ssh": ""}, @@ -58,30 +65,91 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto Short: "[beta] open VSCode or Cursor to your workspace", Long: openLong, Example: openExample, - Args: cmderrors.TransformToValidationError(cobra.ExactArgs(1)), - ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), + Args: cmderrors.TransformToValidationError(func(cmd *cobra.Command, args []string) error { + setDefaultFlag, _ := cmd.Flags().GetString("set-default") + if setDefaultFlag != "" { + return cobra.NoArgs(cmd, args) + } + return cobra.RangeArgs(1, 2)(cmd, args) + }), + ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { + if setDefault != "" { + return handleSetDefault(t, setDefault) + } + setupDoneString := "------ Git repo cloned ------" if waitForSetupToFinish { setupDoneString = "------ Done running execs ------" } - err := runOpenCommand(t, store, args[0], setupDoneString, directory, host) + + editorType, err := determineEditorType(args) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + err = runOpenCommand(t, store, args[0], setupDoneString, directory, host, editorType) if err != nil { return breverrors.WrapAndTrace(err) } return nil }, } - cmd.Flags().BoolVarP(&openWithCursor, "cursor", "c", false, "open cursor instead of VS Code") 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)") 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) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return breverrors.WrapAndTrace(err) + } + + settings := &files.PersonalSettings{ + DefaultEditor: editorType, + } + + err = files.WritePersonalSettings(files.AppFs, homeDir, settings) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + t.Vprint(t.Green("Default editor set to " + editorType + "\n")) + return nil +} + +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) + } + return editorType, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return EditorVSCode, nil + } + + settings, err := files.ReadPersonalSettings(files.AppFs, homeDir) + if err != nil { + return EditorVSCode, nil + } + + return settings.DefaultEditor, nil +} + // Fetch workspace info, then open code editor -func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, setupDoneString string, directory string, host bool) error { //nolint:funlen // define brev command +func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, setupDoneString string, directory string, host bool, editorType string) error { //nolint:funlen // define brev command // todo check if workspace is stopped and start if it if it is stopped fmt.Println("finding your instance...") res := refresh.RunRefreshAsync(tstore) @@ -132,7 +200,7 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s // legacy environments wont support this and cause errrors, // but we don't want to block the user from using vscode _ = writeconnectionevent.WriteWCEOnEnv(tstore, string(localIdentifier)) - err = openVsCodeWithSSH(t, string(localIdentifier), projPath, tstore, setupDoneString) + err = openEditorWithSSH(t, string(localIdentifier), projPath, tstore, setupDoneString, editorType) 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\"" @@ -148,6 +216,20 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s } return errors.New(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 breverrors.WrapAndTrace(err) } // Call analytics for open @@ -232,13 +314,37 @@ func tryToInstallExtensions( } } +func tryToInstallCursorExtensions( + t *terminal.Terminal, + extIDs []string, +) { + for _, extID := range extIDs { + extInstalled, err0 := uutil.IsCursorExtensionInstalled(extID) + if !extInstalled { + err1 := uutil.InstallCursorExtension(extID) + isRemoteInstalled, err2 := uutil.IsCursorExtensionInstalled(extID) + if !isRemoteInstalled { + err := multierror.Append(err0, err1, err2) + t.Print(t.Red("Couldn't install the necessary Cursor extension automatically.\nError: " + err.Error())) + t.Print("\tPlease install Cursor and the following Cursor 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 openVsCodeWithSSH( +func openEditorWithSSH( t *terminal.Terminal, sshAlias string, path string, tstore OpenStore, _ string, + editorType string, ) error { // infinite for loop: res := refresh.RunRefreshAsync(tstore) @@ -255,14 +361,22 @@ func openVsCodeWithSSH( } // todo: add it here - s.Suffix = " Instance is ready. Opening VS Code 🤙" + editorName := "VS Code" + if editorType == EditorCursor { + editorName = "Cursor" + } + s.Suffix = fmt.Sprintf(" Instance is ready. Opening %s 🤙", editorName) time.Sleep(250 * time.Millisecond) s.Stop() t.Vprintf("\n") - tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"}) - - err = openVsCode(sshAlias, path, tstore) + 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) + } if err != nil { return breverrors.WrapAndTrace(err) } @@ -289,7 +403,11 @@ func openVsCodeWithSSH( if strings.Contains(err.Error(), "you are in a remote brev instance;") { return breverrors.WrapAndTrace(err) } - return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open VSCode, try adding it to PATH (you can do this in VSCode by running CMD-SHIFT-P and typing 'install code in path')\n"))) + 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 } @@ -341,3 +459,21 @@ func openVsCode(sshAlias string, path string, store OpenStore) error { } return nil } + +func openCursor(sshAlias string, path string, store OpenStore) error { + cursorString := fmt.Sprintf("vscode-remote://ssh-remote+%s%s", sshAlias, path) + cursorString = shellescape.QuoteCommand([]string{cursorString}) + + windowsPaths := getWindowsCursorPaths(store) + _, err := uutil.TryRunCursorCommand([]string{"--folder-uri", cursorString}, windowsPaths...) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func getWindowsCursorPaths(store vscodePathStore) []string { + wd, _ := store.GetWindowsDir() + 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 +} diff --git a/pkg/files/files.go b/pkg/files/files.go index c05547729..fbf0ad561 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -15,6 +15,10 @@ import ( "github.com/spf13/afero" ) +type PersonalSettings struct { + DefaultEditor string `json:"default_editor"` +} + const ( brevDirectory = ".brev" // This might be better as a context.json?? @@ -92,6 +96,11 @@ func GetOnboardingStepPath(home string) string { return brevOnboardingFilePath } +func GetPersonalSettingsPath(home string) string { + fpath := makeBrevFilePath(personalSettingsCache, home) + return fpath +} + func GetNewBackupSSHConfigFilePath(home string) string { fp := makeBrevFilePath(GetNewBackupSSHConfigFileName(), home) @@ -248,6 +257,24 @@ func CatFile(filePath string) (string, error) { } } +func ReadPersonalSettings(fs afero.Fs, home string) (*PersonalSettings, error) { + settingsPath := GetPersonalSettingsPath(home) + var settings PersonalSettings + err := ReadJSON(fs, settingsPath, &settings) + if err != nil { + return &PersonalSettings{DefaultEditor: "code"}, nil + } + if settings.DefaultEditor == "" { + settings.DefaultEditor = "code" + } + return &settings, nil +} + +func WritePersonalSettings(fs afero.Fs, home string, settings *PersonalSettings) error { + settingsPath := GetPersonalSettingsPath(home) + return OverwriteJSON(fs, settingsPath, settings) +} + // if this doesn't work, just exit // if this doesn't work, just exit diff --git a/pkg/util/util.go b/pkg/util/util.go index 15ab3dbbc..fa23d530a 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -107,6 +107,22 @@ func IsVSCodeExtensionInstalled(extensionID string) (bool, error) { return strings.Contains(string(out), extensionID), nil } +func InstallCursorExtension(extensionID string) error { + _, err := TryRunCursorCommand([]string{"--install-extension", extensionID, "--force"}) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func IsCursorExtensionInstalled(extensionID string) (bool, error) { + out, err := TryRunCursorCommand([]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) @@ -116,6 +132,15 @@ func TryRunVsCodeCommand(args []string, extraPaths ...string) ([]byte, error) { return out, nil } +func TryRunCursorCommand(args []string, extraPaths ...string) ([]byte, error) { + extraPaths = append(commonCursorPaths, extraPaths...) + out, err := runManyCursorCommand(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 { @@ -129,6 +154,19 @@ func runManyVsCodeCommand(vscodepaths []string, args []string) ([]byte, error) { return nil, breverrors.WrapAndTrace(errs.ErrorOrNil()) } +func runManyCursorCommand(cursorpaths []string, args []string) ([]byte, error) { + errs := multierror.Append(nil) + for _, cursorpath := range cursorpaths { + out, err := runCursorCommand(cursorpath, args) + if err != nil { + errs = multierror.Append(errs, err) + } else { + return out, nil + } + } + return nil, breverrors.WrapAndTrace(errs.ErrorOrNil()) +} + func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { cmd := exec.Command(vscodepath, args...) // #nosec G204 res, err := cmd.CombinedOutput() @@ -138,6 +176,15 @@ func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { return res, nil } +func runCursorCommand(cursorpath string, args []string) ([]byte, error) { + cmd := exec.Command(cursorpath, 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", @@ -151,3 +198,14 @@ var commonVSCodePaths = []string{ "/usr/share/code-oss/bin/code-oss", "/usr/share/code/bin/code", } + +var commonCursorPaths = []string{ + "cursor", + "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", + "/mnt/c/Program Files/Cursor/bin/cursor", + "/usr/bin/cursor", + "/usr/local/bin/cursor", + "/snap/bin/cursor", + "/usr/local/share/cursor/bin/cursor", + "/usr/share/cursor/bin/cursor", +}