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 thetest.ymlworkflow, whoselima-e2ejob runs them under QEMU+KVM.proxmoxe2e— run these against a Proxmox host with a test pool set up. They also needPROXMOX_E2E=1and the otherPROXMOX_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 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.