Skip to content

Development

How to build, test, and iterate on sand locally.

Repository layout

sand has two halves that share one repo:

  • A Go TUI/CLI (cmd/sand, internal/…) that creates, clones, resets, and manages VMs — on this machine or another one with Lima, or on a Proxmox host through its API — plus a host-side secrets store.
  • An Ansible provisioner (site.yml, roles/…, group_vars/) that configures a VM once it boots. The Go side embeds and runs it — see The Embedded Playbook for how that actually works.

Inside internal/:

Package What it does
lima Typed wrapper over the limactl CLI. All subprocess execution goes through a Runner interface so code is testable without a real binary.
provider The seam behind "where a VM runs": one implementation each for local Lima, Lima on a remote host over SSH, and Proxmox. Everything above this layer is written once.
profiles The profiles.yaml store — the named connections a provider is built from.
provision Orchestrates create/reset (base build, clone, finalize) and the Ansible run; staging.go moves data across a reset.
ui The Bubble Tea model, views, and commands (board/form/secrets/progress/…).
secrets, registry, manage, browse, vm Host-side secrets store, managed-VM index, shared registry bookkeeping, file browser, domain types.

Entrypoint: cmd/sand/main.go. There are three paths: a headless sand create (internal/manage), the TUI, and a standalone sand shell (cmd/sand/shell.go).

Why the limactl CLI, not a Go API

Lima is written in Go, but it doesn't publish a stable public Go API — its pkg/… packages are internal and change between releases, and importing them would pull Lima's whole dependency tree into sand and pin it to a single Lima version. internal/lima instead wraps the limactl CLI itself, using its structured output (--format json for list, --format '{{ .Field }}' templates for single values) as the supported, documented integration surface. Because limactl logs to stderr (time=… level=… msg=… lines) and writes its JSON/template output to stdout, the runner captures the two streams separately — only stdout is parsed, stderr is surfaced as diagnostics on failure — and the list parser skips any stdout line that isn't a JSON object, so a stray notice degrades to "ignored" rather than failing the listing.

Build, run, format, vet

go build ./cmd/sand      # build the binary
go run ./cmd/sand        # run the TUI
gofmt -l .               # must print nothing; format before committing
go vet ./...

There is deliberately no Makefile, and no Node/npm toolchain anywhere in this repository — including for the docs, below. A contributor who assumes make build works should be corrected here rather than by a failing command.

Testing

go test ./...                                  # unit + integration, no VM needed
go test ./internal/ui -run TestTUI -update     # regenerate TUI golden snapshots
go test -tags limae2e ./...                    # real-VM end-to-end (needs limactl + KVM)
go test -tags proxmoxe2e ./internal/provider   # real-VM end-to-end on Proxmox

go test ./... covers unit tests and the teatest-based TUI golden snapshots and never boots a real VM. Real-VM end-to-end tests are gated behind a build tag, so plain go test ./... skips them:

  • limae2e — run these on a host with Lima, or dispatch the test.yml workflow, whose lima-e2e job runs them under QEMU+KVM.
  • proxmoxe2e — run these against a Proxmox host with a test pool set up. They also need PROXMOX_E2E=1 and the other PROXMOX_E2E_* variables, and skip without them. See Proxmox VE Setup for the pool and the full variable list.

Docs

The documentation site (the one you are reading) is built with MkDocs Material, invoked entirely through uv's uvx — no global Python, no virtualenv, and no Node toolchain:

uvx --with-requirements docs/requirements.txt mkdocs serve          # live preview
uvx --with-requirements docs/requirements.txt mkdocs build --strict # check

--strict is the only quality gate this toolchain offers — it fails the build on any broken link or nav entry pointing at a missing page — and it runs on every pull request. A contributor changing docs should run it locally before pushing.

CI

.github/workflows/test.yml triggers on push to main, on pull_request, and on workflow_dispatch. A plain feature-branch push runs no CI by itself — only once a pull request exists, or via an explicit dispatch:

gh workflow run test.yml --ref <branch>
gh run list --workflow test.yml --branch <branch> --limit 1   # get the run id
gh run watch <id> --exit-status

That dispatch run is distinct from the pull_request run that fires (on the same SHA) once a PR is opened against the branch.

Conventions

  • Commits use Conventional Commits (feat:, fix:, test:, ci:, docs:, chore:, scopes like fix(reset):). Releases are automated by release-please, which parses them — see Releases.
  • Match the surrounding code's comment density and idiom: this codebase favours explanatory comments on the why, not the what.
  • When you change TUI rendering, update the affected golden snapshots (-update) and confirm the text diff is the change you intended.

Regenerating the home-page screenshot

The board image on the site's home page lives at docs/images/board.png. It is rendered deterministically from a committed generator, so it can be re-shot whenever the TUI's colours or layout change — no live VM required.

The generator is TestGenerateHomeBoardShot in internal/ui/boardshot_test.go. It hand-seeds a fixed fleet (the drupal-contrib / lullabotdotcom board you see on the home page — filled gauges, up 3h42m, a Messages log, version 0.5.0) and writes the board's coloured render to the file named in BOARD_SHOT_OUT. It skips when that variable is unset, so a normal go test never runs it. Turn that render into the PNG with freeze (JetBrains Mono ships inside the freeze module, so no font install is needed):

BOARD_SHOT_OUT=/tmp/board.ansi go test ./internal/ui/ -run TestGenerateHomeBoardShot -count=1
freeze /tmp/board.ansi -o docs/images/board.png \
  --font.file "$(go env GOMODCACHE)"/github.com/charmbracelet/freeze@*/font/JetBrainsMono-Regular.ttf \
  --font.family "JetBrains Mono" --font.size 20 --line-height 1.6 \
  --background "#0d1117" --padding 24 --window --border.radius 8

To change what the board shows, edit the seeded values in the generator — that keeps the image reproducible instead of hand-captured. If you would rather shoot a live board with your own VMs and real host stats, the VHS tape next to the image still works (cd docs/images && vhs board.tape); it drives a real sand session, so keep a VM or two around first.