Skip to content

Commit cc084c9

Browse files
authored
feat(BRE2-756): brev shell external node support (#310)
feat(BRE2-756): brev shell, copy, port-forward, open, and refresh external node support
1 parent 5b39967 commit cc084c9

16 files changed

Lines changed: 1261 additions & 51 deletions

pkg/cmd/copy/copy.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"time"
1010

11+
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
12+
1113
"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
1214
"github.com/brevdev/brev-cli/pkg/cmd/completions"
1315
"github.com/brevdev/brev-cli/pkg/cmd/refresh"
@@ -75,7 +77,15 @@ func runCopyCommand(t *terminal.Terminal, cstore CopyStore, source, dest string,
7577
}
7678
}
7779

78-
workspace, err := prepareWorkspace(t, cstore, workspaceNameOrID)
80+
target, err := util.ResolveWorkspaceOrNode(cstore, workspaceNameOrID)
81+
if err != nil {
82+
return breverrors.WrapAndTrace(err)
83+
}
84+
if target.Node != nil {
85+
return copyExternalNode(t, cstore, target.Node, localPath, remotePath, isUpload)
86+
}
87+
88+
workspace, err := prepareWorkspace(t, cstore, target.Workspace)
7989
if err != nil {
8090
return breverrors.WrapAndTrace(err)
8191
}
@@ -116,26 +126,22 @@ func parseCopyArguments(source, dest string) (workspaceNameOrID, remotePath, loc
116126
return destWorkspace, destPath, source, true, nil
117127
}
118128

119-
func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspaceNameOrID string) (*entity.Workspace, error) {
129+
func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspace *entity.Workspace) (*entity.Workspace, error) {
120130
s := t.NewSpinner()
121-
workspace, err := util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
122-
if err != nil {
123-
return nil, breverrors.WrapAndTrace(err)
124-
}
125131

126132
if workspace.Status == "STOPPED" {
127-
err = startWorkspaceIfStopped(t, s, cstore, workspaceNameOrID, workspace)
133+
err := startWorkspaceIfStopped(t, s, cstore, workspace.Name, workspace)
128134
if err != nil {
129135
return nil, breverrors.WrapAndTrace(err)
130136
}
131137
}
132138

133-
err = pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
139+
err := pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
134140
if err != nil {
135141
return nil, breverrors.WrapAndTrace(err)
136142
}
137143

138-
workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
144+
workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspace.Name)
139145
if err != nil {
140146
return nil, breverrors.WrapAndTrace(err)
141147
}
@@ -287,6 +293,28 @@ func startWorkspaceIfStopped(t *terminal.Terminal, s *spinner.Spinner, tstore Co
287293
return nil
288294
}
289295

