Skip to content
Open
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
46 changes: 37 additions & 9 deletions pkg/cmd/copy/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"time"

nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"

"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
"github.com/brevdev/brev-cli/pkg/cmd/completions"
"github.com/brevdev/brev-cli/pkg/cmd/refresh"
Expand Down Expand Up @@ -75,7 +77,15 @@ func runCopyCommand(t *terminal.Terminal, cstore CopyStore, source, dest string,
}
}

workspace, err := prepareWorkspace(t, cstore, workspaceNameOrID)
target, err := util.ResolveWorkspaceOrNode(cstore, workspaceNameOrID)
if err != nil {
return breverrors.WrapAndTrace(err)
}
if target.Node != nil {
return copyExternalNode(t, cstore, target.Node, localPath, remotePath, isUpload)
}

workspace, err := prepareWorkspace(t, cstore, target.Workspace)
if err != nil {
return breverrors.WrapAndTrace(err)
}
Expand Down Expand Up @@ -116,26 +126,22 @@ func parseCopyArguments(source, dest string) (workspaceNameOrID, remotePath, loc
return destWorkspace, destPath, source, true, nil
}

func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspaceNameOrID string) (*entity.Workspace, error) {
func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspace *entity.Workspace) (*entity.Workspace, error) {
s := t.NewSpinner()
workspace, err := util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
if err != nil {
return nil, breverrors.WrapAndTrace(err)
}

if workspace.Status == "STOPPED" {
err = startWorkspaceIfStopped(t, s, cstore, workspaceNameOrID, workspace)
err := startWorkspaceIfStopped(t, s, cstore, workspace.Name, workspace)
if err != nil {
return nil, breverrors.WrapAndTrace(err)
}
}

err = pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
err := pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
if err != nil {
return nil, breverrors.WrapAndTrace(err)
}

workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspace.Name)
if err != nil {
return nil, breverrors.WrapAndTrace(err)
}
Expand Down Expand Up @@ -287,6 +293,28 @@ func startWorkspaceIfStopped(t *terminal.Terminal, s *spinner.Spinner, tstore Co
return nil
}

func copyExternalNode(t *terminal.Terminal, cstore CopyStore, node *nodev1.ExternalNode, localPath, remotePath string, isUpload bool) error {
info, err := util.ResolveExternalNodeSSH(cstore, node)
if err != nil {
return breverrors.WrapAndTrace(err)
}
alias := info.SSHAlias()

// Ensure SSH config is up to date so the alias resolves.
refreshRes := refresh.RunRefreshAsync(cstore)
if err := refreshRes.Await(); err != nil {
return breverrors.WrapAndTrace(err)
}

s := t.NewSpinner()
err = waitForSSHToBeAvailable(alias, s)
if err != nil {
return breverrors.WrapAndTrace(err)
}

return runSCP(t, alias, localPath, remotePath, isUpload)
}

