diff --git a/internal/codespaces/ssh.go b/internal/codespaces/ssh.go index 36c8bf5b2..1807c87fa 100644 --- a/internal/codespaces/ssh.go +++ b/internal/codespaces/ssh.go @@ -25,6 +25,31 @@ func Shell(ctx context.Context, log logger, sshArgs []string, port int, destinat return cmd.Run() } +// Copy runs an scp command over the specified port. The arguments may +// include flags and non-flags, optionally separated by "--". +// Remote files are indicated by a "remote:" prefix, and are resolved +// relative to the remote user's home directory. +func Copy(ctx context.Context, scpArgs []string, port int, destination string) error { + // Beware: invalid syntax causes scp to exit 1 with + // no error message, so don't let that happen. + cmd := exec.CommandContext(ctx, "scp", + "-P", strconv.Itoa(port), + "-o", "NoHostAuthenticationForLocalhost=yes", + "-C", // compression + ) + for _, arg := range scpArgs { + // Replace "remote:" prefix with (e.g.) "root@localhost:". + if rest := strings.TrimPrefix(arg, "remote:"); rest != arg { + arg = destination + ":" + rest + } + cmd.Args = append(cmd.Args, arg) + } + cmd.Stdin = nil + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + // NewRemoteCommand returns an exec.Cmd that will securely run a shell // command on the remote machine. func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, sshArgs ...string) (*exec.Cmd, error) { diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index cd5c599c0..e1e28315d 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -27,6 +27,7 @@ func NewRootCmd(app *App) *cobra.Command { root.AddCommand(newLogsCmd(app)) root.AddCommand(newPortsCmd(app)) root.AddCommand(newSSHCmd(app)) + root.AddCommand(newCpCmd(app)) root.AddCommand(newStopCmd(app)) return root diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 218494a13..d0d3e49fe 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -1,5 +1,7 @@ package codespace +// This file defines the 'gh cs ssh' and 'gh cs cp' subcommands. + import ( "context" "fmt" @@ -7,6 +9,8 @@ import ( "log" "net" "os" + "path/filepath" + "strings" "github.com/cli/cli/v2/internal/codespaces" "github.com/cli/cli/v2/pkg/liveshare" @@ -19,6 +23,7 @@ type sshOptions struct { serverPort int debug bool debugFile string + scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh') } func newSSHCmd(app *App) *cobra.Command { @@ -117,7 +122,13 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e shellClosed := make(chan error, 1) go func() { - shellClosed <- codespaces.Shell(ctx, a.logger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort) + var err error + if opts.scpArgs != nil { + err = codespaces.Copy(ctx, opts.scpArgs, localSSHServerPort, connectDestination) + } else { + err = codespaces.Shell(ctx, a.logger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort) + } + shellClosed <- err }() select { @@ -131,6 +142,67 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e } } +type cpOptions struct { + sshOptions + recursive bool // -r +} + +func newCpCmd(app *App) *cobra.Command { + var opts cpOptions + + cpCmd := &cobra.Command{ + Use: "cp [-r] srcs... dest", + Short: "Copy files between local and remote file systems", + Long: ` +The cp command copies files between the local and remote file systems. + +A 'remote:' prefix on any file name argument indicates that it refers to +the file system of the remote (Codespace) machine. It is resolved relative +to the home directory of the remote user. + +As with the UNIX cp command, the first argument specifies the source and the last +specifies the destination; additional sources may be specified after the first, +if the destination is a directory. + +The -r (recursive) flag is required if any source is a directory. +`, + RunE: func(cmd *cobra.Command, args []string) error { + return app.Copy(cmd.Context(), args, opts) + }, + } + + // We don't expose all sshOptions. + cpCmd.Flags().BoolVarP(&opts.recursive, "recursive", "r", false, "Recursively copy directories") + cpCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace") + return cpCmd +} + +// Copy copies files between the local and remote file systems. +// The mechanics are similar to 'ssh' but using 'scp'. +func (a *App) Copy(ctx context.Context, args []string, opts cpOptions) (err error) { + if len(args) < 2 { + return fmt.Errorf("cp requires source and destination arguments") + } + if opts.recursive { + opts.scpArgs = append(opts.scpArgs, "-r") + } + opts.scpArgs = append(opts.scpArgs, "--") + for _, arg := range args { + if !filepath.IsAbs(arg) && !strings.HasPrefix(arg, "remote:") { + // scp treats a colon in the first path segment as a host identifier. + // Escape it by prepending "./". + // TODO(adonovan): test on Windows, including with a c:\\foo path. + const sep = string(os.PathSeparator) + first := strings.Split(filepath.ToSlash(arg), sep)[0] + if strings.Contains(first, ":") { + arg = "." + sep + arg + } + } + opts.scpArgs = append(opts.scpArgs, arg) + } + return a.SSH(ctx, nil, opts.sshOptions) +} + // fileLogger is a wrapper around an log.Logger configured to write // to a file. It exports two additional methods to get the log file name // and close the file handle when the operation is finished.