296+
func copyExternalNode(t *terminal.Terminal, cstore CopyStore, node *nodev1.ExternalNode, localPath, remotePath string, isUpload bool) error {
297+
info, err := util.ResolveExternalNodeSSH(cstore, node)
298+
if err != nil {
299+
return breverrors.WrapAndTrace(err)
300+
}
301+
alias := info.SSHAlias()
302+
303+
// Ensure SSH config is up to date so the alias resolves.
304+
refreshRes := refresh.RunRefreshAsync(cstore)
305+
if err := refreshRes.Await(); err != nil {
306+
return breverrors.WrapAndTrace(err)
307+
}
308+
309+
s := t.NewSpinner()
310+
err = waitForSSHToBeAvailable(alias, s)
311+
if err != nil {
312+
return breverrors.WrapAndTrace(err)
313+
}
314+
315+
return runSCP(t, alias, localPath, remotePath, isUpload)
316+
}
317+
290318
func pollUntil(s *spinner.Spinner, wsid string, state string, copyStore CopyStore, waitMsg string) error {
291319
isReady := false
292320
s.Suffix = waitMsg

pkg/cmd/copy/copy_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package copy
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseCopyArguments_Upload(t *testing.T) {
8+
ws, remotePath, localPath, isUpload, err := parseCopyArguments("./local.txt", "my-node:/tmp/dest")
9+
if err != nil {
10+
t.Fatalf("unexpected error: %v", err)
11+
}
12+
if ws != "my-node" {
13+
t.Errorf("expected workspace my-node, got %s", ws)
14+
}
15+
if remotePath != "/tmp/dest" {
16+
t.Errorf("expected remotePath /tmp/dest, got %s", remotePath)
17+
}
18+
if localPath != "./local.txt" {
19+
t.Errorf("expected localPath ./local.txt, got %s", localPath)
20+
}
21+
if !isUpload {
22+
t.Error("expected isUpload=true")
23+
}
24+
}
25+
26+
func TestParseCopyArguments_Download(t *testing.T) {
27+
ws, remotePath, localPath, isUpload, err := parseCopyArguments("my-node:/tmp/file", "./local.txt")
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
if ws != "my-node" {
32+
t.Errorf("expected workspace my-node, got %s", ws)
33+
}
34+
if remotePath != "/tmp/file" {
35+
t.Errorf("expected remotePath /tmp/file, got %s", remotePath)
36+
}
37+
if localPath != "./local.txt" {
38+
t.Errorf("expected localPath ./local.txt, got %s", localPath)
39+
}
40+
if isUpload {
41+
t.Error("expected isUpload=false")
42+
}
43+
}
44+
45+
func TestParseCopyArguments_BothLocal(t *testing.T) {
46+
_, _, _, _, err := parseCopyArguments("./a", "./b")
47+
if err == nil {
48+
t.Fatal("expected error when both paths are local")
49+
}
50+
}
51+
52+
func TestParseCopyArguments_BothRemote(t *testing.T) {
53+
_, _, _, _, err := parseCopyArguments("ws1:/a", "ws2:/b")
54+
if err == nil {
55+
t.Fatal("expected error when both paths are remote")
56+
}
57+
}
58+
59+
func TestParseWorkspacePath_Local(t *testing.T) {
60+
ws, fp, err := parseWorkspacePath("/tmp/local/file")
61+
if err != nil {
62+
t.Fatalf("unexpected error: %v", err)
63+
}
64+
if ws != "" {
65+
t.Errorf("expected empty workspace, got %s", ws)
66+
}
67+
if fp != "/tmp/local/file" {
68+
t.Errorf("expected /tmp/local/file, got %s", fp)
69+
}
70+
}
71+
72+
func TestParseWorkspacePath_Remote(t *testing.T) {
73+
ws, fp, err := parseWorkspacePath("my-instance:/remote/path")
74+
if err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
if ws != "my-instance" {
78+
t.Errorf("expected my-instance, got %s", ws)
79+
}
80+
if fp != "/remote/path" {
81+
t.Errorf("expected /remote/path, got %s", fp)
82+
}
83+
}
84+
85+
func TestParseWorkspacePath_InvalidMultipleColons(t *testing.T) {
86+
_, _, err := parseWorkspacePath("ws:path:extra")
87+
if err == nil {
88+
t.Fatal("expected error for multiple colons")
89+
}
90+
}

pkg/cmd/notebook/notebook.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type WorkspaceResult struct {
2525
Err error
2626
}
2727

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

6868
// Port forward on 8888
69-
err2 := portforward.RunPortforward(store, args[0], "8888:8888", false)
69+
err2 := portforward.RunPortforward(t, store, args[0], "8888:8888", false)
7070
if err2 != nil {
7171
return breverrors.WrapAndTrace(err2)
7272
}

pkg/cmd/open/open.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"strings"
1111
"time"
1212

13+
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
14+
1315
"github.com/alessio/shellescape"
1416
"github.com/brevdev/brev-cli/pkg/analytics"
1517
"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
@@ -281,10 +283,18 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
281283
// todo check if workspace is stopped and start if it if it is stopped
282284
fmt.Println("finding your instance...")
283285
res := refresh.RunRefreshAsync(tstore)
284-
workspace, err := util.GetUserWorkspaceByNameOrIDErr(tstore, wsIDOrName)
286+
target, err := util.ResolveWorkspaceOrNode(tstore, wsIDOrName)
285287
if err != nil {
286288
return breverrors.WrapAndTrace(err)
287289
}
290+
if target.Node != nil {
291+
// Await refresh so SSH config entries are written for the node.
292+
if awaitErr := res.Await(); awaitErr != nil {
293+
return breverrors.WrapAndTrace(awaitErr)
294+
}
295+
return openExternalNode(t, tstore, target.Node, directory, editorType)
296+
}
297+
workspace := target.Workspace
288298
if workspace.Status == "STOPPED" { // we start the env for the user
289299
err = startWorkspaceIfStopped(t, tstore, wsIDOrName, workspace)
290300
if err != nil {
@@ -356,6 +366,36 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
356366
return nil
357367
}
358368

369+
func openExternalNode(t *terminal.Terminal, tstore OpenStore, node *nodev1.ExternalNode, directory string, editorType string) error {
370+
info, err := util.ResolveExternalNodeSSH(tstore, node)
371+
if err != nil {
372+
return breverrors.WrapAndTrace(err)
373+
}
374+
alias := info.SSHAlias()
375+
path := info.HomePath()
376+
if directory != "" {
377+
path = directory
378+
}
379+
380+
_ = hello.SetHasRunOpen(true)
381+
382+
s := t.NewSpinner()
383+
s.Start()
384+
s.Suffix = " checking if your node is ready..."
385+
err = waitForSSHToBeAvailable(t, s, alias)
386+
if err != nil {
387+
return breverrors.WrapAndTrace(err)
388+
}
389+
390+
editorName := getEditorName(editorType)
391+
s.Suffix = fmt.Sprintf(" Node is ready. Opening %s", editorName)
392+
time.Sleep(250 * time.Millisecond)
393+
s.Stop()
394+
t.Vprintf("\n")
395+
396+
return openEditorByType(t, editorType, alias, path, tstore)
397+
}
398+
359399
func pushOpenAnalytics(tstore OpenStore, workspace *entity.Workspace) error {
360400
userID := ""
361401
user, err := tstore.GetCurrentUser()

pkg/cmd/open/open_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,43 @@
11
package open
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestIsEditorType(t *testing.T) {
8+
valid := []string{"code", "cursor", "windsurf", "terminal", "tmux"}
9+
for _, v := range valid {
10+
if !isEditorType(v) {
11+
t.Errorf("expected %q to be valid editor type", v)
12+
}
13+
}
14+
15+
invalid := []string{"vim", "emacs", "vscode", "Code", "", "ssh"}
16+
for _, v := range invalid {
17+
if isEditorType(v) {
18+
t.Errorf("expected %q to NOT be valid editor type", v)
19+
}
20+
}
21+
}
22+
23+
func TestGetEditorName(t *testing.T) {
24+
tests := []struct {
25+
input string
26+
want string
27+
}{
28+
{"code", "VSCode"},
29+
{"cursor", "Cursor"},
30+
{"windsurf", "Windsurf"},
31+
{"terminal", "Terminal"},
32+
{"tmux", "tmux"},
33+
{"unknown", "VSCode"},
34+
}
35+
for _, tt := range tests {
36+
t.Run(tt.input, func(t *testing.T) {
37+
got := getEditorName(tt.input)
38+
if got != tt.want {
39+
t.Errorf("getEditorName(%q) = %q, want %q", tt.input, got, tt.want)
40+
}
41+
})
42+
}
43+
}

0 commit comments

Comments
 (0)