Skip to content

Commit afcddc3

Browse files
Implement brev copy command for file transfer between local and remote workspaces
- Add new copy command in pkg/cmd/copy/copy.go following shell command patterns - Support bidirectional copying: local->remote and remote->local - Use scp for file transfer with workspace:path syntax parsing - Include workspace startup logic and SSH connection handling - Register command in SSH Commands section with aliases cp, scp - Add --host flag for copying to/from host machine vs container Co-Authored-By: Alec Fong <alecsanf@usc.edu>
1 parent 53896a9 commit afcddc3

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

pkg/cmd/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/brevdev/brev-cli/pkg/cmd/clipboard"
1010
"github.com/brevdev/brev-cli/pkg/cmd/configureenvvars"
1111
"github.com/brevdev/brev-cli/pkg/cmd/connect"
12+
"github.com/brevdev/brev-cli/pkg/cmd/copy"
1213
"github.com/brevdev/brev-cli/pkg/cmd/create"
1314
"github.com/brevdev/brev-cli/pkg/cmd/delete"
1415
"github.com/brevdev/brev-cli/pkg/cmd/envvars"
@@ -263,6 +264,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor
263264
cmd.AddCommand(configureenvvars.NewCmdConfigureEnvVars(t, loginCmdStore))
264265
cmd.AddCommand(importideconfig.NewCmdImportIDEConfig(t, noLoginCmdStore))
265266
cmd.AddCommand(shell.NewCmdShell(t, loginCmdStore, noLoginCmdStore))
267+
cmd.AddCommand(copy.NewCmdCopy(t, loginCmdStore, noLoginCmdStore))
266268
cmd.AddCommand(open.NewCmdOpen(t, loginCmdStore, noLoginCmdStore))
267269
cmd.AddCommand(ollama.NewCmdOllama(t, loginCmdStore))
268270
cmd.AddCommand(background.NewCmdBackground(t, loginCmdStore))