func pollUntil(s *spinner.Spinner, wsid string, state string, copyStore CopyStore, waitMsg string) error {
isReady := false
s.Suffix = waitMsg
Expand Down
90 changes: 90 additions & 0 deletions pkg/cmd/copy/copy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package copy

import (
"testing"
)

func TestParseCopyArguments_Upload(t *testing.T) {
ws, remotePath, localPath, isUpload, err := parseCopyArguments("./local.txt", "my-node:/tmp/dest")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != "my-node" {
t.Errorf("expected workspace my-node, got %s", ws)
}
if remotePath != "/tmp/dest" {
t.Errorf("expected remotePath /tmp/dest, got %s", remotePath)
}
if localPath != "./local.txt" {
t.Errorf("expected localPath ./local.txt, got %s", localPath)
}
if !isUpload {
t.Error("expected isUpload=true")
}
}

func TestParseCopyArguments_Download(t *testing.T) {
ws, remotePath, localPath, isUpload, err := parseCopyArguments("my-node:/tmp/file", "./local.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != "my-node" {
t.Errorf("expected workspace my-node, got %s", ws)
}
if remotePath != "/tmp/file" {
t.Errorf("expected remotePath /tmp/file, got %s", remotePath)
}
if localPath != "./local.txt" {
t.Errorf("expected localPath ./local.txt, got %s", localPath)
}
if isUpload {
t.Error("expected isUpload=false")
}
}

func TestParseCopyArguments_BothLocal(t *testing.T) {
_, _, _, _, err := parseCopyArguments("./a", "./b")
if err == nil {
t.Fatal("expected error when both paths are local")
}
}

func TestParseCopyArguments_BothRemote(t *testing.T) {
_, _, _, _, err := parseCopyArguments("ws1:/a", "ws2:/b")
if err == nil {
t.Fatal("expected error when both paths are remote")
}
}

func TestParseWorkspacePath_Local(t *testing.T) {
ws, fp, err := parseWorkspacePath("/tmp/local/file")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != "" {
t.Errorf("expected empty workspace, got %s", ws)
}
if fp != "/tmp/local/file" {
t.Errorf("expected /tmp/local/file, got %s", fp)
}
}

func TestParseWorkspacePath_Remote(t *testing.T) {
ws, fp, err := parseWorkspacePath("my-instance:/remote/path")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != "my-instance" {
t.Errorf("expected my-instance, got %s", ws)
}
if fp != "/remote/path" {
t.Errorf("expected /remote/path, got %s", fp)
}
}

func TestParseWorkspacePath_InvalidMultipleColons(t *testing.T) {
_, _, err := parseWorkspacePath("ws:path:extra")
if err == nil {
t.Fatal("expected error for multiple colons")
}
}
4 changes: 2 additions & 2 deletions pkg/cmd/notebook/notebook.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type WorkspaceResult struct {
Err error
}

func NewCmdNotebook(store NotebookStore, _ *terminal.Terminal) *cobra.Command {
func NewCmdNotebook(store NotebookStore, t *terminal.Terminal) *cobra.Command {
cmd := &cobra.Command{
Use: "notebook",
Short: "Open a notebook on your Brev machine",
Expand Down Expand Up @@ -66,7 +66,7 @@ func NewCmdNotebook(store NotebookStore, _ *terminal.Terminal) *cobra.Command {
hello.TypeItToMeUnskippable27("\nClick here to go to your Jupyter notebook:\n\t 👉" + urlType("http://localhost:8888") + "👈\n\n\n")

// Port forward on 8888
err2 := portforward.RunPortforward(store, args[0], "8888:8888", false)
err2 := portforward.RunPortforward(t, store, args[0], "8888:8888", false)
if err2 != nil {
return breverrors.WrapAndTrace(err2)
}
Expand Down
42 changes: 41 additions & 1 deletion pkg/cmd/open/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"strings"
"time"

nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"

"github.com/alessio/shellescape"
"github.com/brevdev/brev-cli/pkg/analytics"
"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
Expand Down Expand Up @@ -281,10 +283,18 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
// todo check if workspace is stopped and start if it if it is stopped
fmt.Println("finding your instance...")
res := refresh.RunRefreshAsync(tstore)
workspace, err := util.GetUserWorkspaceByNameOrIDErr(tstore, wsIDOrName)
target, err := util.ResolveWorkspaceOrNode(tstore, wsIDOrName)
if err != nil {
return breverrors.WrapAndTrace(err)
}
if target.Node != nil {
// Await refresh so SSH config entries are written for the node.
if awaitErr := res.Await(); awaitErr != nil {
return breverrors.WrapAndTrace(awaitErr)
}
return openExternalNode(t, tstore, target.Node, directory, editorType)
}
workspace := target.Workspace
if workspace.Status == "STOPPED" { // we start the env for the user
err = startWorkspaceIfStopped(t, tstore, wsIDOrName, workspace)
if err != nil {
Expand Down Expand Up @@ -356,6 +366,36 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
return nil
}

func openExternalNode(t *terminal.Terminal, tstore OpenStore, node *nodev1.ExternalNode, directory string, editorType string) error {
info, err := util.ResolveExternalNodeSSH(tstore, node)
if err != nil {
return breverrors.WrapAndTrace(err)
}
alias := info.SSHAlias()
path := info.HomePath()
if directory != "" {
path = directory
}

_ = hello.SetHasRunOpen(true)

s := t.NewSpinner()
s.Start()
s.Suffix = " checking if your node is ready..."
err = waitForSSHToBeAvailable(t, s, alias)
if err != nil {
return breverrors.WrapAndTrace(err)
}

editorName := getEditorName(editorType)
s.Suffix = fmt.Sprintf(" Node is ready. Opening %s", editorName)
time.Sleep(250 * time.Millisecond)
s.Stop()
t.Vprintf("\n")

return openEditorByType(t, editorType, alias, path, tstore)
}

func pushOpenAnalytics(tstore OpenStore, workspace *entity.Workspace) error {
userID := ""
user, err := tstore.GetCurrentUser()
Expand Down
42 changes: 42 additions & 0 deletions pkg/cmd/open/open_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,43 @@
package open

import (
"testing"
)

func TestIsEditorType(t *testing.T) {
valid := []string{"code", "cursor", "windsurf", "terminal", "tmux"}
for _, v := range valid {
if !isEditorType(v) {
t.Errorf("expected %q to be valid editor type", v)
}
}

invalid := []string{"vim", "emacs", "vscode", "Code", "", "ssh"}
for _, v := range invalid {
if isEditorType(v) {
t.Errorf("expected %q to NOT be valid editor type", v)
}
}
}

func TestGetEditorName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"code", "VSCode"},
{"cursor", "Cursor"},
{"windsurf", "Windsurf"},
{"terminal", "Terminal"},
{"tmux", "tmux"},
{"unknown", "VSCode"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := getEditorName(tt.input)
if got != tt.want {
t.Errorf("getEditorName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Loading
Loading