99 "fmt"
1010 "os"
1111 "os/exec"
12+ "path/filepath"
1213 "runtime"
1314 "strconv"
1415 "strings"
@@ -28,6 +29,7 @@ import (
2829 "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
2930 "github.com/lima-vm/lima/v2/pkg/sshutil"
3031 "github.com/lima-vm/lima/v2/pkg/store"
32+ "github.com/lima-vm/lima/v2/pkg/uiutil"
3133)
3234
3335const shellHelp = `Execute shell in Lima
@@ -64,9 +66,15 @@ func newShellCommand() *cobra.Command {
6466 shellCmd .Flags ().Bool ("reconnect" , false , "Reconnect to the SSH session" )
6567 shellCmd .Flags ().Bool ("preserve-env" , false , "Propagate environment variables to the shell" )
6668 shellCmd .Flags ().Bool ("start" , false , "Start the instance if it is not already running" )
69+ shellCmd .Flags ().Bool ("sync" , false , "Copy the host working directory to the guest to run AI commands inside VMs (prevents AI agents from breaking the host files)" )
6770 return shellCmd
6871}
6972
73+ const (
74+ rsyncMinimumSrcDirDepth = 4 // Depth of "/Users/USER" is 3.
75+ guestSyncedWorkdir = "~/synced-workdir"
76+ )
77+
7078func shellAction (cmd * cobra.Command , args []string ) error {
7179 ctx := cmd .Context ()
7280 flags := cmd .Flags ()
@@ -150,29 +158,45 @@ func shellAction(cmd *cobra.Command, args []string) error {
150158 }
151159 }
152160
161+ syncHostWorkdir , err := flags .GetBool ("sync" )
162+ if err != nil {
163+ return fmt .Errorf ("failed to get sync flag: %w" , err )
164+ } else if syncHostWorkdir && len (inst .Config .Mounts ) > 0 {
165+ return errors .New ("cannot use `--sync` when the instance has host mounts configured, start the instance with `--mount-none` to disable mounts" )
166+ }
167+
153168 // When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
154169 //
155170 // changeDirCmd := "cd workDir || exit 1" if workDir != ""
156171 // := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
157172 var changeDirCmd string
173+ hostCurrentDir , err := hostCurrentDirectory (ctx , inst )
174+ if err != nil {
175+ changeDirCmd = "false"
176+ logrus .WithError (err ).Warn ("failed to get the current directory" )
177+ }
178+ if syncHostWorkdir {
179+ if _ , err := exec .LookPath ("rsync" ); err != nil {
180+ return fmt .Errorf ("rsync is required for `--sync` but not found: %w" , err )
181+ }
182+
183+ srcWdDepth := len (strings .Split (hostCurrentDir , string (os .PathSeparator )))
184+ if srcWdDepth < rsyncMinimumSrcDirDepth {
185+ return fmt .Errorf ("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)" ,
186+ hostCurrentDir , rsyncMinimumSrcDirDepth , srcWdDepth , "cd to a deeper directory" )
187+ }
188+ }
189+
158190 workDir , err := cmd .Flags ().GetString ("workdir" )
159191 if err != nil {
160192 return err
161193 }
162- if workDir != "" {
194+ switch {
195+ case workDir != "" :
163196 changeDirCmd = fmt .Sprintf ("cd %s || exit 1" , shellescape .Quote (workDir ))
164197 // FIXME: check whether y.Mounts contains the home, not just len > 0
165- } else if len (inst .Config .Mounts ) > 0 || inst .VMType == limatype .WSL2 {
166- hostCurrentDir , err := os .Getwd ()
167- if err == nil && runtime .GOOS == "windows" {
168- hostCurrentDir , err = mountDirFromWindowsDir (ctx , inst , hostCurrentDir )
169- }
170- if err == nil {
171- changeDirCmd = fmt .Sprintf ("cd %s" , shellescape .Quote (hostCurrentDir ))
172- } else {
173- changeDirCmd = "false"
174- logrus .WithError (err ).Warn ("failed to get the current directory" )
175- }
198+ case len (inst .Config .Mounts ) > 0 || inst .VMType == limatype .WSL2 :
199+ changeDirCmd = fmt .Sprintf ("cd %s" , shellescape .Quote (hostCurrentDir ))
176200 hostHomeDir , err := os .UserHomeDir ()
177201 if err == nil && runtime .GOOS == "windows" {
178202 hostHomeDir , err = mountDirFromWindowsDir (ctx , inst , hostHomeDir )
@@ -182,7 +206,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
182206 } else {
183207 logrus .WithError (err ).Warn ("failed to get the home directory" )
184208 }
185- } else {
209+ case syncHostWorkdir :
210+ changeDirCmd = fmt .Sprintf ("cd %s/%s" , guestSyncedWorkdir , shellescape .Quote (filepath .Base (hostCurrentDir )))
211+ default :
186212 logrus .Debug ("the host home does not seem mounted, so the guest shell will have a different cwd" )
187213 }
188214
@@ -267,6 +293,17 @@ func shellAction(cmd *cobra.Command, args []string) error {
267293 }
268294 sshArgs := append ([]string {}, sshExe .Args ... )
269295 sshArgs = append (sshArgs , sshutil .SSHArgsFromOpts (sshOpts )... )
296+
297+ var sshExecForRsync * exec.Cmd
298+ if syncHostWorkdir {
299+ logrus .Infof ("Syncing host current directory(%s) to guest instance..." , hostCurrentDir )
300+ sshExecForRsync = exec .CommandContext (ctx , sshExe .Exe , sshArgs ... )
301+ if err := rsyncDirectory (ctx , cmd , sshExecForRsync , hostCurrentDir , fmt .Sprintf ("%s:%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , guestSyncedWorkdir )); err != nil {
302+ return fmt .Errorf ("failed to sync host working directory to guest instance: %w" , err )
303+ }
304+ logrus .Infof ("Successfully synced host current directory to guest(%s/%s) instance." , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
305+ }
306+
270307 if isatty .IsTerminal (os .Stdout .Fd ()) || isatty .IsCygwinTerminal (os .Stdout .Fd ()) {
271308 // required for showing the shell prompt: https://stackoverflow.com/a/626574
272309 sshArgs = append (sshArgs , "-t" )
@@ -296,7 +333,146 @@ func shellAction(cmd *cobra.Command, args []string) error {
296333 logrus .Debugf ("executing ssh (may take a long)): %+v" , sshCmd .Args )
297334
298335 // TODO: use syscall.Exec directly (results in losing tty?)
299- return sshCmd .Run ()
336+ if err := sshCmd .Run (); err != nil {
337+ return err
338+ }
339+
340+ // Once the shell command finishes, rsync back the changes from guest workdir
341+ // to the host and delete the guest synced workdir only if the user
342+ // confirms the changes.
343+ if syncHostWorkdir {
344+ askUserForRsyncBack (ctx , cmd , inst , sshExecForRsync , hostCurrentDir )
345+ }
346+ return nil
347+ }
348+
349+ func askUserForRsyncBack (ctx context.Context , cmd * cobra.Command , inst * limatype.Instance , sshCmd * exec.Cmd , hostCurrentDir string ) {
350+ message := "⚠️ Accept the changes?"
351+ options := []string {
352+ "Yes" ,
353+ "No" ,
354+ "View the changed contents" ,
355+ }
356+
357+ var hostTmpDest string
358+ remoteSource := fmt .Sprintf ("%s:%s/%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , guestSyncedWorkdir , filepath .Base (hostCurrentDir ))
359+ rsyncToTempDir := false
360+
361+ for {
362+ ans , err := uiutil .Select (message , options )
363+ if err != nil {
364+ if errors .Is (err , uiutil .InterruptErr ) {
365+ logrus .Fatal ("Interrupted by user" )
366+ }
367+ logrus .WithError (err ).Warn ("Failed to open TUI" )
368+ return
369+ }
370+
371+ switch ans {
372+ case 0 : // Yes
373+ dest := filepath .Dir (hostCurrentDir )
374+ if err := rsyncDirectory (ctx , cmd , sshCmd , remoteSource , dest ); err != nil {
375+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host" )
376+ return
377+ }
378+ cleanGuestSyncedWorkdir (ctx , sshCmd )
379+ logrus .Info ("Successfully synced back the changes to host." )
380+ return
381+ case 1 : // No
382+ cleanGuestSyncedWorkdir (ctx , sshCmd )
383+ logrus .Info ("Skipping syncing back the changes to host." )
384+ return
385+ case 2 : // View the changed contents
386+ if ! rsyncToTempDir {
387+ hostTmpDest , err = os .MkdirTemp ("" , "lima-guest-synced-*" )
388+ if err != nil {
389+ logrus .WithError (err ).Warn ("Failed to create temporary directory" )
390+ return
391+ }
392+ defer func () {
393+ if err := os .RemoveAll (hostTmpDest ); err != nil {
394+ logrus .WithError (err ).Warnf ("Failed to clean up temporary directory %s" , hostTmpDest )
395+ }
396+ }()
397+
398+ if err := rsyncDirectory (ctx , cmd , sshCmd , remoteSource , hostTmpDest ); err != nil {
399+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host for viewing" )
400+ return
401+ }
402+ rsyncToTempDir = true
403+ }
404+ diffCmd := exec .CommandContext (ctx , "diff" , "-ru" , "--color=always" , hostCurrentDir , filepath .Join (hostTmpDest , filepath .Base (hostCurrentDir )))
405+ pager := os .Getenv ("PAGER" )
406+ if pager == "" {
407+ pager = "less"
408+ }
409+ lessCmd := exec .CommandContext (ctx , pager , "-R" )
410+ pipeIn , err := lessCmd .StdinPipe ()
411+ if err != nil {
412+ logrus .WithError (err ).Warn ("Failed to get less stdin" )
413+ return
414+ }
415+ diffCmd .Stdout = pipeIn
416+ lessCmd .Stdout = cmd .OutOrStdout ()
417+ lessCmd .Stderr = cmd .OutOrStderr ()
418+
419+ if err := lessCmd .Start (); err != nil {
420+ logrus .WithError (err ).Warn ("Failed to start less" )
421+ return
422+ }
423+ if err := diffCmd .Run (); err != nil {
424+ // Command `diff` returns exit code 1 when files differ.
425+ var exitErr * exec.ExitError
426+ if errors .As (err , & exitErr ) && exitErr .ExitCode () >= 2 {
427+ logrus .WithError (err ).Warn ("Failed to run diff" )
428+ _ = pipeIn .Close ()
429+ return
430+ }
431+ }
432+
433+ _ = pipeIn .Close ()
434+
435+ if err := lessCmd .Wait (); err != nil {
436+ logrus .WithError (err ).Warn ("Failed to wait for less" )
437+ return
438+ }
439+ }
440+ }
441+ }
442+
443+ func cleanGuestSyncedWorkdir (ctx context.Context , sshCmd * exec.Cmd ) {
444+ sshCmd .Args = append (sshCmd .Args , "rm" , "-rf" , guestSyncedWorkdir )
445+ sshRmCmd := exec .CommandContext (ctx , sshCmd .Path , sshCmd .Args ... )
446+ if err := sshRmCmd .Run (); err != nil {
447+ logrus .WithError (err ).Warn ("Failed to clean up guest synced workdir" )
448+ return
449+ }
450+ logrus .Debug ("Successfully cleaned up guest synced workdir." )
451+ }
452+
453+ func hostCurrentDirectory (ctx context.Context , inst * limatype.Instance ) (string , error ) {
454+ hostCurrentDir , err := os .Getwd ()
455+ if err == nil && runtime .GOOS == "windows" {
456+ hostCurrentDir , err = mountDirFromWindowsDir (ctx , inst , hostCurrentDir )
457+ }
458+ return hostCurrentDir , err
459+ }
460+
461+ // Syncs a directory from host to guest and vice-versa. It creates a directory
462+ // named "synced-workdir" in the guest's home directory and copies the contents
463+ // of the host's current working directory into it.
464+ func rsyncDirectory (ctx context.Context , cmd * cobra.Command , sshCmd * exec.Cmd , source , destination string ) error {
465+ rsyncArgs := []string {
466+ "-ah" ,
467+ "-e" , sshCmd .String (),
468+ source ,
469+ destination ,
470+ }
471+ rsyncCmd := exec .CommandContext (ctx , "rsync" , rsyncArgs ... )
472+ rsyncCmd .Stdout = cmd .OutOrStdout ()
473+ rsyncCmd .Stderr = cmd .OutOrStderr ()
474+ logrus .Debugf ("executing rsync: %+v" , rsyncCmd .Args )
475+ return rsyncCmd .Run ()
300476}
301477
302478func mountDirFromWindowsDir (ctx context.Context , inst * limatype.Instance , dir string ) (string , error ) {
0 commit comments