pkg/cmd/copy/copy.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package copy
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
"time"
9+
10+
"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
11+
"github.com/brevdev/brev-cli/pkg/cmd/completions"
12+
"github.com/brevdev/brev-cli/pkg/cmd/refresh"
13+
"github.com/brevdev/brev-cli/pkg/cmd/util"
14+
"github.com/brevdev/brev-cli/pkg/entity"
15+
breverrors "github.com/brevdev/brev-cli/pkg/errors"
16+
"github.com/brevdev/brev-cli/pkg/store"
17+
"github.com/brevdev/brev-cli/pkg/terminal"
18+
"github.com/brevdev/brev-cli/pkg/writeconnectionevent"
19+
"github.com/briandowns/spinner"
20+
21+
"github.com/spf13/cobra"
22+
)
23+
24+
var (
25+
copyLong = "Copy files between your local machine and remote workspace"
26+
copyExample = "brev copy workspace_name:/path/to/remote/file /path/to/local/file\nbrev copy /path/to/local/file workspace_name:/path/to/remote/file"
27+
)
28+
29+
type CopyStore interface {
30+
util.GetWorkspaceByNameOrIDErrStore
31+
refresh.RefreshStore
32+
GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error)
33+
GetWorkspaces(organizationID string, options *store.GetWorkspacesOptions) ([]entity.Workspace, error)
34+
StartWorkspace(workspaceID string) (*entity.Workspace, error)
35+
GetWorkspace(workspaceID string) (*entity.Workspace, error)
36+
GetCurrentUserKeys() (*entity.UserKeys, error)
37+
}
38+
39+
func NewCmdCopy(t *terminal.Terminal, store CopyStore, noLoginStartStore CopyStore) *cobra.Command {
40+
var host bool
41+
cmd := &cobra.Command{
42+
Annotations: map[string]string{"ssh": ""},
43+
Use: "copy",
44+
Aliases: []string{"cp", "scp"},
45+
DisableFlagsInUseLine: true,
46+
Short: "copy files between local and remote workspace",
47+
Long: copyLong,
48+
Example: copyExample,
49+
Args: cmderrors.TransformToValidationError(cobra.ExactArgs(2)),
50+
ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t),
51+
RunE: func(cmd *cobra.Command, args []string) error {
52+
err := runCopyCommand(t, store, args[0], args[1], host)
53+
if err != nil {
54+
return breverrors.WrapAndTrace(err)
55+
}
56+
return nil
57+
},
58+
}
59+
cmd.Flags().BoolVarP(&host, "host", "", false, "copy to/from the host machine instead of the container")
60+
61+
return cmd
62+
}
63+
64+
func runCopyCommand(t *terminal.Terminal, cstore CopyStore, source, dest string, host bool) error {
65+
workspaceNameOrID, remotePath, localPath, isUpload, err := parseCopyArguments(source, dest)
66+
if err != nil {
67+
return breverrors.WrapAndTrace(err)
68+
}
69+
70+
workspace, err := prepareWorkspace(t, cstore, workspaceNameOrID)
71+
if err != nil {
72+
return breverrors.WrapAndTrace(err)
73+
}
74+
75+
sshName, err := setupSSHConnection(t, cstore, workspace, host)
76+
if err != nil {
77+
return breverrors.WrapAndTrace(err)
78+
}
79+
80+
_ = writeconnectionevent.WriteWCEOnEnv(cstore, workspace.DNS)
81+
82+
err = runSCP(sshName, localPath, remotePath, isUpload)
83+
if err != nil {
84+
return breverrors.WrapAndTrace(err)
85+
}
86+
87+
return nil
88+
}
89+
90+
func parseCopyArguments(source, dest string) (workspaceNameOrID, remotePath, localPath string, isUpload bool, err error) {
91+
sourceWorkspace, sourcePath, err := parseWorkspacePath(source)
92+
if err != nil {
93+
return "", "", "", false, err
94+
}
95+
96+
destWorkspace, destPath, err := parseWorkspacePath(dest)
97+
if err != nil {
98+
return "", "", "", false, err
99+
}
100+
101+
if (sourceWorkspace == "" && destWorkspace == "") || (sourceWorkspace != "" && destWorkspace != "") {
102+
return "", "", "", false, breverrors.NewValidationError("exactly one of source or destination must be a workspace path (format: workspace_name:/path)")
103+
}
104+
105+
if sourceWorkspace != "" {
106+
return sourceWorkspace, sourcePath, dest, false, nil
107+
}
108+
return destWorkspace, destPath, source, true, nil
109+
}
110+
111+
func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspaceNameOrID string) (*entity.Workspace, error) {
112+
s := t.NewSpinner()
113+
workspace, err := util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
114+
if err != nil {
115+
return nil, breverrors.WrapAndTrace(err)
116+
}
117+
118+
if workspace.Status == "STOPPED" {
119+
err = startWorkspaceIfStopped(t, s, cstore, workspaceNameOrID, workspace)
120+
if err != nil {
121+
return nil, breverrors.WrapAndTrace(err)
122+
}
123+
}
124+
125+
err = pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
126+
if err != nil {
127+
return nil, breverrors.WrapAndTrace(err)
128+
}
129+
130+
workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
131+
if err != nil {
132+
return nil, breverrors.WrapAndTrace(err)
133+
}
134+
if workspace.Status != "RUNNING" {
135+
return nil, breverrors.New("Workspace is not running")
136+
}
137+
138+
return workspace, nil
139+
}
140+
141+
func setupSSHConnection(t *terminal.Terminal, cstore CopyStore, workspace *entity.Workspace, host bool) (string, error) {
142+
refreshRes := refresh.RunRefreshAsync(cstore)
143+
144+
localIdentifier := workspace.GetLocalIdentifier()
145+
if host {
146+
localIdentifier = workspace.GetHostIdentifier()
147+
}
148+
149+
sshName := string(localIdentifier)
150+
151+
err := refreshRes.Await()
152+
if err != nil {
153+
return "", breverrors.WrapAndTrace(err)
154+
}
155+
156+
s := t.NewSpinner()
157+
err = waitForSSHToBeAvailable(sshName, s)
158+
if err != nil {
159+
return "", breverrors.WrapAndTrace(err)
160+
}
161+
162+
return sshName, nil
163+
}
164+
165+
func parseWorkspacePath(path string) (workspace, filePath string, err error) {
166+
if !strings.Contains(path, ":") {
167+
return "", path, nil
168+
}
169+
170+
parts := strings.Split(path, ":")
171+
if len(parts) != 2 {
172+
return "", "", breverrors.NewValidationError("invalid workspace path format, use workspace_name:/path")
173+
}
174+
175+
return parts[0], parts[1], nil
176+
}
177+
178+
func runSCP(sshAlias, localPath, remotePath string, isUpload bool) error {
179+
var scpCmd *exec.Cmd
180+
181+
if isUpload {
182+
scpCmd = exec.Command("scp", localPath, fmt.Sprintf("%s:%s", sshAlias, remotePath)) //nolint:gosec //sshAlias is validated workspace identifier
183+
} else {
184+
scpCmd = exec.Command("scp", fmt.Sprintf("%s:%s", sshAlias, remotePath), localPath) //nolint:gosec //sshAlias is validated workspace identifier
185+
}
186+
187+
output, err := scpCmd.CombinedOutput()
188+
if err != nil {
189+
return breverrors.WrapAndTrace(fmt.Errorf("scp failed: %s\nOutput: %s", err.Error(), string(output)))
190+
}
191+
192+
return nil
193+
}
194+
195+
func waitForSSHToBeAvailable(sshAlias string, s *spinner.Spinner) error {
196+
counter := 0
197+
s.Suffix = " waiting for SSH connection to be available"
198+
s.Start()
199+
for {
200+
cmd := exec.Command("ssh", "-o", "ConnectTimeout=10", sshAlias, "echo", " ")
201+
out, err := cmd.CombinedOutput()
202+
if err == nil {
203+
s.Stop()
204+
return nil
205+
}
206+
207+
outputStr := string(out)
208+
stdErr := strings.Split(outputStr, "\n")[1]
209+
210+
if counter == 40 || !store.SatisfactorySSHErrMessage(stdErr) {
211+
return breverrors.WrapAndTrace(errors.New("\n" + stdErr))
212+
}
213+
214+
counter++
215+
time.Sleep(1 * time.Second)
216+
}
217+
}
218+
219+
func startWorkspaceIfStopped(t *terminal.Terminal, s *spinner.Spinner, tstore CopyStore, wsIDOrName string, workspace *entity.Workspace) error {
220+
activeOrg, err := tstore.GetActiveOrganizationOrDefault()
221+
if err != nil {
222+
return breverrors.WrapAndTrace(err)
223+
}
224+
workspaces, err := tstore.GetWorkspaceByNameOrID(activeOrg.ID, wsIDOrName)
225+
if err != nil {
226+
return breverrors.WrapAndTrace(err)
227+
}
228+
startedWorkspace, err := tstore.StartWorkspace(workspaces[0].ID)
229+
if err != nil {
230+
return breverrors.WrapAndTrace(err)
231+
}
232+
t.Vprintf(t.Yellow("Instance %s is starting. \n\n", startedWorkspace.Name))
233+
err = pollUntil(s, workspace.ID, entity.Running, tstore, " hang tight 🤙")
234+
if err != nil {
235+
return breverrors.WrapAndTrace(err)
236+
}
237+
workspace, err = util.GetUserWorkspaceByNameOrIDErr(tstore, wsIDOrName)
238+
if err != nil {
239+
return breverrors.WrapAndTrace(err)
240+
}
241+
return nil
242+
}
243+
244+
func pollUntil(s *spinner.Spinner, wsid string, state string, copyStore CopyStore, waitMsg string) error {
245+
isReady := false
246+
s.Suffix = waitMsg
247+
s.Start()
248+
for !isReady {
249+
time.Sleep(5 * time.Second)
250+
ws, err := copyStore.GetWorkspace(wsid)
251+
if err != nil {
252+
return breverrors.WrapAndTrace(err)
253+
}
254+
s.Suffix = waitMsg
255+
if ws.Status == state {
256+
isReady = true
257+
}
258+
}
259+
return nil
260+
}

0 commit comments

Comments
 (0)