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,13 @@ 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+ // Depth of "/Users/USER" is 3.
74+ const rsyncMinimumSrcDirDepth = 4
75+
7076func shellAction (cmd * cobra.Command , args []string ) error {
7177 ctx := cmd .Context ()
7278 flags := cmd .Flags ()
@@ -150,29 +156,45 @@ func shellAction(cmd *cobra.Command, args []string) error {
150156 }
151157 }
152158
159+ syncHostWorkdir , err := flags .GetBool ("sync" )
160+ if err != nil {
161+ return fmt .Errorf ("failed to get sync flag: %w" , err )
162+ } else if syncHostWorkdir && len (inst .Config .Mounts ) > 0 {
163+ return errors .New ("cannot use `--sync` when the instance has host mounts configured, start the instance with `--mount-none` to disable mounts" )
164+ }
165+
153166 // When workDir is explicitly set, the shell MUST have workDir as the cwd, or exit with an error.
154167 //
155168 // changeDirCmd := "cd workDir || exit 1" if workDir != ""
156169 // := "cd hostCurrentDir || cd hostHomeDir" if workDir == ""
157170 var changeDirCmd string
171+ hostCurrentDir , err := hostCurrentDirectory (ctx , inst )
172+ if err != nil {
173+ changeDirCmd = "false"
174+ logrus .WithError (err ).Warn ("failed to get the current directory" )
175+ }
176+ if syncHostWorkdir {
177+ if _ , err := exec .LookPath ("rsync" ); err != nil {
178+ return fmt .Errorf ("rsync is required for `--sync` but not found: %w" , err )
179+ }
180+
181+ srcWdDepth := len (strings .Split (hostCurrentDir , string (os .PathSeparator )))
182+ if srcWdDepth < rsyncMinimumSrcDirDepth {
183+ return fmt .Errorf ("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)" ,
184+ hostCurrentDir , rsyncMinimumSrcDirDepth , srcWdDepth , "cd to a deeper directory" )
185+ }
186+ }
187+
158188 workDir , err := cmd .Flags ().GetString ("workdir" )
159189 if err != nil {
160190 return err
161191 }
162- if workDir != "" {
192+ switch {
193+ case workDir != "" :
163194 changeDirCmd = fmt .Sprintf ("cd %s || exit 1" , shellescape .Quote (workDir ))
164195 // 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- }
196+ case len (inst .Config .Mounts ) > 0 || inst .VMType == limatype .WSL2 :
197+ changeDirCmd = fmt .Sprintf ("cd %s" , shellescape .Quote (hostCurrentDir ))
176198 hostHomeDir , err := os .UserHomeDir ()
177199 if err == nil && runtime .GOOS == "windows" {
178200 hostHomeDir , err = mountDirFromWindowsDir (ctx , inst , hostHomeDir )
@@ -182,7 +204,9 @@ func shellAction(cmd *cobra.Command, args []string) error {
182204 } else {
183205 logrus .WithError (err ).Warn ("failed to get the home directory" )
184206 }
185- } else {
207+ case syncHostWorkdir :
208+ changeDirCmd = fmt .Sprintf ("cd ~%s" , shellescape .Quote (hostCurrentDir ))
209+ default :
186210 logrus .Debug ("the host home does not seem mounted, so the guest shell will have a different cwd" )
187211 }
188212
@@ -267,6 +291,19 @@ func shellAction(cmd *cobra.Command, args []string) error {
267291 }
268292 sshArgs := append ([]string {}, sshExe .Args ... )
269293 sshArgs = append (sshArgs , sshutil .SSHArgsFromOpts (sshOpts )... )
294+
295+ var sshExecForRsync * exec.Cmd
296+ if syncHostWorkdir {
297+ logrus .Infof ("Syncing host current directory(%s) to guest instance..." , hostCurrentDir )
298+ sshExecForRsync = exec .CommandContext (ctx , sshExe .Exe , sshArgs ... )
299+ destDir := fmt .Sprintf ("~%s" , shellescape .Quote (filepath .Dir (hostCurrentDir )))
300+ preRsyncScript := fmt .Sprintf ("mkdir -p ~%s" , shellescape .Quote (hostCurrentDir ))
301+ if err := rsyncDirectory (ctx , cmd , sshExecForRsync , hostCurrentDir , fmt .Sprintf ("%s:%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , destDir ), preRsyncScript ); 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) instance." , 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,149 @@ 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+ hostTmpDest , err := os .MkdirTemp ("" , "lima-guest-synced-*" )
358+ if err != nil {
359+ logrus .WithError (err ).Warn ("Failed to create temporary directory" )
360+ return
361+ }
362+ defer func () {
363+ if err := os .RemoveAll (hostTmpDest ); err != nil {
364+ logrus .WithError (err ).Warnf ("Failed to clean up temporary directory %s" , hostTmpDest )
365+ }
366+ }()
367+ remoteSource := fmt .Sprintf ("%s:~%s" , * inst .Config .User .Name + "@" + inst .SSHAddress , hostCurrentDir )
368+ rsyncToTempDir := false
369+
370+ for {
371+ ans , err := uiutil .Select (message , options )
372+ if err != nil {
373+ if errors .Is (err , uiutil .InterruptErr ) {
374+ logrus .Fatal ("Interrupted by user" )
375+ }
376+ logrus .WithError (err ).Warn ("Failed to open TUI" )
377+ return
378+ }
379+
380+ switch ans {
381+ case 0 : // Yes
382+ dest := filepath .Dir (hostCurrentDir )
383+ if err := rsyncDirectory (ctx , cmd , sshCmd , remoteSource , dest , "" ); err != nil {
384+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host" )
385+ return
386+ }
387+ cleanGuestSyncedWorkdir (ctx , sshCmd , hostCurrentDir )
388+ logrus .Info ("Successfully synced back the changes to host." )
389+ return
390+ case 1 : // No
391+ cleanGuestSyncedWorkdir (ctx , sshCmd , hostCurrentDir )
392+ logrus .Info ("Skipping syncing back the changes to host." )
393+ return
394+ case 2 : // View the changed contents
395+ if ! rsyncToTempDir {
396+ if err := rsyncDirectory (ctx , cmd , sshCmd , remoteSource , hostTmpDest , "" ); err != nil {
397+ logrus .WithError (err ).Warn ("Failed to sync back the changes to host for viewing" )
398+ return
399+ }
400+ rsyncToTempDir = true
401+ }
402+ diffCmd := exec .CommandContext (ctx , "diff" , "-ru" , "--color=always" , hostCurrentDir , filepath .Join (hostTmpDest , filepath .Base (hostCurrentDir )))
403+ pager := os .Getenv ("PAGER" )
404+ if pager == "" {
405+ pager = "less"
406+ }
407+ lessCmd := exec .CommandContext (ctx , pager , "-R" )
408+ pipeIn , err := lessCmd .StdinPipe ()
409+ if err != nil {
410+ logrus .WithError (err ).Warn ("Failed to get less stdin" )
411+ return
412+ }
413+ diffCmd .Stdout = pipeIn
414+ lessCmd .Stdout = cmd .OutOrStdout ()
415+ lessCmd .Stderr = cmd .OutOrStderr ()
416+
417+ if err := lessCmd .Start (); err != nil {
418+ logrus .WithError (err ).Warn ("Failed to start less" )
419+ return
420+ }
421+ if err := diffCmd .Run (); err != nil {
422+ // Command `diff` returns exit code 1 when files differ.
423+ var exitErr * exec.ExitError
424+ if errors .As (err , & exitErr ) && exitErr .ExitCode () >= 2 {
425+ logrus .WithError (err ).Warn ("Failed to run diff" )
426+ _ = pipeIn .Close ()
427+ return
428+ }
429+ }
430+
431+ _ = pipeIn .Close ()
432+
433+ if err := lessCmd .Wait (); err != nil {
434+ logrus .WithError (err ).Warn ("Failed to wait for less" )
435+ return
436+ }
437+ }
438+ }
439+ }
440+
441+ func cleanGuestSyncedWorkdir (ctx context.Context , sshCmd * exec.Cmd , hostCurrentDir string ) {
442+ clean := filepath .Clean (hostCurrentDir )
443+ parts := strings .Split (clean , string (filepath .Separator ))
444+ sshCmd .Args = append (sshCmd .Args , "rm" , "-rf" , fmt .Sprintf ("~/%s" , parts [1 ]))
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 , preRsyncScript string ) error {
465+ rsyncArgs := []string {
466+ "-ah" ,
467+ "-e" , sshCmd .String (),
468+ source ,
469+ destination ,
470+ }
471+ if preRsyncScript != "" {
472+ rsyncArgs = append ([]string {"--rsync-path" , fmt .Sprintf ("%s && rsync" , shellescape .Quote (preRsyncScript ))}, rsyncArgs ... )
473+ }
474+ rsyncCmd := exec .CommandContext (ctx , "rsync" , rsyncArgs ... )
475+ rsyncCmd .Stdout = cmd .OutOrStdout ()
476+ rsyncCmd .Stderr = cmd .OutOrStderr ()
477+ logrus .Debugf ("executing rsync: %+v" , rsyncCmd .Args )
478+ return rsyncCmd .Run ()
300479}
301480
302481func mountDirFromWindowsDir (ctx context.Context , inst * limatype.Instance , dir string ) (string , error ) {
0 commit comments