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 drives Lima to create, clone, reset, and manage VMs, 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. |
provision |
Orchestrates create/reset (base build, limactl 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 ./... 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 the limae2e build tag, so plain go test ./... skips them; run them
locally on a host with Lima (or dispatch the test.yml workflow, whose
lima-e2e job runs them under QEMU+KVM).
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 likefix(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.