From 64c1879ff3fbf5a3cb69c9585b66c6d178fb963b Mon Sep 17 00:00:00 2001 From: Quentin Watts <54119641+qwatts-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:55:09 -0400 Subject: [PATCH 1/4] Add native WSL2 virtual machine backend for Windows The upstream trellis-cli supports Lima for macOS/Linux local development. This adds a WSL2 backend so Windows developers get the same first-class experience via `trellis vm start`. New WSL2 backend (pkg/wsl/): - Manager implementing vm.Manager using wsl.exe commands - WindowsHostsResolver for hosts file management with UAC elevation - Ubuntu rootfs registry (22.04, 24.04) - Bootstrap installs Python, Ansible, Node.js LTS, Corepack - Project files copied to ext4 for native performance (~80ms vs ~14s TTFB) - Auto-stops other trellis distros (shared network namespace) - SyncBack prompt before stopping other running distros - Breadcrumb file for cross-distro SyncBack support New commands: - vm open: Launch VS Code connected to WSL distro - vm sync: Manual WSL-to-Windows file sync - vm trust: Re-import self-signed SSL certs into Windows trust store Enhanced existing commands: - vm start/stop/delete/shell: WSL2 backend support - db open: Works from both Windows and WSL terminals - provision, deploy, vault, galaxy, xdebug-tunnel: Windows host detection with redirect to WSL terminal Other changes: - Windows os.Rename retry loop for antivirus file locks - rundll32 URI handler (fixes cmd.exe & parsing in URIs) - UTF-16LE decoder for wsl.exe output --- .github/copilot-instructions.md | 104 +++ .gitignore | 2 + README.md | 235 +++++-- cmd/db_open.go | 174 +++-- cmd/deploy.go | 4 + cmd/galaxy_install.go | 4 + cmd/new.go | 20 +- cmd/provision.go | 4 + cmd/vault_decrypt.go | 4 + cmd/vault_edit.go | 4 + cmd/vault_encrypt.go | 4 + cmd/vault_view.go | 4 + cmd/vm.go | 31 + cmd/vm_delete.go | 4 + cmd/vm_open.go | 143 ++++ cmd/vm_shell.go | 4 + cmd/vm_start.go | 55 +- cmd/vm_stop.go | 19 + cmd/vm_sync.go | 106 +++ cmd/vm_trust.go | 109 +++ cmd/xdebug_tunnel_close.go | 4 + cmd/xdebug_tunnel_open.go | 4 + github/main.go | 31 +- main.go | 9 + pkg/db_opener/tableplus.go | 29 +- pkg/wsl/hosts.go | 128 ++++ pkg/wsl/manager.go | 1154 +++++++++++++++++++++++++++++++ pkg/wsl/ubuntu.go | 31 + trellis/trellis.go | 32 +- 29 files changed, 2361 insertions(+), 95 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 cmd/vm_open.go create mode 100644 cmd/vm_sync.go create mode 100644 cmd/vm_trust.go create mode 100644 pkg/wsl/hosts.go create mode 100644 pkg/wsl/manager.go create mode 100644 pkg/wsl/ubuntu.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..b7cd5236 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,104 @@ +# Trellis-CLI WSL2 Fork — Workspace Instructions + +## Project Context +This is a fork of [roots/trellis-cli](https://github.com/roots/trellis-cli) adding a native **WSL2 virtual machine manager** for Windows users. The upstream CLI supports Lima (macOS/Linux). Our fork adds a `wsl` backend that manages WSL2 distros via `wsl.exe`, giving Windows developers a first-class Trellis development experience. + +- **Language:** Go (module: `github.com/roots/trellis-cli`) +- **Fork:** https://github.com/qwatts-dev/trellis-cli +- **Upstream:** https://github.com/roots/trellis-cli + +## Architecture + +### VM Manager Interface (`pkg/vm/vm.go`) +All VM backends implement `vm.Manager`. Our WSL backend lives in `pkg/wsl/`. + +```go +type Manager interface { + CreateInstance(name string) error + DeleteInstance(name string) error + InventoryPath() string + StartInstance(name string) error + StopInstance(name string) error + OpenShell(name string, dir string, commandArgs []string) error + RunCommand(args []string, dir string) error + RunCommandPipe(args []string, dir string) (*exec.Cmd, error) +} +``` + +### Key Files + +| File | Purpose | +|---|---| +| `pkg/wsl/manager.go` | Core WSL2 Manager — all vm.Manager methods + Bootstrap + Provision + SyncBack + TrustSslCerts + syncConfigFromWSL + DecodeWslOutput + stopOtherDistros + syncBackDistro | +| `pkg/wsl/hosts.go` | WindowsHostsResolver — manages Windows hosts file with UAC elevation | +| `pkg/wsl/ubuntu.go` | Ubuntu rootfs URL registry (22.04, 24.04) | +| `cmd/vm.go` | `newVmManager()` switch — `case "wsl"` + `wslTerminalRequired()` + `windowsHostRequired()` guards | +| `cmd/vm_open.go` | Opens VS Code in WSL via `--folder-uri vscode-remote://wsl+/path` | +| `cmd/vm_sync.go` | Manual WSL→Windows rsync sync | +| `cmd/vm_trust.go` | Re-imports SSL certs into Windows trust store | +| `cmd/vm_start.go` | WSL bootstrap/provision flow, unprovisioned cleanup | +| `cmd/vm_stop.go` | Auto SyncBack before stop | +| `trellis/trellis.go` | WSL auto-detection in `VmManagerType()`, `ReloadSiteConfigs()`, `CheckVirtualenv` skip | +| `pkg/db_opener/tableplus.go` | `rundll32.exe` for Windows/WSL URI opening, direct `mysql://` for WSL | + +### How It Works + +- **Distro naming**: `trellis-` prefix + dots→hyphens (e.g. `example.com` → `trellis-example-com`) +- **Project on ext4**: Entire project rsync'd to `/home/admin//` during bootstrap. `site/` bind-mounted to `/srv/www//current` via fstab. ~80ms TTFB vs ~14s with DrvFS. +- **Inventory**: `ansible_connection=local`, `ansible_user=admin` (no SSH needed) +- **Keepalive**: `wsl --exec sleep infinity` from Windows keeps distro alive (systemd services alone don't prevent WSL idle shutdown) +- **Bootstrap installs**: Python, Ansible, Node.js LTS, Corepack (yarn/pnpm), rsync +- **One project at a time**: All WSL2 distros share a single network namespace ([MS by-design](https://github.com/microsoft/WSL/issues/4304)). `StartInstance` calls `stopOtherDistros()` which prompts to SyncBack other running `trellis-*` distros before terminating them. +- **openssh-server prevention**: Bootstrap creates `/etc/ssh/sshd_not_to_be_run` so ssh.socket never claims port 22 (we use local connection, not SSH) +- **Breadcrumb file**: Bootstrap writes `/etc/trellis-project-root` (Windows path) so `syncBackDistro()` works for any distro without loading its trellis config +- **Two guard functions**: `wslTerminalRequired()` (redirects Ansible commands from Windows → WSL) and `windowsHostRequired()` (redirects VM management from WSL → Windows) +- **Config sync**: `syncConfigFromWSL()` runs in `NewManager()` when distro is running — rsyncs group_vars/ from ext4→Windows, then `ReloadSiteConfigs()` re-parses in-memory config +- **SSL trust**: Only processes sites with `ssl.enabled: true`. Uses certutil via UAC PowerShell elevation. +- **TablePlus**: `rundll32.exe url.dll,FileProtocolHandler` for URI opening. Direct `mysql://127.0.0.1:3306` (no SSH tunnels needed). +- **UTF-16LE**: `DecodeWslOutput()` handles wsl.exe UTF-16LE BOM + null-byte pairs + +### WSL2 Command Mapping +| Method | wsl.exe Command | +|---|---| +| `CreateInstance` | `wsl --import ` | +| `StartInstance` | `wsl --exec sleep infinity` (keepalive) | +| `StopInstance` | `wsl -t ` | +| `DeleteInstance` | `wsl --unregister ` | +| `OpenShell` | `wsl -d --cd -- ` | +| `RunCommand` | `wsl -d --cd -- ` | + +## Build & Test + +```powershell +# Build both binaries (from the repo root) +go vet ./...; go build -o trellis.exe . +$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o trellis-linux .; Remove-Item Env:GOOS; Remove-Item Env:GOARCH + +# Test against a project (use the locally compiled binary, not the global trellis) +cd path\to\your\trellis-project +path\to\trellis-cli\trellis.exe vm start +``` + +**Important:** Always build BOTH `trellis.exe` (Windows) and `trellis-linux` (cross-compiled for WSL distros). The Linux binary is copied into distros during bootstrap. + +## Coding Conventions +- Follow existing code patterns in `pkg/lima/manager.go` as the reference implementation +- Use `github.com/roots/trellis-cli/command` package for exec (matches upstream style) +- Use `github.com/fatih/color` for colored output (matches upstream) +- Use `github.com/manifoldco/promptui` for interactive prompts (matches upstream pattern) +- Keep WSL-specific code in `pkg/wsl/` — do not scatter Windows logic elsewhere +- Run `go vet ./...` before committing + +## Key Gotchas +- **`/mnt/c/` = 777 permissions**: `.vault_pass` must be copied inside distro with `chmod 600` +- **Do NOT use `fmask=0111` in wsl.conf**: Breaks VS Code WSL extension (`wslServer.sh: Permission denied`) +- **rsync to DrvFS**: Use `-rlpt` not `-a` (chgrp fails). Use `--chmod=D755,F644` for Windows→WSL copies. +- **`cmd /c start` can't handle `&` in URIs**: Use `rundll32 url.dll,FileProtocolHandler` instead +- **PowerShell garbles UTF-8 multibyte**: Use `[ok]` text not `✓` checkmarks +- **`os.Rename` fails on Windows**: Antivirus file locks → retry loop + early file handle close in `github/main.go` +- **WSL2 shared network namespace**: All distros share IP+ports. Only one trellis distro can run services at a time. +- **Node.js on WSL not host**: Unlike upstream Lima (Node on macOS host), WSL project files live on ext4 so Node/yarn must be inside the distro. + +## Testing +- **OS:** Windows 11 with WSL2 enabled +- **Isolation:** If you have a global trellis-cli install, do NOT run the locally compiled binary from your PATH. Always invoke it by its full path to avoid conflicts. diff --git a/.gitignore b/.gitignore index 943061b9..9b3aa7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ trellis-cli +trellis.exe +trellis-linux dist tmp diff --git a/README.md b/README.md index c1a70fcf..a5f96eee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,159 @@ +# trellis-cli (WSL2 Fork) + +> **This is a fork of [roots/trellis-cli](https://github.com/roots/trellis-cli)** that adds native **WSL2 virtual machine support for Windows**. The upstream CLI supports Lima (macOS/Linux). This fork adds a `wsl` backend that manages WSL2 distros via `wsl.exe`, giving Windows developers a first-class Trellis development experience. + +[![Upstream](https://img.shields.io/badge/upstream-roots%2Ftrellis--cli-blue?style=flat-square)](https://github.com/roots/trellis-cli) + +--- + +## What's New: WSL2 VM Backend + +### Overview + +Windows developers can now run `trellis vm start` to get a fully provisioned Trellis development environment powered by WSL2. Each project gets its own isolated Ubuntu distro with nginx, PHP-FPM, MariaDB, and all Trellis services — no manual WSL setup required. + +### Requirements + +- **Windows 11** with WSL2 enabled +- **VS Code** with the [WSL extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) (required for editing project files) +- **trellis-cli** (this fork) + +> **Important:** Project files live on WSL2's native ext4 filesystem for performance. You must use an editor that supports WSL remote development. VS Code with the WSL extension is the recommended (and automated) path. JetBrains IDEs also support WSL remoting but are not automated by `vm open`. + +### Quick Start + +```powershell +# Create a new Trellis project (from PowerShell) +trellis new mysite.com + +# Start the VM (imports Ubuntu, bootstraps Ansible, provisions everything) +cd mysite.com +trellis vm start + +# Open VS Code connected to the WSL distro +trellis vm open + +# From the VS Code integrated terminal (inside WSL): +trellis provision development # Re-provision +trellis db open --app=tableplus # Open database in TablePlus +``` + +### Windows/WSL Development Workflow + +> **Important for Windows developers:** The development workflow differs slightly from macOS/Lima. On macOS, project files live on a shared filesystem, so dependency installs (composer, yarn) and frontend build tools run on the host. On WSL2, project files live on the distro's native ext4 filesystem for performance. **All dependency installs and build commands should be run inside the WSL terminal** (via `trellis vm open` or `trellis vm shell`). + +After `vm start` and `vm open`, run your project's setup steps from the VS Code integrated terminal: + +```bash +# Example: typical Bedrock + Sage project +cd site && composer install +cd web/app/themes/my-theme && composer install && yarn install +yarn dev # Frontend asset watcher +``` + +Node.js LTS and Corepack (yarn/pnpm) are pre-installed in every WSL distro. You do **not** need Node.js on Windows for Trellis development. If your project requires additional CLI tools, you can install them directly in your WSL distro via `trellis vm shell` or the VS Code terminal. + +### Commands + +**New commands** (WSL2 only): + +| Command | Run From | Description | +|------------|----------|---------------------------------------------------------------| +| `vm open` | Windows | Opens VS Code connected to the WSL distro at the project root | +| `vm sync` | Windows | Manually syncs project files from WSL back to Windows | +| `vm trust` | Windows | Re-imports self-signed SSL certs into the Windows trust store | + +**Enhanced for WSL2** (existing commands with added Windows-specific behavior): + +| Command | What Changed | +|-------------------|-----------------------------------------------------------------------------------| +| `vm start` | WSL2 backend: imports Ubuntu distro, bootstraps to ext4, auto-stops other distros | +| `vm stop` | Auto SyncBack (rsync ext4 → Windows) before terminating the distro | +| `vm delete` | Cleans up Windows hosts file entries and SSL certs | +| `vm shell` | Routes to WSL distro; detects when run from wrong host | +| `db open` | Works from both Windows and WSL; uses direct `mysql://` URI (no SSH tunnels) | +| `provision` | Detects Windows host and redirects to WSL terminal | +| `deploy` | Detects Windows host and redirects to WSL terminal | +| `vault *` | Detects Windows host and redirects to WSL terminal | +| `galaxy install` | Detects Windows host and redirects to WSL terminal | +| `xdebug-tunnel *` | Detects Windows host and redirects to WSL terminal | + +### How It Works + +1. **`vm start`** imports an Ubuntu rootfs into a dedicated WSL2 distro (e.g., `trellis-mysite-com`), installs Python/Ansible, copies the project to ext4, runs `ansible-playbook dev.yml`, tunes opcache, trusts SSL certs, and updates the Windows hosts file. + +2. **Project files live on ext4** at `/home/admin//` inside the distro. This gives native filesystem performance (~80ms page loads vs ~14 seconds with Windows filesystem mounts). The `site/` directory is bind-mounted to `/srv/www//current` as Trellis expects. + +3. **`vm open`** launches VS Code with `--folder-uri vscode-remote://wsl+/home/admin/`, connecting directly to the WSL distro. The developer sees the full project (trellis/ + site/ + .git/) and uses git normally from the VS Code terminal. + +4. **`vm stop`** runs an incremental rsync from WSL ext4 back to the Windows filesystem before stopping the distro, keeping the Windows-side repo up to date for GitHub Desktop or other Windows git tools. + +5. **Smart command routing** — Ansible-dependent commands (provision, deploy, vault, etc.) detect when you're on the Windows host and tell you to run them from the WSL terminal instead. VM management commands detect when you're inside WSL and redirect you to Windows. + +### Features + +- **Ext4-native performance** — ~80ms TTFB (vs ~14s with DrvFS/9p bind mounts) +- **Automatic hosts file management** — Adds/removes `*.test` entries in the Windows hosts file (UAC elevation, only when entries change) +- **SSL certificate trust** — Self-signed certs auto-imported into the Windows Trusted Root CA store (sites must have `ssl.enabled: true` in `wordpress_sites.yml`) +- **Bi-directional file sync** — Auto sync on stop; manual `vm sync`; config auto-sync on any Windows-side command +- **Database GUI support** — `db open --app=tableplus` works from both Windows and WSL terminals, using direct `mysql://` URIs (no SSH tunnels needed) +- **Cross-compiled Linux binary** — Automatically deployed into distros so `trellis` commands work from the WSL terminal +- **Distro isolation** — Each project gets its own WSL distro; multiple projects can run simultaneously +- **Resilient lifecycle** — Detects unprovisioned distros and auto-cleans; keepalive process prevents WSL idle shutdown + +### Architecture + +``` +Windows Host WSL2 Distro (trellis-mysite-com) +───────────── ───────────────────────────────── +trellis vm start ──────────────────── wsl --import → bootstrap → provision +trellis vm open ──────────────────── code --folder-uri vscode-remote://wsl+... +trellis vm stop ── rsync ext4→Win ── wsl -t (terminate) +trellis vm trust ── certutil ───────── reads /etc/nginx/ssl/*.cert +trellis db open ── rundll32 URI ───── ansible-playbook → JSON credentials + +C:\Users\...\mysite.com\ /home/admin/mysite.com/ + trellis/ (config, read by Win) trellis/ (config, used by Ansible) + site/ (Windows backup) site/ (ext4, served by nginx) + .git/ (Windows backup) .git/ (ext4, used by VS Code) +``` + +### Configuration + +The WSL backend is auto-selected on Windows. You can explicitly set it in `trellis.cli.yml`: + +```yaml +vm: + manager: "wsl" # "auto" also works (selects wsl on Windows, lima on macOS) + ubuntu: "24.04" # Ubuntu version for the rootfs (22.04 or 24.04) +``` + +### Differences from Lima (macOS) + +| Aspect | Lima (macOS) | WSL2 (Windows) | +|--------------------|-------------------------|-------------------------------| +| VM technology | QEMU/Lima | WSL2 (Hyper-V) | +| Filesystem | virtiofs (FUSE) | ext4 native | +| Networking | Lima port forwarding | WSL2 NAT (automatic) | +| Editor requirement | Any (shared filesystem) | VS Code + WSL extension | +| SSH | Lima manages SSH keys | Not needed (local connection) | +| Ansible connection | `local` | `local` | + +### Known Limitations + +- **One project at a time** — All WSL2 distros share a single network stack ([by design](https://github.com/microsoft/WSL/issues/4304)), so services like MariaDB (3306), nginx (80/443), and openssh-server (22) conflict if multiple distros run simultaneously. `vm start` automatically stops other `trellis-*` distros before starting yours, with an optional SyncBack prompt so you can sync unsaved work back to Windows first. Your data is safe — stopped distros resume exactly where they left off. +- **VS Code is required** for editing project files (they live on WSL2 ext4, not the Windows filesystem) +- **Windows-side files are a backup** — the Windows copy is kept in sync by `vm stop` and `vm sync` but is not the source of truth during development +- **One UAC prompt** per `vm start` (for hosts file and SSL cert trust) — subsequent starts skip UAC if entries haven't changed + +--- + +## Upstream README + +*Everything below is from the original [roots/trellis-cli](https://github.com/roots/trellis-cli).* + +--- + # trellis-cli [![Build status]( https://img.shields.io/github/actions/workflow/status/roots/trellis-cli/ci.yml?branch=master&style=flat-square)](https://github.com/roots/trellis-cli/actions) @@ -165,28 +321,29 @@ Run `trellis` for the complete usage and help. Supported commands so far: -| Command | Description | -| --- | --- | -| `alias` | Generate WP CLI aliases for remote environments | -| `check` | Checks if Trellis requirements are met | -| `db` | Commands for database management | -| `deploy` | Deploys a site to the specified environment | -| `dotenv` | Template .env files to local system | -| `droplet` | Commands for DigitalOcean Droplets | -| `exec` | Exec runs a command in the Trellis virtualenv | -| `galaxy` | Commands for Ansible Galaxy | -| `info` | Displays information about this Trellis project | -| `init` | Initializes an existing Trellis project | -| `key` | Commands for managing SSH keys | -| `logs` | Tails the Nginx log files | -| `new` | Creates a new Trellis project | -| `open` | Opens user-defined URLs (and more) which can act as shortcuts/bookmarks specific to your Trellis projects | -| `provision` | Provisions the specified environment | -| `rollback` | Rollsback the last deploy of the site on the specified environment | -| `ssh` | Connects to host via SSH | -| `valet` | Commands for Laravel Valet | -| `vault` | Commands for Ansible Vault | -| `xdebug-tunnel` | Commands for managing Xdebug tunnels | +| Command | Description | +|-----------------|-----------------------------------------------------------------------------------------------------------| +| `alias` | Generate WP CLI aliases for remote environments | +| `check` | Checks if Trellis requirements are met | +| `db` | Commands for database management | +| `deploy` | Deploys a site to the specified environment | +| `dotenv` | Template .env files to local system | +| `droplet` | Commands for DigitalOcean Droplets | +| `exec` | Exec runs a command in the Trellis virtualenv | +| `galaxy` | Commands for Ansible Galaxy | +| `info` | Displays information about this Trellis project | +| `init` | Initializes an existing Trellis project | +| `key` | Commands for managing SSH keys | +| `logs` | Tails the Nginx log files | +| `new` | Creates a new Trellis project | +| `open` | Opens user-defined URLs (and more) which can act as shortcuts/bookmarks specific to your Trellis projects | +| `provision` | Provisions the specified environment | +| `rollback` | Rollsback the last deploy of the site on the specified environment | +| `ssh` | Connects to host via SSH | +| `valet` | Commands for Laravel Valet | +| `vault` | Commands for Ansible Vault | +| `vm` | Commands for local development virtual machines | +| `xdebug-tunnel` | Commands for managing Xdebug tunnels | ## Configuration There are three ways to set configuration settings for trellis-cli and they are @@ -213,31 +370,31 @@ variables. Current supported settings: -| Setting | Description | Type | Default | -| --- | --- | -- | -- | -| `allow_development_deploys` | Whether to allows deploy to the `development` env | boolean | false | -| `ask_vault_pass` | Set Ansible to always ask for the vault pass | boolean | false | -| `check_for_updates` | Whether to check for new versions of trellis-cli | boolean | true | -| `database_app` | Database app to use in `db open` (Options: `tableplus`, `sequel-ace`)| string | none | -| `load_plugins` | Load external CLI plugins | boolean | true | -| `open` | List of name -> URL shortcuts | map[string]string | none | -| `virtualenv_integration` | Enable automated virtualenv integration | boolean | true | -| `vm` | Options for dev virtual machines | Object | see below | +| Setting | Description | Type | Default | +|-----------------------------|-----------------------------------------------------------------------|-------------------|-----------| +| `allow_development_deploys` | Whether to allows deploy to the `development` env | boolean | false | +| `ask_vault_pass` | Set Ansible to always ask for the vault pass | boolean | false | +| `check_for_updates` | Whether to check for new versions of trellis-cli | boolean | true | +| `database_app` | Database app to use in `db open` (Options: `tableplus`, `sequel-ace`) | string | none | +| `load_plugins` | Load external CLI plugins | boolean | true | +| `open` | List of name -> URL shortcuts | map[string]string | none | +| `virtualenv_integration` | Enable automated virtualenv integration | boolean | true | +| `vm` | Options for dev virtual machines | Object | see below | ### `vm` -| Setting | Description | Type | Default | -| --- | --- | -- | -- | -| `manager` | VM manager (Options: `auto` (depends on OS), `lima`)| string | "auto" | +| Setting | Description | Type | Default | +|-----------|-------------------------------------------------------------|--------|---------| +| `manager` | VM manager (Options: `auto` (depends on OS), `lima`, `wsl`) | string | "auto" | | `ubuntu` | Ubuntu OS version (Options: `18.04`, `20.04`, `22.04`, `24.04`)| string | | `hosts_resolver` | VM hosts resolver (Options: `hosts_file`)| string | | `instance_name` | Custom name for the VM instance | string | First site name alphabetically | | `images` | Custom OS image | object | Set based on `ubuntu` version | #### `images` -| Setting | Description | Type | Default | -| --- | --- | -- | -- | -| `location` | URL of Ubuntu image | string | none | -| `arch` | Architecture of image (eg: `x86_64`, `aarch64`) | string | none | +| Setting | Description | Type | Default | +|------------|-------------------------------------------------|--------|---------| +| `location` | URL of Ubuntu image | string | none | +| `arch` | Architecture of image (eg: `x86_64`, `aarch64`) | string | none | Example config: diff --git a/cmd/db_open.go b/cmd/db_open.go index dde9d0cb..bf775d27 100644 --- a/cmd/db_open.go +++ b/cmd/db_open.go @@ -6,6 +6,8 @@ import ( "flag" "fmt" "os" + "path/filepath" + "runtime" "strings" "github.com/fatih/color" @@ -104,47 +106,132 @@ func (c *DBOpenCommand) Run(args []string) int { return 1 } - // Prepare JSON file for db credentials - dbCredentialsJson, dbCredentialsErr := os.CreateTemp("", "*.json") - if dbCredentialsErr != nil { - c.UI.Error(fmt.Sprintf("Error creating temporary db credentials JSON file: %s", dbCredentialsErr)) + var dbCredentialsByte []byte + var mockUi *cli.MockUi + + // For WSL development, run ansible-playbook inside the distro since + // Ansible is not installed on the Windows host. + if environment == "development" && runtime.GOOS == "windows" && c.Trellis.VmManagerType() == "wsl" { + instanceName, err := c.Trellis.GetVmInstanceName() + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + distro := "trellis-" + strings.ReplaceAll(instanceName, ".", "-") + + // c.Trellis.Path is the trellis dir (e.g. C:\...\testsite.com\trellis). + // Project root is the parent directory. + projectRoot := filepath.Dir(c.Trellis.Path) + projectName := filepath.Base(projectRoot) + + wslProjectRoot := "/home/admin/" + projectName + wslTrellisDir := wslProjectRoot + "/trellis" + wslDest := "/tmp/trellis-db-credentials.json" + + // Dump the ad-hoc playbook files into the Windows trellis dir + // so they're accessible from WSL via the synced ext4 copy. + defer c.playbook.DumpFiles()() + + // Sync the playbook files to WSL so the distro has them. + syncScript := fmt.Sprintf( + `cp %s/dump_db_credentials.yml %s/dump_db_credentials.yml && cp %s/db_credentials.json.j2 %s/db_credentials.json.j2`, + toWslPath(c.Trellis.Path), wslTrellisDir, + toWslPath(c.Trellis.Path), wslTrellisDir, + ) + _ = command.Cmd("wsl", []string{ + "-d", distro, "-u", "admin", + "--", "bash", "-c", syncScript, + }).Run() + + // Build the ansible-playbook command to run inside WSL. + playbookCmd := fmt.Sprintf( + "cd %s && ansible-playbook dump_db_credentials.yml -e env=%s -e site=%s -e dest=%s --inventory=%s/inventory", + wslTrellisDir, environment, siteName, wslDest, wslTrellisDir+"/.trellis/wsl", + ) + + mockUi = cli.NewMockUi() + dumpDbCredentials := command.WithOptions( + command.WithUiOutput(mockUi), + ).Cmd("wsl", []string{ + "-d", distro, "-u", "admin", + "--", "bash", "-c", playbookCmd, + }) + + if err := dumpDbCredentials.Run(); err != nil { + c.UI.Error("Error opening database. Temporary playbook failed to execute:") + c.UI.Error(mockUi.OutputWriter.String()) + c.UI.Error(mockUi.ErrorWriter.String()) + return 1 + } + + // Read the JSON result file from inside the distro. + output, err := command.Cmd("wsl", []string{ + "-d", distro, "-u", "admin", + "--", "cat", wslDest, + }).Output() + if err != nil { + c.UI.Error("Error reading db credentials from WSL distro.") + return 1 + } + dbCredentialsByte = output + + // Clean up the temp file inside WSL. + _ = command.Cmd("wsl", []string{ + "-d", distro, "-u", "admin", + "--", "rm", "-f", wslDest, + }).Run() + + // Clean up the ad-hoc playbook files inside WSL. + _ = command.Cmd("wsl", []string{ + "-d", distro, "-u", "admin", + "--", "rm", "-f", + wslTrellisDir + "/dump_db_credentials.yml", + wslTrellisDir + "/db_credentials.json.j2", + }).Run() + } else { + // Standard path: run ansible-playbook on the host. + dbCredentialsJson, dbCredentialsErr := os.CreateTemp("", "*.json") + if dbCredentialsErr != nil { + c.UI.Error(fmt.Sprintf("Error creating temporary db credentials JSON file: %s", dbCredentialsErr)) + return 1 + } + defer os.Remove(dbCredentialsJson.Name()) + + defer c.playbook.DumpFiles()() + + playbook := ansible.Playbook{ + Name: "dump_db_credentials.yml", + Env: environment, + ExtraVars: map[string]string{ + "site": siteName, + "dest": dbCredentialsJson.Name(), + }, + } + + if environment == "development" { + playbook.SetInventory(c.Trellis.VmInventoryPath()) + } + + mockUi = cli.NewMockUi() + dumpDbCredentials := command.WithOptions( + command.WithUiOutput(mockUi), + ).Cmd("ansible-playbook", playbook.CmdArgs()) + + if err := dumpDbCredentials.Run(); err != nil { + c.UI.Error("Error opening database. Temporary playbook failed to execute:") + c.UI.Error(mockUi.OutputWriter.String()) + c.UI.Error(mockUi.ErrorWriter.String()) + return 1 + } + + var readErr error + dbCredentialsByte, readErr = os.ReadFile(dbCredentialsJson.Name()) + if readErr != nil { + c.UI.Error(fmt.Sprintf("Error reading db credentials JSON file: %s", readErr)) + return 1 + } } - defer os.Remove(dbCredentialsJson.Name()) - defer c.playbook.DumpFiles()() - - // Template db credentials to JSON file. - playbook := ansible.Playbook{ - Name: "dump_db_credentials.yml", - Env: environment, - ExtraVars: map[string]string{ - "site": siteName, - "dest": dbCredentialsJson.Name(), - }, - } - - if environment == "development" { - playbook.SetInventory(c.Trellis.VmInventoryPath()) - } - - mockUi := cli.NewMockUi() - dumpDbCredentials := command.WithOptions( - command.WithUiOutput(mockUi), - ).Cmd("ansible-playbook", playbook.CmdArgs()) - - if err := dumpDbCredentials.Run(); err != nil { - c.UI.Error("Error opening database. Temporary playbook failed to execute:") - c.UI.Error(mockUi.OutputWriter.String()) - c.UI.Error(mockUi.ErrorWriter.String()) - return 1 - } - - // Read db credentials from JSON file. - dbCredentialsByte, readErr := os.ReadFile(dbCredentialsJson.Name()) - if readErr != nil { - c.UI.Error(fmt.Sprintf("Error reading db credentials JSON file: %s", readErr)) - return 1 - } var dbCredentials db_opener.DBCredentials unmarshalErr := json.Unmarshal(dbCredentialsByte, &dbCredentials) if unmarshalErr != nil { @@ -208,3 +295,12 @@ func (c *DBOpenCommand) AutocompleteFlags() complete.Flags { "--app": complete.PredictSet(c.dbOpenerFactory.GetSupportedApps()...), } } + +// toWslPath converts a Windows path to a WSL mount path. +func toWslPath(windowsPath string) string { + p := filepath.ToSlash(windowsPath) + if len(p) >= 2 && p[1] == ':' { + p = "/mnt/" + strings.ToLower(string(p[0])) + p[2:] + } + return p +} diff --git a/cmd/deploy.go b/cmd/deploy.go index eae1be2a..1a275c5a 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -63,6 +63,10 @@ func (c *DeployCommand) Run(args []string) int { return 1 } + if wslTerminalRequired(c.Trellis, c.UI, "deploy "+environment) { + return 1 + } + siteNameArg := c.flags.Arg(1) siteName, siteNameErr := c.Trellis.FindSiteNameFromEnvironment(environment, siteNameArg) if siteNameErr != nil { diff --git a/cmd/galaxy_install.go b/cmd/galaxy_install.go index 60b6e056..8db1711b 100644 --- a/cmd/galaxy_install.go +++ b/cmd/galaxy_install.go @@ -28,6 +28,10 @@ func (c *GalaxyInstallCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "galaxy install") { + return 1 + } + commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0} commandArgumentErr := commandArgumentValidator.validate(args) if commandArgumentErr != nil { diff --git a/cmd/new.go b/cmd/new.go index 53b23dd1..4d28716c 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -141,7 +141,13 @@ func (c *NewCommand) Run(args []string) int { return 1 } - if !c.skipVirtualenv { + // On Windows with WSL, Python/Ansible live inside the VM, not on the host. + // Skip virtualenv init and suppress the "not initialized" warning. + if c.trellis.VmManagerType() == "wsl" { + c.trellis.VenvInitialized = true + } + + if !c.skipVirtualenv && c.trellis.VmManagerType() != "wsl" { initCommand := NewInitCommand(c.UI, c.trellis) code := initCommand.Run([]string{}) @@ -182,10 +188,14 @@ func (c *NewCommand) Run(args []string) int { c.UI.Error("This is probably a trellis-cli bug. Please open an issue at: https://github.com/roots/trellis-cli") } - galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.trellis} - code := galaxyInstallCommand.Run([]string{}) - if code != 0 { - return 1 + // On Windows with WSL, Galaxy roles will be installed inside the VM + // during `trellis vm start`. Skip the host-side install entirely. + if c.trellis.VmManagerType() != "wsl" { + galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.trellis} + code := galaxyInstallCommand.Run([]string{}) + if code != 0 { + return 1 + } } fmt.Printf("\n%s project created with versions:\n", color.GreenString(c.name)) diff --git a/cmd/provision.go b/cmd/provision.go index 8b976e5b..4e49a5cf 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -67,6 +67,10 @@ func (c *ProvisionCommand) Run(args []string) int { return 1 } + if wslTerminalRequired(c.Trellis, c.UI, "provision "+environment) { + return 1 + } + galaxyInstallCommand := &GalaxyInstallCommand{c.UI, c.Trellis} galaxyInstallCommand.Run([]string{}) diff --git a/cmd/vault_decrypt.go b/cmd/vault_decrypt.go index d77e71ce..d65789d0 100644 --- a/cmd/vault_decrypt.go +++ b/cmd/vault_decrypt.go @@ -42,6 +42,10 @@ func (c *VaultDecryptCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "vault decrypt") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/vault_edit.go b/cmd/vault_edit.go index 9b3a9819..ae70cd8f 100644 --- a/cmd/vault_edit.go +++ b/cmd/vault_edit.go @@ -42,6 +42,10 @@ func (c *VaultEditCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "vault edit") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/vault_encrypt.go b/cmd/vault_encrypt.go index b3c5fff7..3d6c8d76 100644 --- a/cmd/vault_encrypt.go +++ b/cmd/vault_encrypt.go @@ -42,6 +42,10 @@ func (c *VaultEncryptCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "vault encrypt") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/vault_view.go b/cmd/vault_view.go index 4e9195e6..99d1f3e2 100644 --- a/cmd/vault_view.go +++ b/cmd/vault_view.go @@ -42,6 +42,10 @@ func (c *VaultViewCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "vault view") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/vm.go b/cmd/vm.go index 14210245..cf4fa91e 100644 --- a/cmd/vm.go +++ b/cmd/vm.go @@ -2,20 +2,51 @@ package cmd import ( "fmt" + "os" "runtime" + "github.com/fatih/color" "github.com/hashicorp/cli" "github.com/roots/trellis-cli/pkg/lima" "github.com/roots/trellis-cli/pkg/vm" + "github.com/roots/trellis-cli/pkg/wsl" "github.com/roots/trellis-cli/trellis" ) +// wslTerminalRequired checks if the user is on Windows with the WSL backend. +// Ansible-dependent commands must be run from the WSL terminal, not Windows. +// Returns true if the command should abort with a helpful message. +func wslTerminalRequired(t *trellis.Trellis, ui cli.Ui, command string) bool { + if runtime.GOOS != "windows" || t.VmManagerType() != "wsl" { + return false + } + + ui.Warn(color.YellowString("This command requires Ansible, which is installed inside your WSL environment.")) + ui.Warn(color.YellowString(fmt.Sprintf("Run `trellis vm open` to launch VS Code in WSL, then run `trellis %s` from the integrated terminal.", command))) + return true +} + +// windowsHostRequired checks if the user is inside WSL trying to run a +// command that manages the VM from the Windows host side. +// Returns true if the command should abort with a helpful message. +func windowsHostRequired(t *trellis.Trellis, ui cli.Ui, command string) bool { + if runtime.GOOS != "linux" || os.Getenv("WSL_DISTRO_NAME") == "" { + return false + } + + ui.Warn(color.YellowString(fmt.Sprintf("'trellis %s' manages the WSL distro from the Windows host.", command))) + ui.Warn(color.YellowString("Run this command from your Windows PowerShell or Command Prompt, not from inside WSL.")) + return true +} + func newVmManager(t *trellis.Trellis, ui cli.Ui) (vm.Manager, error) { vmType := t.VmManagerType() switch vmType { case "lima": return lima.NewManager(t, ui) + case "wsl": + return wsl.NewManager(t, ui) case "mock": return vm.NewMockManager(t, ui) case "": diff --git a/cmd/vm_delete.go b/cmd/vm_delete.go index 58038644..4fa19e55 100644 --- a/cmd/vm_delete.go +++ b/cmd/vm_delete.go @@ -37,6 +37,10 @@ func (c *VmDeleteCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if windowsHostRequired(c.Trellis, c.UI, "vm delete") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/vm_open.go b/cmd/vm_open.go new file mode 100644 index 00000000..d86dd6af --- /dev/null +++ b/cmd/vm_open.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "flag" + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/manifoldco/promptui" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/pkg/wsl" + "github.com/roots/trellis-cli/trellis" + + "github.com/hashicorp/cli" +) + +type VmOpenCommand struct { + UI cli.Ui + Trellis *trellis.Trellis + flags *flag.FlagSet +} + +func NewVmOpenCommand(ui cli.Ui, trellis *trellis.Trellis) *VmOpenCommand { + c := &VmOpenCommand{UI: ui, Trellis: trellis} + c.init() + return c +} + +func (c *VmOpenCommand) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.Usage = func() { c.UI.Info(c.Help()) } +} + +func (c *VmOpenCommand) Run(args []string) int { + if err := c.Trellis.LoadProject(); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + if err := c.flags.Parse(args); err != nil { + return 1 + } + + args = c.flags.Args() + + commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0} + if err := commandArgumentValidator.validate(args); err != nil { + c.UI.Error(err.Error()) + c.UI.Output(c.Help()) + return 1 + } + + if runtime.GOOS != "windows" { + c.UI.Error("'trellis vm open' is only supported on Windows (WSL2).") + c.UI.Info("On macOS/Linux, open your site directory directly in your editor.") + return 1 + } + + instanceName, err := c.Trellis.GetVmInstanceName() + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + distro := "trellis-" + strings.ReplaceAll(instanceName, ".", "-") + + // Warn if the distro isn't running — VS Code's WSL extension will + // silently boot it, but services (nginx, php-fpm, mariadb) won't + // be started. The developer likely wants `vm start` first. + if output, err := command.Cmd("wsl", []string{"-l", "--running", "-q"}).Output(); err == nil { + running := false + decoded := wsl.DecodeWslOutput(output) + for _, line := range strings.Split(decoded, "\n") { + if strings.TrimSpace(line) == distro { + running = true + break + } + } + if !running { + c.UI.Warn("VM is not running. Web services (nginx, PHP, MariaDB) won't be available.") + c.UI.Warn("Run 'trellis vm start' first for the full development environment.\n") + + prompt := promptui.Prompt{ + Label: "Open VS Code anyway", + IsConfirm: true, + } + + if _, err := prompt.Run(); err != nil { + c.UI.Info("Aborted.") + return 0 + } + } + } + + // The full project (trellis/ + site/ + .git/) lives on ext4 at + // /home/admin//. Open VS Code at the project root so the + // developer sees the familiar layout and can use git normally. + projectName := filepath.Base(filepath.Dir(c.Trellis.Path)) + remotePath := fmt.Sprintf("/home/admin/%s", projectName) + + // VS Code's --folder-uri flag opens a folder inside a WSL distro. + // The vscode-remote URI format is: vscode-remote://wsl+/ + c.UI.Info(fmt.Sprintf("Opening VS Code in WSL distro '%s' at %s...", distro, remotePath)) + + folderURI := fmt.Sprintf("vscode-remote://wsl+%s%s", distro, remotePath) + cmd := exec.Command("code", "--folder-uri", folderURI) + if err := cmd.Run(); err != nil { + c.UI.Error(fmt.Sprintf("Could not open VS Code: %v", err)) + c.UI.Info("Make sure VS Code is installed and the 'code' command is in your PATH.") + c.UI.Info(fmt.Sprintf("You can also open VS Code manually and connect to WSL distro '%s'", distro)) + return 1 + } + + return 0 +} + +func (c *VmOpenCommand) Synopsis() string { + return "Opens VS Code in the VM's project directory (Windows/WSL2)" +} + +func (c *VmOpenCommand) Help() string { + helpText := ` +Usage: trellis vm open [options] + +Opens VS Code connected to the WSL2 distro at the project root. + +Your project (trellis/ + site/ + .git/) is copied to the WSL2 ext4 +filesystem during 'trellis vm start' for optimal performance. This +command opens VS Code at that copy so you can edit files and use +git as normal. + +Note: Requires VS Code with the WSL extension installed. +Do NOT edit files on the Windows side after 'vm start' — the WSL +copy is your working directory. + +Options: + -h, --help Show this help +` + + return strings.TrimSpace(helpText) +} diff --git a/cmd/vm_shell.go b/cmd/vm_shell.go index 0f63f19e..65a3fcb9 100644 --- a/cmd/vm_shell.go +++ b/cmd/vm_shell.go @@ -35,6 +35,10 @@ func (c *VmShellCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if windowsHostRequired(c.Trellis, c.UI, "vm shell") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/vm_start.go b/cmd/vm_start.go index 4b1d32c8..eae85868 100644 --- a/cmd/vm_start.go +++ b/cmd/vm_start.go @@ -3,10 +3,14 @@ package cmd import ( "errors" "flag" + "fmt" + "path/filepath" + "runtime" "strings" "github.com/hashicorp/cli" "github.com/roots/trellis-cli/pkg/vm" + "github.com/roots/trellis-cli/pkg/wsl" "github.com/roots/trellis-cli/trellis" ) @@ -35,6 +39,10 @@ func (c *VmStartCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if windowsHostRequired(c.Trellis, c.UI, "vm start") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } @@ -63,11 +71,18 @@ func (c *VmStartCommand) Run(args []string) int { err = manager.StartInstance(instanceName) if err == nil { - c.printInstanceInfo() - return 0 + // If the distro exists but was never fully provisioned (e.g. user + // cancelled during bootstrap), clean it up and re-create. + if wslManager, ok := manager.(*wsl.Manager); ok && !wslManager.IsProvisioned(instanceName) { + c.UI.Warn("Detected unprovisioned WSL distro. Cleaning up and starting fresh...") + _ = manager.DeleteInstance(instanceName) + } else { + c.printInstanceInfo() + return 0 + } } - if !errors.Is(err, vm.ErrVmNotFound) { + if err != nil && !errors.Is(err, vm.ErrVmNotFound) { c.UI.Error("Error starting VM.") c.UI.Error(err.Error()) return 1 @@ -86,7 +101,27 @@ func (c *VmStartCommand) Run(args []string) int { return 1 } - c.UI.Info("\nProvisioning VM...") + fmt.Print("\r\n") + c.UI.Info("Provisioning VM...") + + // For WSL, provisioning runs inside the distro (no host-side Ansible). + // We bootstrap Ansible into the distro first, then run the playbook. + if wslManager, ok := manager.(*wsl.Manager); ok { + if err := wslManager.BootstrapInstance(instanceName); err != nil { + c.UI.Error("Error bootstrapping VM.") + c.UI.Error(err.Error()) + return 1 + } + + if err := wslManager.Provision(instanceName); err != nil { + c.UI.Error("Error provisioning VM.") + c.UI.Error(err.Error()) + return 1 + } + + c.printInstanceInfo() + return 0 + } provisionCmd := NewProvisionCommand(c.UI, c.Trellis) code := provisionCmd.Run([]string{"development"}) @@ -120,10 +155,12 @@ Options: } func (c *VmStartCommand) printInstanceInfo() { - c.UI.Info(` -Your Trellis VM is ready to use! + fmt.Print("\r") + c.UI.Info("\r\nYour Trellis VM is ready to use!\r\n\r\n* Composer and WP-CLI commands need to be run on the virtual machine for any post-provision modifications.\r\n* You can SSH into the machine with 'trellis vm shell'\r\n* Then navigate to your WordPress sites at '/srv/www'") -* 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'`) + if runtime.GOOS == "windows" { + projectName := filepath.Base(filepath.Dir(c.Trellis.Path)) + fmt.Print("\r") + c.UI.Info(fmt.Sprintf("\r\nIMPORTANT -- Windows/WSL2 development workflow:\r\n Your project has been copied to WSL2 at: /home/admin/%s\r\n This is your working directory for editing files and using git.\r\n Do NOT edit files on the Windows side -- they are only used during initial setup.\r\n\r\n To open VS Code in the correct location, run:\r\n trellis vm open", projectName)) + } } diff --git a/cmd/vm_stop.go b/cmd/vm_stop.go index 5d53a0a4..18cc8bc3 100644 --- a/cmd/vm_stop.go +++ b/cmd/vm_stop.go @@ -2,9 +2,11 @@ package cmd import ( "flag" + "runtime" "strings" "github.com/hashicorp/cli" + "github.com/roots/trellis-cli/pkg/wsl" "github.com/roots/trellis-cli/trellis" ) @@ -33,6 +35,10 @@ func (c *VmStopCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if windowsHostRequired(c.Trellis, c.UI, "vm stop") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } @@ -59,6 +65,19 @@ func (c *VmStopCommand) Run(args []string) int { return 1 } + c.UI.Info("Stopping VM...") + + // For WSL on Windows, sync project files back to Windows before stopping. + // This keeps the Windows-side repo up to date so GitHub Desktop + // and other Windows git tools can see the latest changes. + if runtime.GOOS == "windows" { + if wslManager, ok := manager.(*wsl.Manager); ok { + if err := wslManager.SyncBack(instanceName); err != nil { + c.UI.Warn("Warning: " + err.Error()) + } + } + } + if err := manager.StopInstance(instanceName); err != nil { c.UI.Error(err.Error()) return 1 diff --git a/cmd/vm_sync.go b/cmd/vm_sync.go new file mode 100644 index 00000000..36e53fd5 --- /dev/null +++ b/cmd/vm_sync.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "flag" + "runtime" + "strings" + + "github.com/hashicorp/cli" + "github.com/roots/trellis-cli/pkg/wsl" + "github.com/roots/trellis-cli/trellis" +) + +type VmSyncCommand struct { + UI cli.Ui + Trellis *trellis.Trellis + flags *flag.FlagSet +} + +func NewVmSyncCommand(ui cli.Ui, trellis *trellis.Trellis) *VmSyncCommand { + c := &VmSyncCommand{UI: ui, Trellis: trellis} + c.init() + return c +} + +func (c *VmSyncCommand) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.Usage = func() { c.UI.Info(c.Help()) } +} + +func (c *VmSyncCommand) Run(args []string) int { + if err := c.Trellis.LoadProject(); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + if err := c.flags.Parse(args); err != nil { + return 1 + } + + args = c.flags.Args() + + commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0} + if err := commandArgumentValidator.validate(args); err != nil { + c.UI.Error(err.Error()) + c.UI.Output(c.Help()) + return 1 + } + + if runtime.GOOS != "windows" { + c.UI.Error("'trellis vm sync' is only supported on Windows (WSL2).") + return 1 + } + + instanceName, err := c.Trellis.GetVmInstanceName() + 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 + } + + wslManager, ok := manager.(*wsl.Manager) + if !ok { + c.UI.Error("'trellis vm sync' requires the WSL backend.") + return 1 + } + + if err := wslManager.SyncBack(instanceName); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + return 0 +} + +func (c *VmSyncCommand) Synopsis() string { + return "Syncs project files from the WSL2 VM back to Windows" +} + +func (c *VmSyncCommand) Help() string { + helpText := ` +Usage: trellis vm sync [options] + +Syncs project files from the WSL2 VM back to the Windows filesystem. + +On Windows, your project files live on the WSL2 ext4 filesystem for +performance. This command copies changes back to the Windows side so +that GitHub Desktop and other Windows tools can see your latest work. + +This sync runs automatically during 'trellis vm stop'. Use this command +to sync manually without stopping the VM (e.g. before pushing from +GitHub Desktop). + +Direction: WSL → Windows (one-way). Generated files like vendor/ and +node_modules/ are excluded. + +Options: + -h, --help Show this help +` + + return strings.TrimSpace(helpText) +} diff --git a/cmd/vm_trust.go b/cmd/vm_trust.go new file mode 100644 index 00000000..6a0bbcc4 --- /dev/null +++ b/cmd/vm_trust.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "flag" + "runtime" + "strings" + + "github.com/hashicorp/cli" + "github.com/roots/trellis-cli/pkg/wsl" + "github.com/roots/trellis-cli/trellis" +) + +type VmTrustCommand struct { + UI cli.Ui + Trellis *trellis.Trellis + flags *flag.FlagSet +} + +func NewVmTrustCommand(ui cli.Ui, trellis *trellis.Trellis) *VmTrustCommand { + c := &VmTrustCommand{UI: ui, Trellis: trellis} + c.init() + return c +} + +func (c *VmTrustCommand) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.Usage = func() { c.UI.Info(c.Help()) } +} + +func (c *VmTrustCommand) Run(args []string) int { + if err := c.Trellis.LoadProject(); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + c.Trellis.CheckVirtualenv(c.UI) + + if windowsHostRequired(c.Trellis, c.UI, "vm trust") { + return 1 + } + + if err := c.flags.Parse(args); err != nil { + return 1 + } + + args = c.flags.Args() + + commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 0} + if err := commandArgumentValidator.validate(args); err != nil { + c.UI.Error(err.Error()) + c.UI.Output(c.Help()) + return 1 + } + + if runtime.GOOS != "windows" { + c.UI.Error("'trellis vm trust' is only supported on Windows (WSL2).") + return 1 + } + + instanceName, err := c.Trellis.GetVmInstanceName() + 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 + } + + wslManager, ok := manager.(*wsl.Manager) + if !ok { + c.UI.Error("'trellis vm trust' is only supported with the WSL backend.") + return 1 + } + + distro := "trellis-" + strings.ReplaceAll(instanceName, ".", "-") + + if err := wslManager.TrustSslCerts(distro); err != nil { + c.UI.Error("Error trusting SSL certificates: " + err.Error()) + return 1 + } + + return 0 +} + +func (c *VmTrustCommand) Synopsis() string { + return "Imports SSL certificates from the VM into the Windows trust store" +} + +func (c *VmTrustCommand) Help() string { + helpText := ` +Usage: trellis vm trust [options] + +Extracts self-signed SSL certificates from the WSL2 distro and imports +them into the Windows Trusted Root Certification Authorities store. + +This is automatically done during 'trellis vm start' on initial setup. +Run this command after re-provisioning with SSL enabled to trust the +new certificates without restarting the VM. + +Requires admin privileges (a UAC prompt will appear). + +Options: + -h, --help show this help +` + return strings.TrimSpace(helpText) +} diff --git a/cmd/xdebug_tunnel_close.go b/cmd/xdebug_tunnel_close.go index c4be1422..ac3f70c8 100644 --- a/cmd/xdebug_tunnel_close.go +++ b/cmd/xdebug_tunnel_close.go @@ -38,6 +38,10 @@ func (c *XdebugTunnelCloseCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "xdebug-tunnel close") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/cmd/xdebug_tunnel_open.go b/cmd/xdebug_tunnel_open.go index dd9334f9..63a6ba16 100644 --- a/cmd/xdebug_tunnel_open.go +++ b/cmd/xdebug_tunnel_open.go @@ -38,6 +38,10 @@ func (c *XdebugTunnelOpenCommand) Run(args []string) int { c.Trellis.CheckVirtualenv(c.UI) + if wslTerminalRequired(c.Trellis, c.UI, "xdebug-tunnel open") { + return 1 + } + if err := c.flags.Parse(args); err != nil { return 1 } diff --git a/github/main.go b/github/main.go index 20720af4..21b30347 100644 --- a/github/main.go +++ b/github/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "time" @@ -73,6 +74,12 @@ func DownloadRelease(repo string, version string, path string, dest string) (rel return nil, fmt.Errorf("Error extracting the release archive: %v", err) } + // On Windows, close the archive handle before renaming. Open file handles + // prevent directory renames on Windows (but not on macOS/Linux). + if runtime.GOOS == "windows" { + archiveFile.Close() + } + org := strings.Split(repo, "/")[0] dirs, _ := filepath.Glob(fmt.Sprintf("%s-*", org)) @@ -83,9 +90,23 @@ func DownloadRelease(repo string, version string, path string, dest string) (rel for _, dir := range dirs { err := os.Rename(dir, dest) + // On Windows, os.Rename can fail with "Access is denied" when + // antivirus software (e.g. Windows Defender) is scanning the + // freshly extracted files. Retry with backoff to give the scan + // time to finish. + if err != nil && runtime.GOOS == "windows" { + for attempt := range 3 { + time.Sleep(time.Duration(attempt+1) * time.Second) + err = os.Rename(dir, dest) + if err == nil { + break + } + } + } + if err != nil { os.RemoveAll(dir) - return nil, fmt.Errorf("Error deleting temporary directories: %v", err) + return nil, fmt.Errorf("Error renaming extracted directory: %v", err) } } @@ -170,6 +191,14 @@ func extractToDisk(fi archives.FileInfo, dest string) error { } _, err = io.Copy(dst, src) + + // On Windows, close the file immediately to release the handle. + // Unclosed handles prevent the parent directory from being renamed. + // On macOS/Linux this is unnecessary — rename works regardless. + if runtime.GOOS == "windows" { + dst.Close() + } + if err != nil { return err } diff --git a/main.go b/main.go index 10105129..6f105c2a 100644 --- a/main.go +++ b/main.go @@ -191,6 +191,9 @@ func main() { "vm delete": func() (cli.Command, error) { return cmd.NewVmDeleteCommand(ui, trellis), nil }, + "vm open": func() (cli.Command, error) { + return cmd.NewVmOpenCommand(ui, trellis), nil + }, "vm shell": func() (cli.Command, error) { return cmd.NewVmShellCommand(ui, trellis), nil }, @@ -200,6 +203,12 @@ func main() { "vm stop": func() (cli.Command, error) { return cmd.NewVmStopCommand(ui, trellis), nil }, + "vm sync": func() (cli.Command, error) { + return cmd.NewVmSyncCommand(ui, trellis), nil + }, + "vm trust": func() (cli.Command, error) { + return cmd.NewVmTrustCommand(ui, trellis), nil + }, "vm sudoers": func() (cli.Command, error) { return &cmd.VmSudoersCommand{UI: ui, Trellis: trellis}, nil }, diff --git a/pkg/db_opener/tableplus.go b/pkg/db_opener/tableplus.go index 68d3e6e2..8a871737 100644 --- a/pkg/db_opener/tableplus.go +++ b/pkg/db_opener/tableplus.go @@ -2,14 +2,26 @@ package db_opener import ( "fmt" + "os" "os/exec" + "runtime" ) type Tableplus struct{} func (o *Tableplus) Open(c DBCredentials) (err error) { uri := o.uriFor(c) - open := exec.Command("open", uri) + + var open *exec.Cmd + if runtime.GOOS == "windows" || os.Getenv("WSL_DISTRO_NAME") != "" { + // Windows or WSL: use rundll32 to open the URI. cmd /c start + // misparses the & characters in query strings as command separators. + // rundll32.exe is available inside WSL via Windows interop. + open = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", uri) + } else { + // macOS: use open command. + open = exec.Command("open", uri) + } // Intentionally omitting `logCmd` to prevent printing db credentials. if err := open.Run(); err != nil { @@ -20,6 +32,21 @@ func (o *Tableplus) Open(c DBCredentials) (err error) { } func (o *Tableplus) uriFor(c DBCredentials) string { + // For WSL development (ansible_connection=local in inventory), the + // ansible_host will be the inventory hostname "default" — not a real + // SSH host. MariaDB is accessible directly on localhost via WSL port + // routing, so use a direct mysql:// URI without SSH. + if (runtime.GOOS == "windows" || os.Getenv("WSL_DISTRO_NAME") != "") && c.SSHHost == "default" { + return fmt.Sprintf( + "mysql://%s:%s@127.0.0.1:3306/%s?enviroment=%s&name=%s&statusColor=F8B502", + c.DBUser, + c.DBPassword, + c.DBName, + c.WPEnv, + c.DBName, + ) + } + return fmt.Sprintf( "mysql+ssh://%s@%s:%d/%s:%s@%s/%s?usePrivateKey=true&enviroment=%s", c.SSHUser, diff --git a/pkg/wsl/hosts.go b/pkg/wsl/hosts.go new file mode 100644 index 00000000..0d0a4b6b --- /dev/null +++ b/pkg/wsl/hosts.go @@ -0,0 +1,128 @@ +package wsl + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/roots/trellis-cli/app_paths" + "github.com/roots/trellis-cli/command" +) + +// WindowsHostsResolver implements vm.HostsResolver for Windows. +// +// It manages entries in C:\Windows\System32\drivers\etc\hosts, +// using the same trellis marker format as the upstream HostsFileResolver. +// Writing requires admin privileges; the resolver attempts a direct write +// first, then falls back to UAC elevation via PowerShell. +type WindowsHostsResolver struct { + Hosts []string + hostsPath string + tmpHostsPath string +} + +func NewWindowsHostsResolver(hosts []string) *WindowsHostsResolver { + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = `C:\Windows` + } + + return &WindowsHostsResolver{ + Hosts: hosts, + hostsPath: filepath.Join(systemRoot, "System32", "drivers", "etc", "hosts"), + tmpHostsPath: filepath.Join(app_paths.DataDir(), "hosts"), + } +} + +func (h *WindowsHostsResolver) AddHosts(name string, ip string) error { + content, err := h.addHostsContent(name, ip) + if err != nil { + return fmt.Errorf("error updating hosts file: %v", err) + } + + // Skip the write (and UAC prompt) if the hosts file already has + // the correct entries. This avoids an admin elevation on every + // `vm start` when the distro is just resuming. + current, err := os.ReadFile(h.hostsPath) + if err == nil && bytes.Equal(bytes.TrimRight(current, "\r\n\t "), bytes.TrimRight(content, "\r\n\t ")) { + return nil + } + + return h.writeHostsFile(content) +} + +func (h *WindowsHostsResolver) RemoveHosts(name string) error { + content, err := h.removeHostsContent(name) + if err != nil { + return fmt.Errorf("error removing hosts entry: %v", err) + } + return h.writeHostsFile(content) +} + +func (h *WindowsHostsResolver) addHostsContent(name string, ip string) ([]byte, error) { + content, err := h.removeHostsContent(name) + if err != nil { + return nil, err + } + + // Ensure blank line separation from existing hosts content, + // and add a human-readable comment so users know what this is. + entry := fmt.Sprintf( + "\n\n## trellis-start-%s\n# Added by trellis-cli (https://github.com/roots/trellis-cli)\n# To remove: trellis vm delete\n%s %s\n## trellis-end-%s\n", + name, ip, strings.Join(h.Hosts, " "), name) + + // Trim leading newlines if the file already ends with whitespace. + trimmed := bytes.TrimRight(content, "\r\n\t ") + content = append(trimmed, []byte(entry)...) + return content, nil +} + +func (h *WindowsHostsResolver) removeHostsContent(name string) ([]byte, 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)) + content, err := os.ReadFile(h.hostsPath) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", h.hostsPath, err) + } + + content = re.ReplaceAll(content, []byte{}) + return content, nil +} + +func (h *WindowsHostsResolver) 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 + } + + // Try direct write (succeeds if process has admin rights). + if err := os.WriteFile(h.hostsPath, content, 0644); err == nil { + return nil + } + + fmt.Printf("\r\nUpdating %s (admin privileges required -- a UAC prompt will appear)\r\n", h.hostsPath) + + // Elevate via UAC: launch PowerShell as admin to copy the temp file. + // Use double quotes for the inner Copy-Item paths to avoid nested + // single-quote escaping issues in PowerShell's -ArgumentList. + copyCmd := fmt.Sprintf( + `Copy-Item -LiteralPath \"%s\" -Destination \"%s\" -Force`, + h.tmpHostsPath, h.hostsPath, + ) + + return command.Cmd("powershell", []string{ + "-Command", + fmt.Sprintf( + "Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile','-Command','%s'", + copyCmd, + ), + }).Run() +} diff --git a/pkg/wsl/manager.go b/pkg/wsl/manager.go new file mode 100644 index 00000000..a2361167 --- /dev/null +++ b/pkg/wsl/manager.go @@ -0,0 +1,1154 @@ +package wsl + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/hashicorp/cli" + "github.com/manifoldco/promptui" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/pkg/vm" + "github.com/roots/trellis-cli/trellis" +) + +const configDir = "wsl" + +// printStatus prints a status message to the terminal, ensuring the cursor +// starts at column 0. On Windows with ENABLE_VIRTUAL_TERMINAL_PROCESSING +// (needed for ANSI colors), \n is a bare line feed that keeps the current +// column. Without an explicit \r, successive messages drift rightward. +func printStatus(ui cli.Ui, msg string) { + fmt.Print("\r") + ui.Info(msg) +} + +// Manager implements vm.Manager for WSL2 on Windows. +// +// Each trellis project gets its own WSL distro, managed via wsl.exe. +// The distro is created by importing an Ubuntu rootfs tarball with +// `wsl --import`, and all lifecycle operations map to wsl.exe subcommands. +type Manager struct { + ConfigPath string + HostsResolver *WindowsHostsResolver + Sites map[string]*trellis.Site + ui cli.Ui + trellis *trellis.Trellis +} + +// NewManager creates a WSL Manager. This is the constructor called from +// cmd/vm.go when the user's config selects the "wsl" backend. +// +// Go pattern: constructors are regular functions (not methods) named +// New. They return (*Type, error) so callers can handle failure. +func NewManager(trellis *trellis.Trellis, ui cli.Ui) (*Manager, error) { + wslConfigPath := filepath.Join(trellis.ConfigPath(), configDir) + hostNames := trellis.Environments["development"].AllHosts() + + manager := &Manager{ + ConfigPath: wslConfigPath, + HostsResolver: NewWindowsHostsResolver(hostNames), + Sites: trellis.Environments["development"].WordPressSites, + trellis: trellis, + ui: ui, + } + + if err := os.MkdirAll(manager.ConfigPath, 0755); err != nil { + return nil, fmt.Errorf("could not create config directory: %v", err) + } + + // If the distro is running, sync config from WSL ext4 back to the + // Windows filesystem so this Manager (and any command using it) sees + // the latest wordpress_sites.yml, vault.yml, etc. + // + // This handles the common flow: developer edits config inside WSL via + // VS Code, then runs a Windows-side command like `vm trust` or `vm stop`. + siteName, _, _ := trellis.MainSiteFromEnvironment("development") + distro := distroName(siteName) + + if manager.distroRunning(distro) { + manager.syncConfigFromWSL(distro) + + trellis.ReloadSiteConfigs() + manager.Sites = trellis.Environments["development"].WordPressSites + manager.HostsResolver = NewWindowsHostsResolver( + trellis.Environments["development"].AllHosts(), + ) + } + + return manager, nil +} + +// distroName converts a trellis site name (e.g. "wordpress.test") to a +// WSL-safe distro name (e.g. "trellis-wordpress-test"). +// +// WSL distro names cannot contain dots. The "trellis-" prefix prevents +// collisions with user-installed distros like "Ubuntu-24.04". +func distroName(name string) string { + return "trellis-" + strings.ReplaceAll(name, ".", "-") +} + +// --------------------------------------------------------------------------- +// vm.Manager interface implementation +// --------------------------------------------------------------------------- + +func (m *Manager) InventoryPath() string { + return filepath.Join(m.ConfigPath, "inventory") +} + +func (m *Manager) CreateInstance(name string) error { + distro := distroName(name) + + if m.distroExists(distro) { + printStatus(m.ui, fmt.Sprintf("WSL distro '%s' already exists.", distro)) + return nil + } + + // Download Ubuntu rootfs tarball if not already cached. + tarball, err := m.ensureRootfs() + if err != nil { + return err + } + + // Each distro gets its own directory for the virtual disk. + installDir := filepath.Join(m.ConfigPath, distro) + if err = os.MkdirAll(installDir, 0755); err != nil { + return fmt.Errorf("could not create install directory: %v", err) + } + + printStatus(m.ui, fmt.Sprintf("Importing WSL distro '%s'...", distro)) + + err = command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("wsl", []string{"--import", distro, installDir, tarball}).Run() + + if err != nil { + return fmt.Errorf("could not import WSL distro: %v", err) + } + + if err = m.writeInventory(); err != nil { + return err + } + + printStatus(m.ui, fmt.Sprintf("%s WSL distro '%s' created", color.GreenString("[ok]"), distro)) + return nil +} + +func (m *Manager) DeleteInstance(name string) error { + distro := distroName(name) + + if !m.distroExists(distro) { + printStatus(m.ui, "WSL distro does not exist for this project. Run `trellis vm start` to create it.") + return nil + } + + printStatus(m.ui, fmt.Sprintf("Unregistering WSL distro '%s'...", distro)) + + err := command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("wsl", []string{"--unregister", distro}).Run() + + if err != nil { + return fmt.Errorf("could not unregister WSL distro: %v", err) + } + + // Remove site hostnames from the Windows hosts file. + if err := m.HostsResolver.RemoveHosts(distro); err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not remove hosts entry: %v", err)) + } + + // Clean up the distro's virtual disk directory and provisioning marker. + installDir := filepath.Join(m.ConfigPath, distro) + os.RemoveAll(installDir) + os.Remove(filepath.Join(m.ConfigPath, distro+".provisioned")) + + printStatus(m.ui, fmt.Sprintf("%s WSL distro '%s' deleted", color.GreenString("[ok]"), distro)) + return nil +} + +func (m *Manager) StartInstance(name string) error { + distro := distroName(name) + + if !m.distroExists(distro) { + return vm.ErrVmNotFound + } + + if m.distroRunning(distro) { + printStatus(m.ui, fmt.Sprintf("%s WSL distro already running", color.GreenString("[ok]"))) + return nil + } + + // Stop other trellis-* distros. All WSL2 distros share the same network + // namespace (by design — one VM, one network stack), so services like + // MariaDB (3306), nginx (80/443), and PHP-FPM collide if multiple + // distros run simultaneously. + m.stopOtherDistros(distro) + + // Start the distro with a keepalive process. WSL2 terminates distros when + // no user processes are running under PID 2 (the WSL init). systemd services + // (PID 1) do NOT prevent shutdown. Using `wsl --exec` from the Windows side + // creates a process under PID 2, keeping the VM alive indefinitely. + // See: https://github.com/microsoft/WSL/issues/10138 + // + // `sleep infinity` is universally available in all Ubuntu rootfs images, + // including fresh imports before bootstrap installs any packages. + cmd := exec.Command("wsl", "-d", distro, "--exec", "sleep", "infinity") + if err := cmd.Start(); err != nil { + return fmt.Errorf("could not start WSL distro keepalive: %v", err) + } + // Detach — the process outlives trellis-cli. + go cmd.Wait() + + if err := m.writeInventory(); err != nil { + return err + } + + // Add site hostnames to the Windows hosts file (127.0.0.1) so the + // browser can reach the dev site. WSL2 NAT forwards localhost ports + // into the distro automatically. + if err := m.HostsResolver.AddHosts(distro, "127.0.0.1"); err != nil { + return err + } + + printStatus(m.ui, fmt.Sprintf("%s WSL distro '%s' started", color.GreenString("[ok]"), distro)) + return nil +} + +func (m *Manager) StopInstance(name string) error { + distro := distroName(name) + + if !m.distroExists(distro) { + printStatus(m.ui, "WSL distro does not exist for this project. Run `trellis vm start` to create it.") + return nil + } + + if !m.distroRunning(distro) { + printStatus(m.ui, fmt.Sprintf("%s WSL distro already stopped", color.GreenString("[ok]"))) + return nil + } + + // `wsl -t ` terminates (stops) the distro. + err := command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("wsl", []string{"-t", distro}).Run() + + if err != nil { + return fmt.Errorf("could not stop WSL distro: %v", err) + } + + printStatus(m.ui, fmt.Sprintf("%s WSL distro '%s' stopped", color.GreenString("[ok]"), distro)) + return nil +} + +func (m *Manager) OpenShell(name string, dir string, commandArgs []string) error { + distro := distroName(name) + + if !m.distroExists(distro) { + printStatus(m.ui, "WSL distro does not exist for this project. Run `trellis vm start` to create it.") + return nil + } + + // Ensure the distro is running. WSL2 may auto-shutdown idle distros even + // with systemd enabled. Starting it is fast and idempotent. + _ = command.Cmd("wsl", []string{"-d", distro, "--", "/bin/true"}).Run() + + args := []string{"-d", distro} + if dir != "" { + args = append(args, "--cd", dir) + } + if len(commandArgs) > 0 { + args = append(args, "--") + args = append(args, commandArgs...) + } + + return command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("wsl", args).Run() +} + +func (m *Manager) RunCommand(args []string, dir string) error { + instanceName, err := m.trellis.GetVmInstanceName() + if err != nil { + return err + } + + distro := distroName(instanceName) + + if !m.distroExists(distro) { + return fmt.Errorf("WSL distro does not exist. Run `trellis vm start` to create it.") + } + + // Ensure the distro is running (WSL2 may auto-shutdown idle distros). + _ = command.Cmd("wsl", []string{"-d", distro, "--", "/bin/true"}).Run() + + wslArgs := []string{"-d", distro} + if dir != "" { + wslArgs = append(wslArgs, "--cd", dir) + } + wslArgs = append(wslArgs, "--") + wslArgs = append(wslArgs, args...) + + return command.WithOptions( + command.WithTermOutput(), + command.WithLogging(m.ui), + ).Cmd("wsl", wslArgs).Run() +} + +// RunCommandPipe returns an *exec.Cmd that is ready to run but NOT yet started. +// +// Go pattern: returning an *exec.Cmd lets callers wire up their own +// stdin/stdout/stderr pipes before calling cmd.Start() + cmd.Wait(). +// This is used by the logs command to stream output. +func (m *Manager) RunCommandPipe(args []string, dir string) (*exec.Cmd, error) { + instanceName, err := m.trellis.GetVmInstanceName() + if err != nil { + return nil, err + } + + distro := distroName(instanceName) + + if !m.distroExists(distro) { + return nil, fmt.Errorf("WSL distro does not exist. Run `trellis vm start` to create it.") + } + + // Ensure the distro is running (WSL2 may auto-shutdown idle distros). + _ = command.Cmd("wsl", []string{"-d", distro, "--", "/bin/true"}).Run() + + wslArgs := []string{"-d", distro} + if dir != "" { + wslArgs = append(wslArgs, "--cd", dir) + } + wslArgs = append(wslArgs, "--") + wslArgs = append(wslArgs, args...) + + return command.Cmd("wsl", wslArgs), nil +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// distroExists checks whether a WSL distro is registered. +// Parses the output of `wsl -l -q` (one distro name per line). +func (m *Manager) distroExists(distro string) bool { + output, err := command.Cmd("wsl", []string{"-l", "-q"}).Output() + if err != nil { + return false + } + + // wsl.exe outputs UTF-16LE on Windows — decode before parsing. + decoded := DecodeWslOutput(output) + + for _, line := range strings.Split(decoded, "\n") { + if strings.TrimSpace(line) == distro { + return true + } + } + return false +} + +// distroRunning checks whether a WSL distro is currently in the Running state. +// Uses `wsl -l --running -q` which only lists running distros. +func (m *Manager) distroRunning(distro string) bool { + output, err := command.Cmd("wsl", []string{"-l", "--running", "-q"}).Output() + if err != nil { + return false + } + + decoded := DecodeWslOutput(output) + + for _, line := range strings.Split(decoded, "\n") { + if strings.TrimSpace(line) == distro { + return true + } + } + return false +} + +// stopOtherDistros terminates any running trellis-* WSL distros other than +// the one being started. All WSL2 distros share a single network namespace, +// so services (MariaDB 3306, nginx 80/443) from one distro block the same +// ports in every other distro. +func (m *Manager) stopOtherDistros(current string) { + output, err := command.Cmd("wsl", []string{"-l", "--running", "-q"}).Output() + if err != nil { + return + } + + decoded := DecodeWslOutput(output) + + for _, line := range strings.Split(decoded, "\n") { + name := strings.TrimSpace(line) + if name == "" || name == current { + continue + } + if !strings.HasPrefix(name, "trellis-") { + continue + } + + // Offer to SyncBack before stopping. The user may have unsaved + // work in the other distro's ext4 filesystem that hasn't been + // synced to the Windows side yet. + prompt := promptui.Prompt{ + Label: fmt.Sprintf("SyncBack '%s' before stopping", name), + IsConfirm: true, + } + if _, promptErr := prompt.Run(); promptErr == nil { + m.syncBackDistro(name) + } + + printStatus(m.ui, fmt.Sprintf("Stopping '%s' (WSL distros share ports)...", name)) + _ = command.Cmd("wsl", []string{"--terminate", name}).Run() + } +} + +// syncBackDistro syncs project files from a running distro back to Windows. +// It reads the Windows project root from /etc/trellis-project-root (written +// during bootstrap) so it works for any distro, not just the current project. +func (m *Manager) syncBackDistro(distro string) { + // Read the breadcrumb file that stores the Windows project root. + raw, err := command.Cmd("wsl", []string{ + "-d", distro, "--", "cat", "/etc/trellis-project-root", + }).Output() + if err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not read project root from '%s': %v", distro, err)) + return + } + + projectRoot := strings.TrimSpace(string(raw)) + if projectRoot == "" { + m.ui.Warn(fmt.Sprintf("Warning: empty project root in '%s'", distro)) + return + } + + projectName := filepath.Base(projectRoot) + wslProjectDest := "/home/admin/" + projectName + wslProjectWindows := toWslPath(projectRoot) + + printStatus(m.ui, fmt.Sprintf("Syncing '%s' back to Windows...", distro)) + + syncScript := fmt.Sprintf( + `rsync -rlpt --info=progress2 --no-inc-recursive --delete --exclude='vendor/' --exclude='node_modules/' --exclude='.trellis/' %s/ %s/`, + wslProjectDest, wslProjectWindows, + ) + + syncErr := command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{ + "-d", distro, + "-u", "admin", + "--", "bash", "-c", syncScript, + }).Run() + + fmt.Print("\r\033[K") + if syncErr != nil { + m.ui.Warn(fmt.Sprintf("Warning: sync failed for '%s': %v", distro, syncErr)) + } else { + printStatus(m.ui, fmt.Sprintf("%s '%s' synced to Windows", color.GreenString("[ok]"), distro)) + } +} + +// DecodeWslOutput handles the UTF-16LE encoding that wsl.exe produces on +// Windows. Most command-line tools output UTF-8, but wsl.exe is a notable +// exception — it encodes list output as UTF-16LE, sometimes with a BOM +// (byte order mark: 0xFF 0xFE) prefix. +// +// This function detects UTF-16LE by looking for null bytes in the pattern +// typical of ASCII text encoded as UTF-16, then converts to a plain Go +// string (which is UTF-8). +func DecodeWslOutput(raw []byte) string { + if len(raw) < 2 { + return string(raw) + } + + start := 0 + // Skip UTF-16LE BOM if present. + if raw[0] == 0xFF && raw[1] == 0xFE { + start = 2 + } + + // Heuristic: if the second byte of the first pair is 0x00, this is + // likely UTF-16LE (ASCII characters are stored as [char, 0x00]). + if start+1 < len(raw) && raw[start+1] == 0x00 { + var buf []byte + for i := start; i+1 < len(raw); i += 2 { + if raw[i+1] == 0x00 { + buf = append(buf, raw[i]) + } + } + return string(buf) + } + + return string(raw[start:]) +} + +// isProvisioned checks whether a distro has been fully provisioned by looking +// for a marker file written at the end of the Provision step. +func (m *Manager) isProvisioned(distro string) bool { + markerPath := filepath.Join(m.ConfigPath, distro+".provisioned") + _, err := os.Stat(markerPath) + return err == nil +} + +// IsProvisioned checks if the named instance has been fully provisioned. +// Used by vm_start.go to detect partially-created distros that need +// cleanup and re-creation. +func (m *Manager) IsProvisioned(name string) bool { + return m.isProvisioned(distroName(name)) +} + +// markProvisioned writes the marker file that isProvisioned checks. +func (m *Manager) markProvisioned(distro string) { + markerPath := filepath.Join(m.ConfigPath, distro+".provisioned") + _ = os.MkdirAll(filepath.Dir(markerPath), 0755) + _ = os.WriteFile(markerPath, []byte("ok\n"), 0644) +} + +// ensureRootfs returns the path to a cached Ubuntu rootfs tarball, +// downloading it first if necessary. +func (m *Manager) ensureRootfs() (string, error) { + tarball := filepath.Join(m.ConfigPath, "ubuntu-rootfs.tar.gz") + + // Already cached — nothing to do. + if _, err := os.Stat(tarball); err == nil { + return tarball, nil + } + + ubuntuVersion := m.trellis.CliConfig.Vm.Ubuntu + url, ok := UbuntuRootfsURLs[ubuntuVersion] + if !ok { + return "", fmt.Errorf( + "no rootfs download URL for Ubuntu %s\n"+ + "Supported versions: %s\n"+ + "You can manually place a rootfs tarball at:\n %s", + ubuntuVersion, supportedUbuntuVersions(), tarball, + ) + } + + printStatus(m.ui, fmt.Sprintf("Downloading Ubuntu %s rootfs...", ubuntuVersion)) + printStatus(m.ui, fmt.Sprintf(" URL: %s", url)) + + if err := downloadFile(url, tarball); err != nil { + return "", fmt.Errorf("could not download rootfs: %v\n"+ + "You can manually download the rootfs and place it at:\n %s", err, tarball) + } + + printStatus(m.ui, fmt.Sprintf("%s Download complete", color.GreenString("[ok]"))) + return tarball, nil +} + +// downloadFile fetches a URL and writes it to dest atomically (via a temp file). +// Prints download progress to stdout. +func downloadFile(url string, dest string) error { + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + // Write to a temp file first so a partial download doesn't leave a + // corrupt tarball that ensureRootfs would think is valid. + tmp := dest + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + defer func() { + f.Close() + os.Remove(tmp) // clean up on failure; no-op if already renamed + }() + + // Show download progress when Content-Length is available. + totalBytes := resp.ContentLength + var written int64 + buf := make([]byte, 32*1024) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, writeErr := f.Write(buf[:n]); writeErr != nil { + return writeErr + } + written += int64(n) + if totalBytes > 0 { + pct := float64(written) / float64(totalBytes) * 100 + fmt.Printf("\r %.0f%% (%d / %d MB)", pct, written/1024/1024, totalBytes/1024/1024) + } else { + fmt.Printf("\r %d MB downloaded", written/1024/1024) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + } + fmt.Println() + + if err = f.Close(); err != nil { + return err + } + + return os.Rename(tmp, dest) +} + +// writeInventory writes the Ansible inventory file. +// +// For WSL, Ansible runs inside the distro and provisions the local machine, +// so we use ansible_connection=local (no SSH needed). +// +// ansible_user=admin is required so Trellis's development override +// (web_user: "{{ ansible_user | default('web') }}") resolves to 'admin'. +// Without it, web_user defaults to 'web' and directories.yml sets web/ +// to web:www-data — then Composer (running as admin) can't create web/wp/. +// Lima's inventory sets this the same way. +func (m *Manager) writeInventory() error { + inventory := `default ansible_connection=local ansible_user=admin + +[development] +default + +[web] +default +` + if err := os.WriteFile(m.InventoryPath(), []byte(inventory), 0644); err != nil { + return fmt.Errorf("could not write inventory file: %v", err) + } + return nil +} + +// toWslPath converts a Windows path (e.g. C:\Users\foo\bar) to a WSL +// mount path (e.g. /mnt/c/Users/foo/bar). +// +// WSL automatically mounts Windows drives under /mnt/. +func toWslPath(windowsPath string) string { + // Normalize to forward slashes. + p := filepath.ToSlash(windowsPath) + + // Convert drive letter: "C:/..." → "/mnt/c/..." + if len(p) >= 2 && p[1] == ':' { + driveLetter := strings.ToLower(string(p[0])) + p = "/mnt/" + driveLetter + p[2:] + } + + return p +} + +// BootstrapInstance installs Python, pip, and Ansible inside the WSL distro. +// This is called once after the distro is first created, before provisioning. +func (m *Manager) BootstrapInstance(name string) error { + distro := distroName(name) + + printStatus(m.ui, "Bootstrapping WSL distro (installing Ansible)...") + + // Compute project paths. The project root (containing both trellis/ and + // site/) is the parent of the trellis directory. + projectRoot := filepath.Dir(m.trellis.Path) + projectName := filepath.Base(projectRoot) + wslProjectRoot := toWslPath(projectRoot) + wslProjectDest := "/home/admin/" + projectName + + // Run apt-get update + install in a single shell command to minimize + // the number of wsl.exe invocations. Then install Ansible via pip using + // Trellis's requirements.txt. We read from the DrvFS source since the + // ext4 copy hasn't happened yet at this point in the script. + wslTrellisSrc := wslProjectRoot + "/trellis" + + bootstrapScript := `set -e +export DEBIAN_FRONTEND=noninteractive + +# Prevent openssh-server's ssh.socket from starting during install. +# WSL2 pre-binds port 22 with its own SSH relay (kernel-level, no PID). +# Ubuntu 24.04's socket-activated SSH tries to bind the same port, causing +# deb-systemd-invoke to fail and leaving the package half-configured. +# The ssh.socket unit has ConditionPathExists=!/etc/ssh/sshd_not_to_be_run, +# so creating this file makes systemd skip the socket entirely. +# We don't need SSH anyway (ansible_connection=local). +mkdir -p /etc/ssh +touch /etc/ssh/sshd_not_to_be_run + +apt-get update -qq +apt-get install -y -qq python3 python3-pip python3-venv rsync curl ca-certificates gnupg + +# Install Node.js LTS (for Sage/frontend build tools like yarn dev). +# Unlike upstream Lima where Node runs on the host, WSL project files live +# on ext4 — so Node/yarn must be inside the distro where the developer works. +curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - +apt-get install -y -qq nodejs +corepack enable + +pip3 install --break-system-packages --root-user-action=ignore -r ` + wslTrellisSrc + `/requirements.txt + +# Create the web user and group that Trellis expects. +# On a real server these would be created by the 'users' role (server.yml), +# but dev.yml skips that role and assumes they exist. +getent group www-data >/dev/null 2>&1 || groupadd www-data +id -u web >/dev/null 2>&1 || useradd -m -N -g www-data -G www-data -s /bin/bash web +id -u admin >/dev/null 2>&1 || useradd -m -N -g admin -G sudo -s /bin/bash admin + +# Give admin passwordless sudo so Ansible become: yes works. +echo 'admin ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/admin +chmod 440 /etc/sudoers.d/admin + +# Configure WSL. Must come after user creation so admin user exists. +# - systemd=true: required for services (nginx, mariadb, journald, etc.) +# - default=admin: sets the default WSL user +# - metadata,umask=0022: enables Linux permission storage on NTFS. +# Do NOT use fmask=0111 — it strips the execute bit from all DrvFS files, +# which breaks VS Code's WSL extension (wslServer.sh: Permission denied). +# The distro must be restarted for these settings to take effect (handled below). +cat > /etc/wsl.conf << 'WSLCONF' +[boot] +systemd=true + +[user] +default=admin + +[automount] +options = "metadata,umask=0022" +WSLCONF + +# Create .ssh directory for admin user (needed by Ansible known_hosts module). +mkdir -p /home/admin/.ssh +chmod 700 /home/admin/.ssh +chown admin:admin /home/admin/.ssh +` + + // Copy the ENTIRE project (trellis/ + site/ + .git/) from Windows into + // WSL's native ext4 filesystem. This is the critical step that gives us: + // 1. Fast PHP I/O (ext4 vs DrvFS/9p = 77ms vs 14s page loads) + // 2. Intact git repo (developers use VS Code + WSL git as normal) + // 3. Single workspace (trellis/ + site/ together, natural project layout) + // + // The copy goes through 9p (slow) but is a ONE-TIME cost during initial + // setup. After this, the developer works entirely within the WSL distro + // using VS Code's WSL extension. + bootstrapScript += fmt.Sprintf( + "echo 'Copying project files to WSL filesystem...'\nmkdir -p %s && rsync -rlpt --chmod=D755,F644 --info=progress2 %s/ %s/\n", + wslProjectDest, wslProjectRoot, wslProjectDest, + ) + bootstrapScript += fmt.Sprintf( + "chown -R admin:admin %s\n", + wslProjectDest, + ) + + // Write the Windows project root path inside the distro so that + // stopOtherDistros can SyncBack without needing the trellis project loaded. + bootstrapScript += fmt.Sprintf( + "echo '%s' > /etc/trellis-project-root\n", + projectRoot, + ) + + // Strip the execute bit from .vault_pass in the project copy. + // DrvFS metadata marks all files executable; Ansible interprets an + // executable .vault_pass as a script and tries to run it, which fails + // with "Exec format error" since it's a plain text file. + bootstrapScript += fmt.Sprintf( + "chmod 644 %s/trellis/.vault_pass 2>/dev/null || true\n", + wslProjectDest, + ) + + // Copy vault password file to a secure location inside the distro. + // Even though trellis/.vault_pass is now on ext4, Ansible may complain + // about permissions depending on the umask. The dedicated copy is safer. + bootstrapScript += fmt.Sprintf( + "mkdir -p /home/admin/.trellis\n", + ) + bootstrapScript += fmt.Sprintf( + "cp %s/trellis/.vault_pass /home/admin/.trellis/.vault_pass\n", + wslProjectDest, + ) + bootstrapScript += "chmod 600 /home/admin/.trellis/.vault_pass\n" + bootstrapScript += "chown -R admin:admin /home/admin/.trellis\n" + + // Install the trellis CLI binary inside the distro so developers can + // run `trellis provision development`, `trellis db open`, etc. from + // the VS Code WSL terminal. + // + // For fork/dev builds: look for a cross-compiled `trellis-linux` binary + // next to the running executable and copy it in. + // For upstream releases: this would use the official install script instead. + exePath, _ := os.Executable() + linuxBinary := filepath.Join(filepath.Dir(exePath), "trellis-linux") + if _, err := os.Stat(linuxBinary); err == nil { + wslLinuxBinary := toWslPath(linuxBinary) + bootstrapScript += fmt.Sprintf( + "cp %s /usr/local/bin/trellis && chmod 755 /usr/local/bin/trellis\n", + wslLinuxBinary, + ) + } + + // Bind-mount each site's directory from the ext4 project copy to the + // /srv/www/ path that nginx expects. Bind mounts (not symlinks) keep + // $realpath_root within /srv/www/, satisfying PHP-FPM's open_basedir. + for siteName, site := range m.Sites { + siteRelPath := site.LocalPath // e.g. "../site" + // Resolve relative path: trellis/../site → site + siteDirName := filepath.Base(filepath.Join("trellis", siteRelPath)) + + bootstrapScript += fmt.Sprintf( + "mkdir -p /srv/www/%s/current\n", + siteName, + ) + bootstrapScript += fmt.Sprintf( + "mount --bind %s/%s /srv/www/%s/current\n", + wslProjectDest, siteDirName, siteName, + ) + // Add fstab entry so the bind mount survives WSL restarts. + bootstrapScript += fmt.Sprintf( + "grep -q '/srv/www/%s/current' /etc/fstab || echo '%s/%s /srv/www/%s/current none bind,nofail 0 0' >> /etc/fstab\n", + siteName, wslProjectDest, siteDirName, siteName, + ) + } + + err := command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{"-d", distro, "--", "bash", "-c", bootstrapScript}).Run() + + if err != nil { + return fmt.Errorf("could not bootstrap WSL distro: %v", err) + } + + // Restart the distro so /etc/wsl.conf metadata mount option takes effect. + // Without this, chmod/fchmod on /mnt/c/ files will still fail. + printStatus(m.ui, "Restarting WSL distro to apply mount options...") + _ = command.Cmd("wsl", []string{"--terminate", distro}).Run() + err = command.Cmd("wsl", []string{"-d", distro, "--", "/bin/true"}).Run() + if err != nil { + return fmt.Errorf("could not restart WSL distro: %v", err) + } + + // Re-establish the keepalive process. The --terminate above killed the + // original `sleep infinity` started by StartInstance. + keepalive := exec.Command("wsl", "-d", distro, "--exec", "sleep", "infinity") + if err := keepalive.Start(); err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not start keepalive: %v", err)) + } else { + go keepalive.Wait() + } + + printStatus(m.ui, fmt.Sprintf("%s Ansible installed", color.GreenString("[ok]"))) + return nil +} + +// Provision runs ansible-galaxy install and ansible-playbook inside the WSL +// distro. This replaces the host-side ProvisionCommand for WSL. +// +// The trellis directory lives on WSL ext4 at /home/admin//trellis/ +// (copied during bootstrap). This is much faster than reading from /mnt/c/. +func (m *Manager) Provision(name string) error { + distro := distroName(name) + + // Use the ext4 copy of the trellis directory for provisioning. + projectRoot := filepath.Dir(m.trellis.Path) + projectName := filepath.Base(projectRoot) + trellisDir := "/home/admin/" + projectName + "/trellis" + inventoryPath := toWslPath(m.InventoryPath()) + ansibleCfg := trellisDir + "/ansible.cfg" + + // Set ANSIBLE_CONFIG explicitly. Even though the ext4 copy has proper + // permissions, this is consistent with our bootstrap approach. + envPrefix := "export ANSIBLE_CONFIG=" + ansibleCfg + " ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_VAULT_PASSWORD_FILE=/home/admin/.trellis/.vault_pass && " + + // Install Galaxy roles inside WSL + printStatus(m.ui, "Installing Ansible Galaxy roles...") + + galaxyFiles := []string{"galaxy.yml", "requirements.yml"} + for _, f := range galaxyFiles { + fullPath := filepath.Join(m.trellis.Path, f) + if _, err := os.Stat(fullPath); err == nil { + err := command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{ + "-d", distro, + "-u", "admin", + "--cd", trellisDir, + "--", "bash", "-c", envPrefix + "ansible-galaxy install -r " + f, + }).Run() + + if err != nil { + m.ui.Warn(fmt.Sprintf("Warning: ansible-galaxy install failed: %v", err)) + } + break + } + } + + // Run ansible-playbook dev.yml + printStatus(m.ui, "Running Ansible provisioning...") + + err := command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{ + "-d", distro, + "-u", "admin", + "--cd", trellisDir, + "--", "bash", "-c", envPrefix + "ansible-playbook dev.yml --inventory=" + inventoryPath + " -e env=development", + }).Run() + + if err != nil { + return fmt.Errorf("provisioning failed: %v", err) + } + + // Tune opcache for WSL. Even though files are now on ext4 (not DrvFS), + // a small revalidate delay reduces unnecessary stat() calls during + // development. 2 seconds is development-friendly (max 2s stale window). + printStatus(m.ui, "Tuning opcache for WSL performance...") + opcacheScript := `set -e +PHP_VER=$(php -r 'echo PHP_MAJOR_VERSION."_".PHP_MINOR_VERSION;' | tr '_' '.') +printf '[opcache]\nopcache.revalidate_freq=2\n' > /etc/php/${PHP_VER}/fpm/conf.d/99-wsl-performance.ini +systemctl restart php${PHP_VER}-fpm +` + opcacheScriptPath := filepath.Join(m.ConfigPath, "opcache-tune.sh") + if err := os.WriteFile(opcacheScriptPath, []byte(opcacheScript), 0644); err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not write opcache script: %v", err)) + } else { + _ = command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{ + "-d", distro, + "-u", "root", + "--", "bash", toWslPath(opcacheScriptPath), + }).Run() + } + + // Import self-signed SSL certs into the Windows Trusted Root CA store + // so browsers accept https://.test without warnings. + if err := m.TrustSslCerts(distro); err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not trust SSL certs: %v", err)) + } + + // Mark the distro as fully provisioned. StartInstance checks this to + // detect partially-created distros (e.g. cancelled during bootstrap). + m.markProvisioned(distro) + + return nil +} + +// TrustSslCerts extracts self-signed SSL certificates from the WSL distro +// and imports them into the Windows Trusted Root Certification Authorities +// store. This eliminates browser warnings for https://*.test sites. +// +// Only processes sites that have ssl.enabled: true in wordpress_sites.yml. +// Uses certutil.exe via UAC elevation (same pattern as hosts file updates). +func (m *Manager) TrustSslCerts(distro string) error { + var sslSites []string + for siteName, site := range m.Sites { + if site.SslEnabled() { + sslSites = append(sslSites, siteName) + } + } + + if len(sslSites) == 0 { + m.ui.Warn("No SSL-enabled sites found in development config. Set ssl.enabled: true in wordpress_sites.yml.") + return nil + } + + printStatus(m.ui, "Importing SSL certificates into Windows trust store...") + + certDir := filepath.Join(m.ConfigPath, "certs") + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("could not create cert directory: %v", err) + } + + var certPaths []string + for _, siteName := range sslSites { + // Trellis stores certs at /etc/nginx/ssl/.cert + remoteCert := fmt.Sprintf("/etc/nginx/ssl/%s.cert", siteName) + localCert := filepath.Join(certDir, siteName+".crt") + + // Extract the cert from the distro. + output, err := command.Cmd("wsl", []string{ + "-d", distro, "-u", "root", "--", "cat", remoteCert, + }).Output() + + if err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not read cert for %s: %v", siteName, err)) + continue + } + + if err := os.WriteFile(localCert, output, 0644); err != nil { + m.ui.Warn(fmt.Sprintf("Warning: could not save cert for %s: %v", siteName, err)) + continue + } + + certPaths = append(certPaths, localCert) + } + + if len(certPaths) == 0 { + return nil + } + + // Build a PowerShell script that imports all certs in one UAC prompt. + var importCmds []string + for _, certPath := range certPaths { + importCmds = append(importCmds, + fmt.Sprintf(`certutil -addstore Root \"%s\"`, certPath), + ) + } + + script := strings.Join(importCmds, "; ") + + printStatus(m.ui, "Admin privileges required to trust certificates -- a UAC prompt will appear.") + + if err := command.Cmd("powershell", []string{ + "-Command", + fmt.Sprintf( + "Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile','-Command','%s'", + script, + ), + }).Run(); err != nil { + return err + } + + printStatus(m.ui, fmt.Sprintf("SSL certificates trusted for %d site(s).", len(certPaths))) + return nil +} + +// syncConfigFromWSL rsyncs trellis/group_vars/ from the WSL ext4 project +// back to the Windows filesystem. This is called in NewManager when the +// distro is running, so that any config changes made inside WSL (e.g. +// enabling SSL, adding site hosts) are visible to Windows-side commands. +// +// Only syncs group_vars/ (not the full project) to keep it fast — this +// directory contains wordpress_sites.yml and vault.yml which drive most +// command behavior. A full project sync happens in SyncBack/vm stop. +func (m *Manager) syncConfigFromWSL(distro string) { + projectRoot := filepath.Dir(m.trellis.Path) + projectName := filepath.Base(projectRoot) + wslProjectDest := "/home/admin/" + projectName + wslProjectWindows := toWslPath(projectRoot) + + syncScript := fmt.Sprintf( + `rsync -rlpt %s/trellis/group_vars/ %s/trellis/group_vars/`, + wslProjectDest, wslProjectWindows, + ) + + // Best-effort: if rsync fails (distro not ready, rsync missing), + // continue with the existing Windows-side config. + _ = command.Cmd("wsl", []string{ + "-d", distro, + "-u", "admin", + "--", "bash", "-c", syncScript, + }).Run() +} + +// SyncToWSL copies the trellis/ directory from Windows into the WSL ext4 +// project. This is used before re-provisioning so that any config changes +// the developer made on the Windows side are reflected inside WSL. +// +// Only syncs trellis/ (not site/) since site files are edited inside WSL +// via VS Code's WSL extension. Trellis config is the exception because +// developers may edit it from either side. +func (m *Manager) SyncToWSL(name string) error { + distro := distroName(name) + + if !m.distroExists(distro) { + return fmt.Errorf("WSL distro does not exist. Run `trellis vm start` first.") + } + + projectRoot := filepath.Dir(m.trellis.Path) + projectName := filepath.Base(projectRoot) + wslProjectRoot := toWslPath(projectRoot) + wslProjectDest := "/home/admin/" + projectName + + printStatus(m.ui, "Syncing trellis/ config to WSL...") + + syncScript := fmt.Sprintf( + `rsync -rlpt --chmod=D755,F644 --delete %s/trellis/ %s/trellis/`, + wslProjectRoot, wslProjectDest, + ) + + err := command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{ + "-d", distro, + "-u", "admin", + "--", "bash", "-c", syncScript, + }).Run() + + if err != nil { + return fmt.Errorf("sync to WSL failed: %v", err) + } + + printStatus(m.ui, fmt.Sprintf("%s Trellis config synced", color.GreenString("[ok]"))) + return nil +} + +// Reprovision syncs config changes from Windows, then runs provisioning. +// This is the WSL equivalent of `trellis provision development`. +func (m *Manager) Reprovision(name string) error { + if err := m.SyncToWSL(name); err != nil { + return err + } + + return m.Provision(name) +} + +// SyncBack copies changed files from the WSL ext4 project back to the +// Windows filesystem. This keeps the Windows-side repo up to date so +// GitHub Desktop and other Windows tools can see the latest changes. +// +// Uses rsync for efficient incremental sync — only changed files are +// transferred through 9p, making subsequent syncs fast (seconds, not minutes). +// +// Direction: WSL ext4 → Windows (one-way). Never the reverse during sync. +func (m *Manager) SyncBack(name string) error { + distro := distroName(name) + + if !m.distroExists(distro) { + return fmt.Errorf("WSL distro does not exist. Run `trellis vm start` first.") + } + + if !m.distroRunning(distro) { + return fmt.Errorf("WSL distro is not running. Start it with `trellis vm start` first.") + } + + projectRoot := filepath.Dir(m.trellis.Path) + projectName := filepath.Base(projectRoot) + wslProjectDest := "/home/admin/" + projectName + wslProjectWindows := toWslPath(projectRoot) + + printStatus(m.ui, "Syncing project files from WSL to Windows...") + + // rsync flags: + // -rlpt: recursive, links, perms, times (like -a but without group/owner + // which fail on DrvFS with "Operation not permitted") + // --delete: remove files on Windows side that were deleted in WSL + // --exclude: skip directories that are large, generated, or WSL-specific + // --no-inc-recursive: scan all files first so progress % is accurate + // Trailing slashes on source ensure contents are synced, not the dir itself. + syncScript := fmt.Sprintf( + `rsync -rlpt --info=progress2 --no-inc-recursive --delete --exclude='vendor/' --exclude='node_modules/' --exclude='.trellis/' %s/ %s/`, + wslProjectDest, wslProjectWindows, + ) + + err := command.WithOptions( + command.WithTermOutput(), + ).Cmd("wsl", []string{ + "-d", distro, + "-u", "admin", + "--", "bash", "-c", syncScript, + }).Run() + + if err != nil { + return fmt.Errorf("sync failed: %v", err) + } + + // Clear the rsync progress line before printing the final status. + fmt.Print("\r\033[K") + printStatus(m.ui, fmt.Sprintf("%s Project synced to Windows", color.GreenString("[ok]"))) + return nil +} diff --git a/pkg/wsl/ubuntu.go b/pkg/wsl/ubuntu.go new file mode 100644 index 00000000..b4908950 --- /dev/null +++ b/pkg/wsl/ubuntu.go @@ -0,0 +1,31 @@ +package wsl + +import ( + "sort" + "strings" +) + +// UbuntuRootfsURLs maps Ubuntu version numbers to the official cloud-images +// rootfs URLs. These are the WSL-specific images designed for `wsl --import`. +// +// Sources: +// - 22.04: https://cloud-images.ubuntu.com/wsl/jammy/current/ +// - 24.04: https://cdimages.ubuntu.com/ubuntu-wsl/noble/daily-live/current/ +// +// If a URL stops working, the user can manually download a rootfs and place +// it at /wsl/ubuntu-rootfs.tar.gz. +var UbuntuRootfsURLs = map[string]string{ + "22.04": "https://cloud-images.ubuntu.com/wsl/jammy/current/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz", + "24.04": "https://cdimages.ubuntu.com/ubuntu-wsl/noble/daily-live/current/noble-wsl-amd64.wsl", +} + +// supportedUbuntuVersions returns a comma-separated list of supported versions +// for use in error messages. +func supportedUbuntuVersions() string { + versions := make([]string, 0, len(UbuntuRootfsURLs)) + for v := range UbuntuRootfsURLs { + versions = append(versions, v) + } + sort.Strings(versions) + return strings.Join(versions, ", ") +} diff --git a/trellis/trellis.go b/trellis/trellis.go index 7e4c7c1a..58baa17e 100644 --- a/trellis/trellis.go +++ b/trellis/trellis.go @@ -115,6 +115,11 @@ func (t *Trellis) CreateConfigDir() error { } func (t *Trellis) CheckVirtualenv(ui cli.Ui) { + // On Windows with WSL, Python/Ansible live inside the VM — no host virtualenv needed. + if t.VmManagerType() == "wsl" { + return + } + if t.CliConfig.VirtualenvIntegration && !t.venvWarned && !t.VenvInitialized { ui.Warn(` WARNING: This project has not been initialized with trellis-cli and may not work as expected. @@ -222,6 +227,19 @@ func (t *Trellis) LoadProject() error { return nil } +// ReloadSiteConfigs re-parses all wordpress_sites.yml files from disk. +// Call this after syncing config from WSL to ensure the in-memory +// representation matches the updated files on the Windows filesystem. +func (t *Trellis) ReloadSiteConfigs() { + configPaths, _ := filepath.Glob("group_vars/*/wordpress_sites.yml") + + for _, p := range configPaths { + parts := strings.Split(p, string(os.PathSeparator)) + envName := parts[1] + t.Environments[envName] = t.ParseConfig(p) + } +} + func (t *Trellis) EnvironmentNames() []string { var names []string @@ -387,12 +405,24 @@ func (t *Trellis) WriteYamlFile(s interface{}, path string, header string) error func (t *Trellis) VmManagerType() string { switch t.CliConfig.Vm.Manager { case "auto": - if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { + if runtime.GOOS == "darwin" { return "lima" } + if runtime.GOOS == "linux" { + // Inside a WSL distro, WSL_DISTRO_NAME is always set. + if os.Getenv("WSL_DISTRO_NAME") != "" { + return "wsl" + } + return "lima" + } + if runtime.GOOS == "windows" { + return "wsl" + } return "" case "lima": return "lima" + case "wsl": + return "wsl" case "mock": return "mock" default: From 65070869ec870903f86475988e6882a315cd7bd5 Mon Sep 17 00:00:00 2001 From: Quentin Watts <54119641+qwatts-dev@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:34:34 -0400 Subject: [PATCH 2/4] Fix golangci-lint errors (errcheck, staticcheck) --- github/main.go | 4 ++-- pkg/wsl/manager.go | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/github/main.go b/github/main.go index 21b30347..92c75236 100644 --- a/github/main.go +++ b/github/main.go @@ -77,7 +77,7 @@ func DownloadRelease(repo string, version string, path string, dest string) (rel // On Windows, close the archive handle before renaming. Open file handles // prevent directory renames on Windows (but not on macOS/Linux). if runtime.GOOS == "windows" { - archiveFile.Close() + _ = archiveFile.Close() } org := strings.Split(repo, "/")[0] @@ -196,7 +196,7 @@ func extractToDisk(fi archives.FileInfo, dest string) error { // Unclosed handles prevent the parent directory from being renamed. // On macOS/Linux this is unnecessary — rename works regardless. if runtime.GOOS == "windows" { - dst.Close() + _ = dst.Close() } if err != nil { diff --git a/pkg/wsl/manager.go b/pkg/wsl/manager.go index a2361167..7dca4164 100644 --- a/pkg/wsl/manager.go +++ b/pkg/wsl/manager.go @@ -204,7 +204,7 @@ func (m *Manager) StartInstance(name string) error { return fmt.Errorf("could not start WSL distro keepalive: %v", err) } // Detach — the process outlives trellis-cli. - go cmd.Wait() + go func() { _ = cmd.Wait() }() if err := m.writeInventory(); err != nil { return err @@ -553,7 +553,7 @@ func downloadFile(url string, dest string) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) @@ -567,7 +567,7 @@ func downloadFile(url string, dest string) error { return err } defer func() { - f.Close() + _ = f.Close() os.Remove(tmp) // clean up on failure; no-op if already renamed }() @@ -764,9 +764,7 @@ chown admin:admin /home/admin/.ssh // Copy vault password file to a secure location inside the distro. // Even though trellis/.vault_pass is now on ext4, Ansible may complain // about permissions depending on the umask. The dedicated copy is safer. - bootstrapScript += fmt.Sprintf( - "mkdir -p /home/admin/.trellis\n", - ) + bootstrapScript += "mkdir -p /home/admin/.trellis\n" bootstrapScript += fmt.Sprintf( "cp %s/trellis/.vault_pass /home/admin/.trellis/.vault_pass\n", wslProjectDest, @@ -837,7 +835,7 @@ chown admin:admin /home/admin/.ssh if err := keepalive.Start(); err != nil { m.ui.Warn(fmt.Sprintf("Warning: could not start keepalive: %v", err)) } else { - go keepalive.Wait() + go func() { _ = keepalive.Wait() }() } printStatus(m.ui, fmt.Sprintf("%s Ansible installed", color.GreenString("[ok]"))) From 1136e2c63f37f565b1731ab355a98b6d6ade99ae Mon Sep 17 00:00:00 2001 From: Quentin Watts <54119641+qwatts-dev@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:31:55 -0400 Subject: [PATCH 3/4] Fix vm start silently deleting provisioned WSL distros The isProvisioned check relied solely on an external .provisioned marker file. Distros provisioned before the marker system was introduced (or whose marker was lost) were incorrectly identified as unprovisioned and silently deleted on the next vm start. Changes: - isProvisioned() now has a two-tier check: marker file first, then falls back to checking /etc/trellis-project-root (breadcrumb written during bootstrap) inside the distro. Self-heals the marker on success. - vm start now prompts for confirmation before deleting a distro that appears unprovisioned, instead of silently deleting it. --- cmd/vm_start.go | 13 +++++++++++-- pkg/wsl/manager.go | 25 +++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/vm_start.go b/cmd/vm_start.go index eae85868..e8b9cedc 100644 --- a/cmd/vm_start.go +++ b/cmd/vm_start.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/hashicorp/cli" + "github.com/manifoldco/promptui" "github.com/roots/trellis-cli/pkg/vm" "github.com/roots/trellis-cli/pkg/wsl" "github.com/roots/trellis-cli/trellis" @@ -72,9 +73,17 @@ func (c *VmStartCommand) Run(args []string) int { err = manager.StartInstance(instanceName) if err == nil { // If the distro exists but was never fully provisioned (e.g. user - // cancelled during bootstrap), clean it up and re-create. + // cancelled during bootstrap), confirm before deleting. if wslManager, ok := manager.(*wsl.Manager); ok && !wslManager.IsProvisioned(instanceName) { - c.UI.Warn("Detected unprovisioned WSL distro. Cleaning up and starting fresh...") + c.UI.Warn("This WSL distro exists but does not appear to be fully provisioned.") + prompt := promptui.Prompt{ + Label: "Delete and recreate it", + IsConfirm: true, + } + if _, err := prompt.Run(); err != nil { + c.UI.Info("Aborted. Distro was not deleted.") + return 0 + } _ = manager.DeleteInstance(instanceName) } else { c.printInstanceInfo() diff --git a/pkg/wsl/manager.go b/pkg/wsl/manager.go index 7dca4164..22ff9f46 100644 --- a/pkg/wsl/manager.go +++ b/pkg/wsl/manager.go @@ -491,12 +491,29 @@ func DecodeWslOutput(raw []byte) string { return string(raw[start:]) } -// isProvisioned checks whether a distro has been fully provisioned by looking -// for a marker file written at the end of the Provision step. +// isProvisioned checks whether a distro has been fully provisioned. +// +// Primary check: a marker file on the Windows filesystem written at the end +// of the Provision step. Fallback: the /etc/trellis-project-root breadcrumb +// inside the distro (written during bootstrap). This handles distros that +// were provisioned before the marker system existed, or whose marker file +// was lost. When the fallback succeeds, the marker is self-healed so future +// checks are fast. func (m *Manager) isProvisioned(distro string) bool { markerPath := filepath.Join(m.ConfigPath, distro+".provisioned") - _, err := os.Stat(markerPath) - return err == nil + if _, err := os.Stat(markerPath); err == nil { + return true + } + + // Fallback: check for the breadcrumb file inside the running distro. + out, err := exec.Command("wsl", "-d", distro, "--", "cat", "/etc/trellis-project-root").Output() + if err == nil && len(strings.TrimSpace(string(out))) > 0 { + // Self-heal: write the marker so we don't shell into WSL every time. + m.markProvisioned(distro) + return true + } + + return false } // IsProvisioned checks if the named instance has been fully provisioned. From 65ad905ae223667529a8a93a807ba1570f9b76fb Mon Sep 17 00:00:00 2001 From: Quentin Watts <54119641+qwatts-dev@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:43:24 -0400 Subject: [PATCH 4/4] Improve WSL UX: init guard, guard messages, and upstream-ready CLI install - Add WSL guard to 'trellis init': detects WSL backend and prints a message explaining that dependencies are managed inside the VM automatically, instead of failing with a virtualenv error. - Improve windowsHostRequired() guard message: echoes back the actual command to run (e.g. "Run 'trellis vm start' from Windows PowerShell") instead of the generic "Run this command from PowerShell". - Add windowsHostRequired() guard to 'vm open' and 'vm sync': users running these from inside WSL now get the correct "run from PowerShell" message instead of a confusing "only supported on Windows (WSL2)" error. - Make CLI install in bootstrap upstream-ready: checks for a cross-compiled trellis-linux sidecar first (dev/fork builds), falls back to the official install script (scripts/get) for upstream releases. Previously, if no sidecar was found, the distro silently had no CLI binary. --- cmd/init.go | 6 ++++++ cmd/vm.go | 2 +- cmd/vm_open.go | 4 ++++ cmd/vm_sync.go | 4 ++++ pkg/wsl/manager.go | 12 +++++++++--- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 94ca43c0..37eb553a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -48,6 +48,12 @@ func (c *InitCommand) Run(args []string) int { return 1 } + if c.Trellis.VmManagerType() == "wsl" { + c.UI.Info("The WSL backend manages dependencies (Python, Ansible, etc.) automatically inside the VM.") + c.UI.Info("No host-side initialization is needed. Run 'trellis vm start' to set up your environment.") + return 0 + } + if err := c.flags.Parse(args); err != nil { c.UI.Error(err.Error()) return 1 diff --git a/cmd/vm.go b/cmd/vm.go index cf4fa91e..f5890ffa 100644 --- a/cmd/vm.go +++ b/cmd/vm.go @@ -35,7 +35,7 @@ func windowsHostRequired(t *trellis.Trellis, ui cli.Ui, command string) bool { } ui.Warn(color.YellowString(fmt.Sprintf("'trellis %s' manages the WSL distro from the Windows host.", command))) - ui.Warn(color.YellowString("Run this command from your Windows PowerShell or Command Prompt, not from inside WSL.")) + ui.Warn(color.YellowString(fmt.Sprintf("Run 'trellis %s' from Windows PowerShell or Command Prompt, not from inside WSL.", command))) return true } diff --git a/cmd/vm_open.go b/cmd/vm_open.go index d86dd6af..23167c23 100644 --- a/cmd/vm_open.go +++ b/cmd/vm_open.go @@ -52,6 +52,10 @@ func (c *VmOpenCommand) Run(args []string) int { return 1 } + if windowsHostRequired(c.Trellis, c.UI, "vm open") { + return 1 + } + if runtime.GOOS != "windows" { c.UI.Error("'trellis vm open' is only supported on Windows (WSL2).") c.UI.Info("On macOS/Linux, open your site directory directly in your editor.") diff --git a/cmd/vm_sync.go b/cmd/vm_sync.go index 36e53fd5..b7b1e665 100644 --- a/cmd/vm_sync.go +++ b/cmd/vm_sync.go @@ -46,6 +46,10 @@ func (c *VmSyncCommand) Run(args []string) int { return 1 } + if windowsHostRequired(c.Trellis, c.UI, "vm sync") { + return 1 + } + if runtime.GOOS != "windows" { c.UI.Error("'trellis vm sync' is only supported on Windows (WSL2).") return 1 diff --git a/pkg/wsl/manager.go b/pkg/wsl/manager.go index 22ff9f46..9d205cf9 100644 --- a/pkg/wsl/manager.go +++ b/pkg/wsl/manager.go @@ -793,9 +793,13 @@ chown admin:admin /home/admin/.ssh // run `trellis provision development`, `trellis db open`, etc. from // the VS Code WSL terminal. // - // For fork/dev builds: look for a cross-compiled `trellis-linux` binary - // next to the running executable and copy it in. - // For upstream releases: this would use the official install script instead. + // Priority 1 — Dev/fork builds: if a cross-compiled `trellis-linux` + // binary exists next to the running executable, copy it in. This is + // used when testing from source before an upstream release exists. + // + // Priority 2 — Upstream releases: run the official install script + // (scripts/get) inside the distro. This downloads the matching + // linux/amd64 binary from the latest GitHub release. exePath, _ := os.Executable() linuxBinary := filepath.Join(filepath.Dir(exePath), "trellis-linux") if _, err := os.Stat(linuxBinary); err == nil { @@ -804,6 +808,8 @@ chown admin:admin /home/admin/.ssh "cp %s /usr/local/bin/trellis && chmod 755 /usr/local/bin/trellis\n", wslLinuxBinary, ) + } else { + bootstrapScript += "curl -sL https://raw.githubusercontent.com/roots/trellis-cli/master/scripts/get | bash -s\n" } // Bind-mount each site's directory from the ext4 project copy to the