Repository: roots/trellis-cli
Pull request #346 (2023-01-07 02:14:43)

Add VM integration
Adds full integration for managing virtual machines in development via https://github.com/lima-vm/lima as an alternative to Vagrant. Lima stands for "Linux virtual machines (on macOS, in most cases)" and that's exactly our use case.

Lima isn't just a replacement for Vagrant, it also replaces the VM provider like VirtualBox or Parallels too. Lima supports two ways of running guest machines:

* [QEMU](https://www.qemu.org/)
* macOS Virtualization.Framework ("vz")

For this initial integration, we're only supporting the macOS Virtualization.Framework because it offers near-native performance, and that includes file syncing via `virtiofs`.

For macOS Ventura (13.0+) users, we'll eventually recommend using trellis-cli's VM feature over Vagrant as the default since it's easier and faster. We'll look into expanding support for Linux users as well depending on performance of the file mounts on the VM.

Requirements:
* Intel or Apple Silicon
* macOS 13 (Ventura)
* Lima >= 0.14

Usage:
There's 5 new commands:

* `trellis vm start`
* `trellis vm stop`
* `trellis vm delete`
* `trellis vm shell`
* `trellis vm sudoers`

Under the hood, those commands wrap equivalent `limactl` features. Just like the previous Vagrant integration, you can always run `limactl` directly to manage your VMs.

For default use cases, `trellis vm start` can be run without any customization first. It will create a new virtual machine (using Lima) from a generated config file (`project/trellis/.trellis/lima/config/<name>.yml`). The site's `local_path` will be automatically mounted on the VM and your `/etc/hosts` file will be updated.

Note: run `trellis vm sudoers -h` to make `/etc/hosts` file passwordless:
```bash
$ trellis sudoers | sudo tee /etc/sudoers.d/trellis
```

Configuration:
Right now configuration options for the VM are limited. The intention is to the most common use cases without any configuration needed.

A trellis-cli config file (global or project level) supports a new `vm` option. The only useful config option right now is `ubuntu` (to switch between Ubuntu 20.04 and 22.04 for example).

Here's an example of specifying Jammy 22.04:

```yml
vm:
  ubuntu: 22.04
```


Patch:
diff --git a/app_paths/app_paths.go b/app_paths/app_paths.go
index 8702612..6c7d2ab 100644
--- a/app_paths/app_paths.go
+++ b/app_paths/app_paths.go
@@ -15,7 +15,11 @@ const (
 	xdgDataHome      = "XDG_DATA_HOME"
 )
 
-// Config path precedence: TRELLIS_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME.
+// Config path precedence:
+// 1. TRELLIS_CONFIG_DIR
+// 2. XDG_CONFIG_HOME
+// 3. AppData (windows only)
+// 4. HOME
 func ConfigDir() string {
 	var path string
 
@@ -37,7 +41,10 @@ func ConfigPath(path string) string {
 	return filepath.Join(ConfigDir(), path)
 }
 
-// Cache path precedence: XDG_CACHE_HOME, LocalAppData (windows only), HOME.
+// Cache path precedence:
+// 1. XDG_CACHE_HOME
+// 2. LocalAppData (windows only)
+// 3. HOME
 func CacheDir() string {
 	var path string
 	if a := os.Getenv(xdgCacheHome); a != "" {
@@ -50,3 +57,20 @@ func CacheDir() string {
 	}
 	return path
 }
+
+// Data path precedence:
+// 1. XDG_DATA_HOME
+// 2. LocalAppData (windows only)
+// 3. HOME
+func DataDir() string {
+	var path string
+	if a := os.Getenv(xdgDataHome); a != "" {
+		path = filepath.Join(a, "trellis")
+	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
+		path = filepath.Join(b, "Trellis CLI")
+	} else {
+		c, _ := os.UserHomeDir()
+		path = filepath.Join(c, ".local", "share", "trellis")
+	}
+	return path
+}
diff --git a/cli_config/cli_config.go b/cli_config/cli_config.go
index 48a1b13..61672a9 100644
--- a/cli_config/cli_config.go
+++ b/cli_config/cli_config.go
@@ -11,6 +11,18 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
+type VmImage struct {
+	Location string `yaml:"location"`
+	Arch     string `yaml:"arch"`
+}
+
+type VmConfig struct {
+	Manager       string    `yaml:"manager"`
+	HostsResolver string    `yaml:"hosts_resolver"`
+	Images        []VmImage `yaml:"images"`
+	Ubuntu        string    `yaml:"ubuntu"`
+}
+
 type Config struct {
 	AllowDevelopmentDeploys bool              `yaml:"allow_development_deploys"`
 	AskVaultPass            bool              `yaml:"ask_vault_pass"`
@@ -18,11 +30,13 @@ type Config struct {
 	LoadPlugins             bool              `yaml:"load_plugins"`
 	Open                    map[string]string `yaml:"open"`
 	VirtualenvIntegration   bool              `yaml:"virtualenv_integration"`
+	Vm                      VmConfig          `yaml:"vm"`
 }
 
 var (
-	ErrUnsupportedType = errors.New("Invalid env var config setting: value is an unsupported type.")
-	ErrCouldNotParse   = errors.New("Invalid env var config setting: failed to parse value")
+	UnsupportedTypeErr = errors.New("Invalid env var config setting: value is an unsupported type.")
+	CouldNotParseErr   = errors.New("Invalid env var config setting: failed to parse value")
+	InvalidConfigErr   = errors.New("Invalid config file")
 )
 
 func NewConfig(defaultConfig Config) Config {
@@ -37,7 +51,19 @@ func (c *Config) LoadFile(path string) error {
 	}
 
 	if err := yaml.Unmarshal(configYaml, &c); err != nil {
-		return err
+		return fmt.Errorf("%w: %s", InvalidConfigErr, err)
+	}
+
+	if c.Vm.Manager != "lima" && c.Vm.Manager != "auto" && c.Vm.Manager != "mock" {
+		return fmt.Errorf("%w: unsupported value for `vm.manager`. Must be one of: auto, lima", InvalidConfigErr)
+	}
+
+	if c.Vm.Ubuntu != "18.04" && c.Vm.Ubuntu != "20.04" && c.Vm.Ubuntu != "22.04" {
+		return fmt.Errorf("%w: unsupported value for `vm.ubuntu`. Must be one of: 18.04, 20.04, 22.04", InvalidConfigErr)
+	}
+
+	if c.Vm.HostsResolver != "hosts_file" {
+		return fmt.Errorf("%w: unsupported value for `vm.hosts_resolver`. Must be one of: hosts_file", InvalidConfigErr)
 	}
 
 	return nil
@@ -72,7 +98,7 @@ func (c *Config) LoadEnv(prefix string) error {
 					val, err := strconv.ParseBool(value)
 
 					if err != nil {
-						return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a boolean", ErrCouldNotParse, env, value)
+						return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a boolean", CouldNotParseErr, env, value)
 					}
 
 					structValue.SetBool(val)
@@ -80,19 +106,19 @@ func (c *Config) LoadEnv(prefix string) error {
 					val, err := strconv.ParseInt(value, 10, 32)
 
 					if err != nil {
-						return fmt.Errorf("%w '%s'\n'%s' can't be parsed as an integer", ErrCouldNotParse, env, value)
+						return fmt.Errorf("%w '%s'\n'%s' can't be parsed as an integer", CouldNotParseErr, env, value)
 					}
 
 					structValue.SetInt(val)
 				case reflect.Float32:
 					val, err := strconv.ParseFloat(value, 32)
 					if err != nil {
-						return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a float", ErrCouldNotParse, env, value)
+						return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a float", CouldNotParseErr, env, value)
 					}
 
 					structValue.SetFloat(val)
 				default:
-					return fmt.Errorf("%w\n%s setting of type %s is unsupported.", ErrUnsupportedType, env, field.Type.String())
+					return fmt.Errorf("%w\n%s setting of type %s is unsupported.", UnsupportedTypeErr, env, field.Type.String())
 				}
 			}
 		}
diff --git a/cmd/provision.go b/cmd/provision.go
index 40e9a47..cb4f1af 100644
--- a/cmd/provision.go
+++ b/cmd/provision.go
@@ -87,10 +87,12 @@ func (c *ProvisionCommand) Run(args []string) int {
 	var playbookFile string = "server.yml"
 
 	if environment == "development" {
+		os.Setenv("ANSIBLE_HOST_KEY_CHECKING", "false")
 		playbookFile = "dev.yml"
+		devInventoryFile := c.findDevInventory()
 
-		if _, err := os.Stat(filepath.Join(c.Trellis.Path, VagrantInventoryFilePath)); err == nil {
-			playbookArgs = append(playbookArgs, "--inventory-file", VagrantInventoryFilePath)
+		if devInventoryFile != "" {
+			playbookArgs = append(playbookArgs, "--inventory-file", devInventoryFile)
 		}
 	}
 
@@ -160,3 +162,19 @@ func (c *ProvisionCommand) AutocompleteFlags() complete.Flags {
 		"--verbose":    complete.PredictNothing,
 	}
 }
+
+func (c *ProvisionCommand) findDevInventory() string {
+	manager, managerErr := newVmManager(c.Trellis, c.UI)
+	if managerErr == nil {
+		_, vmInventoryErr := os.Stat(manager.InventoryPath())
+		if vmInventoryErr == nil {
+			return manager.InventoryPath()
+		}
+	}
+
+	if _, vagrantInventoryErr := os.Stat(filepath.Join(c.Trellis.Path, VagrantInventoryFilePath)); vagrantInventoryErr == nil {
+		return VagrantInventoryFilePath
+	}
+
+	return ""
+}
diff --git a/cmd/vm.go b/cmd/vm.go
new file mode 100644
index 0000000..5ba75d3
--- /dev/null
+++ b/cmd/vm.go
@@ -0,0 +1,29 @@
+package cmd
+
+import (
+	"fmt"
+	"runtime"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/pkg/lima"
+	"github.com/roots/trellis-cli/pkg/vm"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+func newVmManager(trellis *trellis.Trellis, ui cli.Ui) (manager vm.Manager, err error) {
+	switch trellis.CliConfig.Vm.Manager {
+	case "auto":
+		switch runtime.GOOS {
+		case "darwin":
+			return lima.NewManager(trellis, ui)
+		default:
+			return nil, fmt.Errorf("No VM managers are supported on %s yet.", runtime.GOOS)
+		}
+	case "lima":
+		return lima.NewManager(trellis, ui)
+	case "mock":
+		return vm.NewMockManager(trellis, ui)
+	}
+
+	return nil, fmt.Errorf("VM manager not found")
+}
diff --git a/cmd/vm_delete.go b/cmd/vm_delete.go
new file mode 100644
index 0000000..a656640
--- /dev/null
+++ b/cmd/vm_delete.go
@@ -0,0 +1,118 @@
+package cmd
+
+import (
+	"flag"
+	"strings"
+
+	"github.com/manifoldco/promptui"
+	"github.com/mitchellh/cli"
+	"github.com/posener/complete"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type VmDeleteCommand struct {
+	UI      cli.Ui
+	Trellis *trellis.Trellis
+	flags   *flag.FlagSet
+	force   bool
+}
+
+func NewVmDeleteCommand(ui cli.Ui, trellis *trellis.Trellis) *VmDeleteCommand {
+	c := &VmDeleteCommand{UI: ui, Trellis: trellis}
+	c.init()
+	return c
+}
+
+func (c *VmDeleteCommand) init() {
+	c.flags = flag.NewFlagSet("", flag.ContinueOnError)
+	c.flags.Usage = func() { c.UI.Info(c.Help()) }
+	c.flags.BoolVar(&c.force, "force", false, "Delete VM without confirmation.")
+}
+
+func (c *VmDeleteCommand) Run(args []string) int {
+	if err := c.Trellis.LoadProject(); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	c.Trellis.CheckVirtualenv(c.UI)
+
+	if err := c.flags.Parse(args); err != nil {
+		return 1
+	}
+
+	args = c.flags.Args()
+
+	commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
+	commandArgumentErr := commandArgumentValidator.validate(args)
+	if commandArgumentErr != nil {
+		c.UI.Error(commandArgumentErr.Error())
+		c.UI.Output(c.Help())
+		return 1
+	}
+
+	siteName, err := c.Trellis.FindSiteNameFromEnvironment("development", "")
+	if err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	manager, err := newVmManager(c.Trellis, c.UI)
+	if err != nil {
+		c.UI.Error("Error: " + err.Error())
+		return 1
+	}
+
+	if c.force || c.confirmDeletion() {
+		if err := manager.DeleteInstance(siteName); err != nil {
+			c.UI.Error("Error: " + err.Error())
+			return 1
+		}
+	}
+
+	return 0
+}
+
+func (c *VmDeleteCommand) Synopsis() string {
+	return "Deletes the development virtual machine."
+}
+
+func (c *VmDeleteCommand) Help() string {
+	helpText := `
+Usage: trellis vm delete [options]
+
+Deletes the development virtual machine.
+VMs must be in a stopped state before they can be deleted.
+
+Delete without prompting for confirmation:
+  $ trellis vm delete --force
+
+Options:
+  --force     Delete VM without confirmation.
+  -h, --help  Show this help
+`
+
+	return strings.TrimSpace(helpText)
+}
+
+func (c *VmDeleteCommand) AutocompleteFlags() complete.Flags {
+	return complete.Flags{
+		"--force": complete.PredictNothing,
+	}
+}
+
+func (c *VmDeleteCommand) confirmDeletion() bool {
+	prompt := promptui.Prompt{
+		Label:     "Delete virtual machine",
+		IsConfirm: true,
+	}
+
+	_, err := prompt.Run()
+
+	if err != nil {
+		c.UI.Info("Aborted. Not deleting virtual machine.")
+		return false
+	}
+
+	return true
+}
diff --git a/cmd/vm_shell.go b/cmd/vm_shell.go
new file mode 100644
index 0000000..794ccd5
--- /dev/null
+++ b/cmd/vm_shell.go
@@ -0,0 +1,65 @@
+package cmd
+
+import (
+	"strings"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type VmShellCommand struct {
+	UI      cli.Ui
+	Trellis *trellis.Trellis
+}
+
+func (c *VmShellCommand) Run(args []string) int {
+	if err := c.Trellis.LoadProject(); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	c.Trellis.CheckVirtualenv(c.UI)
+
+	siteName, err := c.Trellis.FindSiteNameFromEnvironment("development", "")
+	if err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	manager, err := newVmManager(c.Trellis, c.UI)
+	if err != nil {
+		c.UI.Error("Error: " + err.Error())
+		return 1
+	}
+
+	if err := manager.OpenShell(siteName, args); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	return 0
+}
+
+func (c *VmShellCommand) Synopsis() string {
+	return "Executes shell in the VM"
+}
+
+func (c *VmShellCommand) Help() string {
+	helpText := `
+Usage: trellis vm shell [options] [COMMAND]
+
+Executes shell in the development virtual machine.
+
+Run an optional command from the VM shell:
+
+  $ trellis vm shell whoami
+
+Arguments:
+  COMMAND  Command to execute
+
+Options:
+  -h, --help show this help
+`
+
+	return strings.TrimSpace(helpText)
+}
diff --git a/cmd/vm_start.go b/cmd/vm_start.go
new file mode 100644
index 0000000..69f715b
--- /dev/null
+++ b/cmd/vm_start.go
@@ -0,0 +1,123 @@
+package cmd
+
+import (
+	"errors"
+	"flag"
+	"strings"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/pkg/vm"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type VmStartCommand struct {
+	UI      cli.Ui
+	Trellis *trellis.Trellis
+	flags   *flag.FlagSet
+}
+
+func NewVmStartCommand(ui cli.Ui, trellis *trellis.Trellis) *VmStartCommand {
+	c := &VmStartCommand{UI: ui, Trellis: trellis}
+	c.init()
+	return c
+}
+
+func (c *VmStartCommand) init() {
+	c.flags = flag.NewFlagSet("", flag.ContinueOnError)
+	c.flags.Usage = func() { c.UI.Info(c.Help()) }
+}
+
+func (c *VmStartCommand) Run(args []string) int {
+	if err := c.Trellis.LoadProject(); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	c.Trellis.CheckVirtualenv(c.UI)
+
+	if err := c.flags.Parse(args); err != nil {
+		return 1
+	}
+
+	args = c.flags.Args()
+
+	commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
+	commandArgumentErr := commandArgumentValidator.validate(args)
+	if commandArgumentErr != nil {
+		c.UI.Error(commandArgumentErr.Error())
+		c.UI.Output(c.Help())
+		return 1
+	}
+
+	siteName, err := c.Trellis.FindSiteNameFromEnvironment("development", "")
+	if err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	manager, err := newVmManager(c.Trellis, c.UI)
+	if err != nil {
+		c.UI.Error("Error: " + err.Error())
+		return 1
+	}
+
+	err = manager.StartInstance(siteName)
+	if err == nil {
+		c.printInstanceInfo()
+		return 0
+	}
+
+	if !errors.Is(err, vm.VmNotFoundErr) {
+		c.UI.Error("Error starting VM.")
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	// VM doesn't exist yet, create it
+	if err = manager.CreateInstance(siteName); err != nil {
+		c.UI.Error("Error creating VM.")
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	c.UI.Info("\nProvisioning VM...")
+
+	provisionCmd := NewProvisionCommand(c.UI, c.Trellis)
+	code := provisionCmd.Run([]string{"development"})
+
+	if code == 0 {
+		c.printInstanceInfo()
+	}
+
+	return code
+}
+
+func (c *VmStartCommand) Synopsis() string {
+	return "Starts a development virtual machine."
+}
+
+func (c *VmStartCommand) Help() string {
+	helpText := `
+Usage: trellis vm start [options]
+
+Starts a development virtual machine.
+If a VM doesn't exist yet, it will be created. If a VM already exists, it will be started.
+
+Note: VM management (under the 'trellis vm' subcommands) is currently only available for macOS Ventura (13.0) and later.
+Lima (https://lima-vm.io/) is the underlying VM manager which requires macOS's new virtualization framework.
+
+Options:
+  -h, --help show this help
+`
+
+	return strings.TrimSpace(helpText)
+}
+
+func (c *VmStartCommand) printInstanceInfo() {
+	c.UI.Info(`
+Your Trellis VM is ready to use!
+
+* Composer and WP-CLI commands need to be run on the virtual machine for any post-provision modifications.
+* You can SSH into the machine with 'trellis vm shell'
+* Then navigate to your WordPress sites at '/srv/www'`)
+}
diff --git a/cmd/vm_stop.go b/cmd/vm_stop.go
new file mode 100644
index 0000000..e0e22f0
--- /dev/null
+++ b/cmd/vm_stop.go
@@ -0,0 +1,85 @@
+package cmd
+
+import (
+	"flag"
+	"strings"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type VmStopCommand struct {
+	UI      cli.Ui
+	Trellis *trellis.Trellis
+	flags   *flag.FlagSet
+}
+
+func NewVmStopCommand(ui cli.Ui, trellis *trellis.Trellis) *VmStopCommand {
+	c := &VmStopCommand{UI: ui, Trellis: trellis}
+	c.init()
+	return c
+}
+
+func (c *VmStopCommand) init() {
+	c.flags = flag.NewFlagSet("", flag.ContinueOnError)
+	c.flags.Usage = func() { c.UI.Info(c.Help()) }
+}
+
+func (c *VmStopCommand) Run(args []string) int {
+	if err := c.Trellis.LoadProject(); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	c.Trellis.CheckVirtualenv(c.UI)
+
+	if err := c.flags.Parse(args); err != nil {
+		return 1
+	}
+
+	args = c.flags.Args()
+
+	commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
+	commandArgumentErr := commandArgumentValidator.validate(args)
+	if commandArgumentErr != nil {
+		c.UI.Error(commandArgumentErr.Error())
+		c.UI.Output(c.Help())
+		return 1
+	}
+
+	siteName, err := c.Trellis.FindSiteNameFromEnvironment("development", "")
+	if err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	manager, err := newVmManager(c.Trellis, c.UI)
+	if err != nil {
+		c.UI.Error("Error: " + err.Error())
+		return 1
+	}
+
+	if err := manager.StopInstance(siteName); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	return 0
+}
+
+func (c *VmStopCommand) Synopsis() string {
+	return "Stops the development virtual machine."
+}
+
+func (c *VmStopCommand) Help() string {
+	helpText := `
+Usage: trellis vm stop [options]
+
+Stops the development virtual machine.
+
+Options:
+  -h, --help show this help
+`
+
+	return strings.TrimSpace(helpText)
+}
diff --git a/cmd/vm_sudoers.go b/cmd/vm_sudoers.go
new file mode 100644
index 0000000..54396fa
--- /dev/null
+++ b/cmd/vm_sudoers.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/pkg/vm"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type VmSudoersCommand struct {
+	UI      cli.Ui
+	Trellis *trellis.Trellis
+}
+
+func (c *VmSudoersCommand) Run(args []string) int {
+	if err := c.Trellis.LoadProject(); err != nil {
+		c.UI.Error(err.Error())
+		return 1
+	}
+
+	c.Trellis.CheckVirtualenv(c.UI)
+
+	commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0}
+	commandArgumentErr := commandArgumentValidator.validate(args)
+	if commandArgumentErr != nil {
+		c.UI.Error(commandArgumentErr.Error())
+		c.UI.Output(c.Help())
+		return 1
+	}
+
+	hostResolver := vm.NewHostsFileResolver([]string{})
+	cmd := hostResolver.SudoersCommand()
+
+	c.UI.Info(fmt.Sprintf("%%staff ALL=(root:wheel) NOPASSWD:NOSETENV: %s", strings.Join(cmd, " ")))
+
+	return 0
+}
+
+func (c *VmSudoersCommand) Synopsis() string {
+	return "Generates sudoers content for passwordless updating of /etc/hosts"
+}
+
+func (c *VmSudoersCommand) Help() string {
+	helpText := `
+Usage: trellis vm sudoers [options]
+
+Generates the content of the /etc/sudoers.d/trellis file.
+This allows trellis-cli to update your /etc/hosts file without having to enter your sudo password.
+
+The content is written to stdout, NOT to the file. This command must not run as the root as shown below.
+
+$ trellis vm sudoers | sudo tee /etc/sudoers.d/trellis
+
+Options:
+  -h, --help show this help
+`
+
+	return strings.TrimSpace(helpText)
+}
diff --git a/help.go b/help.go
new file mode 100644
index 0000000..a3a61f8
--- /dev/null
+++ b/help.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/mitchellh/cli"
+)
+
+func experimentalCommandHelpFunc(app string, f cli.HelpFunc) cli.HelpFunc {
+	return func(commands map[string]cli.CommandFactory) string {
+		var buf bytes.Buffer
+		if len(experimentalCommands) > 0 {
+			buf.WriteString("\n\nExperimental commands:\n")
+		}
+
+		maxKeyLen := 0
+		keys := make([]string, 0, len(experimentalCommands))
+		filteredCommands := make(map[string]cli.CommandFactory)
+
+		for key, command := range commands {
+			for _, experimentalKey := range experimentalCommands {
+				if key != experimentalKey {
+					filteredCommands[key] = command
+				}
+			}
+		}
+
+		for _, key := range experimentalCommands {
+			if len(key) > maxKeyLen {
+				maxKeyLen = len(key)
+			}
+
+			keys = append(keys, key)
+		}
+
+		sort.Strings(keys)
+
+		for _, key := range keys {
+			commandFunc, _ := commands[key]
+			command, _ := commandFunc()
+			key = fmt.Sprintf("%s%s", key, strings.Repeat(" ", maxKeyLen-len(key)))
+			buf.WriteString(fmt.Sprintf("    %s    %s\n", key, command.Synopsis()))
+		}
+
+		return f(filteredCommands) + buf.String()
+	}
+}
diff --git a/main.go b/main.go
index f912dcd..7e4be4f 100644
--- a/main.go
+++ b/main.go
@@ -19,6 +19,9 @@ import (
 // To be replaced by goreleaser build flags.
 var version = "canary"
 var updaterRepo = ""
+var experimentalCommands = []string{
+	"vm",
+}
 
 func main() {
 	c := cli.NewCLI("trellis", version)
@@ -35,6 +38,8 @@ func main() {
 	}
 
 	trellis := trellis.NewTrellis()
+
+	// load global CLI config
 	if err := trellis.LoadCliConfig(); err != nil {
 		ui.Error(err.Error())
 		os.Exit(1)
@@ -134,7 +139,7 @@ func main() {
 			return cmd.NewRollbackCommand(ui, trellis), nil
 		},
 		"shell-init": func() (cli.Command, error) {
-			return &cmd.ShellInitCommand{ui}, nil
+			return &cmd.ShellInitCommand{UI: ui}, nil
 		},
 		"ssh": func() (cli.Command, error) {
 			return cmd.NewSshCommand(ui, trellis), nil
@@ -149,7 +154,7 @@ func main() {
 			}, nil
 		},
 		"vault edit": func() (cli.Command, error) {
-			return &cmd.VaultEditCommand{ui, trellis}, nil
+			return &cmd.VaultEditCommand{UI: ui, Trellis: trellis}, nil
 		},
 		"vault encrypt": func() (cli.Command, error) {
 			return cmd.NewVaultEncryptCommand(ui, trellis), nil
@@ -172,6 +177,27 @@ func main() {
 		"venv hook": func() (cli.Command, error) {
 			return &cmd.VenvHookCommand{UI: ui, Trellis: trellis}, nil
 		},
+		"vm": func() (cli.Command, error) {
+			return &cmd.NamespaceCommand{
+				HelpText:     "Usage: trellis vm <subcommand> [<args>]",
+				SynopsisText: "Commands for managing development virtual machines",
+			}, nil
+		},
+		"vm delete": func() (cli.Command, error) {
+			return cmd.NewVmDeleteCommand(ui, trellis), nil
+		},
+		"vm shell": func() (cli.Command, error) {
+			return &cmd.VmShellCommand{UI: ui, Trellis: trellis}, nil
+		},
+		"vm start": func() (cli.Command, error) {
+			return cmd.NewVmStartCommand(ui, trellis), nil
+		},
+		"vm stop": func() (cli.Command, error) {
+			return cmd.NewVmStopCommand(ui, trellis), nil
+		},
+		"vm sudoers": func() (cli.Command, error) {
+			return &cmd.VmSudoersCommand{UI: ui, Trellis: trellis}, nil
+		},
 		"xdebug-tunnel": func() (cli.Command, error) {
 			return &cmd.NamespaceCommand{
 				HelpText:     "Usage: trellis xdebug-tunnel <subcommand> [<args>]",
@@ -187,6 +213,7 @@ func main() {
 	}
 
 	c.HiddenCommands = []string{"venv", "venv hook"}
+	c.HelpFunc = experimentalCommandHelpFunc(c.Name, cli.BasicHelpFunc("trellis"))
 
 	if trellis.CliConfig.LoadPlugins {
 		pluginPaths := filepath.SplitList(os.Getenv("PATH"))
diff --git a/pkg/lima/files/config.yml b/pkg/lima/files/config.yml
new file mode 100644
index 0000000..abe3b70
--- /dev/null
+++ b/pkg/lima/files/config.yml
@@ -0,0 +1,29 @@
+vmType: "vz"
+rosetta:
+  enabled: false
+images:
+{{ range $image := .Config.Images -}}
+- location: {{ $image.Location }}
+  arch: {{ $image.Arch }}
+{{ end }}
+mounts:
+{{ range $siteName, $site := .Sites -}}
+- location: {{ $site.AbsLocalPath }}
+  mountPoint: /srv/www/{{ $siteName }}/current
+  writable: true
+{{ end }}
+mountType: "virtiofs"
+networks:
+- vzNAT: true
+portForwards:
+{{ range $port := .Config.PortForwards -}}
+- guestPort: {{ $port.GuestPort}}
+  hostPort: {{ $port.HostPort }}
+{{ end }}
+containerd:
+  user: false
+provision:
+- mode: system
+  script: |
+    #!/bin/bash
+    echo "127.0.0.1 $(hostname)" >> /etc/hosts
diff --git a/pkg/lima/files/inventory.txt b/pkg/lima/files/inventory.txt
new file mode 100644
index 0000000..16be068
--- /dev/null
+++ b/pkg/lima/files/inventory.txt
@@ -0,0 +1,7 @@
+default ansible_host=127.0.0.1 ansible_port={{ .SshLocalPort }} ansible_user={{ .Username }} ansible_ssh_common_args='-o StrictHostKeyChecking=no'
+
+[development]
+default
+
+[web]
+default
diff --git a/pkg/lima/instance.go b/pkg/lima/instance.go
new file mode 100644
index 0000000..e37c618
--- /dev/null
+++ b/pkg/lima/instance.go
@@ -0,0 +1,132 @@
+package lima
+
+import (
+	_ "embed"
+	"errors"
+	"fmt"
+	"os"
+	"regexp"
+	"text/template"
+
+	"github.com/roots/trellis-cli/command"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+//go:embed files/config.yml
+var ConfigTemplate string
+
+//go:embed files/inventory.txt
+var inventoryTemplate string
+
+var (
+	ConfigErr = errors.New("Could not write Lima config file")
+	IpErr     = errors.New("Could not determine IP address for VM instance")
+)
+
+type PortForward struct {
+	GuestPort int `yaml:"guestPort"`
+	HostPort  int `yaml:"hostPort"`
+}
+
+type Image struct {
+	Alias    string
+	Location string `yaml:"location"`
+	Arch     string `yaml:"arch"`
+}
+
+type Config struct {
+	Images       []Image       `yaml:"images"`
+	PortForwards []PortForward `yaml:"portForwards"`
+}
+
+type Instance struct {
+	ConfigFile    string
+	InventoryFile string
+	Sites         map[string]*trellis.Site
+	Name          string `json:"name"`
+	Status        string `json:"status"`
+	Dir           string `json:"dir"`
+	Arch          string `json:"arch"`
+	Cpus          int    `json:"cpus"`
+	Memory        int    `json:"memory"`
+	Disk          int    `json:"disk"`
+	SshLocalPort  int    `json:"sshLocalPort,omitempty"`
+	Config        Config `json:"config"`
+	Username      string `json:"username,omitempty"`
+}
+
+func (i *Instance) CreateConfig() error {
+	tpl := template.Must(template.New("lima").Parse(ConfigTemplate))
+
+	file, err := os.Create(i.ConfigFile)
+	if err != nil {
+		return fmt.Errorf("%v: %w", ConfigErr, err)
+	}
+
+	err = tpl.Execute(file, i)
+	if err != nil {
+		return fmt.Errorf("%v: %w", ConfigErr, err)
+	}
+
+	return nil
+}
+
+func (i *Instance) CreateInventoryFile() error {
+	if i.SshLocalPort == 0 {
+		return fmt.Errorf("SshLocalPort is not set. This is a trellis-cli bug.")
+	}
+
+	tpl := template.Must(template.New("lima").Parse(inventoryTemplate))
+
+	file, err := os.Create(i.InventoryFile)
+	if err != nil {
+		return fmt.Errorf("Could not create Ansible inventory file: %v", err)
+	}
+
+	err = tpl.Execute(file, i)
+	if err != nil {
+		return fmt.Errorf("Could not template Ansible inventory file: %v", err)
+	}
+
+	return nil
+}
+
+/*
+Gets the IP address of the instance using the output of `ip route`:
+  default via 192.168.64.1 proto dhcp src 192.168.64.2 metric 100
+  192.168.64.0/24 proto kernel scope link src 192.168.64.2
+  192.168.64.1 proto dhcp scope link src 192.168.64.2 metric 100
+*/
+func (i *Instance) IP() (ip string, err error) {
+	output, err := command.Cmd(
+		"limactl",
+		[]string{"shell", "--workdir", "/", i.Name, "ip", "route", "show", "dev", "lima0"},
+	).CombinedOutput()
+
+	if err != nil {
+		return "", fmt.Errorf("%w: %v\n%s", IpErr, err, string(output))
+	}
+
+	re := regexp.MustCompile(`default via .* src ([0-9\.]+)`)
+	matches := re.FindStringSubmatch(string(output))
+	if len(matches) < 2 {
+		return "", fmt.Errorf("%w: no IP address could be matched in the ip route output\n%s", IpErr, string(output))
+	}
+
+	ip = matches[1]
+
+	return ip, nil
+}
+
+func (i *Instance) Running() bool {
+	return i.Status == "Running"
+}
+
+func (i *Instance) Stopped() bool {
+	return i.Status == "Stopped"
+}
+
+func (i *Instance) getUsername() ([]byte, error) {
+	user, err := command.Cmd("limactl", []string{"shell", i.Name, "whoami"}).Output()
+	return user, err
+}
diff --git a/pkg/lima/lima.go b/pkg/lima/lima.go
new file mode 100644
index 0000000..88ebc33
--- /dev/null
+++ b/pkg/lima/lima.go
@@ -0,0 +1,36 @@
+package lima
+
+import (
+	"fmt"
+	"os/exec"
+	"regexp"
+
+	"github.com/mcuadros/go-version"
+	"github.com/roots/trellis-cli/command"
+)
+
+const (
+	VersionRequired = ">= 0.15.0"
+)
+
+func Installed() error {
+	if _, err := exec.LookPath("limactl"); err != nil {
+		return fmt.Errorf("Lima is not installed.")
+	}
+
+	output, err := command.Cmd("limactl", []string{"-v"}).Output()
+	if err != nil {
+		return fmt.Errorf("Could get determine the version of Lima.")
+	}
+
+	re := regexp.MustCompile(`.*([0-9]+\.[0-9]+\.[0-9]+(-alpha|beta)?)`)
+	v := re.FindStringSubmatch(string(output))
+	constraint := version.NewConstrainGroupFromString(VersionRequired)
+	matched := constraint.Match(v[1])
+
+	if !matched {
+		return fmt.Errorf("Lima version %s does not satisfy required version (%s).", v[1], VersionRequired)
+	}
+
+	return nil
+}
diff --git a/pkg/lima/manager.go b/pkg/lima/manager.go
new file mode 100644
index 0000000..4179317
--- /dev/null
+++ b/pkg/lima/manager.go
@@ -0,0 +1,332 @@
+package lima
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/mcuadros/go-version"
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/command"
+	"github.com/roots/trellis-cli/pkg/vm"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+const (
+	configDir            = "lima"
+	RequiredMacOSVersion = "13.0.0"
+)
+
+var (
+	ConfigPathError    = errors.New("could not create config directory")
+	UnsupportedOSError = errors.New("Unsupported OS or macOS version. The macOS Virtualization Framework requires macOS 13.0 (Ventura) or later.")
+)
+
+type Manager struct {
+	ConfigPath    string
+	Sites         map[string]*trellis.Site
+	HostsResolver vm.HostsResolver
+	ui            cli.Ui
+	trellis       *trellis.Trellis
+}
+
+func NewManager(trellis *trellis.Trellis, ui cli.Ui) (manager *Manager, err error) {
+	if os.Getenv("TRELLIS_BYPASS_LIMA_REQUIREMENTS") != "1" {
+		if err := ensureRequirements(); err != nil {
+			return nil, err
+		}
+	}
+
+	limaConfigPath := filepath.Join(trellis.ConfigPath(), configDir)
+
+	hostNames := trellis.Environments["development"].AllHosts()
+	hostsResolver, err := vm.NewHostsResolver(trellis.CliConfig.Vm.HostsResolver, hostNames)
+
+	if err != nil {
+		return nil, err
+	}
+
+	manager = &Manager{
+		ConfigPath:    limaConfigPath,
+		Sites:         trellis.Environments["development"].WordPressSites,
+		HostsResolver: hostsResolver,
+		trellis:       trellis,
+		ui:            ui,
+	}
+
+	if err = manager.createConfigPath(); err != nil {
+		return nil, fmt.Errorf("%w: %v", ConfigPathError, err)
+	}
+
+	return manager, nil
+}
+
+func (m *Manager) InventoryPath() string {
+	return filepath.Join(m.ConfigPath, "inventory")
+}
+
+func (m *Manager) GetInstance(name string) (Instance, bool) {
+	instances := m.instances()
+	instance, ok := instances[name]
+
+	return instance, ok
+}
+
+func (m *Manager) CreateInstance(name string) error {
+	instance := m.newInstance(name)
+
+	if err := instance.CreateConfig(); err != nil {
+		return err
+	}
+
+	err := command.WithOptions(
+		command.WithTermOutput(),
+		command.WithLogging(m.ui),
+	).Cmd("limactl", []string{"start", "--tty=false", "--name=" + instance.Name, instance.ConfigFile}).Run()
+
+	if err != nil {
+		return err
+	}
+
+	return postStart(m, instance)
+}
+
+func (m *Manager) DeleteInstance(name string) error {
+	instance, ok := m.GetInstance(name)
+
+	if !ok {
+		m.ui.Info("VM does not exist for this project. Run `trellis vm start` to create it.")
+		return nil
+	}
+
+	if instance.Stopped() {
+		return command.WithOptions(
+			command.WithTermOutput(),
+			command.WithLogging(m.ui),
+		).Cmd("limactl", []string{"delete", instance.Name}).Run()
+	} else {
+		return fmt.Errorf("Error: VM is running. Run `trellis vm stop` to stop it.")
+	}
+}
+
+// TODO: set working dir to site path?
+func (m *Manager) OpenShell(name string, commandArgs []string) error {
+	instance, ok := m.GetInstance(name)
+
+	if !ok {
+		m.ui.Info("VM does not exist for this project. Run `trellis vm start` to create it.")
+		return nil
+	}
+
+	if instance.Stopped() {
+		m.ui.Info("VM is not running. Run `trellis vm start` to start it.")
+		return nil
+	}
+
+	args := []string{"shell", instance.Name}
+	args = append(args, commandArgs...)
+
+	return command.WithOptions(
+		command.WithTermOutput(),
+		command.WithLogging(m.ui),
+	).Cmd("limactl", args).Run()
+}
+
+func (m *Manager) StartInstance(name string) error {
+	instance, ok := m.GetInstance(name)
+
+	if !ok {
+		return vm.VmNotFoundErr
+	}
+
+	if instance.Running() {
+		m.ui.Info(fmt.Sprintf("%s VM already running", color.GreenString("[✓]")))
+		return nil
+	}
+
+	err := command.WithOptions(
+		command.WithTermOutput(),
+		command.WithLogging(m.ui),
+	).Cmd("limactl", []string{"start", instance.Name}).Run()
+
+	if err != nil {
+		return err
+	}
+
+	return postStart(m, instance)
+}
+
+func (m *Manager) StopInstance(name string) error {
+	instance, ok := m.GetInstance(name)
+
+	if !ok {
+		m.ui.Info("VM does not exist for this project. Run `trellis vm start` to create it.")
+		return nil
+	}
+
+	if instance.Stopped() {
+		m.ui.Info(fmt.Sprintf("%s VM already stopped", color.GreenString("[✓]")))
+		return nil
+	}
+
+	err := command.WithOptions(
+		command.WithTermOutput(),
+		command.WithLogging(m.ui),
+	).Cmd("limactl", []string{"stop", instance.Name}).Run()
+
+	if err != nil {
+		return fmt.Errorf("Error stopping VM\n%v", err)
+	}
+
+	if err = m.removeHosts(instance); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (m *Manager) hydrateInstance(instance *Instance) error {
+	i, _ := m.GetInstance(instance.Name)
+	tmpJson, err := json.Marshal(i)
+	if err != nil {
+		return fmt.Errorf("Could not marshal instance: %v\nThis is a trellis-cli bug.", err)
+	}
+	if err = json.Unmarshal(tmpJson, instance); err != nil {
+		return fmt.Errorf("Could not unmarshal instance: %v\nThis is a trellis-cli bug.", err)
+	}
+
+	return nil
+}
+
+func (m *Manager) initInstance(instance *Instance) {
+	instance.ConfigFile = filepath.Join(m.ConfigPath, instance.Name+".yml")
+	instance.InventoryFile = m.InventoryPath()
+	instance.Sites = m.Sites
+}
+
+func (m *Manager) newInstance(name string) Instance {
+	instance := Instance{Name: name}
+	m.initInstance(&instance)
+
+	images := []Image{}
+
+	if len(m.trellis.CliConfig.Vm.Images) > 0 {
+		for _, image := range m.trellis.CliConfig.Vm.Images {
+			images = append(images, Image{
+				Location: image.Location,
+				Arch:     image.Arch,
+			})
+		}
+	} else {
+		images = imagesFromVersion(m.trellis.CliConfig.Vm.Ubuntu)
+	}
+
+	config := Config{Images: images}
+	instance.Config = config
+	return instance
+}
+
+func (m *Manager) createConfigPath() error {
+	return os.MkdirAll(m.ConfigPath, 0755)
+}
+
+func (m *Manager) addHosts(instance Instance) error {
+	if err := instance.CreateInventoryFile(); err != nil {
+		return err
+	}
+
+	ip, err := instance.IP()
+	if err != nil {
+		return err
+	}
+
+	if err := m.HostsResolver.AddHosts(instance.Name, ip); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (m *Manager) instances() (instances map[string]Instance) {
+	instances = make(map[string]Instance)
+
+	// Returns line delimited JSON
+	output, _ := command.Cmd("limactl", []string{"ls", "--format=json"}).Output()
+
+	for _, line := range bytes.Split(output, []byte("\n")) {
+		instance := &Instance{}
+		json.Unmarshal([]byte(line), instance)
+		m.initInstance(instance)
+		instances[instance.Name] = *instance
+	}
+
+	return instances
+}
+
+func (m *Manager) removeHosts(instance Instance) error {
+	return m.HostsResolver.RemoveHosts(instance.Name)
+}
+
+func postStart(manager *Manager, instance Instance) error {
+	user, err := instance.getUsername()
+	if err != nil {
+		return fmt.Errorf("Could not get username: %v", err)
+	}
+
+	instance.Username = string(user)
+
+	// Hydrate instance with data from limactl that is only available after starting (mainly the forwarded SSH local port)
+	err = manager.hydrateInstance(&instance)
+	if err != nil {
+		return err
+	}
+
+	if err = manager.addHosts(instance); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func getMacOSVersion() (string, error) {
+	cmd := command.Cmd("sw_vers", []string{"-productVersion"})
+	b, err := cmd.Output()
+	if err != nil {
+		return "", err
+	}
+
+	verTrimmed := strings.TrimSpace(string(b))
+	version := version.Normalize(verTrimmed)
+	return version, nil
+}
+
+func ensureRequirements() error {
+	macOSVersion, err := getMacOSVersion()
+	if err != nil {
+		return UnsupportedOSError
+	}
+
+	if version.Compare(macOSVersion, RequiredMacOSVersion, "<") {
+		return fmt.Errorf("%w", UnsupportedOSError)
+	}
+
+	if err = Installed(); err != nil {
+		return fmt.Errorf(err.Error() + `
+Install or upgrade Lima to continue:
+
+  brew install lima
+
+See https://github.com/lima-vm/lima#getting-started for manual installation options.`)
+	}
+
+	return nil
+}
+
+func imagesFromVersion(version string) []Image {
+	return UbuntuImages[version]
+}
diff --git a/pkg/lima/ubuntu.go b/pkg/lima/ubuntu.go
new file mode 100644
index 0000000..656f6b5
--- /dev/null
+++ b/pkg/lima/ubuntu.go
@@ -0,0 +1,40 @@
+package lima
+
+var UbuntuImages = map[string][]Image{
+	"18.04": {
+		{
+			Alias:    "bionic",
+			Location: "https://cloud-images.ubuntu.com/bionic/current/bionic-server-cloudimg-amd64.img",
+			Arch:     "x86_64",
+		},
+		{
+			Alias:    "bionic",
+			Location: "https://cloud-images.ubuntu.com/bionic/current/bionic-server-cloudimg-arm64.img",
+			Arch:     "aarch64",
+		},
+	},
+	"20.04": {
+		{
+			Alias:    "focal",
+			Location: "https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-amd64.img",
+			Arch:     "x86_64",
+		},
+		{
+			Alias:    "focal",
+			Location: "https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-arm64.img",
+			Arch:     "aarch64",
+		},
+	},
+	"22.04": {
+		{
+			Alias:    "jammy",
+			Location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
+			Arch:     "x86_64",
+		},
+		{
+			Alias:    "jammy",
+			Location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
+			Arch:     "aarch64",
+		},
+	},
+}
diff --git a/pkg/vm/hosts_resolver.go b/pkg/vm/hosts_resolver.go
new file mode 100644
index 0000000..654494c
--- /dev/null
+++ b/pkg/vm/hosts_resolver.go
@@ -0,0 +1,123 @@
+package vm
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/roots/trellis-cli/app_paths"
+	"github.com/roots/trellis-cli/command"
+)
+
+var (
+	HostsRemoveErr = errors.New("Error removing hosts")
+	HostsAddErr    = errors.New("Error adding hosts")
+)
+
+type HostsResolver interface {
+	AddHosts(name string, ip string) error
+	RemoveHosts(name string) error
+}
+
+type HostsFileResolver struct {
+	Hosts        []string
+	hostsPath    string
+	tmpHostsPath string
+}
+
+func NewHostsResolver(resolverType string, hosts []string) (resolver HostsResolver, err error) {
+	switch resolverType {
+	case "hosts_file":
+		return NewHostsFileResolver(hosts), nil
+	default:
+		return nil, fmt.Errorf("Unknown hosts resolver type: %s", resolverType)
+	}
+}
+
+func NewHostsFileResolver(hosts []string) *HostsFileResolver {
+	return &HostsFileResolver{
+		Hosts:        hosts,
+		hostsPath:    "/etc/hosts",
+		tmpHostsPath: filepath.Join(app_paths.DataDir(), "hosts"),
+	}
+}
+
+// TODO: remove Networkable interface
+func (h *HostsFileResolver) AddHosts(name string, ip string) error {
+	content, err := h.addHostsContent(name, ip)
+	if err != nil {
+		return fmt.Errorf("%w: %v.\nThis is probably a trellis-cli bug; please report it.", HostsAddErr, err)
+	}
+
+	return h.writeHostsFile(content)
+}
+
+func (h *HostsFileResolver) RemoveHosts(name string) error {
+	content, err := h.removeHostsContent(name)
+	if err != nil {
+		return fmt.Errorf("%w: %v.\nThis is probably a trellis-cli bug; please report it.", HostsRemoveErr, err)
+	}
+
+	return h.writeHostsFile(content)
+}
+
+func (h *HostsFileResolver) addHostsContent(name string, ip string) (content []byte, err error) {
+	content, err = h.removeHostsContent(name)
+	if err != nil {
+		return []byte{}, err
+	}
+
+	instanceHosts, err := h.generateHosts(name, ip)
+	if err != nil {
+		return []byte{}, err
+	}
+
+	content = append(content, []byte(instanceHosts)...)
+	return content, nil
+}
+
+func (h *HostsFileResolver) SudoersCommand() []string {
+	return []string{"/bin/cp", h.tmpHostsPath, h.hostsPath}
+}
+
+func (h *HostsFileResolver) removeHostsContent(name string) (content []byte, err error) {
+	header := fmt.Sprintf("## trellis-start-%s", name)
+	footer := fmt.Sprintf("## trellis-end-%s", name)
+
+	re := regexp.MustCompile(fmt.Sprintf(`%s([\s\S]*)%s\n`, header, footer))
+	hostsContent, err := os.ReadFile(h.hostsPath)
+	if err != nil {
+		return []byte{}, fmt.Errorf("Error reading %s file: %v", h.hostsPath, err)
+	}
+
+	hostsContent = re.ReplaceAll(hostsContent, []byte{})
+	return hostsContent, nil
+}
+
+func (h *HostsFileResolver) writeHostsFile(content []byte) error {
+	if err := os.MkdirAll(filepath.Dir(h.tmpHostsPath), 0755); err != nil {
+		return err
+	}
+
+	if err := os.WriteFile(h.tmpHostsPath, content, 0644); err != nil {
+		return err
+	}
+
+	fmt.Printf("\nUpdating %s file (sudo may be required, see `trellis vm sudoers` for more details)\n", h.hostsPath)
+
+	return command.WithOptions(
+		command.WithTermOutput(),
+	).Cmd("sudo", h.SudoersCommand()).Run()
+}
+
+func (h *HostsFileResolver) generateHosts(name string, ip string) (string, error) {
+	content := fmt.Sprintf(`## trellis-start-%s
+%s %s
+## trellis-end-%s
+`, name, ip, strings.Join(h.Hosts, " "), name)
+
+	return content, nil
+}
diff --git a/pkg/vm/vm.go b/pkg/vm/vm.go
new file mode 100644
index 0000000..1b1f43a
--- /dev/null
+++ b/pkg/vm/vm.go
@@ -0,0 +1,18 @@
+package vm
+
+import (
+	"errors"
+)
+
+var (
+	VmNotFoundErr = errors.New("vm does not exist")
+)
+
+type Manager interface {
+	CreateInstance(name string) error
+	DeleteInstance(name string) error
+	InventoryPath() string
+	StartInstance(name string) error
+	StopInstance(name string) error
+	OpenShell(name string, commandArgs []string) error
+}
diff --git a/plugin/help_func.go b/plugin/help_func.go
index 952a320..1266dcc 100644
--- a/plugin/help_func.go
+++ b/plugin/help_func.go
@@ -6,17 +6,17 @@ import (
 	"github.com/mitchellh/cli"
 )
 
-func helpFunc(app string, pluginRootCommands []string) cli.HelpFunc {
+func helpFunc(pluginRootCommands []string, f cli.HelpFunc) cli.HelpFunc {
 	return func(commands map[string]cli.CommandFactory) string {
 		var buf bytes.Buffer
 		if len(pluginRootCommands) > 0 {
-			buf.WriteString("\n\nAvailable third party plugin commands are:\n")
+			buf.WriteString("\n\nAvailable plugin commands:\n")
 		}
 
 		for _, p := range pluginRootCommands {
 			buf.WriteString(fmt.Sprintf("    %s\n", p))
 		}
 
-		return cli.BasicHelpFunc(app)(commands) + buf.String()
+		return f(commands) + buf.String()
 	}
 }
diff --git a/plugin/register.go b/plugin/register.go
index 7c4ca4b..06b0ca2 100644
--- a/plugin/register.go
+++ b/plugin/register.go
@@ -33,7 +33,7 @@ func Register(c *cli.CLI, searchPaths []string, validPluginFilenamePrefixes []st
 	// Separate plugin commands from core command lists.
 	c.HiddenCommands = append(c.HiddenCommands, pluginRootCommands...)
 	// Append plugin command list to help text.
-	c.HelpFunc = helpFunc(c.Name, pluginRootCommands)
+	c.HelpFunc = helpFunc(pluginRootCommands, c.HelpFunc)
 }
 
 func rootCommandsFor(v reflect.Value) (rootCommands []string) {
diff --git a/trellis/config.go b/trellis/config.go
index c29739c..554765d 100644
--- a/trellis/config.go
+++ b/trellis/config.go
@@ -2,19 +2,21 @@ package trellis
 
 import (
 	"fmt"
-	"gopkg.in/yaml.v2"
 	"log"
 	"os"
+	"path/filepath"
 	"sort"
 
 	"github.com/roots/trellis-cli/dns"
 	"github.com/weppos/publicsuffix-go/publicsuffix"
+	"gopkg.in/yaml.v2"
 )
 
 const DefaultSiteName = "example.com"
 
 type Site struct {
 	SiteHosts       []SiteHost             `yaml:"site_hosts"`
+	AbsLocalPath    string                 `yaml:"-"`
 	LocalPath       string                 `yaml:"local_path"`
 	AdminEmail      string                 `yaml:"admin_email,omitempty"`
 	Branch          string                 `yaml:"branch,omitempty"`
@@ -47,6 +49,9 @@ func (t *Trellis) ParseConfig(path string) *Config {
 		log.Fatalln(err)
 	}
 
+	for _, site := range config.WordPressSites {
+		site.AbsLocalPath = filepath.Join(t.Path, site.LocalPath)
+	}
 	return config
 }
 
diff --git a/trellis/trellis.go b/trellis/trellis.go
index e167bf3..5fd6396 100644
--- a/trellis/trellis.go
+++ b/trellis/trellis.go
@@ -37,6 +37,11 @@ var DefaultCliConfig = cli_config.Config{
 	LoadPlugins:             true,
 	Open:                    make(map[string]string),
 	VirtualenvIntegration:   true,
+	Vm: cli_config.VmConfig{
+		Manager:       "auto",
+		HostsResolver: "hosts_file",
+		Ubuntu:        "22.04",
+	},
 }
 
 type Trellis struct {


Test patch:
diff --git a/cmd/provision_test.go b/cmd/provision_test.go
index d93dff4..c5c580e 100644
--- a/cmd/provision_test.go
+++ b/cmd/provision_test.go
@@ -72,6 +72,7 @@ func TestProvisionRunValidations(t *testing.T) {
 func TestProvisionRun(t *testing.T) {
 	defer trellis.LoadFixtureProject(t)()
 	trellis := trellis.NewTrellis()
+	trellis.CliConfig.Vm.Manager = "mock"
 
 	cases := []struct {
 		name string
diff --git a/cmd/vm_delete_test.go b/cmd/vm_delete_test.go
new file mode 100644
index 0000000..581a1b1
--- /dev/null
+++ b/cmd/vm_delete_test.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+func TestVmDeleteRunValidations(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+
+	cases := []struct {
+		name            string
+		projectDetected bool
+		args            []string
+		out             string
+		code            int
+	}{
+		{
+			"no_project",
+			false,
+			nil,
+			"No Trellis project detected",
+			1,
+		},
+		{
+			"too_many_args",
+			true,
+			[]string{"foo"},
+			"Error: too many arguments",
+			1,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ui := cli.NewMockUi()
+			trellis := trellis.NewMockTrellis(tc.projectDetected)
+			vmDeleteCommand := NewVmDeleteCommand(ui, trellis)
+
+			code := vmDeleteCommand.Run(tc.args)
+
+			if code != tc.code {
+				t.Errorf("expected code %d to be %d", code, tc.code)
+			}
+
+			combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
+
+			if !strings.Contains(combined, tc.out) {
+				t.Errorf("expected output %q to contain %q", combined, tc.out)
+			}
+		})
+	}
+}
diff --git a/cmd/vm_start_test.go b/cmd/vm_start_test.go
new file mode 100644
index 0000000..143ac68
--- /dev/null
+++ b/cmd/vm_start_test.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+func TestVmStartRunValidations(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+
+	cases := []struct {
+		name            string
+		projectDetected bool
+		args            []string
+		out             string
+		code            int
+	}{
+		{
+			"no_project",
+			false,
+			nil,
+			"No Trellis project detected",
+			1,
+		},
+		{
+			"too_many_args",
+			true,
+			[]string{"foo"},
+			"Error: too many arguments",
+			1,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ui := cli.NewMockUi()
+			trellis := trellis.NewMockTrellis(tc.projectDetected)
+			vmStartCommand := NewVmStartCommand(ui, trellis)
+
+			code := vmStartCommand.Run(tc.args)
+
+			if code != tc.code {
+				t.Errorf("expected code %d to be %d", code, tc.code)
+			}
+
+			combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
+
+			if !strings.Contains(combined, tc.out) {
+				t.Errorf("expected output %q to contain %q", combined, tc.out)
+			}
+		})
+	}
+}
diff --git a/cmd/vm_stop_test.go b/cmd/vm_stop_test.go
new file mode 100644
index 0000000..37b4348
--- /dev/null
+++ b/cmd/vm_stop_test.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+func TestVmStopRunValidations(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+
+	cases := []struct {
+		name            string
+		projectDetected bool
+		args            []string
+		out             string
+		code            int
+	}{
+		{
+			"no_project",
+			false,
+			nil,
+			"No Trellis project detected",
+			1,
+		},
+		{
+			"too_many_args",
+			true,
+			[]string{"foo"},
+			"Error: too many arguments",
+			1,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ui := cli.NewMockUi()
+			trellis := trellis.NewMockTrellis(tc.projectDetected)
+			vmStopCommand := NewVmStopCommand(ui, trellis)
+
+			code := vmStopCommand.Run(tc.args)
+
+			if code != tc.code {
+				t.Errorf("expected code %d to be %d", code, tc.code)
+			}
+
+			combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
+
+			if !strings.Contains(combined, tc.out) {
+				t.Errorf("expected output %q to contain %q", combined, tc.out)
+			}
+		})
+	}
+}
diff --git a/cmd/vm_sudoers_test.go b/cmd/vm_sudoers_test.go
new file mode 100644
index 0000000..ed76959
--- /dev/null
+++ b/cmd/vm_sudoers_test.go
@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+func TestVmSudoersRunValidations(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+
+	cases := []struct {
+		name            string
+		projectDetected bool
+		args            []string
+		out             string
+		code            int
+	}{
+		{
+			"no_project",
+			false,
+			nil,
+			"No Trellis project detected",
+			1,
+		},
+		{
+			"too_many_args",
+			true,
+			[]string{"foo"},
+			"Error: too many arguments",
+			1,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ui := cli.NewMockUi()
+			trellis := trellis.NewMockTrellis(tc.projectDetected)
+			vmSudoersCommand := &VmSudoersCommand{ui, trellis}
+
+			code := vmSudoersCommand.Run(tc.args)
+
+			if code != tc.code {
+				t.Errorf("expected code %d to be %d", code, tc.code)
+			}
+
+			combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
+
+			if !strings.Contains(combined, tc.out) {
+				t.Errorf("expected output %q to contain %q", combined, tc.out)
+			}
+		})
+	}
+}
diff --git a/command/testing.go b/command/testing.go
index 4889536..dfdb268 100644
--- a/command/testing.go
+++ b/command/testing.go
@@ -1,11 +1,22 @@
 package command
 
 import (
+	"encoding/json"
+	"fmt"
 	"io"
 	"os"
 	"os/exec"
+	"strings"
+	"testing"
 )
 
+type MockCommand struct {
+	Command  string   `json:"command"`
+	Args     []string `json:"args"`
+	Output   string   `json:"output"`
+	ExitCode int      `json:"exit_code"`
+}
+
 func MockExecCommand(stdout io.Writer, stderr io.Writer) func(command string, args []string) *exec.Cmd {
 	return func(command string, args []string) *exec.Cmd {
 		cs := []string{"-test.run=TestHelperProcess", "--", command}
@@ -18,3 +29,64 @@ func MockExecCommand(stdout io.Writer, stderr io.Writer) func(command string, ar
 		return cmd
 	}
 }
+
+func MockExecCommands(t *testing.T, commands []MockCommand) func() {
+	t.Helper()
+
+	tmpDir := t.TempDir()
+
+	serializedCommands, err := json.Marshal(commands)
+	if err != nil {
+		t.Fatalf("error serializing commands: %s", err)
+	}
+
+	mockExecCommand := func(command string, args []string) *exec.Cmd {
+		cs := []string{"-test.run=TestCommandHelperProcess", "--", command}
+		cs = append(cs, args...)
+		cmd := exec.Command(os.Args[0], cs...)
+		cmd.Env = []string{
+			"GO_WANT_HELPER_PROCESS=1", fmt.Sprintf("GO_TEST_HELPER_TMP_PATH=%s", tmpDir),
+			"GO_TEST_HELPER_COMMANDS=" + string(serializedCommands),
+		}
+		return cmd
+	}
+
+	Mock(mockExecCommand)
+
+	return func() {
+		Restore()
+	}
+}
+
+func CommandHelperProcess(t *testing.T) {
+	if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
+		return
+	}
+
+	commands := []MockCommand{}
+	err := json.Unmarshal([]byte(os.Getenv("GO_TEST_HELPER_COMMANDS")), &commands)
+	if err != nil {
+		t.Fatalf("error unmarshaling commands: %s", err)
+	}
+
+	command := strings.Join(os.Args[3:len(os.Args)], " ")
+
+	commandExecuted := MockCommand{}
+	commandFound := false
+
+	for _, cmd := range commands {
+		execCmd := exec.Command(cmd.Command, cmd.Args...)
+		if execCmd.String() == command {
+			commandExecuted = cmd
+			commandFound = true
+			break
+		}
+	}
+
+	if !commandFound {
+		t.Fatalf("command not found: %s\nmocked commands: %v", command, commands)
+	}
+
+	fmt.Fprintf(os.Stdout, commandExecuted.Output)
+	os.Exit(commandExecuted.ExitCode)
+}
diff --git a/pkg/lima/instance_test.go b/pkg/lima/instance_test.go
new file mode 100644
index 0000000..f1075b2
--- /dev/null
+++ b/pkg/lima/instance_test.go
@@ -0,0 +1,158 @@
+package lima
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/roots/trellis-cli/command"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+func TestCreateConfig(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	dir := t.TempDir()
+	configFile := filepath.Join(dir, "lima.yaml")
+
+	instance := &Instance{
+		Dir:        dir,
+		ConfigFile: configFile,
+		Config: Config{
+			Images: []Image{
+				{
+					Location: "http://ubuntu.com/focal",
+					Arch:     "aarch64",
+				},
+			},
+			PortForwards: []PortForward{
+				{
+					HostPort:  1234,
+					GuestPort: 80,
+				},
+			},
+		},
+		Sites: trellis.Environments["development"].WordPressSites,
+	}
+
+	err := instance.CreateConfig()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	content, err := os.ReadFile(configFile)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	absSitePath := filepath.Join(trellis.Path, "../site")
+
+	expected := fmt.Sprintf(`vmType: "vz"
+rosetta:
+  enabled: false
+images:
+- location: http://ubuntu.com/focal
+  arch: aarch64
+
+mounts:
+- location: %s
+  mountPoint: /srv/www/example.com/current
+  writable: true
+
+mountType: "virtiofs"
+networks:
+- vzNAT: true
+portForwards:
+- guestPort: 80
+  hostPort: 1234
+
+containerd:
+  user: false
+provision:
+- mode: system
+  script: |
+    #!/bin/bash
+    echo "127.0.0.1 $(hostname)" >> /etc/hosts
+`, absSitePath)
+
+	if string(content) != expected {
+		t.Errorf("expected %s\ngot %s", expected, string(content))
+	}
+}
+
+func TestCreateInventoryFile(t *testing.T) {
+	dir := t.TempDir()
+
+	instance := &Instance{
+		Dir:           dir,
+		InventoryFile: filepath.Join(dir, "inventory"),
+		SshLocalPort:  1234,
+		Username:      "dev",
+	}
+
+	err := instance.CreateInventoryFile()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	content, err := os.ReadFile(instance.InventoryFile)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected := `default ansible_host=127.0.0.1 ansible_port=1234 ansible_user=dev ansible_ssh_common_args='-o StrictHostKeyChecking=no'
+
+[development]
+default
+
+[web]
+default
+`
+
+	if string(content) != expected {
+		t.Errorf("expected %s\ngot %s", expected, string(content))
+	}
+}
+
+func TestIP(t *testing.T) {
+	instance := &Instance{
+		Name: "test",
+	}
+
+	mockOutput := `default via 192.168.64.1 proto dhcp src 192.168.64.2 metric 100
+192.168.64.0/24 proto kernel scope link src 192.168.64.2
+192.168.64.1 proto dhcp scope link src 192.168.64.2 metric 100
+`
+	commands := []command.MockCommand{
+		{
+			Command: "limactl",
+			Args: []string{
+				"shell", "--workdir", "/", instance.Name, "ip", "route", "show", "dev", "lima0",
+			},
+			Output: mockOutput,
+		},
+	}
+	defer command.MockExecCommands(t, commands)()
+
+	ip, err := instance.IP()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expected := "192.168.64.2"
+
+	if ip != expected {
+		t.Errorf("expected %s\ngot %s", expected, ip)
+	}
+}
+
+func TestCommandHelperProcess(t *testing.T) {
+	command.CommandHelperProcess(t)
+}
diff --git a/pkg/lima/manager_test.go b/pkg/lima/manager_test.go
new file mode 100644
index 0000000..31371d8
--- /dev/null
+++ b/pkg/lima/manager_test.go
@@ -0,0 +1,274 @@
+package lima
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/command"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type MockHostsResolver struct {
+	Hosts map[string]string
+}
+
+func TestNewManager(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	tmp := t.TempDir()
+
+	os.OpenFile(filepath.Join(tmp, "limactl"), os.O_CREATE, 0555)
+	path := os.Getenv("PATH")
+	t.Setenv("PATH", fmt.Sprintf("PATH=%s:%s", path, tmp))
+
+	commands := []command.MockCommand{
+		{
+			Command: "sw_vers",
+			Args:    []string{"-productVersion"},
+			Output:  `13.0.1`,
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"-v"},
+			Output:  `limactl version 0.15.0`,
+		},
+	}
+	defer command.MockExecCommands(t, commands)()
+
+	_, err := NewManager(trellis, cli.NewMockUi())
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestNewManagerUnsupportedOS(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	tmp := t.TempDir()
+
+	os.OpenFile(filepath.Join(tmp, "limactl"), os.O_CREATE, 0555)
+	path := os.Getenv("PATH")
+	t.Setenv("PATH", fmt.Sprintf("PATH=%s:%s", path, tmp))
+
+	commands := []command.MockCommand{
+		{
+			Command: "sw_vers",
+			Args:    []string{"-productVersion"},
+			Output:  `12.0.1`,
+		},
+	}
+	defer command.MockExecCommands(t, commands)()
+
+	_, err := NewManager(trellis, cli.NewMockUi())
+	if err == nil {
+		t.Fatal(err)
+	}
+
+	expected := "Unsupported OS or macOS version. The macOS Virtualization Framework requires macOS 13.0 (Ventura) or later."
+
+	if err.Error() != expected {
+		t.Errorf("expected error to be %q, got %q", expected, err.Error())
+	}
+}
+
+func TestInitInstance(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Setenv("TRELLIS_BYPASS_LIMA_REQUIREMENTS", "1")
+
+	manager, err := NewManager(trellis, cli.NewMockUi())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	instance := Instance{Name: "test"}
+	manager.initInstance(&instance)
+
+	if instance.Name != "test" {
+		t.Errorf("expected instance name to be %q, got %q", "test", instance.Name)
+	}
+}
+
+func TestNewInstanceUbuntuVersion(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	trellis.CliConfig.Vm.Ubuntu = "20.04"
+
+	t.Setenv("TRELLIS_BYPASS_LIMA_REQUIREMENTS", "1")
+
+	manager, err := NewManager(trellis, cli.NewMockUi())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	instance := manager.newInstance("test")
+
+	if instance.Name != "test" {
+		t.Errorf("expected instance name to be %q, got %q", "test", instance.Name)
+	}
+
+	if len(instance.Config.Images) != 2 {
+		t.Errorf("expected instance config to have 2 images, got %d", len(instance.Config.Images))
+	}
+
+	if instance.Config.Images[0].Alias != "focal" {
+		t.Errorf("expected instance config to have focal image, got %q", instance.Config.Images[0].Alias)
+	}
+}
+func TestInstances(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Setenv("TRELLIS_BYPASS_LIMA_REQUIREMENTS", "1")
+
+	instanceName := "test"
+
+	instancesJson := fmt.Sprintf(`{"name":"%s","status":"Running","dir":"/foo/test","vmType":"vz","arch":"aarch64","cpuType":"","cpus":4,"memory":4294967296,"disk":107374182400,"network":[{"vzNAT":true,"macAddress":"52:55:55:6f:d9:e3","interface":"lima0"}],"sshLocalPort":60720,"hostAgentPID":9390,"driverPID":9390}
+{"name":"test2","status":"Running","dir":"/foo/test","vmType":"vz","arch":"aarch64","cpuType":"","cpus":4,"memory":4294967296,"disk":107374182400,"network":[{"vzNAT":true,"macAddress":"52:55:55:6f:d9:e3","interface":"lima0"}],"sshLocalPort":60720,"hostAgentPID":9390,"driverPID":9390}`, instanceName)
+
+	commands := []command.MockCommand{
+		{
+			Command: "limactl",
+			Args:    []string{"ls", "--format=json"},
+			Output:  instancesJson,
+		},
+	}
+
+	defer command.MockExecCommands(t, commands)()
+
+	manager, err := NewManager(trellis, cli.NewMockUi())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	instances := manager.instances()
+
+	if len(instances) != 2 {
+		t.Errorf("expected 2 instance, got %d", len(instances))
+	}
+
+	instance, ok := instances[instanceName]
+
+	if !ok {
+		t.Errorf("expected instance with name %s to be present", instanceName)
+	}
+
+	if instance.Name != instanceName {
+		t.Errorf("expected instance name to be %q, got %q", instanceName, instance.Name)
+	}
+}
+
+func TestCreateInstance(t *testing.T) {
+	defer trellis.LoadFixtureProject(t)()
+	trellis := trellis.NewTrellis()
+	if err := trellis.LoadProject(); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Setenv("TRELLIS_BYPASS_LIMA_REQUIREMENTS", "1")
+
+	ui := cli.NewMockUi()
+	manager, err := NewManager(trellis, ui)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	hostsStorage := make(map[string]string)
+	manager.HostsResolver = &MockHostsResolver{Hosts: hostsStorage}
+
+	instanceName := "test"
+	sshPort := 60720
+	username := "user1"
+	ip := "192.168.64.2"
+	configFile := filepath.Join(manager.ConfigPath, instanceName+".yml")
+
+	commands := []command.MockCommand{
+		{
+			Command: "limactl",
+			Args:    []string{"start", "--tty=false", "--name=" + instanceName, configFile},
+			Output:  ``,
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"shell", instanceName, "whoami"},
+			Output:  username,
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"ls", "--format=json"},
+			Output:  fmt.Sprintf(`{"name":"%s","status":"Running","dir":"/foo/test","vmType":"vz","arch":"aarch64","cpuType":"","cpus":4,"memory":4294967296,"disk":107374182400,"network":[{"vzNAT":true,"macAddress":"52:55:55:6f:d9:e3","interface":"lima0"}],"sshLocalPort":%d,"hostAgentPID":9390,"driverPID":9390}`, instanceName, sshPort),
+		},
+		{
+			Command: "limactl",
+			Args:    []string{"shell", "--workdir", "/", instanceName, "ip", "route", "show", "dev", "lima0"},
+			Output: fmt.Sprintf(`default via 192.168.64.1 proto dhcp src %s metric 100
+192.168.64.0/24 proto kernel scope link src 192.168.64.2
+192.168.64.1 proto dhcp scope link src 192.168.64.2 metric 100
+`, ip),
+		},
+	}
+
+	defer command.MockExecCommands(t, commands)()
+
+	if err = manager.CreateInstance(instanceName); err != nil {
+		t.Fatal(err)
+	}
+
+	inventoryContents, err := os.ReadFile(manager.InventoryPath())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expectedInventoryContents := fmt.Sprintf(`default ansible_host=127.0.0.1 ansible_port=%d ansible_user=%s ansible_ssh_common_args='-o StrictHostKeyChecking=no'
+
+[development]
+default
+
+[web]
+default
+`, sshPort, username)
+
+	if string(inventoryContents) != expectedInventoryContents {
+		t.Errorf("expected inventory file to be %s, got %s", expectedInventoryContents, string(inventoryContents))
+	}
+
+	if hostsStorage[instanceName] != ip {
+		t.Errorf("expected hosts entry to be %s, got %s", ip, hostsStorage[instanceName])
+	}
+}
+
+func newMockHostsResolver(hosts map[string]string) MockHostsResolver {
+	return MockHostsResolver{Hosts: hosts}
+}
+
+func (h *MockHostsResolver) AddHosts(name string, ip string) error {
+	h.Hosts[name] = ip
+	return nil
+}
+
+func (h *MockHostsResolver) RemoveHosts(name string) error {
+	delete(h.Hosts, name)
+	return nil
+}
diff --git a/pkg/vm/hosts_resolver_test.go b/pkg/vm/hosts_resolver_test.go
new file mode 100644
index 0000000..289a7db
--- /dev/null
+++ b/pkg/vm/hosts_resolver_test.go
@@ -0,0 +1,393 @@
+package vm
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestRemoveHostsContent(t *testing.T) {
+	tempDir := t.TempDir()
+	hostsPath := filepath.Join(tempDir, "hosts")
+	hosts := []string{"example.test", "www.example.test"}
+
+	h := HostsFileResolver{
+		Hosts:        hosts,
+		hostsPath:    filepath.Join(tempDir, "hosts"),
+		tmpHostsPath: filepath.Join(tempDir, "hosts.tmp"),
+	}
+
+	cases := []struct {
+		name     string
+		content  string
+		expected string
+	}{
+		{
+			"no_trellis_block",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+		},
+		{
+			"trellis_block_end_of_file",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+		},
+		{
+			"trellis_block_middle_of_file",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+		},
+		{
+			"trellis_block_multiple_lines",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+192.168.2.1 new.example.test old.example.test
+## trellis-end-foo-bar
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+		},
+		{
+			"trellis_block_different_vm",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+`,
+		},
+		{
+			"trellis_block_multiple_vm",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+`,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if err := os.WriteFile(hostsPath, []byte(tc.content), 0644); err != nil {
+				t.Fatal(err)
+			}
+
+			content, err := h.removeHostsContent("foo-bar")
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if string(content) != tc.expected {
+				t.Errorf("expected %q, got %q", tc.expected, string(content))
+			}
+		})
+	}
+}
+
+func TestAddHostsContent(t *testing.T) {
+	tempDir := t.TempDir()
+	hostsPath := filepath.Join(tempDir, "hosts")
+	hosts := []string{"example.test", "www.example.test"}
+
+	h := HostsFileResolver{
+		Hosts:        hosts,
+		hostsPath:    filepath.Join(tempDir, "hosts"),
+		tmpHostsPath: filepath.Join(tempDir, "hosts.tmp"),
+	}
+
+	cases := []struct {
+		name     string
+		content  string
+		expected string
+	}{
+		{
+			"no_trellis_block",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+`,
+		},
+		{
+			"trellis_block_end_of_file",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.99.99 example.test www.example.test
+## trellis-end-foo-bar
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+`,
+		},
+		{
+			"trellis_block_middle_of_file",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+255.255.255.255	broadcasthost
+::1             localhost
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+`,
+		},
+		{
+			"trellis_block_different_vm",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+`,
+		},
+		{
+			"trellis_block_multiple_vms",
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+`,
+			`##
+# Host Database
+#
+# localhost is used to configure the loopback interface
+# when the system is booting.  Do not change this entry.
+##
+127.0.0.1	localhost
+255.255.255.255	broadcasthost
+::1             localhost
+## trellis-start-nope
+192.168.2.1 example.test www.example.test
+## trellis-end-nope
+## trellis-start-foo-bar
+192.168.2.1 example.test www.example.test
+## trellis-end-foo-bar
+`,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if err := os.WriteFile(hostsPath, []byte(tc.content), 0644); err != nil {
+				t.Fatal(err)
+			}
+
+			content, err := h.addHostsContent("foo-bar", "192.168.2.1")
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if string(content) != tc.expected {
+				t.Errorf("expected %q, got %q", tc.expected, string(content))
+			}
+		})
+	}
+}
diff --git a/pkg/vm/testing.go b/pkg/vm/testing.go
new file mode 100644
index 0000000..f8ce54d
--- /dev/null
+++ b/pkg/vm/testing.go
@@ -0,0 +1,44 @@
+package vm
+
+import (
+	"github.com/mitchellh/cli"
+	"github.com/roots/trellis-cli/trellis"
+)
+
+type MockVmManager struct {
+	ui      cli.Ui
+	trellis *trellis.Trellis
+}
+
+func NewMockManager(trellis *trellis.Trellis, ui cli.Ui) (manager *MockVmManager, err error) {
+	manager = &MockVmManager{
+		trellis: trellis,
+		ui:      ui,
+	}
+
+	return manager, nil
+}
+
+func (m *MockVmManager) CreateInstance(name string) error {
+	return nil
+}
+
+func (m *MockVmManager) DeleteInstance(name string) error {
+	return nil
+}
+
+func (m *MockVmManager) InventoryPath() string {
+	return ""
+}
+
+func (m *MockVmManager) StartInstance(name string) error {
+	return nil
+}
+
+func (m *MockVmManager) StopInstance(name string) error {
+	return nil
+}
+
+func (m *MockVmManager) OpenShell(name string, commandArgs []string) error {
+	return nil
+}
diff --git a/plugin/help_func_test.go b/plugin/help_func_test.go
index b1e8a1d..82897ae 100644
--- a/plugin/help_func_test.go
+++ b/plugin/help_func_test.go
@@ -1,9 +1,10 @@
 package plugin
 
 import (
-	"github.com/mitchellh/cli"
 	"strings"
 	"testing"
+
+	"github.com/mitchellh/cli"
 )
 
 func TestHelpFunc(t *testing.T) {
@@ -14,9 +15,9 @@ func TestHelpFunc(t *testing.T) {
 	}
 	pluginRootCommands := []string{"foo", "bar"}
 
-	output := helpFunc("app", pluginRootCommands)(coreCommands)
+	output := helpFunc(pluginRootCommands, cli.BasicHelpFunc("app"))(coreCommands)
 
-	expected := "Available third party plugin commands are"
+	expected := "Available plugin commands"
 	if !strings.Contains(output, expected) {
 		t.Errorf("expected output %q to contain %q", output, expected)
 	}
@@ -36,7 +37,7 @@ func TestHelpFuncNoPlugin(t *testing.T) {
 	}
 	pluginRootCommands := []string{}
 
-	output := helpFunc("app", pluginRootCommands)(coreCommands)
+	output := helpFunc(pluginRootCommands, cli.BasicHelpFunc("app"))(coreCommands)
 
 	expected := cli.BasicHelpFunc("app")(coreCommands)
 	if expected != output {
diff --git a/plugin/register_test.go b/plugin/register_test.go
index 46f2b75..197ebb5 100644
--- a/plugin/register_test.go
+++ b/plugin/register_test.go
@@ -58,7 +58,7 @@ func TestIntegrationPluginCommand(t *testing.T) {
 			[]string{"--help"},
 			[]string{},
 			[]string{
-				"Available third party plugin commands are",
+				"Available plugin commands",
 				"spy",
 			},
 		},
@@ -170,7 +170,7 @@ func TestIntegrationPluginListInHelpFunc(t *testing.T) {
 	trellisCommand.Run()
 	output := mockUi.ErrorWriter.String()
 
-	expected := "Available third party plugin commands are"
+	expected := "Available plugin commands"
 	if !strings.Contains(output, expected) {
 		t.Errorf("expected output %q to contain %q", output, expected)
 	}
diff --git a/trellis/testing.go b/trellis/testing.go
index 32e4b51..80d1cec 100644
--- a/trellis/testing.go
+++ b/trellis/testing.go
@@ -4,6 +4,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"runtime"
 	"testing"
 )
 
@@ -18,12 +19,14 @@ func LoadFixtureProject(t *testing.T) func() {
 		t.Fatalf("err: %s", err)
 	}
 
-	os.Chdir("../trellis")
+	_, b, _, _ := runtime.Caller(0)
+	basepath := filepath.Dir(b)
+	os.Chdir(basepath)
 	cmd := exec.Command("cp", "-a", "testdata/trellis", tempDir)
-	err = cmd.Run()
+	output, err := cmd.CombinedOutput()
 
 	if err != nil {
-		t.Fatalf("err: %s", err)
+		t.Fatalf("failed to copy trellis fixture project: %s\n%s", err, output)
 	}
 
 	os.Chdir(filepath.Join(tempDir, "trellis"))