My main development machine is Mac OS X. Sometimes I need to use some Linux specific tool though. Here is my setup for doing that.

VM, shell and ssh setup

Lima-VM launches Linux virtual machines with automatic file sharing and port forwarding.

Install lima-vm using homebrew:

brew install lima

Next I’m creating and starting an ubuntu VM:

limactl create --name=ubuntu template://ubuntu-lts
...
INFO[0005] Run `limactl start ubuntu` to start the instance.

limactl start ubuntu
...
INFO[0012] READY. Run `limactl shell ubuntu` to open the shell.

At this point the ubuntu VM is running, and lima tells us how we can open the shell: limactl shell ubuntu.

I’m a fish shell user, so the next step for me is to execute:

sudo apt-get install fish

inside limactl shell ubuntu.

It was somewhat tricky to set up a proper default shell. I did it by executing:

sudo chsh --shell /usr/bin/fish "$USER"

inside limactl shell ubuntu.

Then, for reasons I do not know, the VM needs to be restarted for the shell change to take effect:

limactl restart ubuntu

After that completes, limactl shell ubuntu should give you a fish shell.

Next up is setting up ssh such that ssh lima-ubuntu will work. I edited ~/.ssh/config and put the following line at the top:

Include ~/.lima/*/ssh.config

and at the bottom of ~/.ssh/config I put the following ilnes:

Host *
  SetEnv TERM=xterm-256color

Now ssh lima-ubuntu should drop you into a fish shell on the VM.

Syncing files and executing remote commands

Lima-VM can do file sharing, but I like to keep things separate.

In a given project I add a file remoterun.sh:

 1#!/usr/bin/env bash
 2
 3set -euo pipefail
 4
 5REMOTE_HOST='lima-ubuntu'
 6REMOTE_PATH='code/my-app'
 7
 8REMOTE_SCRIPT='mkdir -p '"${REMOTE_PATH@Q}"
 9echo "${REMOTE_SCRIPT}" | ssh "${REMOTE_HOST}" /bin/bash -s
10# Creates the remote path if it does not exist.
11# OS X' rsync (version 2.6.9) do not support this out of the box.
12
13# What is ${REMOTE_PATH@Q}?
14# The @Q part expands the variable in a quote safe way.
15# Thus, if you really want, you can put a space inside your remote path.
16# Why would you want to do that though?
17# Don't add a tilde (~) in the path. It won't be expanded.
18
19rsync -aq \
20--include='**.gitignore' \
21--filter=':- .gitignore' \
22--filter='protect node_modules/' \
23--delete \
24. "${REMOTE_HOST}:${REMOTE_PATH@Q}"
25# Sync files based on .gitignore:
26# -a: Archive mode, preserves timestamps, etc.
27# -q: Quiet.
28# --include='**.gitignore': Include files not in .gitignore.
29# --filter=':- .gitignore': Exclude files in .gitignore.
30# --delete: Delete files on the remote which are not in the source.
31# --filter='protect node_modules/': Don't delete the remote path node_modules/
32
33if [[ "0" == "$#" ]]; then
34  echo "No arguments given, synced files only"
35else
36  # shellcheck disable=SC2124
37  # shellcheck disable=SC2016
38  REMOTE_SCRIPT='set -euo pipefail
39shopt -s huponexit
40cd '"${REMOTE_PATH@Q}"' || \
41{ echo "Could not cd to directory! Exiting."; exit 1; }
42'"${@@Q}"' && EXIT_CODE="$?" || EXIT_CODE="$?"
43#echo "Command exited with code $EXIT_CODE"
44exit "$EXIT_CODE"
45'
46  # This script will be executed on the remote.
47  # Changes directory to the remote path and executes the command.
48  # shopt -s huponexit: Send SIGHUP to all jobs when the job exits.
49  echo "${REMOTE_SCRIPT}" | ssh "${REMOTE_HOST}" /bin/bash -s
50fi

With this in place I can do ./remoterun.sh ./my-app.

Executing commands on a file change

On my Mac, I typically use entr to execute a remote command on the VM when a file changes:

1git ls-files | entr -ccr ./remoterun.sh ./my-app arg1 arg2

It is also possible to chain commands:

1git ls-files | entr -ccr ./remoterun.sh bash -c 'npm run build && npm start'

I recommend checking out Julia Evans’ introduction to entr if you are interested in learning more about entr.

Final words

On my machine remoterun.sh adds about 150 milliseconds of extra time for each command. That is fine by me.

That is pretty much it. Let me know if you have suggestions or improvements!