Skip to content
166 changes: 151 additions & 15 deletions pkg/cmd/open/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package open
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
Expand All @@ -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"
Expand All @@ -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 {
Expand All @@ -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": ""},
Expand All @@ -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)
Expand Down Expand Up @@ -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\""
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
27 changes: 27 additions & 0 deletions pkg/files/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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??
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Loading
Loading